manifestSliceForPort(JsonNode manifest) {
}
}
}
- if (includesPort) {
+ // A name the shared file expects here and lists as a pending census gap is the
+ // eject contract's to report (EjectContractTest), which fails once it is registered.
+ if (includesPort && !pending.contains(name)) {
Tier tier = "neutral".equals(entry.path("tier").asText("native"))
? Tier.NEUTRAL : Tier.NATIVE;
slice.put(name, tier);
diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/EjectedGeneratorsCompileTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/EjectedGeneratorsCompileTest.java
index c45b20a48..55cd42079 100644
--- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/EjectedGeneratorsCompileTest.java
+++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/EjectedGeneratorsCompileTest.java
@@ -1,5 +1,6 @@
package com.metaobjects.generator.spring;
+import com.metaobjects.generator.GeneratorRegistry;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
@@ -28,8 +29,7 @@
* classes plus its declared dependencies — exactly what an adopter's new {@code codegen/}
* module depends on after eject). See {@code docs/superpowers/specs/
* 2026-09-22-eject-in-every-port-design.md} (JVM section) and {@code
- * com.metaobjects.generator.GeneratorRegistry}'s {@code ejectPath(...)} calls, whose
- * classnames this list mirrors.
+ * com.metaobjects.generator.GeneratorRegistry}, which this test reads its list from.
*
* This reads straight off {@code src/main/java} rather than through the Maven eject
* mojo (which lives in a downstream module, {@code maven-plugin}, that depends on THIS one —
@@ -43,20 +43,15 @@ public class EjectedGeneratorsCompileTest {
@Rule
public TemporaryFolder tempFolder = new TemporaryFolder();
- /** Every stable name codegen-spring ships as an ejectable reference (GeneratorRegistry). */
- private static final List EJECTABLE_SIMPLE_NAMES = List.of(
- "SpringControllerGenerator",
- "SpringOutputParserGenerator",
- "SpringOutputPromptGenerator",
- "SpringRenderHelperGenerator",
- "SpringFilterAllowlistGenerator",
- "SpringRepositoryGenerator",
- "SpringDtoGenerator",
- "SpringValueObjectGenerator",
- "LlmTraceHelperGenerator",
- "SpringNamesGenerator",
- "JUnitRequirementTestsGenerator"
- );
+ /** Every reference this module ships, read off the registry so the list cannot fall
+ * behind it: the ejectable generators whose class lives in this package. */
+ private static final List EJECTABLE_SIMPLE_NAMES = GeneratorRegistry.list().values().stream()
+ .filter(info -> info.ejectResourcePath() != null)
+ .map(GeneratorRegistry.GeneratorInfo::classname)
+ .filter(name -> name.startsWith(GeneratorRegistry.SUPPORT_PACKAGE + ".")
+ && name.lastIndexOf('.') == GeneratorRegistry.SUPPORT_PACKAGE.length())
+ .map(name -> name.substring(name.lastIndexOf('.') + 1))
+ .collect(java.util.stream.Collectors.toList());
private static final Pattern PACKAGE_LINE = Pattern.compile("(?m)^package\\s+[\\w.]+\\s*;\\s*$");
diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/NoMagicPhysicalNamesTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/NoMagicPhysicalNamesTest.java
index 7a6f1fcbe..7a669645c 100644
--- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/NoMagicPhysicalNamesTest.java
+++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/NoMagicPhysicalNamesTest.java
@@ -4,6 +4,7 @@
import com.metaobjects.generator.EmitsPhysicalNameConstants;
import com.metaobjects.generator.Generator;
import com.metaobjects.generator.GeneratorRegistry;
+import com.metaobjects.generator.direct.MultiFileDirectGeneratorBase;
import com.metaobjects.generator.GeneratorRegistryConformanceTest;
import com.metaobjects.loader.MetaDataLoader;
import org.junit.Rule;
@@ -465,7 +466,13 @@ private Map generate() throws Exception {
Map runArgs = EmitsObjectExtractors.deriveEmitExtractors(
EmitsPhysicalNameConstants.deriveUseNames(args, suite), suite);
for (Generator g : suite) {
- g.setArgs(new HashMap<>(runArgs));
+ Map own = new HashMap<>(runArgs);
+ // A generator that writes ONE file is told its name; a per-object generator
+ // given one would write every object into it.
+ if (!(g instanceof MultiFileDirectGeneratorBase)) {
+ own.put("outputFilename", g.getClass().getSimpleName() + ".out");
+ }
+ g.setArgs(own);
g.execute(loader);
}
diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/ReportingInertTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/ReportingInertTest.java
index 840ef8209..9bd9e6970 100644
--- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/ReportingInertTest.java
+++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/ReportingInertTest.java
@@ -1,5 +1,6 @@
package com.metaobjects.generator.spring;
+import com.metaobjects.generator.direct.MultiFileDirectGeneratorBase;
import com.metaobjects.generator.Generator;
import com.metaobjects.generator.GeneratorRegistry;
import com.metaobjects.generator.GeneratorRegistry.GeneratorInfo;
@@ -110,6 +111,11 @@ private Map emit(String variant, List suite) thro
args.put("type", "class");
args.put("flavor", "pojoAware");
}
+ // A generator that writes ONE file is told its name; a per-object generator
+ // given one would write every object into it.
+ if (!(gen instanceof MultiFileDirectGeneratorBase)) {
+ args.put("outputFilename", gen.getClass().getSimpleName() + ".out");
+ }
gen.setArgs(args).execute(loader);
}
} catch (Exception | LinkageError e) {
diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectContractTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectContractTest.java
new file mode 100644
index 000000000..9d843c221
--- /dev/null
+++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectContractTest.java
@@ -0,0 +1,551 @@
+package com.metaobjects.mojo;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.metaobjects.generator.Generator;
+import com.metaobjects.generator.GeneratorRegistry;
+import com.metaobjects.generator.ProjectClassLoaderAware;
+import com.metaobjects.loader.DirectorySource;
+import com.metaobjects.loader.LoaderOptions;
+import com.metaobjects.loader.MetaDataLoader;
+import com.metaobjects.loader.uri.URIHelper;
+import org.apache.maven.project.MavenProject;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.mockito.Mockito;
+
+import javax.tools.Diagnostic;
+import javax.tools.DiagnosticCollector;
+import javax.tools.JavaCompiler;
+import javax.tools.JavaFileObject;
+import javax.tools.StandardJavaFileManager;
+import javax.tools.StandardLocation;
+import javax.tools.ToolProvider;
+import java.io.ByteArrayInputStream;
+import java.io.DataInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.lang.reflect.Modifier;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
+import java.util.jar.JarFile;
+import java.util.jar.Manifest;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * The eject contract, asserted for the Java port (ADR-0034 Amendment 8).
+ *
+ * Every stock generator can be ejected, and the owned copy holds its emit. Three assertions,
+ * each driven by {@code fixtures/generator-registry-conformance/registry.json}:
+ *
+ * - Census. Every concrete {@link Generator} on this plugin's classpath belongs to a
+ * registry entry, and the registry exposes exactly the names the shared file gives this
+ * port.
+ * - Completeness. Every registered generator ships eject source unless the shared
+ * file exempts it with a reason, and the reference sources the jars ship are exactly the
+ * ones the registry names.
+ * - Depth. Each generator, ejected ALONE by the real mojo into an empty project,
+ * compiles; references nothing in the generator packages outside the engine surface; and
+ * emits what the packaged generator emits.
+ *
+ * Where the contract does not hold yet, the shared file's {@code pending} list says so. A
+ * pending entry that passes fails here, and so does a gap that is not listed.
+ *
+ * It lives in this module because this is where the eject mojo is, and the only module that
+ * sees every codegen module at once.
+ */
+public class EjectContractTest {
+
+ private static final String PORT = "java";
+ private static final String OWNED_PACKAGE = "com.acme.codegen";
+ private static final String OWNED_RUNTIME_PACKAGE = "com.acme.runtime";
+
+ @Rule
+ public TemporaryFolder tempFolder = new TemporaryFolder();
+
+ // ---------------------------------------------------------------------------------
+ // the shared file
+ // ---------------------------------------------------------------------------------
+
+ private static Path repoRoot() {
+ for (Path p = Path.of("").toAbsolutePath(); p != null; p = p.getParent()) {
+ if (Files.isDirectory(p.resolve("fixtures")) && Files.isDirectory(p.resolve("server"))) return p;
+ }
+ throw new IllegalStateException("could not locate the repo root");
+ }
+
+ private static final Path MANIFEST_PATH =
+ repoRoot().resolve("fixtures/generator-registry-conformance/registry.json");
+
+ private static JsonObject manifest() {
+ try {
+ return JsonParser.parseString(Files.readString(MANIFEST_PATH, StandardCharsets.UTF_8)).getAsJsonObject();
+ } catch (IOException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ private static List strings(JsonElement array) {
+ List out = new ArrayList<>();
+ for (JsonElement e : array.getAsJsonArray()) out.add(e.getAsString());
+ return out;
+ }
+
+ /** The generators the shared file expects this port to expose. */
+ private static Set portSlice() {
+ Set out = new TreeSet<>();
+ for (Map.Entry g : manifest().getAsJsonObject("generators").entrySet()) {
+ if (strings(g.getValue().getAsJsonObject().get("ports")).contains(PORT)) out.add(g.getKey());
+ }
+ return out;
+ }
+
+ private static boolean isExempt(String name) {
+ return manifest().getAsJsonObject("generators").getAsJsonObject(name).get("eject").isJsonObject();
+ }
+
+ private static JsonArray allPending() {
+ return manifest().getAsJsonObject("eject").getAsJsonArray("pending");
+ }
+
+ /** This port's pending entries for one assertion: generator to the union of what its
+ * entries list in {@code outside} (one entry per gap; empty for census and completeness). */
+ private static Map> pending(String assertion) {
+ Map> out = new TreeMap<>();
+ for (JsonElement e : allPending()) {
+ JsonObject p = e.getAsJsonObject();
+ if (!PORT.equals(p.get("port").getAsString()) || !assertion.equals(p.get("assertion").getAsString())) continue;
+ Set outside = out.computeIfAbsent(p.get("generator").getAsString(), k -> new TreeSet<>());
+ if (p.has("outside")) outside.addAll(strings(p.get("outside")));
+ }
+ return out;
+ }
+
+ /** {@code **} matches any run of characters, {@code *} one dotted segment. */
+ private static Pattern glob(String pattern) {
+ StringBuilder regex = new StringBuilder();
+ Matcher m = Pattern.compile("\\*\\*|\\*|[^*]+").matcher(pattern);
+ while (m.find()) {
+ String part = m.group();
+ regex.append(part.equals("**") ? ".*" : part.equals("*") ? "[^.]*" : Pattern.quote(part));
+ }
+ return Pattern.compile(regex.toString());
+ }
+
+ private static List surface(String key) {
+ return strings(manifest().getAsJsonObject("eject").getAsJsonObject("engineSurface")
+ .getAsJsonObject(PORT).get(key));
+ }
+
+ /** True for a class that writes output: inside the generator packages, and not engine. */
+ private static boolean outsideEngineSurface(String className) {
+ return surface("codegenPackages").stream().anyMatch(p -> glob(p).matcher(className).matches())
+ && surface("engine").stream().noneMatch(p -> glob(p).matcher(className).matches());
+ }
+
+ // ---------------------------------------------------------------------------------
+ // the classpath
+ // ---------------------------------------------------------------------------------
+
+ /** Every classpath entry, with a launcher jar's manifest {@code Class-Path} expanded. */
+ private static List classpath() throws IOException {
+ List out = new ArrayList<>();
+ for (String entry : System.getProperty("java.class.path").split(File.pathSeparator)) {
+ Path path = Path.of(entry);
+ out.add(path);
+ if (!Files.isRegularFile(path)) continue;
+ try (JarFile jar = new JarFile(path.toFile())) {
+ Manifest mf = jar.getManifest();
+ String classPath = mf == null ? null : mf.getMainAttributes().getValue("Class-Path");
+ if (classPath == null) continue;
+ for (String url : classPath.trim().split("\\s+")) {
+ out.add(url.startsWith("file:") ? Path.of(java.net.URI.create(url)) : path.resolveSibling(url));
+ }
+ }
+ }
+ return out;
+ }
+
+ /** Every resource path on the classpath under {@code prefix} (a directory or a jar). */
+ private static Set resourcesUnder(String prefix) throws IOException {
+ Set out = new TreeSet<>();
+ for (Path entry : classpath()) {
+ if (Files.isDirectory(entry)) {
+ Path root = entry.resolve(prefix);
+ if (!Files.isDirectory(root)) continue;
+ try (Stream s = Files.walk(root)) {
+ s.filter(Files::isRegularFile)
+ .forEach(p -> out.add(entry.relativize(p).toString().replace(File.separatorChar, '/')));
+ }
+ } else if (Files.isRegularFile(entry) && entry.toString().endsWith(".jar")) {
+ try (JarFile jar = new JarFile(entry.toFile())) {
+ jar.stream().filter(e -> !e.isDirectory() && e.getName().startsWith(prefix))
+ .forEach(e -> out.add(e.getName()));
+ }
+ }
+ }
+ return out;
+ }
+
+ private static final String GENERATOR_PACKAGE_PATH = "com/metaobjects/";
+
+ /** Every concrete {@link Generator} class the plugin's classpath holds, test classes aside. */
+ private static Set generatorClasses() throws Exception {
+ Set out = new TreeSet<>();
+ for (String resource : resourcesUnder(GENERATOR_PACKAGE_PATH)) {
+ if (!resource.endsWith(".class") || resource.contains("$")) continue;
+ String name = resource.substring(0, resource.length() - ".class".length()).replace('/', '.');
+ Class> c;
+ try {
+ c = Class.forName(name, false, EjectContractTest.class.getClassLoader());
+ } catch (LinkageError | ClassNotFoundException e) {
+ continue; // an optional dependency this classpath does not carry
+ }
+ URL from = c.getProtectionDomain().getCodeSource().getLocation();
+ if (from.toString().contains("test-classes")) continue;
+ if (Generator.class.isAssignableFrom(c) && !c.isInterface() && !Modifier.isAbstract(c.getModifiers())) {
+ out.add(name);
+ }
+ }
+ return out;
+ }
+
+ // ---------------------------------------------------------------------------------
+ // census
+ // ---------------------------------------------------------------------------------
+
+ private static List javaEntries() {
+ return EjectSupport.allEntries().stream()
+ .filter(e -> e.port == EjectSupport.Port.JAVA).collect(Collectors.toList());
+ }
+
+ @Test
+ public void censusEveryGeneratorClassBelongsToARegistryEntry() throws Exception {
+ Set registered = new TreeSet<>();
+ for (EjectSupport.Entry e : EjectSupport.allEntries()) {
+ // A generator's bases and writers are copied with it, and some are generators.
+ if (e.classname != null) registered.add(e.classname);
+ registered.addAll(e.sources());
+ }
+ // Kotlin names its classes by constructor, so its registry is asked for instances.
+ com.metaobjects.generator.kotlin.GeneratorRegistryKt.getGENERATOR_REGISTRY().values()
+ .forEach(info -> registered.add(info.getFactory().invoke().getClass().getName()));
+
+ Set found = generatorClasses();
+ assertTrue("the classpath scan found too few generators to be believed: " + found, found.size() > 20);
+ Set unregistered = new TreeSet<>(found);
+ unregistered.removeAll(registered);
+ assertTrue("generator classes no registry entry names, so no catalog, eject goal or test sees them: "
+ + unregistered + ". Register each in its port's GeneratorRegistry and in " + MANIFEST_PATH + ".",
+ unregistered.isEmpty());
+ }
+
+ @Test
+ public void censusTheRegistryExposesTheNamesTheSharedFileGivesThisPort() {
+ Set expected = portSlice();
+ Set actual = new TreeSet<>(GeneratorRegistry.list().keySet());
+ Set extra = new TreeSet<>(actual);
+ extra.removeAll(expected);
+ assertEquals("registered, but the shared file does not list it for java", Set.of(), extra);
+ Set missing = new TreeSet<>(expected);
+ missing.removeAll(actual);
+ assertEquals("expected in the Java registry by the shared file; a pending census entry must list each",
+ pending("census").keySet(), missing);
+ }
+
+ // ---------------------------------------------------------------------------------
+ // completeness
+ // ---------------------------------------------------------------------------------
+
+ @Test
+ public void completenessEveryRegisteredGeneratorShipsEjectSourceUnlessExempt() {
+ Set waiting = pending("completeness").keySet();
+ List problems = new ArrayList<>();
+ Set registered = new TreeSet<>();
+ for (EjectSupport.Entry e : javaEntries()) {
+ registered.add(e.stableName);
+ boolean ejectable = e.resourcePath != null;
+ if (isExempt(e.stableName)) {
+ if (ejectable) problems.add(e.stableName + ": exempt in the shared file but ejectable; drop the exemption");
+ continue;
+ }
+ if (ejectable && waiting.contains(e.stableName)) {
+ problems.add(e.stableName + ": ejectable now; remove its pending completeness entry");
+ }
+ if (!ejectable && !waiting.contains(e.stableName)) {
+ problems.add(e.stableName + ": no eject source, and the shared file does not exempt it");
+ }
+ }
+ for (String name : waiting) {
+ if (!registered.contains(name)) {
+ problems.add(name + ": pending completeness entry for a generator this port does not register");
+ }
+ }
+ assertTrue(String.join("\n", problems), problems.isEmpty());
+ }
+
+ /** The pom include lists, read from what they produced: a reference source the registry
+ * does not name is shipped for nobody, and one it names and no jar ships cannot be ejected. */
+ @Test
+ public void completenessTheShippedReferenceSourcesAreExactlyTheOnesTheRegistryNames() throws IOException {
+ String root = GeneratorRegistry.EJECT_RESOURCE_ROOT;
+ Set named = new TreeSet<>();
+ for (EjectSupport.Entry e : javaEntries()) {
+ if (e.resourcePath == null) continue;
+ for (String classname : e.sources()) named.add(root + EjectSupport.simpleNameOf(classname) + ".java");
+ for (String runtime : e.runtime) named.add(GeneratorRegistry.EJECT_RUNTIME_RESOURCE_ROOT + runtime + ".java");
+ }
+ for (String support : GeneratorRegistry.EJECT_SUPPORT) {
+ named.add(GeneratorRegistry.EJECT_SUPPORT_RESOURCE_ROOT + support + ".java");
+ }
+ assertEquals(named, resourcesUnder(root));
+ }
+
+ // ---------------------------------------------------------------------------------
+ // depth
+ // ---------------------------------------------------------------------------------
+
+ private static List ejectable() {
+ return javaEntries().stream().filter(e -> e.resourcePath != null).collect(Collectors.toList());
+ }
+
+ /** Eject one generator by itself, through the real mojo, into an empty project. */
+ private Path ejectAlone(String stableName) throws Exception {
+ Path projectDir = tempFolder.newFolder().toPath();
+ MavenProject project = Mockito.mock(MavenProject.class);
+ Mockito.when(project.getBasedir()).thenReturn(projectDir.toFile());
+ Mockito.when(project.getGroupId()).thenReturn("com.acme");
+ Mockito.when(project.getArtifactId()).thenReturn("widgets");
+ Mockito.when(project.getVersion()).thenReturn("1.0.0");
+ Mockito.when(project.getDependencies()).thenReturn(List.of());
+ MetaDataEjectMojo mojo = new MetaDataEjectMojo();
+ mojo.project = project;
+ mojo.names = stableName;
+ mojo.port = PORT;
+ mojo.execute();
+ return projectDir;
+ }
+
+ /** Compile what eject wrote under {@code codegen/}, alone, against the published classpath. */
+ private Path compileAlone(String stableName, Path projectDir) throws IOException {
+ List sources;
+ try (Stream s = Files.walk(projectDir.resolve("codegen/src/main/java"))) {
+ sources = s.filter(p -> p.toString().endsWith(".java")).map(Path::toFile).collect(Collectors.toList());
+ }
+ assertFalse(stableName + ": eject wrote no source", sources.isEmpty());
+ Path classes = tempFolder.newFolder().toPath();
+ JavaCompiler javac = ToolProvider.getSystemJavaCompiler();
+ DiagnosticCollector diagnostics = new DiagnosticCollector<>();
+ try (StandardJavaFileManager fm = javac.getStandardFileManager(diagnostics, null, StandardCharsets.UTF_8)) {
+ fm.setLocation(StandardLocation.CLASS_OUTPUT, List.of(classes.toFile()));
+ javac.getTask(null, fm, diagnostics,
+ List.of("-proc:none", "-classpath", System.getProperty("java.class.path")),
+ null, fm.getJavaFileObjectsFromFiles(sources)).call();
+ }
+ String errors = diagnostics.getDiagnostics().stream()
+ .filter(d -> d.getKind() == Diagnostic.Kind.ERROR).map(d -> " " + d).collect(Collectors.joining("\n"));
+ assertEquals(stableName + ", ejected alone, does not compile", "", errors);
+ return classes;
+ }
+
+ private static final Pattern PACKAGED_CLASS = Pattern.compile("com/metaobjects/[A-Za-z0-9_/$]+");
+
+ /**
+ * Every packaged class a compiled class file names: the class names, descriptors and
+ * signatures in its constant pool. A string the generator EMITS never qualifies: emitted
+ * Java spells packages with dots, and a constant holding whitespace is not a name.
+ */
+ static Set referencedClasses(byte[] classFile) throws IOException {
+ Set out = new TreeSet<>();
+ DataInputStream in = new DataInputStream(new ByteArrayInputStream(classFile));
+ in.skipBytes(8); // magic, minor, major
+ int count = in.readUnsignedShort();
+ for (int i = 1; i < count; i++) {
+ int tag = in.readUnsignedByte();
+ switch (tag) {
+ case 1: {
+ String text = in.readUTF();
+ if (text.indexOf('.') >= 0 || text.chars().anyMatch(Character::isWhitespace)) break;
+ Matcher m = PACKAGED_CLASS.matcher(text);
+ while (m.find()) out.add(m.group().replace('/', '.').replaceFirst("\\$.*", ""));
+ break;
+ }
+ case 3: case 4: case 9: case 10: case 11: case 12: case 17: case 18: in.skipBytes(4); break;
+ case 5: case 6: in.skipBytes(8); i++; break;
+ case 7: case 8: case 16: case 19: case 20: in.skipBytes(2); break;
+ case 15: in.skipBytes(3); break;
+ default: throw new IOException("unknown constant pool tag " + tag);
+ }
+ }
+ return out;
+ }
+
+ private static Set outsideEngineSurface(Path classes) throws IOException {
+ Set outside = new TreeSet<>();
+ try (Stream s = Files.walk(classes)) {
+ for (Path file : s.filter(p -> p.toString().endsWith(".class")).collect(Collectors.toList())) {
+ for (String name : referencedClasses(Files.readAllBytes(file))) {
+ if (outsideEngineSurface(name)) outside.add(name);
+ }
+ }
+ }
+ return outside;
+ }
+
+ @Test
+ public void depthAnOwnedCopyReferencesOnlyTheEngineSurface() throws Exception {
+ Map> waiting = pending("depth");
+ List problems = new ArrayList<>();
+ Set names = new TreeSet<>();
+ for (EjectSupport.Entry e : ejectable()) {
+ names.add(e.stableName);
+ Set actual = outsideEngineSurface(compileAlone(e.stableName, ejectAlone(e.stableName)));
+ Set expected = waiting.getOrDefault(e.stableName, Set.of());
+ if (actual.equals(expected)) continue;
+ if (expected.isEmpty()) {
+ problems.add(e.stableName + ": its owned copy calls emit code that stays in the package: " + actual
+ + ". Hand it over with the generator; a reference to the engine belongs in "
+ + "eject.engineSurface.java.engine.");
+ } else if (actual.isEmpty()) {
+ problems.add(e.stableName + ": its owned copy is whole now; remove its pending depth entry");
+ } else {
+ problems.add(e.stableName + ": pending depth entries list " + expected
+ + ", the owned copy references " + actual);
+ }
+ }
+ for (String name : waiting.keySet()) {
+ if (!names.contains(name)) problems.add(name + ": pending depth entry for a generator that is not ejectable");
+ }
+ assertTrue(String.join("\n", problems), problems.isEmpty());
+ }
+
+ @Test
+ public void depthEveryEngineSurfaceEntryNamesARealClass() {
+ List stale = new ArrayList<>();
+ for (String name : surface("engine")) {
+ if (name.contains("*")) continue;
+ try {
+ Class.forName(name, false, getClass().getClassLoader());
+ } catch (ClassNotFoundException | LinkageError e) {
+ stale.add(name);
+ }
+ }
+ assertEquals("eject.engineSurface.java.engine names classes that do not exist", List.of(), stale);
+ }
+
+ /** A shared model, and the directory its template refs resolve under. */
+ private static final class Corpus {
+ final String label;
+ final Path model;
+ final Path templates;
+
+ Corpus(String label, String model, String templates) {
+ this.label = label;
+ this.model = repoRoot().resolve("fixtures").resolve(model);
+ this.templates = templates == null ? null : repoRoot().resolve("fixtures").resolve(templates);
+ }
+
+ private MetaDataLoader loader;
+
+ /** Loaded once and shared by both sides: generated headers name the loader. */
+ MetaDataLoader loader() throws IOException {
+ if (loader != null) return loader;
+ // A loader's name is an identifier.
+ MetaDataLoader loading = new MetaDataLoader(LoaderOptions.create(false, false, true),
+ MetaDataLoader.SUBTYPE_MANUAL, "ejectContract_" + label.replace('-', '_'));
+ if (Files.isDirectory(model)) {
+ loading.init();
+ loading.load(new DirectorySource(model, new DirectorySource.Options()).expandToList());
+ } else {
+ loading.setSourceURIs(List.of(URIHelper.toURI(
+ "model:file:" + model.toAbsolutePath().toString().replace('\\', '/'))));
+ loading.init();
+ }
+ assertTrue(label + ": " + loading.getErrors(), loading.getErrors().isEmpty());
+ loader = loading;
+ return loader;
+ }
+ }
+
+ /** Shared models that between them make every ejectable generator emit. */
+ private static final List CORPORA = List.of(
+ new Corpus("fitness", "persistence-conformance/canonical/meta.fitness.json",
+ "persistence-conformance/canonical/prompts"),
+ new Corpus("requirements", "requirement-test-identity-conformance/worked-example/input", null),
+ new Corpus("llm-call", "conformance/ai-trace-prompt-nested/input", null));
+
+ private Map run(Generator generator, Corpus corpus, MetaDataLoader loader, boolean packaged)
+ throws IOException {
+ Path out = tempFolder.newFolder().toPath();
+ Map args = new LinkedHashMap<>();
+ args.put("outputDir", out.toString());
+ args.put("packageName", "acme");
+ args.put("type", "interface"); // the one argument "entity" requires
+ // Required by the generators that read template text, whatever the model holds.
+ args.put("templateRoot", (corpus.templates != null ? corpus.templates : CORPORA.get(0).templates).toString());
+ args.put("testPackage", "com.example.requirements");
+ args.put("witnessClass", "com.example.Witnesses");
+ args.put("warnUncovered", "false");
+ // The one difference an owned copy's output is allowed: it imports the helper runtime
+ // the adopter owns. The packaged generator is pointed at the same package to compare.
+ if (packaged) args.put("runtimePackage", OWNED_RUNTIME_PACKAGE);
+ if (generator instanceof ProjectClassLoaderAware) {
+ ((ProjectClassLoaderAware) generator).setProjectClassLoader(getClass().getClassLoader());
+ }
+ generator.setArgs(args).execute(loader);
+ Map tree = new TreeMap<>();
+ try (Stream s = Files.walk(out)) {
+ for (Path p : s.filter(Files::isRegularFile).collect(Collectors.toList())) {
+ tree.put(out.relativize(p).toString(), Files.readString(p, StandardCharsets.UTF_8));
+ }
+ }
+ return tree;
+ }
+
+ @Test
+ public void depthAnUnchangedOwnedCopyEmitsWhatThePackagedGeneratorEmits() throws Exception {
+ List silent = new ArrayList<>();
+ for (EjectSupport.Entry e : ejectable()) {
+ Path classes = compileAlone(e.stableName, ejectAlone(e.stableName));
+ int emitted = 0;
+ try (URLClassLoader cl = new URLClassLoader(new URL[]{classes.toUri().toURL()}, getClass().getClassLoader())) {
+ for (Corpus corpus : CORPORA) {
+ String where = e.stableName + " over " + corpus.label;
+ Generator packaged = (Generator) Class.forName(e.classname).getDeclaredConstructor().newInstance();
+ Generator owned = (Generator) Class.forName(OWNED_PACKAGE + "." + e.simpleName(), true, cl)
+ .getDeclaredConstructor().newInstance();
+ Map a = run(packaged, corpus, corpus.loader(), true);
+ Map b = run(owned, corpus, corpus.loader(), false);
+ emitted += a.size();
+ assertEquals(where, a.keySet(), b.keySet());
+ for (Map.Entry file : a.entrySet()) {
+ assertEquals(where + ": " + file.getKey(), file.getValue(), b.get(file.getKey()));
+ }
+ }
+ }
+ // An empty emit on both sides compares equal and proves nothing.
+ if (emitted == 0) silent.add(e.stableName);
+ }
+ assertEquals("emitted nothing over the corpora, so nothing was compared", List.of(), silent);
+ }
+}
diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectSupportTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectSupportTest.java
index 6e8211d19..280dbc26a 100644
--- a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectSupportTest.java
+++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectSupportTest.java
@@ -33,18 +33,6 @@ public void everyEntryHasAPortAndAStableName() {
}
}
- @Test
- public void onJavaOnlyTheGenericTemplateIsRegisteredButNotEjectable() {
- java.util.Set notEjectable = new java.util.TreeSet<>();
- for (EjectSupport.Entry e : EjectSupport.allEntries()) {
- if (e.port == EjectSupport.Port.JAVA && e.resourcePath == null) notEjectable.add(e.stableName);
- }
- // PINNED: template is the Mustache primitive, with no emit logic to own. entity and
- // extractor were the other two until ADR-0034 Amendment 6; a generator that joins
- // this set has to be ruled on, not slipped in.
- assertEquals(java.util.Set.of("template"), notEjectable);
- }
-
@Test
public void entityAndExtractorAreEjectableOnBothPortsAndSoNeedAPort() {
Map> catalog = EjectSupport.ejectableByStableName();
diff --git a/server/python/tests/codegen/test_eject.py b/server/python/tests/codegen/test_eject.py
index c267dac3e..105f001b8 100644
--- a/server/python/tests/codegen/test_eject.py
+++ b/server/python/tests/codegen/test_eject.py
@@ -41,8 +41,9 @@ def _project(tmp_path: Path, generators: str) -> Path:
def test_every_ejectable_entry_names_its_source(tmp_path: Path) -> None:
ejectable = [e for e in GENERATOR_REGISTRY.values() if e.source_module is not None]
- # `template` is a primitive with no emit logic of its own; everything else is ejectable.
- assert {e.name for e in ejectable} == set(GENERATOR_REGISTRY) - {"template"}
+ # Which generators are ejectable, and why the rest are not, is asserted against the
+ # shared file in tests/conformance/test_eject_contract.py.
+ assert ejectable
for e in ejectable:
module = importlib.import_module(e.source_module)
assert callable(getattr(module, e.symbol)), e.name
diff --git a/server/python/tests/conformance/eject_contract.py b/server/python/tests/conformance/eject_contract.py
new file mode 100644
index 000000000..181d540c3
--- /dev/null
+++ b/server/python/tests/conformance/eject_contract.py
@@ -0,0 +1,78 @@
+"""The shared eject contract, as this port reads it (ADR-0034 Amendment 8).
+
+``fixtures/generator-registry-conformance/registry.json`` says, for every generator, whether
+an owned copy holds its emit (``"eject": "full"``) or why it is exempt, and in its top-level
+``eject`` block what an owned copy may still import (the engine surface) and every place the
+contract does not hold yet (``pending``). The tests in ``test_eject_contract.py`` assert the
+contract; this module only reads the file.
+"""
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+from typing import Any
+
+PORT = "python"
+
+ASSERTIONS = ("census", "completeness", "depth")
+
+
+def manifest_path() -> Path:
+ """Walk up to the repo root (the directory holding both ``fixtures/`` and ``server/``)."""
+ p = Path(__file__).resolve()
+ while p != p.parent:
+ candidate = p / "fixtures" / "generator-registry-conformance" / "registry.json"
+ if (p / "server").is_dir() and candidate.is_file():
+ return candidate
+ p = p.parent
+ raise RuntimeError("fixtures/generator-registry-conformance/registry.json not found")
+
+
+def manifest() -> dict[str, Any]:
+ return json.loads(manifest_path().read_text(encoding="utf-8"))
+
+
+def port_slice() -> dict[str, dict[str, Any]]:
+ """The generators the manifest expects this port to expose."""
+ return {n: s for n, s in manifest()["generators"].items() if PORT in s.get("ports", [])}
+
+
+def is_exempt(name: str) -> bool:
+ return isinstance(manifest()["generators"][name]["eject"], dict)
+
+
+def pending(assertion: str) -> dict[str, list[dict[str, Any]]]:
+ """This port's pending entries for one assertion, by generator. A generator has one
+ entry per gap: a depth entry's ``outside`` lists the imports that gap closes."""
+ assert assertion in ASSERTIONS, assertion
+ out: dict[str, list[dict[str, Any]]] = {}
+ for p in manifest()["eject"]["pending"]:
+ if p["port"] == PORT and p["assertion"] == assertion:
+ out.setdefault(p["generator"], []).append(p)
+ return out
+
+
+def _glob(pattern: str) -> re.Pattern[str]:
+ """``**`` matches any run of characters, ``*`` one dotted segment."""
+ out = ""
+ for part in re.split(r"(\*\*|\*)", pattern):
+ out += ".*" if part == "**" else "[^.]*" if part == "*" else re.escape(part)
+ return re.compile(out + r"\Z")
+
+
+class EngineSurface:
+ """What an owned copy may import from the port's codegen packages."""
+
+ def __init__(self) -> None:
+ spec = manifest()["eject"]["engineSurface"][PORT]
+ self._scope = [_glob(p) for p in spec["codegenPackages"]]
+ self._engine = [_glob(p) for p in spec["engine"]]
+ self.engine = list(spec["engine"])
+
+ def in_codegen_packages(self, module: str) -> bool:
+ return any(p.match(module) for p in self._scope)
+
+ def outside(self, module: str) -> bool:
+ """True when *module* writes output: inside the codegen packages, not engine."""
+ return self.in_codegen_packages(module) and not any(p.match(module) for p in self._engine)
diff --git a/server/python/tests/conformance/test_eject_contract.py b/server/python/tests/conformance/test_eject_contract.py
new file mode 100644
index 000000000..c603a41c4
--- /dev/null
+++ b/server/python/tests/conformance/test_eject_contract.py
@@ -0,0 +1,342 @@
+"""The eject contract, asserted for the Python port (ADR-0034 Amendment 8).
+
+Every stock generator can be ejected, and the owned copy holds its emit. Three assertions,
+each driven by ``fixtures/generator-registry-conformance/registry.json``:
+
+* **Census.** Every generator class under ``metaobjects.codegen`` belongs to a registry
+ entry, and the registry exposes exactly the names the shared file gives this port.
+* **Completeness.** Every registered generator ships eject source, unless the shared file
+ exempts it with a reason.
+* **Depth.** Each generator, ejected alone into an empty project, loads; imports nothing
+ from the codegen packages outside the engine surface; and emits what the packaged
+ generator emits.
+
+Where the contract does not hold yet, the shared file's ``pending`` list says so. A pending
+entry that passes fails here, and so does a gap that is not listed.
+"""
+from __future__ import annotations
+
+import ast
+import importlib
+import importlib.util
+import inspect
+import json
+import os
+import pkgutil
+import subprocess
+import sys
+from collections import Counter
+from pathlib import Path
+
+import pytest
+
+import metaobjects
+import metaobjects.codegen
+from metaobjects.codegen import eject as owned
+from metaobjects.codegen.generator import Generator
+from metaobjects.codegen.generator_registry import GENERATOR_REGISTRY, GeneratorBuildContext
+
+from . import eject_contract as contract
+
+REPO = contract.manifest_path().parents[2]
+FIXTURES = REPO / "fixtures"
+
+#: Models that between them make every ejectable generator emit. ``files`` are loaded
+#: together; ``templates`` is the directory template refs resolve under.
+CORPORA: dict[str, dict[str, object]] = {
+ "fitness": {
+ "files": [FIXTURES / "persistence-conformance" / "canonical" / "meta.fitness.json"],
+ "templates": FIXTURES / "persistence-conformance" / "canonical" / "prompts",
+ },
+ "requirements": {
+ "files": sorted(
+ (FIXTURES / "requirement-test-identity-conformance" / "worked-example" / "input").iterdir()
+ ),
+ },
+ "llm-call": {
+ "files": sorted((FIXTURES / "conformance" / "ai-trace-prompt-nested" / "input").iterdir()),
+ },
+}
+
+#: The one difference an owned copy's output is allowed: it imports the helper runtime the
+#: adopter owns, emitted beside it, where packaged output imports the installed one.
+PACKAGED_RUNTIME_IMPORT = "from metaobjects.codegen.runtime."
+OWNED_RUNTIME_IMPORT = "from ._runtime."
+OWNED_RUNTIME_DIR = "_runtime/"
+
+
+# ---------------------------------------------------------------------------------------
+# census
+# ---------------------------------------------------------------------------------------
+
+
+def _generator_classes() -> set[type]:
+ """Every class under ``metaobjects.codegen`` that is a generator: it has ``generate``."""
+ found: set[type] = set()
+ for info in pkgutil.walk_packages(metaobjects.codegen.__path__, "metaobjects.codegen."):
+ module = importlib.import_module(info.name)
+ for _, cls in inspect.getmembers(module, inspect.isclass):
+ if cls.__module__ != info.name or cls is Generator:
+ continue
+ if callable(getattr(cls, "generate", None)):
+ found.add(cls)
+ return found
+
+
+def test_census_every_generator_class_belongs_to_a_registry_entry() -> None:
+ registered = {type(e.factory(GeneratorBuildContext())) for e in GENERATOR_REGISTRY.values()}
+ unregistered = sorted(f"{c.__module__}.{c.__name__}" for c in _generator_classes() - registered)
+ assert not unregistered, (
+ "generator classes no registry entry constructs, so no catalog, eject command or "
+ f"test sees them: {unregistered}. Register each in generator_registry.py and in "
+ f"{contract.manifest_path().name}."
+ )
+
+
+def test_census_the_registry_exposes_the_names_the_shared_file_gives_this_port() -> None:
+ expected = set(contract.port_slice())
+ actual = set(GENERATOR_REGISTRY)
+ pending = set(contract.pending("census"))
+ assert actual - expected == set(), "registered, but the shared file does not list it for python"
+ assert expected - actual == pending, (
+ "the shared file expects these generators in the Python registry and they are not "
+ f"there: {sorted(expected - actual)}; pending census entries: {sorted(pending)}. "
+ "Register the generator, or remove the pending entry of one that is now registered."
+ )
+
+
+# ---------------------------------------------------------------------------------------
+# completeness
+# ---------------------------------------------------------------------------------------
+
+
+def test_completeness_every_registered_generator_ships_eject_source_unless_exempt() -> None:
+ ejectable = {e.name for e in owned.ejectable()}
+ pending = set(contract.pending("completeness"))
+ problems: list[str] = []
+ for name in sorted(GENERATOR_REGISTRY):
+ if contract.is_exempt(name):
+ if name in ejectable:
+ problems.append(f"{name}: exempt in the shared file but ejectable; drop the exemption")
+ continue
+ if name in ejectable and name in pending:
+ problems.append(f"{name}: ejectable now; remove its pending completeness entry")
+ if name not in ejectable and name not in pending:
+ problems.append(f"{name}: no eject source, and the shared file does not exempt it")
+ problems += [f"{n}: pending completeness entry for a generator this port does not register"
+ for n in sorted(pending - set(GENERATOR_REGISTRY))]
+ assert not problems, "\n".join(problems)
+
+
+# ---------------------------------------------------------------------------------------
+# depth
+# ---------------------------------------------------------------------------------------
+
+
+#: The calls that import a module named by a string: ``importlib.import_module("m")``,
+#: a bare ``import_module("m")`` and ``__import__("m")``.
+_IMPORT_CALLS = {"import_module", "__import__"}
+
+
+def _imported_by_call(node: ast.Call) -> str | None:
+ """The module a call imports by a string LITERAL, or ``None``. A name built at run time
+ cannot be read here; that limit is stated in ADR-0034 Amendment 8."""
+ func = node.func
+ name = func.attr if isinstance(func, ast.Attribute) else func.id if isinstance(func, ast.Name) else None
+ if name not in _IMPORT_CALLS or not node.args:
+ return None
+ first = node.args[0]
+ return first.value if isinstance(first, ast.Constant) and isinstance(first.value, str) else None
+
+
+def _imports(path: Path, module_name: str) -> set[str]:
+ """Every module *path* imports, resolved to a dotted name: its import statements, and
+ its ``import_module`` / ``__import__`` calls on a string literal. A ``from p import a``
+ names ``p.a`` when that is a module, else ``p``."""
+ package = module_name.rpartition(".")[0]
+ out: set[str] = set()
+ for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
+ if isinstance(node, ast.Call):
+ named = _imported_by_call(node)
+ if named is not None:
+ out.add(named)
+ elif isinstance(node, ast.Import):
+ out.update(alias.name for alias in node.names)
+ elif isinstance(node, ast.ImportFrom):
+ base = node.module or ""
+ if node.level:
+ parent = package.rsplit(".", node.level - 1)[0] if node.level > 1 else package
+ base = f"{parent}.{base}" if base else parent
+ for alias in node.names:
+ candidate = f"{base}.{alias.name}"
+ is_module = base.startswith("metaobjects") and _is_module(candidate)
+ out.add(candidate if is_module else base)
+ return out
+
+
+def _is_module(name: str) -> bool:
+ try:
+ return importlib.util.find_spec(name) is not None
+ except (ImportError, ValueError):
+ return False
+
+
+def _eject_alone(name: str, root: Path) -> list[Path]:
+ """Eject *name* by itself into the empty project at *root*; the files eject wrote."""
+ root.mkdir(parents=True)
+ result = owned.eject([name], root)
+ assert result.code == 0, result.err
+ return sorted(p for p in (root / "codegen").rglob("*.py") if p.name != "__init__.py")
+
+
+def _outside_engine_surface(name: str, root: Path) -> list[str]:
+ surface = contract.EngineSurface()
+ outside: set[str] = set()
+ for path in _eject_alone(name, root):
+ module_name = ".".join(path.relative_to(root).with_suffix("").parts)
+ outside.update(m for m in _imports(path, module_name) if surface.outside(m))
+ return sorted(outside)
+
+
+def test_depth_an_owned_copy_imports_only_the_engine_surface(tmp_path: Path) -> None:
+ pending = contract.pending("depth")
+ ejectable = {e.name for e in owned.ejectable()}
+ problems: list[str] = []
+ for name in sorted(ejectable):
+ actual = _outside_engine_surface(name, tmp_path / owned.owned_stem(name))
+ expected = sorted(m for entry in pending.get(name, []) for m in entry["outside"])
+ if actual == expected:
+ continue
+ if not expected:
+ problems.append(
+ f"{name}: its owned copy imports emit code that stays in the package: {actual}. "
+ "Hand it over with the generator; an import of the engine belongs in "
+ "eject.engineSurface.python.engine."
+ )
+ elif not actual:
+ problems.append(f"{name}: its owned copy is whole now; remove its pending depth entry")
+ else:
+ problems.append(f"{name}: pending depth entry lists {expected}, the owned copy imports {actual}")
+ problems += [f"{n}: pending depth entry for a generator that is not ejectable"
+ for n in sorted(set(pending) - ejectable)]
+ assert not problems, "\n".join(problems)
+
+
+def test_depth_the_scan_reads_string_imports_as_well_as_import_statements(tmp_path: Path) -> None:
+ probe = tmp_path / "probe.py"
+ probe.write_text(
+ "import importlib\n"
+ "from importlib import import_module\n"
+ "a = importlib.import_module('metaobjects.codegen.extract_delegate_emitter')\n"
+ "b = import_module('metaobjects.codegen.type_map')\n"
+ "c = __import__('metaobjects.codegen.fr010_field_mapping')\n"
+ "d = importlib.import_module(name)\n"
+ "from metaobjects.apidocs import PythonApiDocsRenderer\n"
+ "from metaobjects.apidocs import naming\n",
+ encoding="utf-8",
+ )
+ found = _imports(probe, "codegen.generators.probe")
+ assert {
+ "metaobjects.codegen.extract_delegate_emitter",
+ "metaobjects.codegen.type_map",
+ "metaobjects.codegen.fr010_field_mapping",
+ "metaobjects.apidocs",
+ "metaobjects.apidocs.naming",
+ } <= found
+ surface = contract.EngineSurface()
+ assert sorted(m for m in found if surface.outside(m)) == [
+ "metaobjects.apidocs",
+ "metaobjects.codegen.extract_delegate_emitter",
+ "metaobjects.codegen.fr010_field_mapping",
+ ]
+
+
+def test_depth_every_engine_surface_entry_names_a_real_module() -> None:
+ stale = [m for m in contract.EngineSurface().engine if "*" not in m and not _is_module(m)]
+ assert not stale, f"eject.engineSurface.python.engine names modules that do not exist: {stale}"
+
+
+#: Runs in a fresh interpreter with the project root as its working directory, so the owned
+#: copy is loaded the way an adopter's ``metaobjects gen`` loads it and nothing this test
+#: process imported is in the way. Prints ``{"owned": {...}, "packaged": {...}}``, each
+#: ``{corpus: {relative path: content}}``.
+_DRIVER = r"""
+import json, sys, tempfile
+from pathlib import Path
+
+root, name, corpora = Path(sys.argv[1]), sys.argv[2], json.loads(sys.argv[3])
+
+from metaobjects import MetaDataLoader
+from metaobjects.codegen import eject as owned
+from metaobjects.codegen.config import GenConfig
+from metaobjects.codegen.generator_registry import GENERATOR_REGISTRY, GeneratorBuildContext
+from metaobjects.codegen.runner import run_gen
+from metaobjects.loader.sources import FileSource
+
+entry = GENERATOR_REGISTRY[name]
+out = {"owned": {}, "packaged": {}}
+for corpus, spec in corpora.items():
+ loaded = MetaDataLoader(strict=True).load([FileSource(Path(f)) for f in spec["files"]])
+ assert not loaded.errors, [str(e) for e in loaded.errors]
+ ctx = GeneratorBuildContext(template_root=spec.get("templates"))
+ mine, err = owned.build_owned(owned.owned_token(entry), root, ctx)
+ assert err is None, err
+ for side, generator in (("owned", mine), ("packaged", entry.factory(ctx))):
+ with tempfile.TemporaryDirectory() as tmp:
+ run_gen(GenConfig(out_dir=tmp, emit_package_init=False), loaded.root, generators=[generator])
+ out[side][corpus] = {
+ p.relative_to(tmp).as_posix(): p.read_text(encoding="utf-8")
+ for p in sorted(Path(tmp).rglob("*")) if p.is_file()
+ }
+print(json.dumps(out))
+"""
+
+
+def _run_alone(name: str, root: Path) -> dict[str, dict[str, dict[str, str]]]:
+ src = str(Path(metaobjects.__file__).resolve().parents[1])
+ env = {**os.environ, "PYTHONPATH": os.pathsep.join(
+ p for p in (src, os.environ.get("PYTHONPATH", "")) if p)}
+ corpora = {
+ corpus: {"files": [str(f) for f in spec["files"]], # type: ignore[union-attr]
+ **({"templates": str(spec["templates"])} if "templates" in spec else {})}
+ for corpus, spec in CORPORA.items()
+ }
+ proc = subprocess.run(
+ [sys.executable, "-c", _DRIVER, str(root), name, json.dumps(corpora)],
+ cwd=root, env=env, capture_output=True, text=True,
+ )
+ assert proc.returncode == 0, f"{name}, ejected alone, does not load and run:\n{proc.stderr}"
+ return json.loads(proc.stdout)
+
+
+def _lines(text: str) -> Counter[str]:
+ return Counter(line for line in text.splitlines() if line.strip())
+
+
+@pytest.mark.parametrize("name", sorted(e.name for e in owned.ejectable()))
+def test_depth_an_unchanged_owned_copy_emits_what_the_packaged_generator_emits(
+ name: str, tmp_path: Path
+) -> None:
+ root = tmp_path / owned.owned_stem(name)
+ _eject_alone(name, root)
+ result = _run_alone(name, root)
+ hands_runtime_over = owned.ejected_source(GENERATOR_REGISTRY[name]) != owned.packaged_source(
+ GENERATOR_REGISTRY[name])
+
+ emitted = 0
+ for corpus in CORPORA:
+ packaged, mine = result["packaged"][corpus], result["owned"][corpus]
+ emitted += len(packaged)
+ extra = sorted(set(mine) - set(packaged))
+ assert sorted(set(packaged) - set(mine)) == [], (name, corpus)
+ if not hands_runtime_over:
+ assert extra == [], (name, corpus)
+ assert mine == packaged, (name, corpus)
+ continue
+ # Import sorting moves the now-relative runtime imports below the third-party
+ # block, so this one comparison is over lines, not positions.
+ assert all(OWNED_RUNTIME_DIR in path for path in extra), (name, corpus, extra)
+ for path, text in packaged.items():
+ expected = text.replace(PACKAGED_RUNTIME_IMPORT, OWNED_RUNTIME_IMPORT)
+ assert _lines(mine[path]) == _lines(expected), (name, corpus, path)
+ assert emitted > 0, f"{name} emitted nothing over {sorted(CORPORA)}: nothing was compared"
diff --git a/server/python/tests/conformance/test_generator_registry_conformance.py b/server/python/tests/conformance/test_generator_registry_conformance.py
index 57f4d1bc9..40bc22020 100644
--- a/server/python/tests/conformance/test_generator_registry_conformance.py
+++ b/server/python/tests/conformance/test_generator_registry_conformance.py
@@ -16,6 +16,8 @@
from metaobjects.codegen.generator_registry import GENERATOR_REGISTRY, GeneratorBuildContext
+from . import eject_contract
+
PORT = "python"
@@ -45,7 +47,9 @@ def _manifest_python_slice() -> dict[str, dict]:
def test_registry_name_set_equals_manifest_python_slice() -> None:
- expected = set(_manifest_python_slice().keys())
+ # A name the shared file expects here and lists as a pending census gap is the eject
+ # contract's to report (test_eject_contract.py), which fails once it is registered.
+ expected = set(_manifest_python_slice().keys()) - set(eject_contract.pending("census"))
actual = set(GENERATOR_REGISTRY.keys())
extras = sorted(actual - expected)
diff --git a/server/typescript/packages/cli/test/eject-contract.test.ts b/server/typescript/packages/cli/test/eject-contract.test.ts
new file mode 100644
index 000000000..de26eeb34
--- /dev/null
+++ b/server/typescript/packages/cli/test/eject-contract.test.ts
@@ -0,0 +1,578 @@
+// The eject contract, asserted for the TypeScript port (ADR-0034 Amendment 8).
+//
+// Every stock generator can be ejected, and the owned copy holds its emit. Three
+// assertions, each driven by fixtures/generator-registry-conformance/registry.json:
+//
+// census every generator factory a published codegen package exports belongs to
+// a catalog entry, so none ships outside the catalog, `meta eject` and
+// every gate that reads them.
+// completeness every catalog entry ships a reference template, unless the shared file
+// exempts it with a reason.
+// depth each generator, ejected ALONE into an empty project by the real
+// `meta eject`, loads; reaches nothing in the codegen packages outside
+// the engine surface (values, types and dynamic imports alike); and emits
+// what the packaged generator emits.
+//
+// Where the contract does not hold yet, the shared file's `pending` list says so. A
+// pending entry that passes fails here, and so does a gap that is not listed.
+//
+// This lives in `cli` for the reason catalog-conformance.test.ts does: it is the only
+// package that sees all three registry slices, and the only one that owns `meta eject`.
+
+import { afterAll, beforeAll, describe, expect, test } from "bun:test";
+import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { join, relative, resolve } from "node:path";
+import { pathToFileURL } from "node:url";
+import ts from "typescript";
+import {
+ buildPkMap, buildRelationMap, defineConfig, makeRenderContext, runGen, HTTP_RUNTIME_PACKAGE, OWNED_RUNTIME_DIR,
+} from "@metaobjectsdev/codegen-ts";
+import type { GenContext, Generator } from "@metaobjectsdev/codegen-ts";
+import { MetaDataLoader } from "@metaobjectsdev/metadata";
+import { FileSource } from "@metaobjectsdev/metadata/core";
+import { catalogPackages, factoryModuleOf, listCatalog, packageOf } from "../src/lib/catalog.js";
+import { ejectGenerator } from "../src/commands/eject.js";
+
+const PORT = "typescript";
+const REPO_ROOT = resolve(import.meta.dirname, "..", "..", "..", "..", "..");
+const PACKAGES = join(REPO_ROOT, "server", "typescript", "packages");
+const FIXTURES = join(REPO_ROOT, "fixtures");
+const MANIFEST_PATH = join(FIXTURES, "generator-registry-conformance", "registry.json");
+/** Inside the workspace, so an ejected generator resolves `@metaobjectsdev/*` as a project's does. */
+const WORKSPACE_TMP = resolve(import.meta.dirname, "fixtures", "__tmp__");
+const CODEGEN_ENGINE = "@metaobjectsdev/codegen-ts";
+
+const ASSERTIONS = ["census", "completeness", "depth"] as const;
+type Assertion = (typeof ASSERTIONS)[number];
+interface Pending { generator: string; port: string; assertion: Assertion; gap: number; outside?: string[] }
+interface Manifest {
+ ports: string[];
+ generators: Record;
+ eject: {
+ engineSurface: Record;
+ exemptEmitters: { name: string; port: string; path: string; exempt: string; ruling: string }[];
+ pending: Pending[];
+ };
+}
+const manifest = JSON.parse(readFileSync(MANIFEST_PATH, "utf8")) as Manifest;
+
+/** This port's pending entries for one assertion, by generator (one entry per gap). */
+function pending(assertion: Assertion): Map {
+ const out = new Map();
+ for (const p of manifest.eject.pending) {
+ if (p.port !== PORT || p.assertion !== assertion) continue;
+ out.set(p.generator, [...(out.get(p.generator) ?? []), p]);
+ }
+ return out;
+}
+
+const isExempt = (name: string): boolean => manifest.generators[name]?.eject !== "full";
+
+/** `**` matches any run of characters, `*` one path segment. */
+function glob(pattern: string): RegExp {
+ const body = pattern
+ .split(/(\*\*|\*)/)
+ .map((part) => (part === "**" ? ".*" : part === "*" ? "[^/]*" : part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")))
+ .join("");
+ return new RegExp(`^${body}$`);
+}
+
+const SURFACE = manifest.eject.engineSurface[PORT]!;
+const CODEGEN_PACKAGES = SURFACE.codegenPackages.map(glob);
+const ENGINE = SURFACE.engine.map(glob);
+/** True for a module that writes output: inside the codegen packages, not engine. */
+const isOutsideEngineSurface = (module: string): boolean =>
+ CODEGEN_PACKAGES.some((p) => p.test(module)) && !ENGINE.some((p) => p.test(module));
+
+const COMPILER_OPTIONS: ts.CompilerOptions = {
+ module: ts.ModuleKind.ESNext,
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
+ // The condition Bun resolves the workspace packages by: source, not a built `dist/`.
+ customConditions: ["bun"],
+ target: ts.ScriptTarget.ES2023,
+ jsx: ts.JsxEmit.ReactJSX,
+ strict: true,
+ noEmit: true,
+ skipLibCheck: true,
+ types: [],
+};
+
+const catalog = listCatalog();
+
+/** Which packages a release publishes: the repository's one definition, read not restated. */
+const { publishSet } = (await import(pathToFileURL(join(REPO_ROOT, "scripts", "publish-set.mjs")).href)) as {
+ publishSet: () => { set: { dir: string }[] };
+};
+
+// ---------------------------------------------------------------------------------------
+// census
+// ---------------------------------------------------------------------------------------
+
+/**
+ * Every exported function, in every public entry point of every PUBLISHED package that is
+ * or depends on the codegen engine, whose return type is a `Generator`. Read from the
+ * types, never called: `templateGenerator` and `sharedModelFile` take required options.
+ */
+function exportedGeneratorFactories(): Map {
+ const entryFiles: string[] = [];
+ for (const { dir } of publishSet().set) {
+ const pkg = JSON.parse(readFileSync(join(REPO_ROOT, dir, "package.json"), "utf8")) as {
+ name: string;
+ dependencies?: Record;
+ peerDependencies?: Record;
+ exports?: Record;
+ };
+ const deps = { ...pkg.dependencies, ...pkg.peerDependencies };
+ if (pkg.name !== CODEGEN_ENGINE && !(CODEGEN_ENGINE in deps)) continue;
+ for (const target of Object.values(pkg.exports ?? {})) {
+ const source = typeof target === "string" ? target : target.bun;
+ if (source?.endsWith(".ts") === true) entryFiles.push(join(REPO_ROOT, dir, source));
+ }
+ }
+ const generatorModule = join(PACKAGES, "codegen-ts", "src", "generator.ts");
+ const program = ts.createProgram({ rootNames: [...entryFiles, generatorModule], options: COMPILER_OPTIONS });
+ const checker = program.getTypeChecker();
+ const moduleOf = (file: string) => checker.getSymbolAtLocation(program.getSourceFile(file)!)!;
+ const generatorSymbol = checker.getExportsOfModule(moduleOf(generatorModule)).find((s) => s.name === "Generator")!;
+ const generatorType = checker.getDeclaredTypeOfSymbol(generatorSymbol);
+
+ const found = new Map();
+ for (const file of entryFiles) {
+ for (const exported of checker.getExportsOfModule(moduleOf(file))) {
+ const target = exported.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(exported) : exported;
+ const declaration = target.valueDeclaration;
+ if (declaration === undefined) continue;
+ const returnsGenerator = checker
+ .getTypeOfSymbolAtLocation(target, declaration)
+ .getCallSignatures()
+ .some((signature) => {
+ const returned = signature.getReturnType();
+ return !(returned.flags & ts.TypeFlags.Any) && checker.isTypeAssignableTo(returned, generatorType);
+ });
+ if (returnsGenerator) found.set(exported.name, relative(PACKAGES, declaration.getSourceFile().fileName));
+ }
+ }
+ return found;
+}
+
+describe("eject contract — census", () => {
+ test("every exported generator factory belongs to a catalog entry", () => {
+ const factories = exportedGeneratorFactories();
+ // Vacuity guard: the scan reads types, so a resolution failure finds nothing at all.
+ expect(factories.size).toBeGreaterThan(20);
+ const registered = new Set(catalog.map((e) => e.factoryExport));
+ const unregistered = [...factories]
+ .filter(([name]) => !registered.has(name))
+ .map(([name, file]) => `${name} (${file})`)
+ .sort();
+ expect(
+ unregistered,
+ "generator factories no catalog entry names, so `meta gen --list`, `meta eject` and every " +
+ `gate that reads the catalog cannot see them. Register each in its package's registry and in ${MANIFEST_PATH}.`,
+ ).toEqual([]);
+ });
+
+ test("the catalog exposes the names the shared file gives this port", () => {
+ const expected = Object.entries(manifest.generators).filter(([, e]) => e.ports.includes(PORT)).map(([n]) => n);
+ const actual = new Set(catalog.map((e) => e.name));
+ const missing = expected.filter((n) => !actual.has(n)).sort();
+ expect(missing, "expected in the catalog by the shared file; a pending census entry must list each").toEqual(
+ [...pending("census").keys()].sort(),
+ );
+ });
+
+ test("every exempt non-generator emitter names code that exists, with a reason and a ruling", () => {
+ for (const emitter of manifest.eject.exemptEmitters) {
+ expect(manifest.ports).toContain(emitter.port);
+ expect(existsSync(join(REPO_ROOT, emitter.path)), `${emitter.name}: ${emitter.path}`).toBe(true);
+ expect(emitter.exempt.trim()).not.toBe("");
+ expect(emitter.ruling.trim()).not.toBe("");
+ }
+ });
+});
+
+// ---------------------------------------------------------------------------------------
+// completeness
+// ---------------------------------------------------------------------------------------
+
+describe("eject contract — completeness", () => {
+ test("every catalog entry ships a reference template unless the shared file exempts it", () => {
+ const waiting = pending("completeness");
+ const registered = new Set(catalog.map((e) => e.name));
+ const problems: string[] = [];
+ for (const entry of catalog) {
+ if (isExempt(entry.name)) {
+ if (entry.ejectable) problems.push(`${entry.name}: exempt in the shared file but ejectable; drop the exemption`);
+ continue;
+ }
+ if (entry.ejectable && waiting.has(entry.name)) {
+ problems.push(`${entry.name}: ejectable now; remove its pending completeness entry`);
+ }
+ if (!entry.ejectable && !waiting.has(entry.name)) {
+ problems.push(`${entry.name}: no reference template, and the shared file does not exempt it`);
+ }
+ }
+ for (const name of waiting.keys()) {
+ if (!registered.has(name)) problems.push(`${name}: pending completeness entry for a generator the catalog does not hold`);
+ }
+ expect(problems).toEqual([]);
+ });
+
+ test("each package's reference directory holds exactly its ejectable generators", () => {
+ // `REFERENCE_GENERATOR_NAMES` and its two siblings are hand-kept lists; this holds
+ // each to the files on disk, so a template cannot ship unlisted or be listed unshipped.
+ for (const pkg of catalogPackages()) {
+ const dir = join(PACKAGES, pkg.slice(pkg.indexOf("/") + 1), "src", "reference");
+ const onDisk = readdirSync(dir).map((f) => f.replace(/\.tsx?$/, "")).sort();
+ const listed = catalog.filter((e) => e.ejectable && packageOf(e.name) === pkg).map((e) => e.name).sort();
+ expect(onDisk, dir).toEqual(listed);
+ }
+ });
+});
+
+// ---------------------------------------------------------------------------------------
+// depth
+// ---------------------------------------------------------------------------------------
+
+interface Owned {
+ name: string;
+ /** The empty project `meta eject ` ran in. */
+ root: string;
+ /** Every file eject wrote, absolute: the generator, and any runtime handed over with it. */
+ files: string[];
+ generatorPath: string;
+ handsRuntimeOver: boolean;
+}
+
+function filesUnder(dir: string): string[] {
+ if (!existsSync(dir)) return [];
+ return readdirSync(dir, { recursive: true, withFileTypes: true })
+ .filter((f) => f.isFile())
+ .map((f) => join(f.parentPath, f.name))
+ .sort();
+}
+
+/**
+ * The codegen-package modules the owned files reach, outside the engine surface. Each
+ * imported binding is resolved through any re-exporting barrel to the file that declares
+ * it. Three ways in are counted: a static import or re-export (a type-only one too, since
+ * a type declared in an emit module ties the copy to that module's shape), an inline
+ * `import("…").Type`, and a dynamic `import("…")`, which is counted as the module it names.
+ */
+function outsideEngineSurface(files: readonly string[]): string[] {
+ const sources = files.filter((f) => /\.tsx?$/.test(f));
+ const program = ts.createProgram({ rootNames: sources, options: COMPILER_OPTIONS });
+ const checker = program.getTypeChecker();
+ const outside = new Set();
+
+ const recordFile = (fileName: string): void => {
+ const module = relative(PACKAGES, fileName);
+ if (isOutsideEngineSurface(module)) outside.add(module);
+ };
+ const record = (symbol: ts.Symbol | undefined): void => {
+ if (symbol === undefined) return;
+ const target = symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol;
+ for (const declaration of target.declarations ?? []) recordFile(declaration.getSourceFile().fileName);
+ };
+ const isPackage = (specifier: ts.Node | undefined): specifier is ts.StringLiteral =>
+ specifier !== undefined && ts.isStringLiteral(specifier) && !specifier.text.startsWith(".");
+ /** Everything a module exports, for an `import *` or an `export *`. */
+ const recordAll = (specifier: ts.StringLiteral): void => {
+ const module = checker.getSymbolAtLocation(specifier);
+ if (module !== undefined) for (const exported of checker.getExportsOfModule(module)) record(exported);
+ };
+
+ const visit = (node: ts.Node): void => {
+ if (ts.isImportDeclaration(node) && isPackage(node.moduleSpecifier)) {
+ const clause = node.importClause;
+ if (clause?.name !== undefined) record(checker.getSymbolAtLocation(clause.name));
+ const bindings = clause?.namedBindings;
+ if (bindings !== undefined && ts.isNamespaceImport(bindings)) recordAll(node.moduleSpecifier);
+ else if (bindings !== undefined) for (const e of bindings.elements) record(checker.getSymbolAtLocation(e.name));
+ } else if (ts.isExportDeclaration(node) && isPackage(node.moduleSpecifier)) {
+ const clause = node.exportClause;
+ if (clause !== undefined && ts.isNamedExports(clause)) {
+ for (const e of clause.elements) record(checker.getSymbolAtLocation(e.name));
+ } else {
+ recordAll(node.moduleSpecifier);
+ }
+ } else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument) && isPackage(node.argument.literal)) {
+ // `import("pkg").Name`: the named member when there is one, else the whole module.
+ const named = node.qualifier === undefined ? undefined : checker.getSymbolAtLocation(node.qualifier);
+ if (named !== undefined) record(named);
+ else recordAll(node.argument.literal);
+ } else if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
+ // `await import("pkg")` hands back the whole module, so the module is what counts:
+ // a barrel that re-exports emit code is itself outside the engine surface.
+ const specifier = node.arguments[0];
+ if (isPackage(specifier)) {
+ const module = checker.getSymbolAtLocation(specifier);
+ for (const declaration of module?.declarations ?? []) recordFile(declaration.getSourceFile().fileName);
+ }
+ }
+ ts.forEachChild(node, visit);
+ };
+ for (const file of sources) visit(program.getSourceFile(file)!);
+ return [...outside].sort();
+}
+
+interface Corpus {
+ label: string;
+ files: string[];
+ /** Copied to `/templates`, where render-helper's gen-time drift gate reads. */
+ templates?: string;
+}
+
+function inputs(...segments: string[]): string[] {
+ const dir = join(FIXTURES, ...segments);
+ return readdirSync(dir).sort().map((f) => join(dir, f));
+}
+
+/** Shared models that between them make every ejectable generator emit. */
+const CORPORA: Corpus[] = [
+ { label: "fitness", files: [join(FIXTURES, "persistence-conformance", "canonical", "meta.fitness.json")] },
+ {
+ label: "template-output",
+ files: [join(FIXTURES, "template-output-render-conformance", "meta.json")],
+ templates: join(FIXTURES, "template-output-render-conformance", "templates"),
+ },
+ { label: "requirements", files: inputs("requirement-test-identity-conformance", "worked-example", "input") },
+ { label: "stored-proc", files: inputs("conformance", "parameter-ref-on-stored-proc", "input") },
+ // The grid tier is TypeScript-only, so its model is this port's own fixture.
+ { label: "data-grid", files: [join(PACKAGES, "codegen-ts-tanstack", "test", "fixtures", "single-entity.json")] },
+ { label: "llm-call", files: inputs("conformance", "ai-trace-prompt-nested", "input") },
+];
+
+/**
+ * The options a factory cannot be called without, given to the packaged factory and the
+ * owned one alike. `shared-model` publishes nothing until it is told what to include.
+ */
+const FACTORY_OPTIONS: Record = {
+ "shared-model": { name: "shared", include: ["**"], version: "1.0.0" },
+};
+
+/** The key a refusal is recorded under, in place of the files a run would have written. */
+const REFUSED = "";
+
+type Factory = (opts?: unknown) => Generator;
+const build = (factory: Factory, name: string): Generator =>
+ name in FACTORY_OPTIONS ? factory(FACTORY_OPTIONS[name]) : factory();
+
+/** Run one generator over one corpus the way its door runs it; the files it wrote. */
+async function emit(
+ generator: Generator,
+ corpus: Corpus,
+ projectRoot: string,
+ outDir: string,
+ throughDocsDoor: boolean,
+): Promise> {
+ const loaded = await new MetaDataLoader().load(corpus.files.map((f) => new FileSource(f)));
+ expect(loaded.errors, corpus.label).toEqual([]);
+ const settings = { outDir, extStyle: "none", dbImport: "../db", dialect: "postgres" } as const;
+ if (throughDocsDoor) {
+ // `meta docs` runs a docs generator, never `runGen`, which skips one: `.generate(ctx)`
+ // with the generator's own `filter` as `ctx.matches`.
+ const ctx: GenContext = {
+ entities: loaded.root.objects(),
+ loadedRoot: loaded.root,
+ matches: generator.filter ?? (() => true),
+ config: settings,
+ renderContext: makeRenderContext({
+ dialect: "postgres",
+ loadedRoot: loaded.root,
+ outDir,
+ dbImport: "../db",
+ apiPrefix: "",
+ pkMap: buildPkMap(loaded.root),
+ relationMap: buildRelationMap(loaded.root),
+ }),
+ projectRoot,
+ warn: () => {},
+ };
+ return Object.fromEntries((await generator.generate(ctx)).map((f) => [f.path, f.content]));
+ }
+ try {
+ await runGen({
+ config: defineConfig({ ...settings, generators: [generator] }),
+ metadata: loaded.root,
+ projectRoot,
+ sourceFiles: corpus.files,
+ });
+ } catch (e) {
+ // A generator may refuse a model (`shared-model` re-reads the source files strictly).
+ // The refusal is then what the two sides are compared on: the same words, or a failure.
+ return { [REFUSED]: e instanceof Error ? e.message : String(e) };
+ }
+ const out: Record = {};
+ for (const file of filesUnder(outDir)) out[relative(outDir, file)] = readFileSync(file, "utf8");
+ return out;
+}
+
+/**
+ * The one difference an owned copy's output is allowed: it imports the HTTP adapter the
+ * adopter owns in `codegen/runtime/`, where packaged output imports the package. The
+ * formatter orders a package import and a relative one differently, so the import block
+ * is compared as a set of statements and everything after it exactly.
+ */
+function adapterNeutral(source: string): string {
+ const adapter = "@@ADAPTER@@";
+ const moved = source
+ .replace(new RegExp(`${HTTP_RUNTIME_PACKAGE}/[\\w-]+`, "g"), adapter)
+ .replace(new RegExp(`(?:\\.\\./)+${OWNED_RUNTIME_DIR}/[\\w-]+/[\\w-]+`, "g"), adapter);
+ const IMPORT = /^import [^;]*;\n/gm;
+ return [...(moved.match(IMPORT) ?? [])].sort().join("") + moved.replace(IMPORT, "");
+}
+
+describe("eject contract — depth", () => {
+ const owned = new Map();
+
+ beforeAll(async () => {
+ mkdirSync(WORKSPACE_TMP, { recursive: true });
+ for (const entry of catalog) {
+ if (!entry.ejectable) continue;
+ const root = mkdtempSync(join(WORKSPACE_TMP, "eject-contract-"));
+ const result = await ejectGenerator({ cwd: root, name: entry.name });
+ owned.set(entry.name, {
+ name: entry.name,
+ root,
+ files: filesUnder(join(root, "codegen")),
+ generatorPath: join(root, result.path),
+ handsRuntimeOver: (result.runtime?.files ?? []).length > 0,
+ });
+ }
+ });
+
+ afterAll(() => {
+ for (const { root } of owned.values()) rmSync(root, { recursive: true, force: true });
+ });
+
+ test("the scan counts a type-only import, an inline import type and a dynamic import", () => {
+ // The scan is what the assertion below rests on, so it is held to a file whose
+ // answer is known: three ways into emit code, and one import of the engine.
+ const root = mkdtempSync(join(WORKSPACE_TMP, "eject-contract-probe-"));
+ try {
+ const probe = join(root, "probe.ts");
+ writeFileSync(
+ probe,
+ [
+ 'import type { Generator, RequirementTestsOpts } from "@metaobjectsdev/codegen-ts";',
+ 'export type Row = import("@metaobjectsdev/codegen-ts").RequirementRow;',
+ "export type Kept = [Generator, RequirementTestsOpts];",
+ 'export const load = () => import("@metaobjectsdev/codegen-ts/generators");',
+ "",
+ ].join("\n"),
+ );
+ expect(outsideEngineSurface([probe])).toEqual([
+ "codegen-ts/src/generators/index.ts",
+ "codegen-ts/src/generators/requirement-tests.ts",
+ "codegen-ts/src/generators/requirements-view.ts",
+ ]);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ }, 60_000);
+
+ test("an owned copy imports only the engine surface", () => {
+ const waiting = pending("depth");
+ const problems: string[] = [];
+ for (const [name, copy] of owned) {
+ const actual = outsideEngineSurface(copy.files);
+ const expected = (waiting.get(name) ?? []).flatMap((p) => p.outside ?? []).sort();
+ if (JSON.stringify(actual) === JSON.stringify(expected)) continue;
+ if (expected.length === 0) {
+ problems.push(
+ `${name}: its owned copy imports emit code that stays in the package: ${actual.join(", ")}. ` +
+ `Hand it over with the generator; an import of the engine belongs in eject.engineSurface.${PORT}.engine.`,
+ );
+ } else if (actual.length === 0) {
+ problems.push(`${name}: its owned copy is whole now; remove its pending depth entry`);
+ } else {
+ problems.push(`${name}: pending depth entries list [${expected.join(", ")}], the owned copy imports [${actual.join(", ")}]`);
+ }
+ }
+ for (const name of waiting.keys()) {
+ if (!owned.has(name)) problems.push(`${name}: pending depth entry for a generator that is not ejectable`);
+ }
+ expect(problems).toEqual([]);
+ }, 60_000);
+
+ test("every engine surface entry names a real module", () => {
+ const stale = SURFACE.engine.filter((m) => !m.includes("*") && !existsSync(join(PACKAGES, m)));
+ expect(stale).toEqual([]);
+ });
+
+ for (const entry of catalog) {
+ if (!entry.ejectable) continue;
+ test(`${entry.name}: an unchanged owned copy loads alone and emits what the packaged generator emits`, async () => {
+ const copy = owned.get(entry.name)!;
+ const module = (await import(copy.generatorPath)) as Record;
+ const factory = module[entry.factoryExport];
+ expect(typeof factory, `${copy.generatorPath} exports ${entry.factoryExport}`).toBe("function");
+ // The packaged side is the factory an adopter imports, by the name the catalog prints.
+ const shipped = ((await import(factoryModuleOf(entry.name)!)) as Record)[entry.factoryExport];
+ const throughDocsDoor = entry.docsSurface !== undefined;
+
+ let emitted = 0;
+ for (const corpus of CORPORA) {
+ if (corpus.templates !== undefined) cpSync(corpus.templates, join(copy.root, "templates"), { recursive: true });
+ // Same depth under the project root on both sides, so a relative import of the
+ // owned adapter is spelled the same way in each.
+ const packaged = await emit(
+ build(shipped as Factory, entry.name), corpus, copy.root, join(copy.root, "out", corpus.label, "packaged"), throughDocsDoor);
+ const mine = await emit(
+ build(factory as Factory, entry.name), corpus, copy.root, join(copy.root, "out", corpus.label, "owned"), throughDocsDoor);
+ rmSync(join(copy.root, "templates"), { recursive: true, force: true });
+ emitted += Object.keys(packaged).filter((path) => path !== REFUSED).length;
+ expect(Object.keys(mine).sort(), `${entry.name} over ${corpus.label}`).toEqual(Object.keys(packaged).sort());
+ for (const [path, content] of Object.entries(packaged)) {
+ const [a, b] = copy.handsRuntimeOver ? [adapterNeutral(content), adapterNeutral(mine[path]!)] : [content, mine[path]!];
+ expect(`${path}:\n${b}`, `${entry.name} over ${corpus.label}`).toBe(`${path}:\n${a}`);
+ }
+ }
+ // An empty emit on both sides compares equal and proves nothing.
+ expect(emitted, `${entry.name} emitted nothing over ${CORPORA.map((c) => c.label).join(", ")}`).toBeGreaterThan(0);
+ });
+ }
+});
+
+// ---------------------------------------------------------------------------------------
+// the shared file itself
+// ---------------------------------------------------------------------------------------
+
+describe("eject contract — the shared file", () => {
+ test("every exemption carries a reason and a ruling", () => {
+ for (const [name, spec] of Object.entries(manifest.generators)) {
+ if (spec.eject === "full") continue;
+ expect(Object.keys(spec.eject).sort(), name).toEqual(["exempt", "ruling"]);
+ expect(spec.eject.exempt.trim(), name).not.toBe("");
+ expect(spec.eject.ruling.trim(), name).not.toBe("");
+ }
+ });
+
+ test("every port declares an engine surface", () => {
+ expect(Object.keys(manifest.eject.engineSurface).sort()).toEqual([...manifest.ports].sort());
+ });
+
+ test("every pending entry is well formed, and listed once", () => {
+ const seen = new Set();
+ for (const entry of manifest.eject.pending) {
+ const where = JSON.stringify(entry);
+ expect(Object.keys(entry).filter((key) => key !== "outside").sort(), where)
+ .toEqual(["assertion", "gap", "generator", "port"]);
+ expect(manifest.ports, where).toContain(entry.port);
+ expect(ASSERTIONS as readonly string[], where).toContain(entry.assertion);
+ expect(Object.keys(manifest.generators), where).toContain(entry.generator);
+ expect(Number.isInteger(entry.gap), where).toBe(true);
+ expect(entry.outside !== undefined, where).toBe(entry.assertion === "depth");
+ if (entry.outside !== undefined) {
+ expect(entry.outside.length, where).toBeGreaterThan(0);
+ expect(entry.outside, where).toEqual([...entry.outside].sort());
+ }
+ // One entry per generator, port and assertion; a depth entry per gap as well.
+ const identity = JSON.stringify(
+ [entry.generator, entry.port, entry.assertion, entry.outside === undefined ? null : entry.gap]);
+ expect(seen.has(identity), `duplicate pending entry: ${where}`).toBe(false);
+ seen.add(identity);
+ }
+ });
+});
diff --git a/server/typescript/packages/cli/test/gen-list.test.ts b/server/typescript/packages/cli/test/gen-list.test.ts
index 9f045bb97..4ea16ca66 100644
--- a/server/typescript/packages/cli/test/gen-list.test.ts
+++ b/server/typescript/packages/cli/test/gen-list.test.ts
@@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { genCommand } from "../src/commands/gen.js";
+import { listCatalog } from "../src/lib/catalog.js";
// ADR-0021 D3 — `meta gen --list` discoverability surface.
//
@@ -93,29 +94,21 @@ describe("meta gen --list (ADR-0021 D3)", () => {
}
});
- test("marks each generator that ships no reference copy as package-only, and says why", async () => {
+ test("marks exactly the generators that ship no reference copy as package-only, and says why", async () => {
// The note above says `meta eject` copies a generator; for an entry with no
// reference template that is false, so the row has to say so where it is read.
+ // WHICH entries those are is the eject contract's to rule on (eject-contract.test.ts,
+ // against the shared manifest); this holds the mark to the catalog, row by row.
const tmp = mkdtempSync(join(tmpdir(), "meta-gen-list-"));
try {
await genCommand(["--list"], tmp);
const text = out();
const line = (name: string) => text.split("\n").find((l) => l.startsWith(` ${name} `)) ?? "";
- // The one entry left: the Mustache primitive, where the adopter already supplies
- // the template and there is no emit logic to own (ADR-0034 Amendment 7).
- expect(line("template")).toContain("package-only");
- expect(text.split("\n").filter((l) => l.includes("[package-only]")).length).toBe(1);
- // An ejectable entry is never marked. `callable` and `trace-helper` were marked
- // until they gained reference templates; `shared-model` and the docs tier until
- // Amendment 7.
- expect(line("callable")).not.toContain("package-only");
- expect(line("trace-helper")).not.toContain("package-only");
- expect(line("shared-model")).not.toContain("package-only");
- expect(line("docs")).not.toContain("package-only");
- expect(line("api-docs")).not.toContain("package-only");
- expect(line("prompt-render")).not.toContain("package-only");
- expect(line("entity")).not.toContain("package-only");
- expect(text).toContain("package-only = no reference template ships");
+ const catalog = listCatalog();
+ for (const entry of catalog) {
+ expect(line(entry.name).includes("package-only"), entry.name).toBe(!entry.ejectable);
+ }
+ expect(text.includes("package-only = no reference template ships")).toBe(catalog.some((e) => !e.ejectable));
} finally {
rmSync(tmp, { recursive: true, force: true });
}
diff --git a/server/typescript/packages/cli/test/unit/reporting-inert.test.ts b/server/typescript/packages/cli/test/unit/reporting-inert.test.ts
index 612f717fa..1e3e83b64 100644
--- a/server/typescript/packages/cli/test/unit/reporting-inert.test.ts
+++ b/server/typescript/packages/cli/test/unit/reporting-inert.test.ts
@@ -150,6 +150,9 @@ const ADDED_FOR_WITH: Readonly> = {
};
/** The one existing file a served report changes: it gains the report's export line. */
const BARREL = `${OUT}/index.ts`;
+/** The agent UI page, which `agent-docs` writes when the run also wires the client tier. It
+ * is one page for the whole model, so a served report adds its section to it. */
+const AGENT_UI_PAGE = `${OUT}/agent/ui.md`;
/** The client UI generators that emit NOTHING for a report: a report has a list hook and no
* grid, grid hook or form. */
const UI_TIER = ["grid", "grid-hook", "form"] as const;
@@ -158,16 +161,17 @@ const UI_TIER = ["grid", "grid-hook", "form"] as const;
const SOURCELESS_REPORTS = ["ProgramEngagement", "DailyRevenue", "ProgramCatalogue"] as const;
/** Assert `actual` is `expected` plus exactly `added`, with every shared file
- * byte-identical except the barrel. */
+ * byte-identical except the barrel and any whole-model page named in `grows`. */
function expectOnlyAdds(
expected: Record,
actual: Record,
added: readonly string[],
+ grows: readonly string[] = [],
): void {
expect(Object.keys(actual).filter((p) => !(p in expected)).sort()).toEqual([...added].sort());
expect(Object.keys(expected).filter((p) => !(p in actual))).toEqual([]);
for (const [path, content] of Object.entries(expected)) {
- if (path === BARREL) continue;
+ if (path === BARREL || grows.includes(path)) continue;
expect({ path, content: actual[path] }).toEqual({ path, content });
}
// Nothing at all for a sourceless report, in any file name.
@@ -306,7 +310,16 @@ describe("FR-044 a sourceless report is inert; a served report emits exactly its
expect(expected[""]).toBeUndefined();
expect(actual[""]).toBeUndefined();
expect(Object.keys(expected).length).toBeGreaterThan(10);
- expectOnlyAdds(expected, actual, Object.values(ADDED_FOR_WITH).flat());
+ expectOnlyAdds(expected, actual, Object.values(ADDED_FOR_WITH).flat(), [AGENT_UI_PAGE]);
+ // The agent UI page moves by the served report's section alone: every line it had is
+ // still there, in order, and the section names the report.
+ const before = expected[AGENT_UI_PAGE]!.split("\n");
+ const after = actual[AGENT_UI_PAGE]!.split("\n");
+ let kept = 0;
+ for (const line of after) if (line === before[kept]) kept++;
+ expect(kept).toBe(before.length);
+ expect(after.filter((l) => !before.includes(l)).join("\n")).toContain("StoreTotals");
+ for (const name of SOURCELESS_REPORTS) expect(actual[AGENT_UI_PAGE]).not.toContain(name);
// In the full suite the barrel re-exports the report's modules.
expect(actual[BARREL]).toContain("StoreTotals");
expect(expected[BARREL]).not.toContain("StoreTotals");
diff --git a/server/typescript/packages/codegen-ts/src/generator-registry.ts b/server/typescript/packages/codegen-ts/src/generator-registry.ts
index d2e0aa01a..1133d8088 100644
--- a/server/typescript/packages/codegen-ts/src/generator-registry.ts
+++ b/server/typescript/packages/codegen-ts/src/generator-registry.ts
@@ -48,6 +48,8 @@ import {
traceHelperFile,
sharedModelFile,
requirementTests,
+ requirementsFile,
+ agentDocsFile,
cubeModel,
} from "./generators/index.js";
@@ -310,6 +312,31 @@ export const generatorRegistry: Record = {
note: "neutral artifact — use `meta docs` (the single docs door, ADR-0021 D1); not part of the recommended `meta gen` native suite.",
},
+ requirements: {
+ name: "requirements",
+ kind: "generator",
+ layer: "docs",
+ description: "The requirement ledger as documentation: requirements.md for people and requirements.toon for tools.",
+ tier: "neutral",
+ factory: () => requirementsFile(),
+ factoryExport: "requirementsFile",
+ options: "outDir?, target?",
+ ejectable: ejectable("requirements"),
+ note: "neutral artifact — `meta docs` writes it as its requirements surface; a project that declares no requirement.* node gets no file.",
+ },
+ "agent-docs": {
+ name: "agent-docs",
+ kind: "generator",
+ layer: "docs",
+ description: "Pages written for a coding agent working in the project: the schema, the UI surface and the requirements.",
+ tier: "neutral",
+ factory: () => agentDocsFile(),
+ factoryExport: "agentDocsFile",
+ options: "subDir?, schema?, columnNamingStrategy?, target?",
+ ejectable: ejectable("agent-docs"),
+ note: "neutral artifact — `meta docs --agent` writes it; not part of the recommended `meta gen` native suite.",
+ },
+
// ----- capability -------------------------------------------------------
// Not chosen by browsing this list — chosen because the MODEL already asks for
// them. `meta gen --list --probe` reports a real file count per entry.
diff --git a/server/typescript/packages/codegen-ts/src/generators/index.ts b/server/typescript/packages/codegen-ts/src/generators/index.ts
index 402e79796..8e113c67c 100644
--- a/server/typescript/packages/codegen-ts/src/generators/index.ts
+++ b/server/typescript/packages/codegen-ts/src/generators/index.ts
@@ -14,10 +14,12 @@
//
// Which entries have a reference copy is declared once, in `REFERENCE_GENERATOR_NAMES`
// (reference-templates.ts); `meta gen --list` marks every other entry `package-only` —
-// today that is `template` alone, the Mustache primitive, which has no emit logic of its
-// own. `docsFile` and `apiDocsFile` are exported here as the generators `meta docs` runs;
-// they need no wiring. An ejected copy of either is listed in `docs: { generators: [...] }`,
-// and `meta docs` then runs the copy (Amendment 7).
+// today that is `template`, the Mustache primitive, which has no emit logic of its own,
+// and `requirements` and `agent-docs`, which do not ship a reference template yet.
+//
+// `docsFile` and `apiDocsFile` are exported here as the generators `meta docs` runs; they
+// need no wiring. An ejected copy of either is listed in `docs: { generators: [...] }`, and
+// `meta docs` then runs the copy (Amendment 7).
// See spec/decisions/ADR-0034-codegen-scaffold-and-own.md.
export { entityFile, type EntityFileOpts } from "./entity-file.js";
diff --git a/server/typescript/packages/codegen-ts/src/generators/requirement-tests.ts b/server/typescript/packages/codegen-ts/src/generators/requirement-tests.ts
index 63e0ba132..a441b841b 100644
--- a/server/typescript/packages/codegen-ts/src/generators/requirement-tests.ts
+++ b/server/typescript/packages/codegen-ts/src/generators/requirement-tests.ts
@@ -27,15 +27,14 @@ import {
requirementTestUnits,
requirementTestIdentity,
NO_CONCERN,
+ type RequirementTestArgs,
type RequirementTestGrain,
+ type RequirementTestRenderer,
type RequirementView,
} from "../requirement-walk.js";
-import {
- renderRequirementTest,
- type RequirementTestArgs,
-} from "../templates/requirement-test.js";
+import { renderRequirementTest } from "../templates/requirement-test.js";
-export type RequirementTestRenderer = (a: RequirementTestArgs) => string;
+export type { RequirementTestRenderer };
export interface RequirementTestsOpts {
/** Generator name — surfaces in diagnostics and drift logs. */
diff --git a/server/typescript/packages/codegen-ts/src/requirement-walk.ts b/server/typescript/packages/codegen-ts/src/requirement-walk.ts
index 85252d274..ae0c0197e 100644
--- a/server/typescript/packages/codegen-ts/src/requirement-walk.ts
+++ b/server/typescript/packages/codegen-ts/src/requirement-walk.ts
@@ -176,6 +176,39 @@ export function assertRequirementTestGrain(grain: unknown): asserts grain is Req
}
}
+/**
+ * What a requirement-test renderer is handed for one stub. Declared here, beside the
+ * walk that fills it, because it is the contract a renderer is written against: the
+ * packaged renderer and an owned one read the same record.
+ */
+export interface RequirementTestArgs {
+ readonly view: RequirementView;
+ /** The fan-out key this stub stands for: `.` under the default grain,
+ * the reference as authored under `grain: "member"`, `*` when the requirement
+ * resolves no target. Always equal to `unit`, which is the name the other language
+ * ports use for it. */
+ readonly concern: string;
+ readonly statement: string;
+ readonly counterexample: string;
+ /** The claims this stub covers. Each carries its own `concern`, which stays the
+ * target's `.` in both grains. */
+ readonly targets: readonly ResolvedClaim[];
+ readonly disposition?: string | undefined;
+ readonly trackedBy?: readonly string[] | undefined;
+ // The test's identity record (`RequirementTestIdentity`) — the same six values in
+ // every language port. They are DATA for an application's renderer: the default
+ // renderer below reads none of them, so its output does not move when they do.
+ readonly package: string;
+ readonly unit: string;
+ readonly id: string;
+ readonly witnessKey: string;
+ readonly skip: "planned" | "retired" | null;
+ readonly digest: string;
+}
+
+/** Turns one stub's record into the text of its test file. */
+export type RequirementTestRenderer = (a: RequirementTestArgs) => string;
+
/** One generated test. The same record in every language port. */
export interface RequirementTestIdentity {
/** The requirement's effective package. */
diff --git a/server/typescript/packages/codegen-ts/src/templates/requirement-test.ts b/server/typescript/packages/codegen-ts/src/templates/requirement-test.ts
index dd55d76c2..ccccbed92 100644
--- a/server/typescript/packages/codegen-ts/src/templates/requirement-test.ts
+++ b/server/typescript/packages/codegen-ts/src/templates/requirement-test.ts
@@ -16,32 +16,11 @@ import {
REQUIREMENT_STATUS_RETIRED,
} from "@metaobjectsdev/metadata";
import { GENERATED_HEADER } from "../constants.js";
-import type { RequirementView, ResolvedClaim } from "../requirement-walk.js";
+import type { RequirementTestArgs, ResolvedClaim } from "../requirement-walk.js";
-export interface RequirementTestArgs {
- readonly view: RequirementView;
- /** The fan-out key this stub stands for: `.` under the default grain,
- * the reference as authored under `grain: "member"`, `*` when the requirement
- * resolves no target. Always equal to `unit`, which is the name the other language
- * ports use for it. */
- readonly concern: string;
- readonly statement: string;
- readonly counterexample: string;
- /** The claims this stub covers. Each carries its own `concern`, which stays the
- * target's `.` in both grains. */
- readonly targets: readonly ResolvedClaim[];
- readonly disposition?: string | undefined;
- readonly trackedBy?: readonly string[] | undefined;
- // The test's identity record (`RequirementTestIdentity`) — the same six values in
- // every language port. They are DATA for an application's renderer: the default
- // renderer below reads none of them, so its output does not move when they do.
- readonly package: string;
- readonly unit: string;
- readonly id: string;
- readonly witnessKey: string;
- readonly skip: "planned" | "retired" | null;
- readonly digest: string;
-}
+// Declared with the walk: it is the contract between the walk and ANY renderer, so an
+// owned generator reads it from there and not from this module.
+export type { RequirementTestArgs };
/**
* Statuses whose stub is SKIPPED rather than failing.
diff --git a/server/typescript/packages/codegen-ts/test/generator-registry.test.ts b/server/typescript/packages/codegen-ts/test/generator-registry.test.ts
index cf12c582b..4b0ddf5f7 100644
--- a/server/typescript/packages/codegen-ts/test/generator-registry.test.ts
+++ b/server/typescript/packages/codegen-ts/test/generator-registry.test.ts
@@ -39,7 +39,7 @@ const EXPECTED_NATIVE = [
// Neutral / `meta docs`-owned (Tier-2). Present in the registry for identity +
// discoverability, but flagged so they are NOT part of the recommended native
// `meta gen` surface.
-const EXPECTED_NEUTRAL = ["docs"] as const;
+const EXPECTED_NEUTRAL = ["docs", "requirements", "agent-docs"] as const;
describe("generator-registry (ADR-0021 D3)", () => {
test("contains every expected stable name", () => {
@@ -88,12 +88,15 @@ describe("generator-registry (ADR-0021 D3)", () => {
});
// The other direction. The standalone `mermaid-er` generator was the one docs-layer
- // entry `meta gen` still ran; 1.1 removed it, so every docs-layer entry is one
- // `meta docs` runs.
- test("no docs-layer entry is left that `meta gen` runs", () => {
+ // entry `meta gen` still ran by design; 1.1 removed it.
+ test("the docs-layer entries `meta gen` does not skip are the two `meta docs` engines", () => {
const run = Object.values(generatorRegistry)
.filter((e) => e.layer === "docs" && !isPackagedDocsGenerator(e.factory().name));
- expect(run.map((e) => e.name)).toEqual([]);
+ // `requirements` and `agent-docs` are the engines behind `meta docs` and
+ // `meta docs --agent`. They were in no registry until the eject census found them
+ // (ADR-0034 Amendment 8), and `meta gen` does not skip a wired factory of either, as
+ // it did not before they had rows. Pinned, so a third cannot join unnoticed.
+ expect(run.map((e) => e.name).sort()).toEqual(["agent-docs", "requirements"]);
});
// Two definitions have to agree: the runner skips by implementation name
diff --git a/spec/decisions/ADR-0034-codegen-scaffold-and-own.md b/spec/decisions/ADR-0034-codegen-scaffold-and-own.md
index 4cb1c7511..fe8b74f20 100644
--- a/spec/decisions/ADR-0034-codegen-scaffold-and-own.md
+++ b/spec/decisions/ADR-0034-codegen-scaffold-and-own.md
@@ -48,7 +48,8 @@ component library — code you copy into your project and own."
framework: read the project → pick/compose → copy → wire → customize). The actor —
a human or Claude — does the copy + config wiring with plain file operations. A
copied generator imports the *primitives* from the package but **owns the emit
- logic**. Tooling stays minimal: `meta init` scaffolds a sensible default generator
+ logic** (this was the intent, not yet what every generator shipped: see Amendment 8).
+ Tooling stays minimal: `meta init` scaffolds a sensible default generator
set + local-import config for a running start; it does **not** interview the
project or select templates. **Adding a new starting point is adding a documented
file, never a CLI change.**
@@ -226,7 +227,9 @@ reference is scaffolding; this amendment stops describing it as anything else.
framework-specific traps. They stay in the package and keep their tests and the
api-contract lanes, but they are not a promise. An adopter who ejects the route
generator owns the call into them and can mount routes by hand instead.
-- **Ejecting a helper hands over ALL of its code (follow-through, 2026-09-26).** "An adopter
+- **Ejecting a helper hands over ALL of its code (follow-through, 2026-09-26; true of the
+ runtime helpers and the `routes` composition described here, and made the rule for every
+ generator's emit by Amendment 8).** "An adopter
who ejects the route generator owns the call into them" was half of owning it: the
output still imported the adapter from the package, so an adapter defect still waited on
a release. `meta eject routes` / `routes-hono` / `entity` now also copy the adapter
@@ -640,3 +643,302 @@ emit logic of its own, so a copy would own nothing. TypeScript, Java, C# and Pyt
the same way, and `meta gen --list` marks it `package-only`. This is the one entry the rule
"every stock generator can be ejected" does not reach, and it is not a gap to close. The
rest of the audit's list is outside TypeScript and is not covered by this amendment.
+
+### Amendment 8 (2026-10-11) — the eject contract: every generator hands over its emit, and a test in every port holds it to that
+
+**Ruling (maintainer).** Asked whether every stock generator should be ejectable: *"Yes,
+everything should be ejectable."* An audit of all five ports followed. It found that three
+separate pieces of work had each made a named list ejectable, that the rule had become
+stricter each time, and that the stricter rule was never applied back to the whole catalog.
+The newest generator had been built as a thin copy two weeks after "ejecting a helper hands
+over all of its code" was written, with every lane green. No test stated the rule, so nothing
+failed when a generator broke it. The maintainer accepted the audit's six recommendations:
+
+1. **The owned copy holds the emit, in TypeScript too.** A TypeScript reference template that
+ calls a packaged `render*` composer hands over which objects get a file and where it
+ lands, and nothing that writes the file. That is not ownership. The TypeScript references
+ are made whole by copying the template modules a reference calls into the project with
+ it, the way `meta eject` already copies the HTTP adapter, and not by inlining them by
+ hand.
+2. **Naming and type-mapping tables are engine.** They stay packaged and public. Everything
+ that returns emitted text is handed over.
+3. **The docs command's emitter is in scope in C#, Python, Java and Kotlin**, as the
+ TypeScript docs tier already is. The docs-site builders are exempt: `meta docs
+ --scaffold-site` already copies the site's templates and assets, and those are what an
+ adopter changes.
+4. **The Java generators that no registry listed are registered**, and the four that are not
+ deprecated get eject source. The deprecated Mustache generator is registered and exempt.
+5. **`template` is exempt in every port that ships it.** It is the Mustache primitive: the
+ template and the walk the adopter supplies are its emit logic.
+6. **The prompt-tier emitters are handed over in every port**, although a shared corpus
+ pins their output byte for byte. `shared-model` got the same answer for the same
+ objection.
+
+**Supersedes.**
+
+- **FR-040 section 4.2**, "Not chosen: shipping fat reference templates that inline the
+ renderer". The choice it rejected is the rule now, reached by copying the template
+ modules and not by inlining.
+- **The eject-in-every-port design's rule** "A copy imports only public API. Where a
+ reference generator uses an internal helper, the helper is made public in the same
+ change." A helper that writes output is copied with the generator. Making it public
+ leaves the adopter calling code they cannot edit.
+- **Decision 2's clause** "A copied generator imports the primitives from the package but
+ owns the emit logic", as a description of what shipped. It described the intent. Until
+ this amendment's pending list is empty it does not describe every generator.
+- **Amendment 3's "Ejecting a helper hands over ALL of its code"**, as a description of what
+ shipped. It was true of the runtime helpers a generator's output imports and of the
+ TypeScript `routes` composition. It was not yet true of the emit behind `entity`,
+ `queries`, `names`, the prompt tier or the UI tier.
+
+**The standard.** For every stock generator, in every port:
+
+- **It is in the registry.** A generator that is not registered is outside the catalog, the
+ eject command and every test that reads them.
+- **It ships eject source**, unless the shared manifest exempts it with a written reason
+ and the ruling that grants it.
+- **Its owned copy holds its emit.** Ejected alone into an empty project, the copy compiles
+ or loads, calls nothing that writes output from the package, and emits what the packaged
+ generator emits.
+
+**The engine surface.** An owned copy still imports the package. What it may import is
+written down per port, in the shared manifest
+(`fixtures/generator-registry-conformance/registry.json`, `eject.engineSurface`):
+
+- `codegenPackages` names the namespaces that hold generators and their emit code.
+- `engine` lists the members of those namespaces an owned copy may still import: the
+ generator base and runner, the file writer and formatter, questions about the model (which
+ objects are served, the TPH plan, which templates respond), and the naming and
+ type-mapping tables of ruling 2.
+- Everything else inside `codegenPackages` writes output and is handed over with the
+ generator.
+- Everything outside `codegenPackages` is core by Amendment 3 (the loader and metamodel,
+ the runtime, render and the reply parser) and is imported freely.
+
+Adding a module to `engine` is a reviewed decision in one file. It cannot happen by leaving
+a name off a list.
+
+**Engine-surface names that were a judgment call.** Most of each `engine` list is a base
+class, a runner, a writer or a naming table, and needs no argument. These names sit closer
+to the line. Each answers a question about the model and returns no emitted text, which is
+the rule of ruling 2, but each was classified by that rule and not ruled on by name. They
+are listed so a maintainer can review them in one place. A name on an `engine` list is not
+checked further by the guard.
+
+| Port | Names |
+|---|---|
+| TypeScript | `requirement-walk.ts`, `relation-resolver.ts`, `render-context.ts`, `templates/find-inbound.ts`, `templates/find-templates.ts`, `render-engine/framework-provider.ts` |
+| C# | `ReportRows`, `PackageBindingResolver`, `GeneratorApplicability` |
+| Java | `BaseObjectCodeGenerator`, `SpringM2mSupport`, `AutoSetSupport`, `TphPlan`, `FindInbound` |
+| Kotlin | `ExposedApi`, `ExposedImports`, `KotlinGenUtil`, `KotlinM2mSupport` |
+| Python | `value_objects`, `find_inbound`, `tph_plan`, `requirement_walk` |
+
+**Exemptions.** Each is an entry in the shared manifest with its reason.
+
+| What | Port | Why |
+|---|---|---|
+| `template` | TypeScript, C#, Java, Python | The Mustache primitive. The adopter's template and walk are its emit logic (ruling 5). |
+| `mustache` | Java | Deprecated in favour of `template` (ADR-0021 D4), kept so existing poms build (ruling 4). |
+| The docs-site builders | TypeScript | Not a generator. The site's templates and assets are already copied into the project (ruling 3). |
+
+Angular's generators are outside the census because the package is not published
+(ADR-0048). The census reads the publish set, so they enter it on promotion.
+
+**The guard.** Every port has an eject-contract test, driven by the shared manifest. Each
+generator there carries `"eject": "full"` or an exemption.
+
+1. **Census.** The test scans what the port ships for every generator implementation and
+ fails on one the registry does not hold: every exported factory whose type returns a
+ `Generator` in the published TypeScript codegen packages, every concrete `Generator`
+ class on the Maven plugin's classpath, every `IGenerator` in the shipped C# assemblies,
+ every class with a `generate` method under `metaobjects.codegen`. It also holds the
+ registry to the names the manifest gives the port.
+2. **Completeness.** Every registered generator ships eject source unless exempt. The other
+ hand-kept lists are derived from the registry or held equal to it: the TypeScript
+ reference directories, the JVM reference sources each jar ships, the C# embedded
+ resources, and the compile-test lists.
+3. **Depth.** Each generator is ejected alone and compiled or loaded. The test resolves what
+ the copy references in the codegen packages and fails on anything outside the engine
+ surface. The unchanged copy must emit what the packaged generator emits, over shared
+ models that make every generator emit something. One difference is allowed: an owned
+ copy's output imports the helper runtime the adopter owns.
+
+ How each port resolves a reference:
+
+ | Port | What is read | What counts |
+ |---|---|---|
+ | TypeScript | The compiler's checker, each binding followed through any barrel to the file that declares it | A static import or re-export, type-only ones too; an inline `import("…").Type`; a dynamic `import("…")`, counted as the module it names |
+ | C# | The Roslyn semantic model of the owned file | Every type the file names |
+ | Java, Kotlin | The constant pool of the compiled copy | Every class the bytecode refers to |
+ | Python | The syntax tree of the owned module | Import statements, and `importlib.import_module` / `__import__` on a string literal |
+
+ A type declared in an emit module counts in TypeScript because a copy that imports it is
+ still tied to that module's shape.
+
+The tests that asserted a gap were removed with the lists they pinned: a `--list` test that
+required named generators to be `package-only`, and a Java test that pinned the set of
+generators with no eject source.
+
+**The pending list, and the release gate.** The standard is ahead of the code. Every place
+it does not hold yet is one entry in the manifest's `eject.pending` list: the generator, the
+port, the assertion and the gap number below, and for a depth entry the packaged modules the
+owned copy still references. The list works in both directions.
+
+- A gap that is not listed fails the port's test.
+- A listed entry that now passes fails it too, so closing a gap means deleting its entry in
+ the same change. A depth entry is compared module by module, so a copy that stops
+ importing one of three packaged modules fails until the entry says so.
+
+**1.1.0 does not ship with a non-empty pending list.** `scripts/eject-release-gate.mjs`
+refuses a version with no prerelease suffix while the list holds an entry. A manifest that
+cannot be read is refused. A prerelease version (`1.1.0-rc.3`, or `1.1.0rc3` as PyPI spells
+it) is allowed, because a release candidate is how the remaining entries get exercised by an
+adopter. An empty list is a
+proven one: each port's test fails on a gap that is not listed, so nothing can be left out
+of the list to pass the gate.
+
+Every scripted path that publishes or tags a release calls the gate:
+
+- `scripts/release.mjs` calls it first in its preflight, before anything is built or
+ published.
+- `scripts/finish-release.mjs` calls it as gate 0, before the tag.
+- The four publish workflows (`publish-npm.yml`, `publish-python.yml`, `publish-java.yml`,
+ `publish-csharp.yml`) run it as the step after checkout, with the version that workflow
+ is about to publish: the committed one, or in `publish-csharp.yml` the `version` input
+ when one is given.
+
+A tag push cannot be exercised in the repository's own checks, so a test is what holds the
+workflows to the rule. `scripts/eject-release-gate.test.ts` parses each workflow, walks
+each job's steps in order to check that the gate step comes before the first publishing
+step, and runs the gate step's own shell against a throwaway repository. The same test
+fails when a publish workflow is added that it does not list.
+
+Four publishes are typed by hand, and only the procedure holds them to the rule. They are
+the `bun publish` loop that promotes npm (`docs/RELEASING.md` step 3), the `bun publish` of
+an isolated one-package patch, the `uv publish` that is the PyPI fallback when its workflow
+cannot run, and the local `mvn -Prelease deploy`. `docs/RELEASING.md` has the gate as the
+command before each. An operator who skips it is not stopped at the publish. The first
+refusal is then `scripts/finish-release.mjs`, which withholds the tag.
+
+**The gaps.** `gap` in a pending entry is a number from this list.
+
+| # | Gap | State |
+|---|---|---|
+| 0 | TypeScript generators with no reference template: `shared-model`, `docs`, `api-docs`. `mermaid-er` is removed, not ejected. | Closed by Amendment 7. The three copies call packaged builders, which is gap 3 |
+| 1 | Java `routes`, `dto` and `value-object` did not compile when ejected alone. | Closed before this amendment |
+| 2 | TypeScript `requirementsFile` and `agentDocsFile` were in no registry. | Registered here as `requirements` and `agent-docs`; reference templates pending |
+| 3 | TypeScript reference templates that call packaged composers. | Pending |
+| 4 | Prompt-tier emitters that stay packaged in C#, Python, Java and Kotlin. | Closed in C#. Pending in Python, Java and Kotlin |
+| 5 | Fragment emitters behind otherwise whole generators (shared enums, M:N navigation, doc comments, record builders). | Closed in C#. Pending in Python, Java and Kotlin |
+| 6 | The docs command's emitter in C#, Python, Java and Kotlin is not a registered `api-docs`. | Closed in C#. Pending in Python, Java and Kotlin |
+| 7 | Java generators outside the registry. | Registered here as `plantuml`, `html-docs`, `ai-docs`, `json-schema` and `mustache`; eject source pending for the first four |
+| 8 | The docs-site builders. | Exempt (ruling 3) |
+| 9 | Python generators that called another generator's packaged module. | Closed before this amendment |
+| 10 | Angular has no reference templates. | Outside the census until the package is published |
+| 11 | Tests that asserted a gap, and hand-kept lists. | Closed here |
+| 12 | Documents that claimed more than the code delivered. | Corrected here, below |
+| 13 | An owned Java `entity` constructs the packaged `extractor` generator when no extractor is in the run. Found by the guard on its first run. | Pending. It closes with the Java work on gaps 4, 5 and 7, which owns the `entity` and `extractor` copies |
+
+**What the guard found on its first run**, beyond the gaps the audit listed:
+
+- Gap 13.
+- The Java PlantUML generator threw on any model whose package has one segment. It had no
+ registry entry, so no registry-driven test had ever run it. Registering it put it in two
+ such suites, both failed, and the generator is fixed.
+- The registry-driven suites in each port now run the newly registered generators too.
+- The TypeScript `requirement-tests` copy imported two types, the renderer's argument record
+ and the renderer signature, from the packaged generator and its template. They are the
+ contract a renderer is written against, so they are now declared beside the requirement
+ walk, which is engine. The package's exports are unchanged.
+
+**Stated limits of the guard.** Each of these is a place where the guard checks less than
+the standard says. They are written down so that a green run is not read as more than it
+is.
+
+*What the reference scan cannot see.*
+
+- **JVM: a dependency that leaves no class reference in bytecode.** The scan reads the
+ compiled constant pool. `javac` inlines a `static final` String or primitive constant, and
+ the Kotlin compiler inlines a `const val` and the body of an `inline` function, so a copy
+ that reads one of those from an emit class shows no reference to it. The constant pool is
+ used because it is one reader for both JVM languages and it sees what the code calls
+ after resolution, with no second parser to keep in step with two compilers.
+- **TypeScript and Python: a module name built at run time.** `import(name)` and
+ `import_module(name)` with a computed name cannot be resolved by reading the file. A
+ string literal is read in both.
+
+*What is compiled, and from what.*
+
+- **Kotlin depth does not run the eject goal.** The goal lives in `maven-plugin`, which
+ depends on `codegen-kotlin`, and the Kotlin compile test has to live in `codegen-kotlin`.
+ The test reads the reference source the jar ships and rewrites its package line, which
+ is the one edit eject makes to a Kotlin copy. If eject starts doing more than that, this
+ test will not see it.
+- **C# compiles the generator and the emitters eject hands over with it**, which is every
+ file eject writes under `codegen/generators`. The helper-runtime files eject writes
+ elsewhere are compiled by `HelperRuntimeTests`, against the web framework they need.
+
+*What "emits what the packaged generator emits" allows.*
+
+- **One difference per port: the helper-runtime import.** Amendment 3 hands the runtime
+ over, so an owned copy's output imports it from the project and the packaged output
+ imports it from the package. TypeScript compares the import block as a set for the three
+ generators that hand the HTTP adapter over. Python compares `routes` as a multiset of
+ lines. C# rewrites one `using`. Java points the packaged generator at the owned runtime
+ package.
+- **"Emits something" is per generator, across the shared models.** No one model makes
+ every generator emit: a prompt generator needs a template, a grid generator a layout.
+ Each generator must emit over at least one model, and is compared over empty output on
+ the models that do not concern it.
+- **TypeScript `shared-model` refuses one of the shared models**, because it re-reads the
+ sources strictly. The packaged and the owned generator must refuse it with the same
+ message. That is a comparison of two refusals, not of two outputs.
+- **Java compares `entity` in one flavour** (`type=interface`). The flavours are covered by
+ `EntityExtractorEjectRoundTripTest`.
+
+*What the census does not reach.*
+
+- **Angular**, because its package is not published (above).
+- **Python modules outside `metaobjects.codegen`.** The scan walks that package, which is
+ where every generator lives today.
+- **`exemptEmitters`.** An entry is checked for a path that exists, a reason and a ruling.
+ That the code at the path is not a generator is the ruling's claim, and no test checks
+ it.
+- **One hand-kept list is left:** the compile test in the Java `codegen-base` module, which
+ cannot see the registry from where it sits.
+
+*What changed in existing tests so the new rows could run.*
+
+- **The older registry-conformance tests in C#, Java, Kotlin and Python subtract the names
+ with a pending census entry.** The manifest lists `api-docs` for those ports (gap 6) and
+ the Java, Kotlin and Python registries do not hold it yet. The pending entry is the one
+ record of that difference. Deleting the entry removes the subtraction, as it has in C#.
+- **Two registry-driven Java suites pass an `outputFilename` argument** to generators that
+ write one file, so the five newly registered rows run in them. It is a test argument;
+ no generator changed.
+- **The TypeScript test "no docs-layer entry is left that `meta gen` runs" now names two:**
+ `requirements` and `agent-docs`. `meta gen` runs a wired factory of either. That was
+ true before they were registered and is visible now.
+
+**Document corrections.** Each of these claimed more than the code delivered.
+
+- Decision 2 and Amendment 3 of this ADR, superseded above.
+- `docs/features/own-your-codegen.md` said owning a generator means owning the code it
+ writes and the code that code calls. It now says where that is not yet true and points
+ here.
+- `server/java/README.md` listed the PlantUML and Mustache modules as ejectable codegen.
+ Neither had eject source.
+- The Java registry's documentation pointed at a design passage for "why some registered
+ generators deliberately carry no eject source". No such passage exists. It now points at
+ the shared manifest, where each exemption has its reason.
+- `fixtures/generator-registry-conformance/README.md` gained the `eject` contract.
+
+**What does not change.**
+
+- **The core and helper line of Amendment 3.** A generator is still a reference helper.
+- **Amendment 5.** Every stock generator still runs as shipped, by import or by name.
+ Ejecting is how an adopter changes what one emits.
+- **Existing owned copies.** They keep running. A copy made before a gap closes keeps
+ calling the package where it did; re-ejecting takes the whole emit.
+- **Packaged output.** This amendment adds registry rows and tests. No generator emits
+ different bytes because of it.
diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json
index aaa6228ac..9f6410b68 100644
--- a/tsconfig.scripts.json
+++ b/tsconfig.scripts.json
@@ -146,6 +146,7 @@
"scripts/finish-release.mjs",
"scripts/site-inject-ci.mjs",
"scripts/check-migration-guides-vs-registry.mjs",
- "scripts/maven-coordinate.mjs"
+ "scripts/maven-coordinate.mjs",
+ "scripts/eject-release-gate.mjs"
]
}