Project vocabulary: declare each {@link MetaDataTypeProvider} as a bean + * ({@code @Bean MetaDataTypeProvider contactVocabulary() { return new ContactVocabulary(); }}) + * and the application loader composes it.
+ * *Or use @Bean methods directly:
*{@code
* @Configuration
@@ -82,6 +89,14 @@ public class MetaDataLoaderConfiguration {
*/
private String physicalNamingConfig;
+ /**
+ * A project's own vocabulary: every {@link MetaDataTypeProvider} bean in the context, in
+ * {@code @Order} order, is composed after the metamodel set into the application loader's
+ * registry (sealed and strict, like the default). Null when this class is built by hand.
+ */
+ @Autowired(required = false)
+ private ObjectProvider typeProviders;
+
/**
* Creates MetaDataLoader from application properties.
*
@@ -111,6 +126,9 @@ public MetaDataLoader applicationMetaDataLoader() throws Exception {
// Preserve the pre-unification strict=true default (the no-options
// fromUris overload routes through createManual which uses strict=false).
LoaderOptions opts = LoaderOptions.create(false, false, true);
+ if (typeProviders != null) {
+ opts.setProviders(typeProviders.orderedStream().toList());
+ }
// ADR-0058: the naming is resolved BEFORE the load call so the loader's own
// "no config found" WARN fires only when neither this property, the default
// classpath:.metaobjects/config.json, nor the file: walk found a project config.
diff --git a/server/java/core-spring/src/test/java/com/metaobjects/spring/MetaDataLoaderProvidersTest.java b/server/java/core-spring/src/test/java/com/metaobjects/spring/MetaDataLoaderProvidersTest.java
new file mode 100644
index 0000000000..b6eef28dea
--- /dev/null
+++ b/server/java/core-spring/src/test/java/com/metaobjects/spring/MetaDataLoaderProvidersTest.java
@@ -0,0 +1,65 @@
+package com.metaobjects.spring;
+
+import com.metaobjects.attr.BooleanAttribute;
+import com.metaobjects.attr.StringAttribute;
+import com.metaobjects.field.StringField;
+import com.metaobjects.layout.MetaLayout;
+import com.metaobjects.loader.MetaDataLoader;
+import com.metaobjects.registry.MetaDataRegistry;
+import com.metaobjects.registry.MetaDataTypeProvider;
+import org.junit.Test;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A {@link MetaDataTypeProvider} bean in the context is composed into the application
+ * loader's registry, so metadata using the project's own vocabulary loads strict and clean.
+ */
+public class MetaDataLoaderProvidersTest {
+
+ public static final class PickerLayout extends MetaLayout {
+ public PickerLayout(String name) { super("picker", name); }
+ }
+
+ static MetaDataTypeProvider contactVocabulary() {
+ return new MetaDataTypeProvider() {
+ @Override public String getProviderId() { return "spring-test-contact-vocabulary"; }
+ @Override public String[] getDependencies() { return new String[]{"field-types", "layout-types"}; }
+ @Override public void registerTypes(MetaDataRegistry registry) {
+ registry.registerType(PickerLayout.class, def -> {
+ def.type("layout").subType("picker").inheritsFrom("layout", "base");
+ def.optionalAttributeWithConstraints("displayField")
+ .ofType(StringAttribute.SUBTYPE_STRING).asSingle();
+ });
+ registry.extendType(StringField.class, def ->
+ def.optionalAttributeWithConstraints("searchable")
+ .ofType(BooleanAttribute.SUBTYPE_BOOLEAN).asSingle());
+ }
+ };
+ }
+
+ private final ApplicationContextRunner runner = new ApplicationContextRunner()
+ .withConfiguration(AutoConfigurations.of(MetaDataAutoConfiguration.class))
+ .withPropertyValues("metaobjects.metadata-sources=classpath:metadata/test-custom-vocabulary.json");
+
+ @Test
+ public void aProviderBeanIsComposedIntoTheApplicationLoader() {
+ runner.withBean(MetaDataTypeProvider.class, MetaDataLoaderProvidersTest::contactVocabulary)
+ .run(ctx -> {
+ MetaDataLoader loader = ctx.getBean(MetaDataLoader.class);
+ assertThat(loader.getErrors()).isEmpty();
+ assertThat(loader.getTypeRegistry().getTypeDefinition("layout", "picker")).isNotNull();
+ });
+ }
+
+ @Test
+ public void withoutTheProviderBeanTheVocabularyIsRefused() {
+ runner.run(ctx -> {
+ MetaDataLoader loader = ctx.getBean(MetaDataLoader.class);
+ assertThat(loader.getErrors()).extracting(e -> e.getCode().get().name())
+ .containsExactly("ERR_UNKNOWN_SUBTYPE", "ERR_UNKNOWN_ATTR");
+ });
+ }
+}
diff --git a/server/java/core-spring/src/test/resources/metadata/test-custom-vocabulary.json b/server/java/core-spring/src/test/resources/metadata/test-custom-vocabulary.json
new file mode 100644
index 0000000000..62bbe85046
--- /dev/null
+++ b/server/java/core-spring/src/test/resources/metadata/test-custom-vocabulary.json
@@ -0,0 +1,11 @@
+{
+ "metadata.root": {
+ "package": "acme::contacts",
+ "children": [
+ { "object.value": { "name": "Contact", "children": [
+ { "field.string": { "name": "name", "@searchable": true } },
+ { "layout.picker": { "name": "contactPicker", "@displayField": "name" } }
+ ] } }
+ ]
+ }
+}
diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java
index 7551d86102..f1df2868a6 100644
--- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java
+++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java
@@ -336,6 +336,61 @@ public void setLoader(LoaderParam loaderConfig) {
this.loaderConfig = loaderConfig;
}
+ /**
+ * A project's own metamodel vocabulary: fully-qualified names of
+ * {@link com.metaobjects.registry.MetaDataTypeProvider} classes (public, no-arg constructor)
+ * composed after the metamodel set for every goal that loads metadata
+ * ({@code generate}, {@code verify}, {@code fmt}, {@code editor}). Explicit list only — no
+ * classpath scan, so a library jar's own SPI providers never leak into the measured vocabulary.
+ * The classes are loaded through the project class loader: they must be in a dependency module
+ * or in the plugin's own {@code }; a provider in the module being built is not
+ * compiled yet at {@code generate-sources}.
+ */
+ @Parameter(property = "meta.providers")
+ public List providers;
+
+ private List resolvedProviders;
+
+ /** Instantiate {@link #providers} (once per execution) through {@code projectClassLoader}. */
+ protected List resolveProviders(ClassLoader projectClassLoader)
+ throws MojoExecutionException {
+ if (resolvedProviders != null) return resolvedProviders;
+ List out = new ArrayList<>();
+ if (providers != null) {
+ for (String raw : providers) {
+ String className = raw == null ? "" : raw.trim();
+ if (className.isEmpty()) {
+ throw new MojoExecutionException(" contains an empty class name");
+ }
+ Class> c;
+ try {
+ c = Class.forName(className, true, projectClassLoader);
+ } catch (ClassNotFoundException | LinkageError ex) {
+ throw new MojoExecutionException(" class [" + className + "] was not found. "
+ + "A provider must be on the plugin's classpath: in a dependency module of this project "
+ + "or in the plugin's . A provider in the same module is not compiled yet "
+ + "at generate-sources, so it cannot be used here.", ex);
+ }
+ if (!com.metaobjects.registry.MetaDataTypeProvider.class.isAssignableFrom(c)) {
+ throw new MojoExecutionException(" class [" + className + "] does not implement "
+ + com.metaobjects.registry.MetaDataTypeProvider.class.getName());
+ }
+ try {
+ out.add((com.metaobjects.registry.MetaDataTypeProvider)
+ c.getConstructor().newInstance());
+ } catch (NoSuchMethodException ex) {
+ throw new MojoExecutionException(" class [" + className
+ + "] needs a public no-argument constructor", ex);
+ } catch (ReflectiveOperationException ex) {
+ throw new MojoExecutionException(" class [" + className
+ + "] could not be instantiated: " + ex, ex);
+ }
+ }
+ }
+ resolvedProviders = List.copyOf(out);
+ return resolvedProviders;
+ }
+
@Parameter
public Map globals;
public Map getGlobals() {
@@ -426,6 +481,7 @@ public void execute() throws MojoExecutionException, MojoFailureException
warnIfAgentContextStale();
ClassLoader projectClassLoader = createProjectClassLoader();
+ resolveProviders(projectClassLoader);
MetaDataLoader loader = createLoader(projectClassLoader);
@@ -607,6 +663,14 @@ protected void warnIfAgentContextStale() {
protected MetaDataLoader createLoader(ClassLoader projectClassLoader) {
+ // Normally primed by the goal's execute() (which reports a bad entry as a
+ // MojoExecutionException); a direct caller that skipped that gets the same message unchecked.
+ List extraProviders;
+ try {
+ extraProviders = resolveProviders(projectClassLoader);
+ } catch (MojoExecutionException e) {
+ throw new MetaDataException(e.getMessage(), e);
+ }
LoaderConfigurable configurable = null;
String loaderClass = loaderConfig.getClassname();
String loaderName = loaderConfig.getName();
@@ -625,6 +689,12 @@ protected MetaDataLoader createLoader(ClassLoader projectClassLoader) {
MetaDataLoader.SUBTYPE_MANUAL, loaderName);
}
+ // The project's own vocabulary (): the loader composes its registry from the
+ // metamodel set plus these, sealed and strict like the default.
+ if (!extraProviders.isEmpty()) {
+ configurable.getLoader().getLoaderOptions().setProviders(extraProviders);
+ }
+
// Configure the loader using the new pattern
String sourceDir = null;
File srcDir = getSourceDir();
diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataFmtMojo.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataFmtMojo.java
index a346341ffe..ef95c0bcb9 100644
--- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataFmtMojo.java
+++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataFmtMojo.java
@@ -64,6 +64,7 @@ public void execute() throws MojoExecutionException, MojoFailureException {
warnIfAgentContextStale();
ClassLoader projectClassLoader = createProjectClassLoader();
+ resolveProviders(projectClassLoader); // a bad entry fails here, before any load
// ONE real directory walk — to learn the resolved file list via the full
// //.metaobjects/config.json ladder, and to
// fail fast (via createLoader's own failOnLoaderErrors) if the project
@@ -77,6 +78,8 @@ public void execute() throws MojoExecutionException, MojoFailureException {
List targets = resolveTargets(allFiles);
boolean strict = !isLax();
+ // Every fresh reload in the safety check carries the same as the baseline.
+ List extraProviders = resolveProviders(projectClassLoader);
String loaderName = getLoader().getName();
List libraries = getLoader().getLibraries();
List librarySources =
@@ -97,7 +100,7 @@ public void execute() throws MojoExecutionException, MojoFailureException {
// .load(List) requires init() to have run first, but takes the actual
// source list as a separate call — see FmtSupport.buildSources.
MetaDataLoader fresh = new MetaDataLoader(
- LoaderOptions.create(false, false, strict), MetaDataLoader.SUBTYPE_MANUAL,
+ LoaderOptions.create(false, false, strict).setProviders(extraProviders), MetaDataLoader.SUBTYPE_MANUAL,
loaderName);
fresh.init();
return fresh;
@@ -110,7 +113,7 @@ public void execute() throws MojoExecutionException, MojoFailureException {
// a loader name may not — that is a SEPARATE identifier, the `sourceId`
// CanonicalJsonParser takes, for provenance only).
MetaDataLoader standalone = new MetaDataLoader(
- LoaderOptions.create(false, false, strict), MetaDataLoader.SUBTYPE_MANUAL,
+ LoaderOptions.create(false, false, strict).setProviders(extraProviders), MetaDataLoader.SUBTYPE_MANUAL,
loaderNameForFile);
standalone.init();
return standalone;
diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java
index c5f7dc14fa..964a29a5cb 100644
--- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java
+++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java
@@ -341,6 +341,7 @@ private void verifyTemplates() throws MojoExecutionException, MojoFailureExcepti
}
ClassLoader projectClassLoader = createProjectClassLoader();
+ resolveProviders(projectClassLoader);
MetaDataLoader loader = createLoader(projectClassLoader);
runFieldLintAdvisory(loader);
int requirementErrors = runRequirementGate(loader);
@@ -376,6 +377,7 @@ private void verifyTemplates() throws MojoExecutionException, MojoFailureExcepti
private void verifyCodegen() throws MojoExecutionException, MojoFailureException {
ClassLoader projectClassLoader = createProjectClassLoader();
+ resolveProviders(projectClassLoader);
MetaDataLoader loader = createLoader(projectClassLoader);
runFieldLintAdvisory(loader);
int requirementErrors = runRequirementGate(loader);
diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MojoProvidersTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MojoProvidersTest.java
new file mode 100644
index 0000000000..8a1d4df313
--- /dev/null
+++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MojoProvidersTest.java
@@ -0,0 +1,169 @@
+package com.metaobjects.mojo;
+
+import com.metaobjects.attr.BooleanAttribute;
+import com.metaobjects.attr.StringAttribute;
+import com.metaobjects.field.StringField;
+import com.metaobjects.generator.kotlin.KotlinEntityGenerator;
+import com.metaobjects.layout.MetaLayout;
+import com.metaobjects.registry.MetaDataRegistry;
+import com.metaobjects.registry.MetaDataTypeProvider;
+import org.apache.maven.plugin.MojoExecutionException;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * The {@code } parameter reaches every goal that loads metadata: a project's own
+ * vocabulary loads under generate, verify and fmt, and is refused (strict) without it.
+ */
+public class MojoProvidersTest {
+
+ public static final class PickerLayout extends MetaLayout {
+ public PickerLayout(String name) { super("picker", name); }
+ }
+
+ /** Public, no-arg: what {@code } instantiates. */
+ public static final class ContactVocabulary implements MetaDataTypeProvider {
+ @Override public String getProviderId() { return "mojo-test-contact-vocabulary"; }
+ @Override public String[] getDependencies() { return new String[]{"field-types", "layout-types"}; }
+ @Override public void registerTypes(MetaDataRegistry registry) {
+ registry.registerType(PickerLayout.class, def -> {
+ def.type("layout").subType("picker").inheritsFrom("layout", "base");
+ def.optionalAttributeWithConstraints("displayField")
+ .ofType(StringAttribute.SUBTYPE_STRING).asSingle();
+ });
+ registry.extendType(StringField.class, def ->
+ def.optionalAttributeWithConstraints("searchable")
+ .ofType(BooleanAttribute.SUBTYPE_BOOLEAN).asSingle());
+ }
+ }
+
+ public static final class NoPublicCtor implements MetaDataTypeProvider {
+ public NoPublicCtor(String ignored) { }
+ @Override public String getProviderId() { return "x"; }
+ @Override public void registerTypes(MetaDataRegistry registry) { }
+ }
+
+ private static final String PROVIDER = ContactVocabulary.class.getName();
+
+ private static final String CONTACTS =
+ "{\n \"metadata.root\": {\n \"package\": \"examples\",\n \"children\": [\n"
+ + " {\n \"object.value\": {\n \"name\": \"Contact\",\n"
+ + " \"children\": [\n"
+ + " {\n \"field.string\": {\n \"name\": \"name\",\n"
+ + " \"@searchable\": true\n }\n },\n"
+ + " {\n \"layout.picker\": {\n \"name\": \"contactPicker\",\n"
+ + " \"@displayField\": \"name\"\n }\n }\n"
+ + " ]\n }\n }\n ]\n }\n}\n";
+
+ private Path dir;
+ private Path out;
+
+ @Before
+ public void setUp() throws Exception {
+ dir = Files.createTempDirectory("mojo-providers-meta");
+ out = Files.createTempDirectory("mojo-providers-out");
+ Files.writeString(dir.resolve("meta.contacts.json"), CONTACTS, StandardCharsets.UTF_8);
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ for (Path root : List.of(dir, out)) {
+ try (var walk = Files.walk(root)) {
+ walk.sorted(java.util.Comparator.reverseOrder()).forEach(p -> {
+ try { Files.delete(p); } catch (Exception ignored) { }
+ });
+ }
+ }
+ }
+
+ private void configure(AbstractMetaDataMojo mojo, String... providerClasses) {
+ mojo.setLoader(LoaderParam.builder("providers-test")
+ .withClassname("com.metaobjects.loader.MetaDataLoader")
+ .withSourceDir(dir.toString())
+ .build());
+ mojo.setGenerators(new ArrayList<>(List.of(GeneratorParam.builder(KotlinEntityGenerator.class.getName())
+ .withArg("outputDir", out.toString()).build())));
+ mojo.setGlobals(Collections.emptyMap());
+ mojo.providers = providerClasses.length == 0 ? null : List.of(providerClasses);
+ }
+
+ @Test
+ public void generateRefusesTheVocabularyWithoutProviders() {
+ MetaDataGeneratorMojo gen = new MetaDataGeneratorMojo();
+ configure(gen);
+ Exception e = assertThrows(Exception.class, gen::execute);
+ assertTrue(String.valueOf(e.getMessage()), e.getMessage().contains("strict provenance (2 error(s))"));
+ }
+
+ @Test
+ public void generateLoadsTheVocabularyWithProviders() throws Exception {
+ MetaDataGeneratorMojo gen = new MetaDataGeneratorMojo();
+ configure(gen, PROVIDER);
+ gen.execute();
+ }
+
+ @Test
+ public void verifyLoadsTheVocabularyWithProviders() throws Exception {
+ MetaDataGeneratorMojo gen = new MetaDataGeneratorMojo();
+ configure(gen, PROVIDER);
+ gen.execute();
+
+ MetaDataVerifyMojo verify = new MetaDataVerifyMojo();
+ configure(verify, PROVIDER);
+ verify.execute();
+
+ MetaDataVerifyMojo without = new MetaDataVerifyMojo();
+ configure(without);
+ assertThrows(Exception.class, without::execute);
+ }
+
+ @Test
+ public void fmtLoadsTheVocabularyWithProvidersInEveryReload() throws Exception {
+ MetaDataFmtMojo fmt = new MetaDataFmtMojo();
+ configure(fmt, PROVIDER);
+ fmt.execute();
+ assertEquals(CONTACTS, Files.readString(dir.resolve("meta.contacts.json"), StandardCharsets.UTF_8));
+
+ MetaDataFmtMojo without = new MetaDataFmtMojo();
+ configure(without);
+ assertThrows(Exception.class, without::execute);
+ }
+
+ @Test
+ public void aMissingClassNamesTheClasspathRule() {
+ MetaDataGeneratorMojo gen = new MetaDataGeneratorMojo();
+ configure(gen, "com.example.DoesNotExist");
+ MojoExecutionException e = assertThrows(MojoExecutionException.class, gen::execute);
+ assertTrue(e.getMessage(), e.getMessage().contains("com.example.DoesNotExist"));
+ assertTrue(e.getMessage(), e.getMessage().contains("plugin's classpath"));
+ assertTrue(e.getMessage(), e.getMessage().contains("not compiled yet at generate-sources"));
+ }
+
+ @Test
+ public void aNonProviderClassIsRefused() {
+ MetaDataGeneratorMojo gen = new MetaDataGeneratorMojo();
+ configure(gen, String.class.getName());
+ MojoExecutionException e = assertThrows(MojoExecutionException.class, gen::execute);
+ assertTrue(e.getMessage(), e.getMessage().contains("does not implement"));
+ }
+
+ @Test
+ public void aProviderWithoutAPublicNoArgConstructorIsRefused() {
+ MetaDataGeneratorMojo gen = new MetaDataGeneratorMojo();
+ configure(gen, NoPublicCtor.class.getName());
+ MojoExecutionException e = assertThrows(MojoExecutionException.class, gen::execute);
+ assertTrue(e.getMessage(), e.getMessage().contains("public no-argument constructor"));
+ }
+}
diff --git a/server/java/metadata-ktx/src/main/kotlin/com/metaobjects/metadata/ktx/Loader.kt b/server/java/metadata-ktx/src/main/kotlin/com/metaobjects/metadata/ktx/Loader.kt
index b2516ce973..90de803f4b 100644
--- a/server/java/metadata-ktx/src/main/kotlin/com/metaobjects/metadata/ktx/Loader.kt
+++ b/server/java/metadata-ktx/src/main/kotlin/com/metaobjects/metadata/ktx/Loader.kt
@@ -4,6 +4,7 @@ import com.metaobjects.loader.DirectorySource
import com.metaobjects.loader.LoaderOptions
import com.metaobjects.loader.MetaDataLoader
import com.metaobjects.loader.MetaDataSource.MetaDataFormat
+import com.metaobjects.registry.MetaDataTypeProvider
import java.net.URI
import java.nio.file.Path
@@ -13,26 +14,42 @@ import java.nio.file.Path
* [MetaDataLoader] static factory.
*/
-/** Load metadata from a filesystem directory; pass [DirectorySource.Options] to tune expansion. */
+/**
+ * The options a `providers` argument rides on. Empty [providers] returns [opts] untouched (so the
+ * default load is byte-identical); otherwise the providers are set on [opts], or on the
+ * `createManual` defaults (lax, quiet, unregistered) when [opts] is null.
+ */
+private fun withProviders(opts: LoaderOptions?, providers: List): LoaderOptions? =
+ if (providers.isEmpty()) opts
+ else (opts ?: LoaderOptions.create(false, false, false)).setProviders(providers)
+
+/**
+ * Load metadata from a filesystem directory; pass [DirectorySource.Options] to tune expansion and
+ * [providers] to add a project's own vocabulary (composed after the metamodel set into a sealed registry; the
+ * directory load itself stays lax, as it is without providers).
+ */
fun loadDirectory(
name: String,
directory: Path,
opts: DirectorySource.Options = DirectorySource.Options(),
-): MetaDataLoader = MetaDataLoader.fromDirectory(name, directory, opts)
+ providers: List = emptyList(),
+): MetaDataLoader = MetaDataLoader.fromDirectory(name, directory, opts, null, providers)
-/** Load metadata from a list of URIs; pass [LoaderOptions] to tune (e.g. `strict=true`). */
+/** Load metadata from a list of URIs; pass [LoaderOptions] to tune (e.g. `strict=true`) and [providers] for a project's own vocabulary. */
fun loadUris(
name: String,
uris: List,
opts: LoaderOptions? = null,
-): MetaDataLoader = MetaDataLoader.fromUris(name, uris, opts)
+ providers: List = emptyList(),
+): MetaDataLoader = MetaDataLoader.fromUris(name, uris, withProviders(opts, providers))
-/** Load metadata from classpath resource paths; pass [LoaderOptions] to tune. */
+/** Load metadata from classpath resource paths; pass [LoaderOptions] to tune and [providers] for a project's own vocabulary. */
fun loadResources(
name: String,
resources: List,
opts: LoaderOptions? = null,
-): MetaDataLoader = MetaDataLoader.fromResources(name, resources, opts)
+ providers: List = emptyList(),
+): MetaDataLoader = MetaDataLoader.fromResources(name, resources, withProviders(opts, providers))
/**
* Load metadata from a single inline string (defaults to JSON); pass [LoaderOptions] to
@@ -48,4 +65,5 @@ fun loadString(
content: String,
format: MetaDataFormat = MetaDataFormat.JSON,
opts: LoaderOptions? = null,
-): MetaDataLoader = MetaDataLoader.fromString(name, content, format, opts)
+ providers: List = emptyList(),
+): MetaDataLoader = MetaDataLoader.fromString(name, content, format, withProviders(opts, providers))
diff --git a/server/java/metadata-ktx/src/test/kotlin/com/metaobjects/metadata/ktx/ProviderVocabularyTest.kt b/server/java/metadata-ktx/src/test/kotlin/com/metaobjects/metadata/ktx/ProviderVocabularyTest.kt
new file mode 100644
index 0000000000..b969889078
--- /dev/null
+++ b/server/java/metadata-ktx/src/test/kotlin/com/metaobjects/metadata/ktx/ProviderVocabularyTest.kt
@@ -0,0 +1,93 @@
+package com.metaobjects.metadata.ktx
+
+import com.metaobjects.field.StringField
+import com.metaobjects.io.json.CanonicalJsonSerializer
+import com.metaobjects.layout.MetaLayout
+import com.metaobjects.loader.LoaderOptions
+import com.metaobjects.registry.MetaDataRegistry
+import com.metaobjects.registry.MetaDataTypeProvider
+import com.metaobjects.source.JsonSource
+import java.nio.charset.StandardCharsets
+import java.nio.file.Files
+import java.nio.file.Path
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+/**
+ * A project's own vocabulary, written in Kotlin and passed to the metadata-ktx loader as
+ * `providers = listOf(...)`. Runs the two shared `provider-custom-vocabulary-*` conformance
+ * fixtures: loaded with the provider the input matches `expected.json`; loaded without it the
+ * strict load refuses it with the expected codes, in order, at the expected JSON paths.
+ */
+class ProviderVocabularyTest {
+
+ /** The project's own layout subtype. */
+ class PickerLayout(name: String) : MetaLayout("picker", name)
+
+ /** `layout.picker` + `field.string` gains `@searchable` / `@valueLabels`. */
+ class ContactVocabulary : MetaDataTypeProvider {
+ override fun getProviderId() = "example-custom-vocabulary"
+
+ // The Java port's real provider ids; the cross-port alias "metaobjects-core-types" is
+ // not a Java provider id.
+ override fun getDependencies() = arrayOf("field-types", "layout-types")
+
+ override fun registerTypes(registry: MetaDataRegistry) {
+ registry.registerType(PickerLayout::class.java) { def ->
+ def.type("layout").subType("picker").inheritsFrom("layout", "base")
+ def.optionalAttributeWithConstraints("displayField").ofType("string").asSingle()
+ }
+ registry.extendType(StringField::class.java) { def ->
+ def.optionalAttributeWithConstraints("searchable").ofType("boolean").asSingle()
+ def.optionalAttributeWithConstraints("valueLabels").ofType("string").asArray()
+ }
+ }
+ }
+
+ private fun fixtures(): Path {
+ var p: Path? = Path.of(System.getProperty("user.dir")).toAbsolutePath()
+ while (p != null && !Files.exists(p.resolve("fixtures/conformance"))) p = p.parent
+ assertTrue(p != null, "could not locate fixtures/conformance from user.dir")
+ return p!!.resolve("fixtures/conformance")
+ }
+
+ private fun read(p: Path) = String(Files.readAllBytes(p), StandardCharsets.UTF_8)
+
+ private val strict = { LoaderOptions.create(false, false, true) }
+
+ @Test fun `success fixture loads with providers and matches the canonical output`() {
+ val dir = fixtures().resolve("provider-custom-vocabulary-success")
+ val loader = loadString(
+ "examples", read(dir.resolve("input/meta.contacts.json")),
+ opts = strict(), providers = listOf(ContactVocabulary()),
+ )
+ assertTrue(loader.errors.isEmpty(), "unexpected errors: ${loader.errors}")
+ assertEquals(
+ read(dir.resolve("expected.json")).trim(),
+ CanonicalJsonSerializer.canonicalSerialize(loader.root).trim(),
+ )
+ }
+
+ @Test fun `missing-provider fixture is refused with the expected codes in order`() {
+ val dir = fixtures().resolve("provider-custom-vocabulary-missing-provider-fails")
+ val loader = loadString("examples", read(dir.resolve("input/meta.contacts.json")), opts = strict())
+ val got = loader.errors.map {
+ it.code.get().name to (it.envelope.get() as JsonSource).jsonPath()
+ }
+ val base = "\$['metadata.root'].children[0]['object.value'].children"
+ assertEquals(
+ listOf(
+ "ERR_UNKNOWN_SUBTYPE" to "$base[2]['layout.picker']",
+ "ERR_UNKNOWN_ATTR" to "$base[0]['field.string']",
+ "ERR_UNKNOWN_ATTR" to "$base[1]['field.string']",
+ ),
+ got,
+ )
+ }
+
+ @Test fun `no providers leaves the default registry in place`() {
+ val loader = loadString("x", """{ "metadata.root": { "package": "acme", "children": [] } }""")
+ assertTrue(loader.typeRegistry.isSealed)
+ }
+}
diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/LoaderOptions.java b/server/java/metadata/src/main/java/com/metaobjects/loader/LoaderOptions.java
index e4759bd999..74ce2192e3 100644
--- a/server/java/metadata/src/main/java/com/metaobjects/loader/LoaderOptions.java
+++ b/server/java/metadata/src/main/java/com/metaobjects/loader/LoaderOptions.java
@@ -1,5 +1,10 @@
package com.metaobjects.loader;
+import com.metaobjects.registry.MetaDataTypeProvider;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
import java.util.Objects;
@@ -13,6 +18,9 @@ public class LoaderOptions {
private boolean verbose = true;
private boolean strict = true;
+ /** Extra vocabulary providers composed into the loader's registry; empty = the shared default. */
+ private List providers = List.of();
+
public LoaderOptions() {}
public static LoaderOptions create(boolean shouldRegister, boolean verbose, boolean strict ) {
@@ -53,6 +61,22 @@ public boolean isStrict() {
return strict;
}
+ /**
+ * A project's own vocabulary: the loader composes
+ * {@link com.metaobjects.registry.RegistryManifest#composeLoaderRegistry(Collection)} with these
+ * providers appended after the metamodel set, instead of using the shared default registry.
+ * Empty (the default) leaves loading byte-identical to a loader given no providers.
+ */
+ public LoaderOptions setProviders(Collection extends MetaDataTypeProvider> providers) {
+ this.providers = providers == null ? List.of() : List.copyOf(new ArrayList<>(providers));
+ return this;
+ }
+
+ /** The extra providers (never null, unmodifiable). */
+ public List getProviders() {
+ return providers;
+ }
+
///////////////////////////////////////////////////////////////////////////
// Misc Functions
@@ -63,7 +87,8 @@ public boolean equals(Object o) {
LoaderOptions that = (LoaderOptions) o;
return shouldRegister == that.shouldRegister &&
verbose == that.verbose &&
- strict == that.strict;
+ strict == that.strict &&
+ providers.equals(that.providers);
}
@Override
diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/MetaDataLoader.java b/server/java/metadata/src/main/java/com/metaobjects/loader/MetaDataLoader.java
index 28b6c0326b..f09473d809 100644
--- a/server/java/metadata/src/main/java/com/metaobjects/loader/MetaDataLoader.java
+++ b/server/java/metadata/src/main/java/com/metaobjects/loader/MetaDataLoader.java
@@ -175,6 +175,7 @@ public class MetaDataLoader implements LoaderConfigurable {
// this list AFTER catching the load's (final) thrown MetaDataException and
// merges the two into a single per-fixture error set.
private final List errors = new ArrayList<>();
+ private final List deferredAttrErrors = new ArrayList<>();
// FR5c — envelope-shaped warnings accumulator (cross-port parallel channel).
//
@@ -717,7 +718,21 @@ public static MetaDataLoader fromDirectory(String name, Path directory, Director
*/
public static MetaDataLoader fromDirectory(String name, Path directory,
DirectorySource.Options opts, List libraries) {
- MetaDataLoader loader = createManual(false, name);
+ return fromDirectory(name, directory, opts, libraries, List.of());
+ }
+
+ /**
+ * {@link #fromDirectory(String, Path, DirectorySource.Options, List)} with a project's own
+ * vocabulary: {@code providers} are composed after the metamodel set (see
+ * {@link LoaderOptions#setProviders}). Empty is byte-identical to the overload without it.
+ *
+ * @param providers extra {@link com.metaobjects.registry.MetaDataTypeProvider}s; null or empty adds none
+ */
+ public static MetaDataLoader fromDirectory(String name, Path directory,
+ DirectorySource.Options opts, List libraries,
+ List providers) {
+ MetaDataLoader loader = new MetaDataLoader(
+ LoaderOptions.create(false, false, false).setProviders(providers), SUBTYPE_MANUAL, name);
try {
loader.init();
List sources = new ArrayList<>(
@@ -1036,6 +1051,23 @@ public void addError(MetaDataException error) {
errors.add(error);
}
+ /**
+ * Record an {@code ERR_UNKNOWN_ATTR} found while parsing, to be appended to {@link #getErrors()}
+ * once every source of this batch has been parsed. The cross-port contract orders structural
+ * errors (unknown type / subtype, ...) ahead of attribute-schema errors, because the TypeScript
+ * reference checks attributes in a pass after the tree is built; recording this one inline would
+ * interleave it in document order (see {@code provider-custom-vocabulary-missing-provider-fails}).
+ */
+ public void addDeferredAttrError(MetaDataException error) {
+ if (error == null) return;
+ deferredAttrErrors.add(error);
+ }
+
+ private void flushDeferredAttrErrors() {
+ errors.addAll(deferredAttrErrors);
+ deferredAttrErrors.clear();
+ }
+
/**
* Clear accumulated errors. Called at the start of {@link #load(List)} so
* a fresh batch does not see stale errors from a prior load on the same
@@ -1043,6 +1075,7 @@ public void addError(MetaDataException error) {
*/
void clearErrors() {
errors.clear();
+ deferredAttrErrors.clear();
}
/**
@@ -1158,11 +1191,24 @@ public MetaDataRegistry getTypeRegistry() {
// ai*/json*/object.managed tooling vocabulary). Downstream apps that
// need extra vocabulary use setTypeRegistry(compose(...)) — their own
// unsealed registry.
- typeRegistry = com.metaobjects.registry.RegistryManifest.defaultLoaderRegistry();
+ typeRegistry = resolveDefaultTypeRegistry();
}
return typeRegistry;
}
+ /**
+ * The registry this loader uses when none was set explicitly: the shared sealed default, or —
+ * when {@link LoaderOptions#getProviders()} is non-empty — a sealed registry composed from the
+ * metamodel set plus those providers (the sanctioned project-vocabulary path).
+ */
+ private com.metaobjects.registry.MetaDataRegistry resolveDefaultTypeRegistry() {
+ List extra =
+ loaderOptions == null ? List.of() : loaderOptions.getProviders();
+ return extra.isEmpty()
+ ? com.metaobjects.registry.RegistryManifest.defaultLoaderRegistry()
+ : com.metaobjects.registry.RegistryManifest.composeLoaderRegistry(extra);
+ }
+
public MetaDataLoader setTypeRegistry(MetaDataRegistry typeRegistry) {
this.typeRegistry = typeRegistry;
return this;
@@ -1441,7 +1487,7 @@ protected void initDefaultRegistries() {
if (typeRegistry == null) {
// ADR-0023 Decision 2 — default to the sealed defined-provider-set
// registry (see getTypeRegistry()), not the polluted SPI singleton.
- typeRegistry = com.metaobjects.registry.RegistryManifest.defaultLoaderRegistry();
+ typeRegistry = resolveDefaultTypeRegistry();
log.debug("Initialized default (sealed metamodel) MetaDataRegistry for loader: {}", getName());
}
@@ -1796,6 +1842,7 @@ public MetaDataLoader load(List sources) {
}
} finally {
deferringOverlays = false;
+ flushDeferredAttrErrors();
}
// ADR-0055 — apply every queued overlay now, before super resolution.
diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/parser/json/CanonicalJsonParser.java b/server/java/metadata/src/main/java/com/metaobjects/loader/parser/json/CanonicalJsonParser.java
index e4fc2442cc..93873239a9 100644
--- a/server/java/metadata/src/main/java/com/metaobjects/loader/parser/json/CanonicalJsonParser.java
+++ b/server/java/metadata/src/main/java/com/metaobjects/loader/parser/json/CanonicalJsonParser.java
@@ -1364,7 +1364,7 @@ private void processAttributes(MetaData md, JsonObject body) {
if (isStrictLoad()
&& !isDeclaredAttribute(md, attrName)
&& !isExemptPropertiesAttr(entry.getValue())) {
- getLoader().addError(new MetaDataException(
+ getLoader().addDeferredAttrError(new MetaDataException(
"Unknown attribute '" + JSON_ATTR_PREFIX + attrName + "' on "
+ md.getType() + "." + md.getSubType() + " '" + md.getName()
+ "' in file [" + getFilename() + "] — not declared by any "
diff --git a/server/java/metadata/src/main/java/com/metaobjects/registry/RegistryManifest.java b/server/java/metadata/src/main/java/com/metaobjects/registry/RegistryManifest.java
index 596e86fd55..9c97e70d21 100644
--- a/server/java/metadata/src/main/java/com/metaobjects/registry/RegistryManifest.java
+++ b/server/java/metadata/src/main/java/com/metaobjects/registry/RegistryManifest.java
@@ -207,13 +207,7 @@ public static MetaDataRegistry defaultLoaderRegistry() {
synchronized (DEFAULT_LOCK) {
r = defaultLoaderRegistry;
if (r == null) {
- r = composeMetamodelRegistry();
- // The core constraint set is normally lazy-loaded on first
- // getAllValidationConstraints() read — which, post-seal, would
- // happen DURING a load and trip the seal guard. Force it now,
- // while still unsealed, so the sealed registry is fully built.
- r.getAllValidationConstraints();
- r.seal();
+ r = composeLoaderRegistry(List.of());
defaultLoaderRegistry = r;
}
}
@@ -221,6 +215,32 @@ public static MetaDataRegistry defaultLoaderRegistry() {
return r;
}
+ /**
+ * The sanctioned way to give a loader a project's own vocabulary: compose the
+ * metamodel provider set PLUS {@code extra} (a project's {@link MetaDataTypeProvider}s),
+ * run the strict spec-description pipeline, and SEAL the result — exactly what
+ * {@link #defaultLoaderRegistry()} does for zero extras, so a project that adds
+ * vocabulary is held to the same strict provenance (ADR-0023) as one that does not.
+ * Every {@code MetaDataLoader} entry point that accepts providers
+ * ({@code LoaderOptions.setProviders}, the {@code from*} factories, the Maven
+ * plugin's {@code }, the Spring auto-configuration, the Kotlin facade)
+ * routes through here. Unlike {@code defaultLoaderRegistry()} the result is a NEW
+ * registry on every call.
+ *
+ * @param extra additional providers composed after the metamodel provider set
+ * @return a new, sealed registry
+ */
+ public static MetaDataRegistry composeLoaderRegistry(Collection extra) {
+ MetaDataRegistry r = composeMetamodelRegistry(extra);
+ // The core constraint set is normally lazy-loaded on first
+ // getAllValidationConstraints() read — which, post-seal, would
+ // happen DURING a load and trip the seal guard. Force it now,
+ // while still unsealed, so the sealed registry is fully built.
+ r.getAllValidationConstraints();
+ r.seal();
+ return r;
+ }
+
private static volatile MetaDataRegistry defaultLoaderRegistry;
private static final Object DEFAULT_LOCK = new Object();
diff --git a/server/java/metadata/src/test/java/com/metaobjects/conformance/ConformanceTest.java b/server/java/metadata/src/test/java/com/metaobjects/conformance/ConformanceTest.java
index f067080630..ac113346be 100644
--- a/server/java/metadata/src/test/java/com/metaobjects/conformance/ConformanceTest.java
+++ b/server/java/metadata/src/test/java/com/metaobjects/conformance/ConformanceTest.java
@@ -362,6 +362,11 @@ static void runConformanceChecks(FixtureDiscovery.Fixture fix,
String loaderName = detectLoaderName(inputFiles);
LoaderOptions opts = LoaderOptions.create(false, false, true);
+ // A fixture naming the project-vocabulary provider loads through the SANCTIONED path:
+ // LoaderOptions.setProviders -> RegistryManifest.composeLoaderRegistry (sealed, strict).
+ if (fix.hasProvidersJson && fix.requiredProviders.contains("example-custom-vocabulary")) {
+ opts.setProviders(List.of(ConformanceTestProviders.EXAMPLE_CUSTOM_VOCABULARY));
+ }
MetaDataLoader loader = new MetaDataLoader(opts, MetaDataLoader.SUBTYPE_MANUAL, loaderName);
// A fixture that requires the test-only example-template-briefing provider
// gets its OWN registry (core + briefing) via setTypeRegistry — no global
diff --git a/server/java/metadata/src/test/java/com/metaobjects/conformance/ConformanceTestProviders.java b/server/java/metadata/src/test/java/com/metaobjects/conformance/ConformanceTestProviders.java
index 27cfb3f36f..3e6f25f3b2 100644
--- a/server/java/metadata/src/test/java/com/metaobjects/conformance/ConformanceTestProviders.java
+++ b/server/java/metadata/src/test/java/com/metaobjects/conformance/ConformanceTestProviders.java
@@ -17,7 +17,10 @@
*/
package com.metaobjects.conformance;
+import com.metaobjects.attr.BooleanAttribute;
import com.metaobjects.attr.StringAttribute;
+import com.metaobjects.field.StringField;
+import com.metaobjects.layout.MetaLayout;
import com.metaobjects.registry.MetaDataRegistry;
import com.metaobjects.registry.MetaDataTypeProvider;
import com.metaobjects.template.MetaTemplate;
@@ -66,6 +69,41 @@ public static void registerTypes(MetaDataRegistry registry) {
@Override public String getDescription() { return "Test-only: registers a fictional template.briefing subtype."; }
};
+ /** Test-only `layout.picker` node — a MetaLayout subclass, as a project's own node class would be. */
+ public static final class PickerLayout extends MetaLayout {
+ public PickerLayout(String name) {
+ super("picker", name);
+ }
+ }
+
+ /**
+ * A project's own vocabulary (provider-custom-vocabulary-* fixtures): registers
+ * {@code layout.picker} (+ optional {@code @displayField}) and extends
+ * {@code field.string} with optional {@code @searchable} (boolean) and
+ * {@code @valueLabels} (string array). Mirrors the TypeScript reference
+ * {@code exampleCustomVocabularyProvider}.
+ */
+ public static final MetaDataTypeProvider EXAMPLE_CUSTOM_VOCABULARY = new MetaDataTypeProvider() {
+ @Override public String getProviderId() { return "example-custom-vocabulary"; }
+ @Override public String[] getDependencies() { return new String[]{"field-types", "layout-types"}; }
+ @Override public void registerTypes(MetaDataRegistry registry) {
+ registry.registerType(PickerLayout.class, def -> {
+ def.type("layout").subType("picker")
+ .description("A picker that chooses one row of the owning object — test-only.")
+ .inheritsFrom("layout", "base");
+ def.optionalAttributeWithConstraints("displayField")
+ .ofType(StringAttribute.SUBTYPE_STRING).asSingle();
+ });
+ registry.extendType(StringField.class, def -> {
+ def.optionalAttributeWithConstraints("searchable")
+ .ofType(BooleanAttribute.SUBTYPE_BOOLEAN).asSingle();
+ def.optionalAttributeWithConstraints("valueLabels")
+ .ofType(StringAttribute.SUBTYPE_STRING).asArray();
+ });
+ }
+ @Override public String getDescription() { return "Test-only: a project's own layout.picker + field.string attrs."; }
+ };
+
public static final MetaDataTypeProvider CYCLE_A = noop("cycle-a", "cycle-b");
public static final MetaDataTypeProvider CYCLE_B = noop("cycle-b", "cycle-a");
public static final MetaDataTypeProvider DEPENDS_ON_MISSING = noop("depends-on-missing", "does-not-exist");
@@ -82,6 +120,7 @@ public static void registerTypes(MetaDataRegistry registry) {
*/
public static final Map TEST_PROVIDERS = Map.of(
"example-template-briefing", EXAMPLE_TEMPLATE_BRIEFING,
+ "example-custom-vocabulary", EXAMPLE_CUSTOM_VOCABULARY,
"cycle-a", CYCLE_A,
"cycle-b", CYCLE_B,
"depends-on-missing", DEPENDS_ON_MISSING,
diff --git a/server/java/metadata/src/test/java/com/metaobjects/conformance/YamlTwinConformanceTest.java b/server/java/metadata/src/test/java/com/metaobjects/conformance/YamlTwinConformanceTest.java
index b713282ab6..01b25e613d 100644
--- a/server/java/metadata/src/test/java/com/metaobjects/conformance/YamlTwinConformanceTest.java
+++ b/server/java/metadata/src/test/java/com/metaobjects/conformance/YamlTwinConformanceTest.java
@@ -158,6 +158,9 @@ private static final class Outcome {
private static Outcome load(FixtureDiscovery.Fixture f, List files, List texts,
String loaderName, MetaDataSource.MetaDataFormat format) {
LoaderOptions opts = LoaderOptions.create(false, false, true);
+ if (f.hasProvidersJson && f.requiredProviders.contains("example-custom-vocabulary")) {
+ opts.setProviders(List.of(ConformanceTestProviders.EXAMPLE_CUSTOM_VOCABULARY));
+ }
MetaDataLoader loader = new MetaDataLoader(opts, MetaDataLoader.SUBTYPE_MANUAL, loaderName);
if (f.hasProvidersJson && f.requiredProviders.contains("example-template-briefing")) {
com.metaobjects.registry.MetaDataRegistry reg =
diff --git a/server/java/metadata/src/test/java/com/metaobjects/loader/LoaderProvidersTest.java b/server/java/metadata/src/test/java/com/metaobjects/loader/LoaderProvidersTest.java
new file mode 100644
index 0000000000..4b46d9e045
--- /dev/null
+++ b/server/java/metadata/src/test/java/com/metaobjects/loader/LoaderProvidersTest.java
@@ -0,0 +1,97 @@
+package com.metaobjects.loader;
+
+import com.metaobjects.MetaDataException;
+import com.metaobjects.attr.BooleanAttribute;
+import com.metaobjects.attr.StringAttribute;
+import com.metaobjects.field.StringField;
+import com.metaobjects.layout.MetaLayout;
+import com.metaobjects.registry.MetaDataRegistry;
+import com.metaobjects.registry.MetaDataTypeProvider;
+import com.metaobjects.registry.RegistryManifest;
+import org.junit.Test;
+
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import static org.junit.Assert.*;
+
+/** A project's own vocabulary reaches the loader through every {@code from*} factory. */
+public class LoaderProvidersTest {
+
+ /** A project's own layout subtype. */
+ public static final class PickerLayout extends MetaLayout {
+ public PickerLayout(String name) { super("picker", name); }
+ }
+
+ static final MetaDataTypeProvider PROVIDER = new MetaDataTypeProvider() {
+ @Override public String getProviderId() { return "loader-providers-test"; }
+ @Override public String[] getDependencies() { return new String[]{"field-types", "layout-types"}; }
+ @Override public void registerTypes(MetaDataRegistry registry) {
+ registry.registerType(PickerLayout.class, def -> {
+ def.type("layout").subType("picker").inheritsFrom("layout", "base");
+ def.optionalAttributeWithConstraints("displayField")
+ .ofType(StringAttribute.SUBTYPE_STRING).asSingle();
+ });
+ registry.extendType(StringField.class, def ->
+ def.optionalAttributeWithConstraints("searchable")
+ .ofType(BooleanAttribute.SUBTYPE_BOOLEAN).asSingle());
+ }
+ };
+
+ static final String JSON = """
+ {"metadata.root": {"package": "p", "children": [
+ {"object.value": {"name": "Contact", "children": [
+ {"field.string": {"name": "name", "@searchable": true}},
+ {"layout.picker": {"name": "pick", "@displayField": "name"}}
+ ]}}
+ ]}}
+ """;
+
+ private static LoaderOptions strict() { return LoaderOptions.create(false, false, true); }
+
+ @Test public void fromStringComposesProviders() {
+ MetaDataLoader loader = MetaDataLoader.fromString("p", JSON,
+ MetaDataSource.MetaDataFormat.JSON, strict().setProviders(List.of(PROVIDER)));
+ assertTrue(loader.getErrors().isEmpty());
+ assertTrue(loader.getTypeRegistry().isSealed());
+ assertNotSame(RegistryManifest.defaultLoaderRegistry(), loader.getTypeRegistry());
+ }
+
+ @Test public void withoutProvidersTheStrictLoadRefusesAndDefaultRegistryIsShared() {
+ MetaDataLoader loader = MetaDataLoader.createManual(false, "p");
+ loader.getLoaderOptions().setStrict(true);
+ assertSame(RegistryManifest.defaultLoaderRegistry(), loader.getTypeRegistry());
+ MetaDataLoader refused = MetaDataLoader.fromString("p", JSON,
+ MetaDataSource.MetaDataFormat.JSON, strict());
+ assertEquals(List.of("ERR_UNKNOWN_SUBTYPE", "ERR_UNKNOWN_ATTR"),
+ refused.getErrors().stream().map(e -> e.getCode().get().name()).toList());
+ }
+
+ @Test public void fromDirectoryAndFromUrisAndFromResourcesAcceptProviders() throws Exception {
+ Path dir = Files.createTempDirectory("providers-test");
+ Path f = dir.resolve("meta.contacts.json");
+ Files.writeString(f, JSON);
+ MetaDataLoader d = MetaDataLoader.fromDirectory("p", dir,
+ new DirectorySource.Options(), null, List.of(PROVIDER));
+ assertTrue(d.getTypeRegistry().hasType("layout"));
+ assertNotNull(d.getTypeRegistry().getTypeDefinition("layout", "picker"));
+
+ MetaDataLoader u = MetaDataLoader.fromUris("p", List.of(URI.create(f.toUri().toString())),
+ strict().setProviders(List.of(PROVIDER)));
+ assertNotNull(u.getTypeRegistry().getTypeDefinition("layout", "picker"));
+ }
+
+ @Test public void composeLoaderRegistryIsFreshAndSealedEachCall() {
+ MetaDataRegistry a = RegistryManifest.composeLoaderRegistry(List.of(PROVIDER));
+ MetaDataRegistry b = RegistryManifest.composeLoaderRegistry(List.of(PROVIDER));
+ assertNotSame(a, b);
+ assertTrue(a.isSealed());
+ }
+
+ @Test public void defaultOptionsEqualityIgnoresNothingNew() {
+ assertEquals(LoaderOptions.create(false, false, true), LoaderOptions.create(false, false, true));
+ assertNotEquals(strict(), strict().setProviders(List.of(PROVIDER)));
+ }
+}
diff --git a/server/python/src/metaobjects/cli.py b/server/python/src/metaobjects/cli.py
index 06817f3b69..d04d23e195 100644
--- a/server/python/src/metaobjects/cli.py
+++ b/server/python/src/metaobjects/cli.py
@@ -1178,8 +1178,9 @@ def _cmd_fmt(args: argparse.Namespace) -> int:
Mirrors the TS reference surface exactly: no ``metadata_dir`` positional —
files come from the neutral ``.metaobjects/config.json`` ladder
- (:func:`resolve_metadata_location`, rungs 3-4; this command does not read
- this port's own ``metaobjects.config.yaml`` rung 2, matching the scope the
+ (:func:`resolve_metadata_location`, rungs 3-4; this command reads only the
+ ``providers`` of this port's own ``metaobjects.config.yaml`` (never its rung 2
+ ``metadata`` location), matching the scope the
C# and Java ports also keep for `fmt`), optionally narrowed by explicit
file arguments.
"""
@@ -1191,6 +1192,20 @@ def _cmd_fmt(args: argparse.Namespace) -> int:
providers_extra, providers_ok = _providers_from_args(args)
if not providers_ok:
return 1
+ # The project's own `providers:` (metaobjects.config.yaml) reach fmt too, merged with
+ # --provider. Only the providers are read from the config: the metadata location stays
+ # on the neutral ladder (see the docstring).
+ config_path = _find_config(args)
+ if config_path is not None:
+ try:
+ fmt_config = load_project_config(config_path)
+ except ConfigError as exc:
+ print(f"error: {exc}", file=sys.stderr)
+ return 1
+ config_providers, config_providers_ok = _config_providers(fmt_config)
+ if not config_providers_ok:
+ return 1
+ providers_extra = [*config_providers, *providers_extra]
providers = [*core_providers, *providers_extra]
registry = compose_registry(providers)
@@ -2587,11 +2602,17 @@ def unread(*lines: str) -> "_VerifyModel | None":
config = load_project_config(config_path) if config_path is not None else None
libraries: "list[str] | None" = None
if config is not None:
- providers, provider_errors = _resolve_config_providers(config)
- if provider_errors:
+ # Merge, never overwrite: the project's providers AND --provider both apply.
+ flag_provider_errors = provider_errors
+ config_providers, config_provider_errors = _resolve_config_providers(config)
+ if config_provider_errors:
return unread(
- "error: invalid provider in config:", *(f" {m}" for m in provider_errors)
+ "error: invalid provider in config:",
+ *(f" {m}" for m in config_provider_errors),
)
+ if flag_provider_errors:
+ return unread("error: invalid --provider:", *(f" {m}" for m in flag_provider_errors))
+ providers = [*config_providers, *providers]
libraries = config.libraries
start = project_root_for(config.metadata_dir())
else:
@@ -3117,7 +3138,14 @@ def _build_parser() -> argparse.ArgumentParser:
action="append",
default=None,
metavar="MODULE:SYMBOL",
- help="consumer-registered Provider(s), repeatable (module:symbol)",
+ help="consumer-registered Provider(s), repeatable (module:symbol); merged with the "
+ "`providers:` of metaobjects.config.yaml",
+ )
+ fmt_p.add_argument(
+ "--config",
+ default=None,
+ help="path to the metaobjects.config.yaml whose `providers:` apply "
+ "(default: ./metaobjects.config.yaml in cwd)",
)
fmt_p.set_defaults(func=_cmd_fmt)
diff --git a/server/python/src/metaobjects/loader/meta_data_loader.py b/server/python/src/metaobjects/loader/meta_data_loader.py
index 4589a04003..10aac86085 100644
--- a/server/python/src/metaobjects/loader/meta_data_loader.py
+++ b/server/python/src/metaobjects/loader/meta_data_loader.py
@@ -106,6 +106,9 @@ def get_envelope_warnings(self) -> list[LoaderWarning]:
return list(self.envelope_warnings)
+_NODE_SKIPPING_CODES = frozenset({ErrorCode.ERR_UNKNOWN_SUBTYPE, ErrorCode.ERR_UNKNOWN_TYPE})
+
+
class MetaDataLoader:
"""Source-polymorphic metadata loader.
@@ -152,7 +155,12 @@ def load(self, sources: list[MetaDataSource]) -> LoadResult:
# Today these only arrive from merge sites (see merge_roots);
# the channel is exposed here for future parser-emitted ones.
result.envelope_warnings.extend(parsed.envelope_warnings)
- if not parsed.errors:
+ if not parsed.errors or all(
+ e.code in _NODE_SKIPPING_CODES for e in parsed.errors
+ ):
+ # An unknown type/subtype drops only that node; the rest of the tree is
+ # intact, so keep it and let the validation passes report what else is
+ # wrong in the same load (cross-port: the TS loader does the same).
roots.append(parsed.root)
if roots:
diff --git a/server/python/tests/codegen/test_cli_config_providers.py b/server/python/tests/codegen/test_cli_config_providers.py
new file mode 100644
index 0000000000..260314a4cf
--- /dev/null
+++ b/server/python/tests/codegen/test_cli_config_providers.py
@@ -0,0 +1,160 @@
+"""A project's custom providers reach every metadata-loading CLI entry point.
+
+``providers: ["module:symbol"]`` in ``metaobjects.config.yaml`` and ``--provider
+module:symbol`` are two spellings of the same thing and must MERGE wherever the CLI loads
+metadata: ``gen``, ``verify`` (codegen, templates, the requirement gate and field lint)
+and ``fmt``.
+"""
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+
+import pytest
+
+from metaobjects.cli import main
+
+# The project's own vocabulary: a new layout.picker subtype + two attrs on field.string.
+VOCAB_MODULE = '''
+from metaobjects.meta.presentation.layout.meta_layout import MetaLayout
+from metaobjects.provider import Provider
+from metaobjects.registry import AttrSchema, ChildRule, TypeDefinition
+from metaobjects.shared.base_types import TYPE_ATTR
+
+vocab = Provider("acme-vocabulary", dependencies=("metaobjects-core-types",))
+vocab.add(TypeDefinition(
+ type="layout", sub_type="picker", factory=MetaLayout,
+ attrs=[AttrSchema("displayField", "string", required=False)],
+ child_rules=[ChildRule(TYPE_ATTR, "*")],
+))
+vocab.on_register(lambda registry: registry.extend(
+ "field", "string",
+ attributes=[
+ AttrSchema("searchable", "boolean", required=False),
+ AttrSchema("valueLabels", "string", required=False, is_array=True),
+ ],
+))
+'''
+
+# A second, independent provider, supplied on the command line.
+FLAG_MODULE = '''
+from metaobjects.meta.meta_data import MetaData
+from metaobjects.provider import Provider
+from metaobjects.registry import TypeDefinition
+
+flag_provider = Provider("acme-flag", ("metaobjects-core-types",))
+flag_provider.add(TypeDefinition(
+ type="validator", sub_type="geocheck",
+ factory=lambda t, s, n: MetaData(t, s, n), description="A custom validator",
+))
+'''
+
+CUSTOM_META = {
+ "metadata.root": {
+ "package": "acme",
+ "children": [
+ {
+ "object.value": {
+ "name": "Contact",
+ "children": [
+ {"field.string": {"name": "name", "@searchable": True}},
+ {"layout.picker": {"name": "contactPicker", "@displayField": "name"}},
+ ],
+ }
+ }
+ ],
+ }
+}
+
+
+@pytest.fixture
+def project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
+ meta = tmp_path / "metaobjects"
+ meta.mkdir()
+ (meta / "meta.contacts.json").write_text(json.dumps(CUSTOM_META), encoding="utf-8")
+ (tmp_path / "acme_vocab_mod.py").write_text(VOCAB_MODULE, encoding="utf-8")
+ (tmp_path / "acme_flag_mod.py").write_text(FLAG_MODULE, encoding="utf-8")
+ (tmp_path / "metaobjects.config.yaml").write_text(
+ 'metadata: metaobjects\nproviders: ["acme_vocab_mod:vocab"]\n'
+ "targets:\n app:\n outDir: gen\n generators: []\n",
+ encoding="utf-8",
+ )
+ monkeypatch.chdir(tmp_path)
+ saved = list(sys.path)
+ sys.path.insert(0, str(tmp_path)) # for the --provider (flag) module
+ yield tmp_path
+ sys.path[:] = saved
+ for name in ("acme_vocab_mod", "acme_flag_mod"):
+ sys.modules.pop(name, None)
+
+
+_SHARED_FIXTURE = (
+ Path(__file__).resolve().parents[4] / "fixtures" / "conformance" / "provider-custom-vocabulary-success"
+)
+
+
+def _with_geocheck(meta: dict) -> dict:
+ meta = json.loads(json.dumps(meta))
+ meta["metadata.root"]["children"][0]["object.value"]["children"][0]["field.string"][
+ "children"
+ ] = [{"validator.geocheck": {"name": "chk"}}]
+ return meta
+
+
+def _use_shared_fixture(project: Path) -> Path:
+ path = project / "metaobjects" / "meta.contacts.json"
+ meta = json.loads((_SHARED_FIXTURE / "input" / "meta.contacts.json").read_text(encoding="utf-8"))
+ path.write_text(json.dumps(meta), encoding="utf-8") # compact, not canonical
+ return path
+
+
+def test_fmt_honors_config_providers(project: Path) -> None:
+ path = _use_shared_fixture(project)
+ assert main(["fmt", "--check"]) == 1
+ assert main(["fmt"]) == 0
+ assert main(["fmt", "--check"]) == 0
+ assert json.loads(path.read_text(encoding="utf-8")) == json.loads(
+ (_SHARED_FIXTURE / "expected.json").read_text(encoding="utf-8")
+ )
+
+
+def test_fmt_rewrites_a_file_using_the_config_vocabulary(project: Path) -> None:
+ path = _use_shared_fixture(project)
+ assert main(["fmt"]) == 0
+ expected = (_SHARED_FIXTURE / "expected.json").read_text(encoding="utf-8")
+ assert path.read_text(encoding="utf-8").rstrip("\n") == expected.rstrip("\n")
+
+
+def test_fmt_without_config_providers_still_refuses_the_vocabulary(project: Path, capsys) -> None:
+ path = _use_shared_fixture(project)
+ before = path.read_text(encoding="utf-8")
+ (project / "metaobjects.config.yaml").unlink()
+ assert main(["fmt"]) == 1
+ captured = capsys.readouterr()
+ assert "ERR_UNKNOWN_SUBTYPE" in captured.out + captured.err
+ assert path.read_text(encoding="utf-8") == before
+
+
+def test_fmt_merges_config_and_flag_providers(project: Path) -> None:
+ path = project / "metaobjects" / "meta.contacts.json"
+ path.write_text(json.dumps(_with_geocheck(CUSTOM_META)), encoding="utf-8")
+ assert main(["fmt"]) == 1
+ assert main(["fmt", "--provider", "acme_flag_mod:flag_provider"]) == 0
+ assert json.loads(path.read_text(encoding="utf-8")) == _with_geocheck(CUSTOM_META)
+
+
+def test_verify_keeps_config_and_flag_providers(project: Path) -> None:
+ """Metadata needing BOTH the config provider's vocabulary and the flag provider's
+ validator loads clean through every verify gate (requirement gate + field lint)."""
+ (project / "metaobjects" / "meta.contacts.json").write_text(
+ json.dumps(_with_geocheck(CUSTOM_META)), encoding="utf-8"
+ )
+ assert main(["verify", "--provider", "acme_flag_mod:flag_provider"]) == 0
+
+
+def test_verify_without_the_flag_provider_fails_on_its_validator(project: Path) -> None:
+ (project / "metaobjects" / "meta.contacts.json").write_text(
+ json.dumps(_with_geocheck(CUSTOM_META)), encoding="utf-8"
+ )
+ assert main(["verify"]) != 0
diff --git a/server/python/tests/conformance/conformance_adapter.py b/server/python/tests/conformance/conformance_adapter.py
index f49f406da2..5ab97f2e02 100644
--- a/server/python/tests/conformance/conformance_adapter.py
+++ b/server/python/tests/conformance/conformance_adapter.py
@@ -65,6 +65,42 @@ def _example_template_briefing_provider() -> Provider:
return p
+def _example_custom_vocabulary_provider() -> Provider:
+ """Test-only: a project's own vocabulary (layout.picker + field.string attrs).
+
+ Mirrors the TS adapter's ``exampleCustomVocabularyProvider``: registers a new
+ ``layout.picker`` subtype (one optional ``displayField``) and extends
+ ``field.string`` with an optional boolean ``searchable`` and an optional
+ string-array ``valueLabels``. The ``provider-custom-vocabulary-*`` fixtures load
+ clean with it and are refused without it.
+ """
+ from metaobjects.meta.presentation.layout.meta_layout import MetaLayout
+
+ p = Provider("example-custom-vocabulary", dependencies=("metaobjects-core-types",))
+ p.add(
+ TypeDefinition(
+ type="layout",
+ sub_type="picker",
+ factory=MetaLayout,
+ attrs=[AttrSchema("displayField", "string", required=False)],
+ child_rules=[ChildRule(TYPE_ATTR, "*")],
+ )
+ )
+
+ def _extend(registry) -> None:
+ registry.extend(
+ "field",
+ "string",
+ attributes=[
+ AttrSchema("searchable", "boolean", required=False),
+ AttrSchema("valueLabels", "string", required=False, is_array=True),
+ ],
+ )
+
+ p.on_register(_extend)
+ return p
+
+
def _noop_provider(provider_id: str, *deps: str) -> Provider:
return Provider(provider_id, dependencies=tuple(deps))
@@ -96,6 +132,7 @@ def _noop_provider(provider_id: str, *deps: str) -> Provider:
"metaobjects-template": prompt_provider,
# Test-only — provider-extension-* fixtures.
"example-template-briefing": _example_template_briefing_provider(),
+ "example-custom-vocabulary": _example_custom_vocabulary_provider(),
"cycle-a": _noop_provider("cycle-a", "cycle-b"),
"cycle-b": _noop_provider("cycle-b", "cycle-a"),
"depends-on-missing": _noop_provider("depends-on-missing", "does-not-exist"),
diff --git a/server/typescript/packages/cli/src/commands/export.ts b/server/typescript/packages/cli/src/commands/export.ts
index fc9f1c3a34..7db145be2f 100644
--- a/server/typescript/packages/cli/src/commands/export.ts
+++ b/server/typescript/packages/cli/src/commands/export.ts
@@ -3,8 +3,9 @@ import { writeFile } from "node:fs/promises";
import { parseExportArgs } from "../lib/args.js";
import { log } from "../lib/log.js";
import { FileSource } from "@metaobjectsdev/metadata/core";
-import { TypeRegistry, registerCoreTypes, MetaDataLoader, canonicalSerialize } from "@metaobjectsdev/metadata";
-import { registerForgeTypes, resolveCollection } from "@metaobjectsdev/sdk";
+import { composeRegistry, MetaDataLoader, canonicalSerialize } from "@metaobjectsdev/metadata";
+import { defaultLoadMemoryProviders, forgeTypesProvider, resolveCollection } from "@metaobjectsdev/sdk";
+import { loadProjectProviders } from "../lib/load-metaobjects-config.js";
import { collectionLoadOptions } from "../lib/collection-load-options.js";
import { describeError } from "../lib/error-text.js";
@@ -19,12 +20,6 @@ export async function exportCommand(args: string[], cwd: string): Promise !base.some((b) => b.id === p.id))]);
+
// `loadAndExportJson` only accepts a scanned directory (`MetaDataLoader.fromDirectory`);
// `resolveCollection` already resolved the file SET (declared `sources`, or the
// `metaobjects/` default), so load that list directly via the same loader +
diff --git a/server/typescript/packages/cli/src/commands/prompt-snapshot.ts b/server/typescript/packages/cli/src/commands/prompt-snapshot.ts
index 230ae5ec5d..5578068869 100644
--- a/server/typescript/packages/cli/src/commands/prompt-snapshot.ts
+++ b/server/typescript/packages/cli/src/commands/prompt-snapshot.ts
@@ -61,17 +61,21 @@ export async function promptSnapshotCommand(args: string[], cwd: string): Promis
// or invalid, fall back to defaults — the loader still works for any
// metadata that only uses core+forge subtypes.
let configLoadOptions: ReturnType = {};
- try {
- // `metaobjects.config.ts` gets its own nearest-ancestor walk, not `projectRoot`:
- // it answers a different question from `.metaobjects/config.json` (design §4.6)
- // and in a Maven- or pip-rooted monorepo the two legitimately sit in different
- // directories, so reading it from the collection's would silently drop this
- // package's providers (#326). Same path whenever the two files sit together.
- configLoadOptions = loadMemoryOptionsFrom(
- await loadMetaobjectsConfig(resolveGenConfigDir(cwd, projectRoot)),
- );
- } catch {
- configLoadOptions = {};
+ // `metaobjects.config.ts` gets its own nearest-ancestor walk, not `projectRoot`:
+ // it answers a different question from `.metaobjects/config.json` (design §4.6)
+ // and in a Maven- or pip-rooted monorepo the two legitimately sit in different
+ // directories, so reading it from the collection's would silently drop this
+ // package's providers (#326). Same path whenever the two files sit together.
+ const genConfigDir = resolveGenConfigDir(cwd, projectRoot);
+ if (existsSync(join(genConfigDir, "metaobjects.config.ts"))) {
+ try {
+ configLoadOptions = loadMemoryOptionsFrom(await loadMetaobjectsConfig(genConfigDir));
+ } catch (err) {
+ // A config that exists but fails to load is reported as itself, not as the
+ // unknown-subtype error its providers would have prevented.
+ log.error(`metaobjects.config.ts failed to load: ${describeError(err)}`);
+ return 1;
+ }
}
let root;
diff --git a/server/typescript/packages/cli/src/commands/types.ts b/server/typescript/packages/cli/src/commands/types.ts
index 36f4a16648..8378583e3b 100644
--- a/server/typescript/packages/cli/src/commands/types.ts
+++ b/server/typescript/packages/cli/src/commands/types.ts
@@ -20,7 +20,8 @@
// The `--json` flag this once advertised in its own --help, twice, is not coming back: the
// CLI rejects a bare `--json` before a command ever sees its args (index.ts, `formatAlias`),
// on purpose — one global spelling for all three formats.
-import { composeRegistry, buildVocabularyCatalog } from "@metaobjectsdev/metadata";
+import { composeRegistry, buildVocabularyCatalog, type MetaDataTypeProvider } from "@metaobjectsdev/metadata";
+import { loadProjectProviders } from "../lib/load-metaobjects-config.js";
import { defaultLoadMemoryProviders } from "@metaobjectsdev/sdk";
import { log } from "../lib/log.js";
import { emitStructured, type OutputFormat } from "../lib/format.js";
@@ -257,7 +258,18 @@ export async function typesCommand(args: string[], fmt: OutputFormat = "text"):
// once in this list, and printing it as registered vocabulary was part of how a TS-only
// registration read as cross-port: `meta types forge --all` listed 20 attrs no other port
// has. One composition, one door.
- const registry = composeRegistry([...defaultLoadMemoryProviders], { validate: true });
+ // Plus the project's own providers when a `metaobjects.config.ts` governs this directory,
+ // so project vocabulary (`layout.picker`, extended attrs) is listed. No config: unchanged.
+ let projectProviders: readonly MetaDataTypeProvider[] = [];
+ try {
+ projectProviders = await loadProjectProviders(process.cwd(), process.cwd());
+ } catch (err) {
+ const msg = `metaobjects.config.ts failed to load: ${describeError(err)}`;
+ log.error(msg);
+ emitStructured({ error: msg, hint: "fix the config, or run outside the project for the built-in vocabulary" }, fmt);
+ return 1;
+ }
+ const registry = composeRegistry([...defaultLoadMemoryProviders, ...projectProviders], { validate: true });
const catalog = buildVocabularyCatalog(registry);
// Flatten the catalog into searchable entries.
diff --git a/server/typescript/packages/cli/src/commands/upgrade.ts b/server/typescript/packages/cli/src/commands/upgrade.ts
index 4e1bcbf95f..01e2a2b3b0 100644
--- a/server/typescript/packages/cli/src/commands/upgrade.ts
+++ b/server/typescript/packages/cli/src/commands/upgrade.ts
@@ -27,7 +27,9 @@ import {
coreProviders,
SUBTYPE_BASE,
retirementSuggestions,
+ type MetaDataTypeProvider,
} from "@metaobjectsdev/metadata";
+import { loadProjectProviders } from "../lib/load-metaobjects-config.js";
import { log } from "../lib/log.js";
import { describeError } from "../lib/error-text.js";
import { unknownFlagMessage } from "../lib/strict-args.js";
@@ -124,7 +126,18 @@ export async function upgradeCommand(args: string[], cwd: string): Promise id.type))]
diff --git a/server/typescript/packages/cli/src/commands/verify.ts b/server/typescript/packages/cli/src/commands/verify.ts
index 991aebeaea..4ed889e740 100644
--- a/server/typescript/packages/cli/src/commands/verify.ts
+++ b/server/typescript/packages/cli/src/commands/verify.ts
@@ -309,6 +309,16 @@ export async function verifyCommand(
forgeConfig = undefined;
configLoadError = e;
}
+ // A config that EXISTS but fails to load must be reported as itself in EVERY mode: continuing
+ // without its providers turns a config error into a misleading ERR_UNKNOWN_SUBTYPE on the
+ // custom vocabulary those providers register.
+ if (forgeConfig === undefined && existsSync(join(genConfigDir, "metaobjects.config.ts"))) {
+ log.error(
+ "meta verify: metaobjects.config.ts exists but failed to load: " +
+ (configLoadError instanceof Error ? configLoadError.message : String(configLoadError)),
+ );
+ return 2;
+ }
// Both of the gen config's contributions to the load, together — see
// `loadMemoryOptionsFrom`. Threading `providers` and forgetting `libraries` is how
// a shipped library became unloadable through the CLI (#333).
@@ -319,13 +329,7 @@ export async function verifyCommand(
// keeps a passing bare verify; the note says what was skipped and why.
if (!flags.anyExplicit) {
if (forgeConfig !== undefined) runCodegen = true;
- else if (existsSync(join(genConfigDir, "metaobjects.config.ts"))) {
- log.error(
- "meta verify: metaobjects.config.ts exists but failed to load, so the codegen gate cannot run: " +
- (configLoadError instanceof Error ? configLoadError.message : String(configLoadError)),
- );
- return 2;
- } else say("meta verify — codegen gate skipped: no metaobjects.config.ts found, so there is no generated output to diff against");
+ else say("meta verify — codegen gate skipped: no metaobjects.config.ts found, so there is no generated output to diff against");
}
// ADR-0023 strict-by-default (#96): verify loads strict unless --lax is passed,
diff --git a/server/typescript/packages/cli/src/lib/dependency-load.ts b/server/typescript/packages/cli/src/lib/dependency-load.ts
index 3cdf2488fc..26ec4ef22a 100644
--- a/server/typescript/packages/cli/src/lib/dependency-load.ts
+++ b/server/typescript/packages/cli/src/lib/dependency-load.ts
@@ -35,6 +35,7 @@ import {
type ResolvedDependency,
} from "@metaobjectsdev/sdk";
import { collectionLoadOptions } from "./collection-load-options.js";
+import { loadProjectProviders } from "./load-metaobjects-config.js";
/** `resolveCollection`'s code for "no `sources` declared and no default directory". */
const ERR_COLLECTION_NOT_FOUND = "ERR_COLLECTION_NOT_FOUND";
@@ -130,7 +131,14 @@ export async function proveDependencySetLoads(configDir: string): Promise 0 ? { providers } : {}) };
try {
await loadMemory(collection.configDir, loadOptions);
return { scope: "full" };
diff --git a/server/typescript/packages/cli/src/lib/fmt-engine.ts b/server/typescript/packages/cli/src/lib/fmt-engine.ts
index 48d6ed6862..0a165bd5ce 100644
--- a/server/typescript/packages/cli/src/lib/fmt-engine.ts
+++ b/server/typescript/packages/cli/src/lib/fmt-engine.ts
@@ -25,7 +25,7 @@ import {
} from "@metaobjectsdev/metadata";
import { FileSource } from "@metaobjectsdev/metadata/core";
import { resolveCollection, defaultLoadMemoryProviders, type Collection } from "@metaobjectsdev/sdk";
-import { loadMetaobjectsConfig, loadMemoryOptionsFrom } from "./load-metaobjects-config.js";
+import { loadProjectProviders } from "./load-metaobjects-config.js";
export type FmtFileStatus =
| "formatted"
@@ -57,18 +57,6 @@ export interface FmtRunOptions {
readonly files?: readonly string[];
}
-async function projectProviders(collection: Collection): Promise {
- try {
- const cfg = await loadMetaobjectsConfig(collection.configDir);
- return loadMemoryOptionsFrom(cfg).providers ?? [];
- } catch {
- // No metaobjects.config.ts (common for a non-TS-server project running
- // `meta fmt` just for schema-neutral formatting) — format against the
- // core vocabulary only, exactly like a project with no custom providers.
- return [];
- }
-}
-
export async function runFmt(opts: FmtRunOptions): Promise {
let collection: Collection;
try {
@@ -77,7 +65,14 @@ export async function runFmt(opts: FmtRunOptions): Promise {
return { files: [], fatal: err instanceof Error ? err.message : String(err) };
}
- const providers = await projectProviders(collection);
+ // Nearest-ancestor config (like every other command); a config that exists but fails
+ // to load is fatal rather than silently "no providers".
+ let providers: readonly MetaDataTypeProvider[];
+ try {
+ providers = await loadProjectProviders(opts.projectRoot, collection.configDir);
+ } catch (err) {
+ return { files: [], fatal: `metaobjects.config.ts failed to load: ${err instanceof Error ? err.message : String(err)}` };
+ }
const registry = composeRegistry([...defaultLoadMemoryProviders, ...providers]);
let targets: readonly string[];
diff --git a/server/typescript/packages/cli/src/lib/load-metaobjects-config.ts b/server/typescript/packages/cli/src/lib/load-metaobjects-config.ts
index 848b1e4c30..606dc55fc7 100644
--- a/server/typescript/packages/cli/src/lib/load-metaobjects-config.ts
+++ b/server/typescript/packages/cli/src/lib/load-metaobjects-config.ts
@@ -309,6 +309,23 @@ export async function resolveGenCollection(
}
}
+/**
+ * The custom providers a project's `metaobjects.config.ts` contributes, for a command run
+ * from `startDir` (`fallback` = the collection's directory, as for `resolveGenConfigDir`).
+ *
+ * Absent config: `[]` (core vocabulary only). A config that EXISTS but fails to load
+ * THROWS, so the caller reports the real error instead of a downstream
+ * `ERR_UNKNOWN_SUBTYPE` that the config's own providers would have prevented.
+ */
+export async function loadProjectProviders(
+ startDir: string,
+ fallback: string,
+): Promise {
+ const dir = resolveGenConfigDir(startDir, fallback);
+ if (!existsSync(join(dir, CONFIG_FILE))) return [];
+ return loadMemoryOptionsFrom(await loadMetaobjectsConfig(dir)).providers ?? [];
+}
+
/**
* A `metaobjects.config.ts` that still sets column naming (ADR-0058). Its own class because
* the commands that tolerate a missing or broken config (`meta migrate` falls back to
diff --git a/server/typescript/packages/cli/test/config-providers-everywhere.test.ts b/server/typescript/packages/cli/test/config-providers-everywhere.test.ts
new file mode 100644
index 0000000000..2901644e86
--- /dev/null
+++ b/server/typescript/packages/cli/test/config-providers-everywhere.test.ts
@@ -0,0 +1,203 @@
+import { describe, test, expect, afterAll, spyOn } from "bun:test";
+import { mkdtemp, rm, mkdir, writeFile, readFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { exportCommand } from "../src/commands/export.js";
+import { typesCommand } from "../src/commands/types.js";
+import { verifyCommand } from "../src/commands/verify.js";
+import { runFmt } from "../src/lib/fmt-engine.js";
+import { proveDependencySetLoads } from "../src/lib/dependency-load.js";
+
+// A project's own `metaobjects.config.ts` providers must reach EVERY command that loads
+// metadata, not just gen/migrate. The vocabulary mirrors the shared conformance fixture
+// `provider-custom-vocabulary-success`: a new subtype (`layout.picker @displayField`) and
+// two attrs added to `field.string` (`@searchable`, `@valueLabels`).
+
+const dirs: string[] = [];
+afterAll(async () => {
+ for (const d of dirs) await rm(d, { recursive: true, force: true });
+});
+
+const PROVIDER_SRC = [
+ `import { TypeId, MetaLayout } from "@metaobjectsdev/metadata";`,
+ `import { TYPE_FIELD, TYPE_LAYOUT, FIELD_SUBTYPE_STRING, ATTR_SUBTYPE_STRING, ATTR_SUBTYPE_BOOLEAN } from "@metaobjectsdev/metadata/constants";`,
+ `const vocab = {`,
+ ` id: "test-custom-vocabulary",`,
+ ` dependencies: ["metaobjects-core-types"],`,
+ ` registerTypes(registry) {`,
+ ` registry.register({`,
+ ` typeId: new TypeId(TYPE_LAYOUT, "picker"),`,
+ ` description: "A picker over the owning object.",`,
+ ` factory: (typeId, name) => new MetaLayout(typeId, name),`,
+ ` childRules: [],`,
+ ` attributes: [{ name: "displayField", valueType: ATTR_SUBTYPE_STRING, required: false, description: "Field shown per choice." }],`,
+ ` });`,
+ ` registry.extend(TYPE_FIELD, FIELD_SUBTYPE_STRING, {`,
+ ` attributes: [`,
+ ` { name: "searchable", valueType: ATTR_SUBTYPE_BOOLEAN, required: false, description: "Offer in search." },`,
+ ` { name: "valueLabels", valueType: ATTR_SUBTYPE_STRING, isArray: true, required: false, description: "Display labels." },`,
+ ` ],`,
+ ` });`,
+ ` },`,
+ `};`,
+].join("\n");
+
+const GOOD_CONFIG = [
+ `import { defineConfig } from "@metaobjectsdev/codegen-ts";`,
+ PROVIDER_SRC,
+ `export default defineConfig({ outDir: "out", dialect: "postgres", generators: [], providers: [vocab] });`,
+].join("\n");
+
+const BROKEN_CONFIG = `export default (() => { throw new Error("boom-from-config"); })();\n`;
+
+const META = JSON.stringify({
+ "metadata.root": {
+ package: "examples",
+ children: [
+ {
+ "object.value": {
+ name: "Contact",
+ children: [
+ { "field.string": { name: "name", "@searchable": true } },
+ { "field.string": { name: "status", "@valueLabels": ["Active", "On hold"] } },
+ { "layout.picker": { name: "contactPicker", "@displayField": "name" } },
+ ],
+ },
+ },
+ ],
+ },
+});
+
+async function project(config: string | undefined): Promise {
+ const root = await mkdtemp(join(tmpdir(), "mts-cfgprov-"));
+ dirs.push(root);
+ await mkdir(join(root, "metaobjects"), { recursive: true });
+ await writeFile(join(root, "metaobjects", "meta.app.json"), META, "utf8");
+ if (config !== undefined) await writeFile(join(root, "metaobjects.config.ts"), config, "utf8");
+ return root;
+}
+
+async function captured(fn: () => Promise): Promise<{ result: T; err: string; out: string }> {
+ const errs: string[] = [];
+ const outs: string[] = [];
+ const e = spyOn(console, "error").mockImplementation((...a: unknown[]) => { errs.push(a.map(String).join(" ")); });
+ const l = spyOn(console, "log").mockImplementation((...a: unknown[]) => { outs.push(a.map(String).join(" ")); });
+ try {
+ return { result: await fn(), err: errs.join("\n"), out: outs.join("\n") };
+ } finally {
+ e.mockRestore();
+ l.mockRestore();
+ }
+}
+
+describe("config providers reach every metadata-loading command", () => {
+ test("export loads custom vocabulary; without the provider it is refused", async () => {
+ const root = await project(GOOD_CONFIG);
+ const out = join(root, "export.json");
+ const { result } = await captured(() => exportCommand(["--out", out], root));
+ expect(result).toBe(0);
+ expect(await readFile(out, "utf8")).toContain("layout.picker");
+
+ const bare = await project(undefined);
+ const { result: bareCode } = await captured(() => exportCommand(["--out", join(bare, "x.json")], bare));
+ expect(bareCode).toBe(1);
+ });
+
+ test("export accepts a config that opts into forge vocabulary itself", async () => {
+ const config = [
+ `import { defineConfig } from "@metaobjectsdev/codegen-ts";`,
+ `import { forgeTypesProvider } from ${JSON.stringify(join(import.meta.dir, "..", "..", "sdk", "src", "index.ts"))};`,
+ PROVIDER_SRC,
+ `export default defineConfig({ outDir: "out", dialect: "postgres", generators: [], providers: [forgeTypesProvider, vocab] });`,
+ ].join("\n");
+ const root = await project(config);
+ const out = join(root, "export.json");
+ const { result, err } = await captured(() => exportCommand(["--out", out], root));
+ expect(err).not.toContain("ERR_PROVIDER_DUPLICATE_ID");
+ expect(result).toBe(0);
+ expect(await readFile(out, "utf8")).toContain("layout.picker");
+ });
+
+ test("export surfaces a broken config as itself", async () => {
+ const root = await project(BROKEN_CONFIG);
+ const { result, err } = await captured(() => exportCommand([], root));
+ expect(result).toBe(1);
+ expect(err).toContain("boom-from-config");
+ });
+
+ test("meta deps proof loads the full set with the provider (not 'port-only vocabulary')", async () => {
+ const root = await project(GOOD_CONFIG);
+ expect((await proveDependencySetLoads(root)).scope).toBe("full");
+ });
+
+ test("meta fmt finds the config in an ancestor of the collection and in-place", async () => {
+ const root = await project(GOOD_CONFIG);
+ const r = await runFmt({ projectRoot: root, check: true });
+ expect(r.fatal).toBeUndefined();
+ expect(r.files.every((f) => f.status !== "error")).toBe(true);
+ });
+
+ test("meta fmt run from a sub-project reads that sub-project's config, not the collection's", async () => {
+ // The collection (.metaobjects/config.json + metadata) sits at the repo root; the
+ // TypeScript config sits in a package below it. Reading the config from the
+ // collection's directory (the old behaviour) never saw it.
+ const root = await project(undefined);
+ await mkdir(join(root, ".git"), { recursive: true });
+ await mkdir(join(root, ".metaobjects"), { recursive: true });
+ await writeFile(join(root, ".metaobjects", "config.json"), JSON.stringify({ schema_version: 1 }), "utf8");
+ const app = join(root, "app");
+ await mkdir(app, { recursive: true });
+ await writeFile(join(app, "metaobjects.config.ts"), GOOD_CONFIG, "utf8");
+ const r = await runFmt({ projectRoot: app, check: true });
+ expect(r.fatal).toBeUndefined();
+ expect(r.files.length).toBeGreaterThan(0);
+ expect(r.files.every((f) => f.status !== "error")).toBe(true);
+ });
+
+ test("meta fmt reports a broken config instead of dropping providers", async () => {
+ const root = await project(BROKEN_CONFIG);
+ const r = await runFmt({ projectRoot: root, check: true });
+ expect(r.fatal).toContain("boom-from-config");
+ });
+
+ test("meta types lists project vocabulary when a config is present", async () => {
+ const root = await project(GOOD_CONFIG);
+ const prev = process.cwd();
+ process.chdir(root);
+ try {
+ const { result, out } = await captured(() => typesCommand(["layout.picker"]));
+ expect(result).toBe(0);
+ expect(out).toContain("layout.picker");
+ const attrs = await captured(() => typesCommand(["searchable"]));
+ expect(attrs.out).toContain("searchable");
+ } finally {
+ process.chdir(prev);
+ }
+ });
+
+ test("meta types is unchanged with no config", async () => {
+ const root = await project(undefined);
+ const prev = process.cwd();
+ process.chdir(root);
+ try {
+ const { out } = await captured(() => typesCommand(["layout.picker"]));
+ expect(out).not.toContain("layout.picker ");
+ expect(out.toLowerCase()).toContain("no vocabulary matches");
+ } finally {
+ process.chdir(prev);
+ }
+ });
+
+ test("strict verify passes custom vocabulary with the provider and surfaces a broken config", async () => {
+ const root = await project(GOOD_CONFIG);
+ const ok = await captured(() => verifyCommand(["--templates"], root));
+ expect(ok.err).not.toContain("ERR_UNKNOWN");
+ expect(ok.result).toBe(0);
+
+ const broken = await project(BROKEN_CONFIG);
+ const bad = await captured(() => verifyCommand(["--templates"], broken));
+ expect(bad.result).toBe(2);
+ expect(bad.err).toContain("boom-from-config");
+ expect(bad.err).not.toContain("ERR_UNKNOWN_SUBTYPE");
+ });
+});
diff --git a/server/typescript/packages/metadata/test/conformance/adapter.ts b/server/typescript/packages/metadata/test/conformance/adapter.ts
index b5f1bdc4a8..77991fbe4e 100644
--- a/server/typescript/packages/metadata/test/conformance/adapter.ts
+++ b/server/typescript/packages/metadata/test/conformance/adapter.ts
@@ -26,9 +26,11 @@ import { canonicalSerialize, canonicalSerializeEffective } from "../../src/seria
import { ParseError } from "../../src/errors.js";
import { TypeId } from "../../src/registry.js";
import { MetaTemplate } from "../../src/template/meta-template.js";
-import { TYPE_TEMPLATE, TYPE_ATTR } from "../../src/shared/base-types.js";
+import { TYPE_TEMPLATE, TYPE_ATTR, TYPE_FIELD, TYPE_LAYOUT } from "../../src/shared/base-types.js";
import { CHILD_RULE_WILDCARD } from "../../src/shared/structural.js";
-import { ATTR_SUBTYPE_STRING } from "../../src/core/attr/attr-constants.js";
+import { ATTR_SUBTYPE_BOOLEAN, ATTR_SUBTYPE_STRING } from "../../src/core/attr/attr-constants.js";
+import { MetaLayout } from "../../src/presentation/layout/meta-layout.js";
+import { FIELD_SUBTYPE_STRING } from "../../src/core/field/field-constants.js";
import { navigate } from "./navigator.js";
import { binding } from "./binding.js";
@@ -76,6 +78,37 @@ const exampleTemplateBriefingProvider: MetaDataTypeProvider = {
},
};
+/**
+ * The project-vocabulary case (provider-custom-vocabulary-* fixtures): a
+ * downstream project's own provider that REGISTERS one new subtype
+ * (`layout.picker`, carrying its own `@displayField`) and EXTENDS an existing
+ * type (`field.string`) with two optional attrs (`@searchable`,
+ * `@valueLabels`). Every port carries the same provider, written in its own
+ * language, so the same metadata loads clean with it and is refused without it.
+ */
+const exampleCustomVocabularyProvider: MetaDataTypeProvider = {
+ id: "example-custom-vocabulary",
+ dependencies: ["metaobjects-core-types"],
+ description: "Test-only — a project's own vocabulary: layout.picker + field.string attrs.",
+ registerTypes(registry) {
+ registry.register({
+ typeId: new TypeId(TYPE_LAYOUT, "picker"),
+ description: "A picker that chooses one row of the owning object — test-only.",
+ factory: (typeId, name) => new MetaLayout(typeId, name),
+ childRules: [],
+ attributes: [
+ { name: "displayField", valueType: ATTR_SUBTYPE_STRING, required: false, description: "Field shown for each choice." },
+ ],
+ });
+ registry.extend(TYPE_FIELD, FIELD_SUBTYPE_STRING, {
+ attributes: [
+ { name: "searchable", valueType: ATTR_SUBTYPE_BOOLEAN, required: false, description: "Offer this field in search." },
+ { name: "valueLabels", valueType: ATTR_SUBTYPE_STRING, isArray: true, required: false, description: "Display labels for the field's values." },
+ ],
+ });
+ },
+};
+
/** Two providers that name each other as a dependency — surfaces ERR_PROVIDER_DEPENDENCY_CYCLE. */
const cycleAProvider: MetaDataTypeProvider = {
id: "cycle-a",
@@ -126,6 +159,7 @@ export const PROVIDERS: Readonly> = {
[uiProvider.id]: uiProvider, // "metaobjects-ui" (filter/sort field markers)
// Test-only — provider-extension-* fixtures.
"example-template-briefing": exampleTemplateBriefingProvider,
+ "example-custom-vocabulary": exampleCustomVocabularyProvider,
"cycle-a": cycleAProvider,
"cycle-b": cycleBProvider,
"depends-on-missing": dependsOnMissingProvider,
diff --git a/server/typescript/packages/sdk/src/config.ts b/server/typescript/packages/sdk/src/config.ts
index 40d127843d..ae2e385592 100644
--- a/server/typescript/packages/sdk/src/config.ts
+++ b/server/typescript/packages/sdk/src/config.ts
@@ -239,6 +239,16 @@ export const ConfigSchema = z.object({
csharp: z.strictObject({
outDir: z.string().min(1).optional(),
namespace: z.string().min(1).optional(),
+ /**
+ * The project's own metadata providers: each names a BUILT .NET assembly (a path relative
+ * to the directory holding `.metaobjects/`) and the provider's full type name. Only the C#
+ * port reads this; the TypeScript port validates it so the shared file loads, and takes
+ * its providers from `defineConfig({ providers })` in `metaobjects.config.ts` instead.
+ */
+ providers: z.array(z.strictObject({
+ assembly: z.string().min(1),
+ type: z.string().min(1),
+ })).optional(),
}).optional(),
}).strict();
diff --git a/server/typescript/packages/sdk/test/config.test.ts b/server/typescript/packages/sdk/test/config.test.ts
index 2464517e85..0d964d4531 100644
--- a/server/typescript/packages/sdk/test/config.test.ts
+++ b/server/typescript/packages/sdk/test/config.test.ts
@@ -284,3 +284,19 @@ describe("csharp.outDir (the C# port's generated-code directory)", () => {
expect(ConfigSchema.safeParse({ schema_version: 1, csharp: { outDir: "" } }).success).toBe(false);
});
});
+
+describe("csharp.providers (read by the C# port only)", () => {
+ test("a config carrying provider assemblies validates", () => {
+ const ok = ConfigSchema.safeParse({
+ schema_version: 1,
+ csharp: { providers: [{ assembly: "bin/Debug/net8.0/Acme.Vocabulary.dll", type: "Acme.Vocabulary.AcmeProvider" }] },
+ });
+ expect(ok.success).toBe(true);
+ });
+ test("rejects an empty field, a missing field, and an unknown key", () => {
+ const bad = (p: unknown) => ConfigSchema.safeParse({ schema_version: 1, csharp: { providers: p } }).success;
+ expect(bad([{ assembly: "", type: "A.B" }])).toBe(false);
+ expect(bad([{ assembly: "a.dll" }])).toBe(false);
+ expect(bad([{ assembly: "a.dll", type: "A.B", extra: 1 }])).toBe(false);
+ });
+});