Skip to content

Commit 593af0e

Browse files
dmealingclaude
andcommitted
feat(java): the shipped library is loadable, so the generator that consumes it has an input (#332)
The Java port shipped `LlmTraceHelperGenerator` and no way at all to load the metadata it exists to consume: no `libraries` loader option, no embed of the canonical `library/` tree. A JVM adopter following the documented `extends: "metaobjects::ai::LlmCallBase"` path could not get past load. A generator shipped without its input. What let that survive is the more useful half. Every test of that generator declares its OWN `LlmCallBase` inline under a different package — the bypass ADR-0024 already names, "the green tests pass only because they bypass the shipped base with bespoke entities" — so the suite could not tell a world where the library loads from one where it does not exist. A hand-copied base also drifts silently: the copies stay green while the shipped file moves. Three pieces, each mirroring TS and Python rather than inventing a third shape: - `EmbeddedLibrary` — a generated CLASS of string constants, not a `src/main/resources` copy. A resource can be dropped or mangled by build configuration (resource filtering, shading, repackaging) and the failure would surface much later as ERR_UNRESOLVED_SUPER against the adopter's own metadata; a class constant cannot go missing without the class going missing. Same rationale Python records for embedding as a source module. Emitted by the EXISTING `scripts/generate-embedded-library.ts` rather than a second script — two scripts walking one tree are two things that can drift, and an embed's whole job is to be byte-identical to its source. - `LibrarySources` — on-disk-first (a checkout picks up edits to the canonical YAML with no regeneration), embedded fallback (every consumer of the published jar). An unrecognised package contributes no sources and is NOT an error here: that is the cross-port contract for the programmatic door, so a caller asking for a package this version does not ship can still load its own metadata. - The opt-in: `MetaDataLoader.setLibraries(...)`, a `fromDirectory(..., libraries)` overload, `LoaderConfiguration.getLibraries()` (a `default` method — this interface is the build-tool seam and an implementor outside this repo must keep compiling), and a pom `<loader><libraries><library>ai</library></libraries>`. Libraries load BEFORE the project's own sources: super resolution is order-independent, so that is determinism rather than correctness, but a load order that differs per port is the kind of difference that only shows up in someone else's bug report. An unknown name in a POM is a HARD failure listing the packages this version ships, matching what the TS and Python config readers do. The asymmetry with the programmatic door is deliberate and is the same one they draw: a name a human typed into a build file is a mistake worth failing on, because skipped it resurfaces as ERR_UNRESOLVED_SUPER pointing at the module's OWN metadata — the wrong place to send someone looking. Gated by three things a green suite could not previously distinguish: - `LibraryLoadTest` — the positive arm, the NEGATIVE arm (without the opt-in the same model must still fail, and the assertion names `LlmCallBase` rather than merely requiring non-empty errors: the loader wraps the real diagnostic in a "Failed to load from directory <path>" envelope that names nothing, and the first draft of this assertion passed on that envelope), and the freshness gate. - `TraceHelperOnShippedLibraryTest` — runs the generator against the SHIPPED base, with ADR-0024 FIX #1 asserted BOTH directions. One direction alone is worthless: "every row key is a field" passes on a recorder that writes nothing, "every field is a row key" passes on one that writes the whole world, and only the equality says the two agree. It is also the claim a hand-copied base can never make, because it compares a copy against itself. - The freshness gate was proven by BREAKING it — appending a line to `library/ai/llm-call.yaml` turns it red with "EmbeddedLibrary is stale for ref …; run: bun run scripts/generate-embedded-library.ts", then restored. metadata + maven-plugin + codegen-spring: 202 + 34 + all green, 0 failures. C# has the same gap and is not addressed here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 6d8e95d commit 593af0e

12 files changed

Lines changed: 793 additions & 11 deletions

File tree

docs/features/cli.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,24 @@ libraries: [ai]
181181
was registered *for* the command line while its input was unreachable *through* it
182182
([#333](https://github.com/metaobjectsdev/metaobjects/issues/333)).
183183

184+
On the JVM the same opt-in is a pom element, read by `metaobjects:generate` and
185+
`metaobjects:verify`:
186+
187+
```xml
188+
<loader>
189+
<name>my-model</name>
190+
<libraries><library>ai</library></libraries>
191+
</loader>
192+
```
193+
194+
and programmatically, `MetaDataLoader.fromDirectory(name, dir, opts, List.of("ai"))` or
195+
`loader.setLibraries(List.of("ai"))` before `init()`. Java had neither the option nor an
196+
embed until [#332](https://github.com/metaobjectsdev/metaobjects/issues/332): the port
197+
shipped `LlmTraceHelperGenerator` with no way to load the metadata that generator exists to
198+
consume, and its tests stayed green only by declaring a bespoke `LlmCallBase` inline under
199+
a different package — the bypass ADR-0024 already named, and the reason a port can ship a
200+
generator it cannot feed without anyone noticing.
201+
184202
## `meta gen` / `meta verify` run an advisory anti-pattern pass (Node `meta`)
185203

186204
Both `meta verify` and a real `meta gen` write run (not `--dry-run`) end with a

scripts/generate-embedded-library.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,24 @@ const outFile = join(
4747
"library",
4848
"embedded-library.generated.ts",
4949
);
50+
// The JVM embed is emitted from THIS script rather than a second one, deliberately: two
51+
// scripts walking the same tree are two things that can drift, and an embed's whole job is
52+
// to be byte-identical to the canonical source. Python keeps its own
53+
// (`server/python/scripts/generate_embedded_library.py`) because it shipped first, gated
54+
// by its own byte-identity test.
55+
const javaOutFile = join(
56+
repoRoot,
57+
"server",
58+
"java",
59+
"metadata",
60+
"src",
61+
"main",
62+
"java",
63+
"com",
64+
"metaobjects",
65+
"library",
66+
"EmbeddedLibrary.java",
67+
);
5068

5169
function collect(dir: string): string[] {
5270
const out: string[] = [];
@@ -95,6 +113,60 @@ ${body}
95113
`;
96114

97115
writeFileSync(outFile, source, "utf-8");
116+
117+
// Java: a generated CLASS holding string constants, not a src/main/resources copy. A
118+
// resource can be dropped or mangled by build configuration — resource filtering, shading,
119+
// a repackaging plugin — and the failure would surface much later as ERR_UNRESOLVED_SUPER
120+
// against the adopter's own metadata, which is the wrong place to send someone looking. A
121+
// class constant cannot go missing without the class going missing. Same rationale Python
122+
// records for embedding as a source module.
123+
//
124+
// `JSON.stringify` output IS a valid Java string literal: every escape it emits (\" \\ \b
125+
// \f \n \r \t \uXXXX) means the same thing in both languages. That keeps this emitter free
126+
// of a Java-version dependency — no text blocks, so the language level can stay wherever
127+
// the build puts it.
128+
const javaEntries = entries
129+
.map((e) => ` m.put(${JSON.stringify(e.ref)}, ${JSON.stringify(e.content)});`)
130+
.join("\n");
131+
132+
const javaSource = `package com.metaobjects.library;
133+
134+
import java.util.Collections;
135+
import java.util.LinkedHashMap;
136+
import java.util.Map;
137+
138+
/**
139+
* AUTO-GENERATED by scripts/generate-embedded-library.ts — DO NOT EDIT.
140+
*
141+
* <p>Canonical source: the repo-root {@code library/} tree.
142+
* Regenerate with {@code bun run scripts/generate-embedded-library.ts}.</p>
143+
*
144+
* <p>Embeds the canonical library files as string constants so they resolve wherever the
145+
* on-disk {@code library/} directory is unavailable — which is every consumer of the
146+
* published jar. Keys are refs: the path under {@code library/} minus the {@code .yaml}
147+
* extension (e.g. {@code "ai/llm-call"}).</p>
148+
*
149+
* <p>{@code EmbeddedLibraryFreshnessTest} gates this file byte-for-byte against the
150+
* canonical tree, so a stale embed cannot ship.</p>
151+
*/
152+
public final class EmbeddedLibrary {
153+
154+
private EmbeddedLibrary() {}
155+
156+
/** Ref to exact file contents, insertion-ordered by ref. */
157+
public static final Map<String, String> CONTENT;
158+
159+
static {
160+
Map<String, String> m = new LinkedHashMap<>();
161+
${javaEntries}
162+
CONTENT = Collections.unmodifiableMap(m);
163+
}
164+
}
165+
`;
166+
167+
writeFileSync(javaOutFile, javaSource, "utf-8");
168+
98169
console.log(
99-
`wrote ${entries.length} embedded library file(s) to ${relative(repoRoot, outFile)}`,
170+
`wrote ${entries.length} embedded library file(s) to ${relative(repoRoot, outFile)} ` +
171+
`and ${relative(repoRoot, javaOutFile)}`,
100172
);
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package com.metaobjects.generator.spring;
2+
3+
import com.metaobjects.field.MetaField;
4+
import com.metaobjects.library.LibrarySources;
5+
import com.metaobjects.loader.InMemoryStringSource;
6+
import com.metaobjects.loader.LoaderOptions;
7+
import com.metaobjects.loader.MetaDataLoader;
8+
import com.metaobjects.object.MetaObject;
9+
import org.junit.Rule;
10+
import org.junit.Test;
11+
import org.junit.rules.TemporaryFolder;
12+
13+
import java.nio.file.Files;
14+
import java.nio.file.Path;
15+
import java.util.ArrayList;
16+
import java.util.Collections;
17+
import java.util.HashMap;
18+
import java.util.List;
19+
import java.util.Map;
20+
import java.util.TreeSet;
21+
22+
import static org.junit.Assert.assertEquals;
23+
import static org.junit.Assert.assertNotNull;
24+
import static org.junit.Assert.assertTrue;
25+
26+
/**
27+
* #332 — the trace-helper generator runs against the SHIPPED
28+
* {@code metaobjects::ai::LlmCallBase}, not a hand-copied one.
29+
*
30+
* <p>Every other test of this generator declares its own {@code LlmCallBase} inline under a
31+
* different package. That is the bypass ADR-0024 already named — "the green tests pass only
32+
* because they bypass the shipped base with bespoke entities" — and it is precisely what let
33+
* the Java port ship this generator while having no way at all to LOAD the metadata it
34+
* exists to consume. A hand-copied base can also drift from the real one silently: the
35+
* copies stay green while the shipped file moves.</p>
36+
*
37+
* <p>This test loads the real library through the {@code libraries} opt-in and asserts both
38+
* directions of ADR-0024 FIX #1 — the fields the generator emits are exactly the shipped
39+
* base's effective fields, no more and no fewer.</p>
40+
*/
41+
public class TraceHelperOnShippedLibraryTest {
42+
43+
/**
44+
* A concrete call entity extending the SHIPPED base — no local copy of it anywhere.
45+
* The request/response value objects and the nested {@code template.prompt} carrying
46+
* {@code @responseRef} are what the generator's applies-to predicate requires; only the
47+
* BASE is different from the other tests of this generator, which is the whole point.
48+
*/
49+
private static final String META = "{\"metadata.root\": {"
50+
+ " \"package\": \"acme::app\","
51+
+ " \"children\": ["
52+
+ " { \"object.value\": { \"name\": \"GreetRequest\", \"children\": ["
53+
+ " { \"field.string\": { \"name\": \"name\", \"@required\": true } }"
54+
+ " ]}},"
55+
+ " { \"object.value\": { \"name\": \"GreetResponse\", \"children\": ["
56+
+ " { \"field.string\": { \"name\": \"greeting\", \"@required\": true } }"
57+
+ " ]}},"
58+
+ " { \"object.entity\": {"
59+
+ " \"name\": \"GreetingCall\","
60+
+ " \"extends\": \"metaobjects::ai::LlmCallBase\","
61+
+ " \"children\": ["
62+
+ " { \"source.rdb\": { \"@table\": \"greeting_call\", \"@role\": \"primary\" } },"
63+
+ " { \"identity.primary\": { \"name\": \"pk\", \"@fields\": [\"spanId\"] } },"
64+
+ " { \"template.prompt\": { \"name\": \"greetingPrompt\","
65+
+ " \"@payloadRef\": \"acme::app::GreetRequest\","
66+
+ " \"@responseRef\": \"acme::app::GreetResponse\" } }"
67+
+ " ]"
68+
+ " } }"
69+
+ " ]"
70+
+ "}}";
71+
72+
@Rule
73+
public TemporaryFolder tmp = new TemporaryFolder();
74+
75+
private MetaDataLoader loadWithShippedLibrary(String name) {
76+
MetaDataLoader loader = new MetaDataLoader(
77+
LoaderOptions.create(false, false, true),
78+
MetaDataLoader.SUBTYPE_MANUAL, name);
79+
loader.setLibraries(Collections.singletonList("ai"));
80+
loader.init();
81+
loader.load(List.of(new InMemoryStringSource(META, name + "/meta.json")));
82+
return loader;
83+
}
84+
85+
@Test
86+
public void generatesAHelperForAnEntityExtendingTheShippedBase() throws Exception {
87+
MetaDataLoader loader = loadWithShippedLibrary("trace-shipped");
88+
89+
MetaObject base = loader.getMetaObjectByName("metaobjects::ai::LlmCallBase");
90+
assertNotNull("the SHIPPED base must be in the model — that is the whole point", base);
91+
92+
MetaObject call = loader.getMetaObjectByName("acme::app::GreetingCall");
93+
assertNotNull("GreetingCall must load", call);
94+
95+
Path gen = tmp.newFolder("gen").toPath();
96+
LlmTraceHelperGenerator generator = new LlmTraceHelperGenerator();
97+
Map<String, String> args = new HashMap<>();
98+
args.put("outputDir", gen.toString());
99+
generator.setArgs(args);
100+
generator.execute(loader);
101+
102+
Path helper = gen.resolve("acme/app/GreetingCallTraceHelper.java");
103+
assertTrue("a helper must be emitted for an entity extending the SHIPPED base, at " + helper,
104+
Files.exists(helper));
105+
}
106+
107+
/**
108+
* ADR-0024 FIX #1, both directions: the recorder's row keys are exactly the shipped
109+
* base's effective fields.
110+
*
111+
* <p>One direction alone is worthless here. "Every row key is a field" passes on a
112+
* recorder that writes nothing; "every field is a row key" passes on one that writes the
113+
* whole world. Only the equality says the two agree, which is the claim — and it is the
114+
* claim a hand-copied base can never make, because it is comparing a copy against
115+
* itself.</p>
116+
*/
117+
@Test
118+
public void theShippedBaseFieldsAreExactlyWhatTheRecorderWrites() {
119+
MetaDataLoader loader = loadWithShippedLibrary("trace-shipped-fields");
120+
MetaObject base = loader.getMetaObjectByName("metaobjects::ai::LlmCallBase");
121+
assertNotNull(base);
122+
123+
// ADR-0039: the RESOLVING accessor. An own-only read would drop anything the base
124+
// itself inherits, and the row keys are about the EFFECTIVE field set.
125+
List<String> baseFields = new ArrayList<>();
126+
for (MetaField f : base.getMetaFields()) baseFields.add(f.getName());
127+
128+
MetaObject call = loader.getMetaObjectByName("acme::app::GreetingCall");
129+
List<String> callFields = new ArrayList<>();
130+
for (MetaField f : call.getMetaFields()) callFields.add(f.getName());
131+
132+
assertTrue("the base must declare fields at all — an empty set would make the "
133+
+ "equality below vacuously true", baseFields.size() >= 10);
134+
assertTrue("every shipped base field reaches the concrete entity through extends",
135+
callFields.containsAll(baseFields));
136+
assertEquals("the concrete entity adds no fields of its own in this fixture, so the "
137+
+ "two sets must be equal — a difference means extends dropped or invented one",
138+
new TreeSet<>(baseFields), new TreeSet<>(callFields));
139+
}
140+
141+
@Test
142+
public void theLibraryIsNotLoadedUnlessAskedFor() {
143+
// The negative arm for the generator path specifically: without the opt-in there is
144+
// no base, so there is nothing for the generator to key on. A test suite that only
145+
// ever declares its own base cannot tell these two worlds apart, which is exactly
146+
// how a generator came to ship without its input.
147+
assertTrue("the ai package must be one this build ships",
148+
LibrarySources.knownPackages().contains("ai"));
149+
assertTrue("and it must contribute nothing when not requested",
150+
LibrarySources.librarySources(Collections.emptyList()).isEmpty());
151+
}
152+
}

server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,8 +269,32 @@ protected MetaDataLoader createLoader(ClassLoader projectClassLoader) {
269269
sources = neutralSources;
270270
}
271271

272+
// An unknown <library> name is a HARD failure naming the ones this version ships,
273+
// not a silent skip. `LibrarySources` skips deliberately for a programmatic caller
274+
// — asking for a package a given version does not ship should not stop that caller
275+
// loading its own metadata — but a name typed into a pom is a mistake worth failing
276+
// on: skipped, it resurfaces later as ERR_UNRESOLVED_SUPER pointing at the module's
277+
// OWN metadata, which is the wrong place to send someone looking. Same line the TS
278+
// and Python config readers draw, in the same place.
279+
List<String> libraries = loaderConfig.getLibraries();
280+
if (libraries != null && !libraries.isEmpty()) {
281+
List<String> available = com.metaobjects.library.LibrarySources.knownPackages();
282+
List<String> unknown = new ArrayList<>();
283+
for (String lib : libraries) {
284+
if (!available.contains(lib)) unknown.add(lib);
285+
}
286+
if (!unknown.isEmpty()) {
287+
// A MetaDataException, matching `failOnLoaderErrors` just below: this method
288+
// is not declared to throw the checked Maven type, and both failures are the
289+
// same kind of thing — the model this goal was asked to load cannot be.
290+
throw new MetaDataException(
291+
"<loader><libraries> names unknown package(s) " + unknown
292+
+ "; available: " + available);
293+
}
294+
}
295+
272296
MavenLoaderConfiguration.configure(configurable, sourceDir, projectClassLoader,
273-
sources, loaderArgs(strict));
297+
sources, libraries, loaderArgs(strict));
274298

275299
MetaDataLoader loader = configurable.getLoader();
276300

server/java/maven-plugin/src/main/java/com/metaobjects/mojo/LoaderParam.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ public class LoaderParam {
1414
private String classname = null;
1515
private String sourceDir = null;
1616
private List<String> sources = null;
17+
private List<String> libraries = null;
1718
private List<String> filters = null;
1819

1920
public LoaderParam() {}
@@ -58,6 +59,25 @@ public void setClassname(String classname) {
5859
this.classname = classname;
5960
}
6061

62+
/**
63+
* MetaObjects-shipped library packages this module loads alongside its own metadata —
64+
* {@code <libraries><library>ai</library></libraries>} makes
65+
* {@code extends: metaobjects::ai::LlmCallBase} resolve (#332).
66+
*
67+
* @return the requested package names, or null if not set
68+
*/
69+
public List<String> getLibraries() {
70+
return libraries;
71+
}
72+
73+
/**
74+
* Set the MetaObjects-shipped library packages to load.
75+
* @param libraries package names (e.g. {@code ["ai"]})
76+
*/
77+
public void setLibraries(List<String> libraries) {
78+
this.libraries = libraries;
79+
}
80+
6181
/**
6282
* Get the source directory path for metadata files
6383
* @return Source directory path, or null if not set

server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MavenLoaderConfiguration.java

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,33 @@ public class MavenLoaderConfiguration {
3434
* @param sourceDir The Maven source directory
3535
* @param classLoader The Maven project class loader
3636
* @param sources The list of source files
37+
* @param libraries MetaObjects-shipped library packages to load first (#332)
3738
* @param globals The global arguments map
3839
*/
39-
public static void configure(LoaderConfigurable configurable,
40-
String sourceDir,
40+
public static void configure(LoaderConfigurable configurable,
41+
String sourceDir,
4142
ClassLoader classLoader,
42-
List<String> sources,
43+
List<String> sources,
44+
Map<String, String> globals) {
45+
configure(configurable, sourceDir, classLoader, sources, null, globals);
46+
}
47+
48+
/**
49+
* Configure a LoaderConfigurable instance, opting into MetaObjects-shipped library
50+
* packages alongside the configured sources.
51+
*
52+
* @param configurable The loader to configure
53+
* @param sourceDir The Maven source directory
54+
* @param classLoader The Maven project class loader
55+
* @param sources The list of source files
56+
* @param libraries MetaObjects-shipped library packages to load first (#332); may be null
57+
* @param globals The global arguments map
58+
*/
59+
public static void configure(LoaderConfigurable configurable,
60+
String sourceDir,
61+
ClassLoader classLoader,
62+
List<String> sources,
63+
List<String> libraries,
4364
Map<String, String> globals) {
4465

4566

@@ -54,6 +75,7 @@ public static void configure(LoaderConfigurable configurable,
5475
.sourceDir(sourceDir)
5576
.classLoader(classLoader)
5677
.sources(sources)
78+
.libraries(libraries)
5779
.arguments(globals)
5880
.build();
5981

0 commit comments

Comments
 (0)