Skip to content

Commit 7febb2a

Browse files
dmealingclaude
andcommitted
fix(maven-plugin): a <sourceDir> naming a directory must load that directory
`<loader><sourceDir>` with no `<sources>` loaded nothing, ran every generator against an empty model, wrote zero files, and reported BUILD SUCCESS. The precedence ladder consults the port-neutral config only when the pom names NEITHER key — naming either means the pom owns the concern — so `<sourceDir>` alone took the pom-owns-it branch with an empty source list and nothing said so. Two things make it worth more than its size. It is the shape the shipped adopter guidance teaches (`agent-context/skills/metaobjects-codegen/ references/java.md` shows exactly this pom). And it is the remedy 0.24.0's own ERR_COLLECTION_NOT_FOUND prints — "declare <sourceDir>/<sources> explicitly" — so following the fix for a silently-empty model put you straight back into a silently-empty model. A directory now expands through the SAME DirectorySource walk the loader performs for a directory source (via SourceResolver), rather than a second definition of what counts as metadata. A `<sourceDir>` that exists but holds nothing is ERR_COLLECTION_NOT_FOUND instead of an empty model: reaching the expensive outcome by a different road is still the expensive outcome. A nonexistent one already threw and still does — pinned here alongside, because this change makes the branch tolerant and that is how it could go quiet again. Purely additive: a pom naming `<sources>`, or naming neither, is untouched. Found while generating the website showcase with all five ports; verified end to end by running the documented pom against the rebuilt plugin, not only through the mojo unit tests. The showcase's own pom drops its explicit `<sources>` and adopts the documented shape, and `regen-showcase.ts` gains the matching guard from the other side: a port that exits 0 having written no files is a failure, not a `✓`. Without it, running the showcase against a plugin predating this fix would EMPTY the committed Java and Kotlin trees and still print success — the same defect one layer up. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NynrRND6ZUwGvq3ZUTCfxG
1 parent 49f4626 commit 7febb2a

5 files changed

Lines changed: 242 additions & 3 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,40 @@ preserved the wire shape exactly. Everything below is what went wrong on the way
129129
`"type": "commonjs"` explicitly — false on the dominant first-touch path. (Introduced by
130130
`0.24.4`'s own fix to that same line, which corrected the tense and got the premise wrong.)
131131

132+
### Fixed — two ports scattering or skipping work while reporting success (Maven, NuGet)
133+
134+
Found the same way as the eight above, by a different exercise: generating ONE small model
135+
with every port, from the repo root, to build the corpus the website publishes as real
136+
`meta gen` output. Both are the shape this release keeps finding — a tool saying something
137+
untrue about work it had just done.
138+
139+
- **Java/Kotlin: `<loader><sourceDir>` with no `<sources>` loaded NOTHING, ran the
140+
generators against an empty model, wrote zero files, and reported `BUILD SUCCESS`.**
141+
The precedence ladder consults the port-neutral config only when the pom names *neither*
142+
key — naming either means the pom owns the concern — so `<sourceDir>` alone took the
143+
pom-owns-it branch with an empty source list. That is the shape the shipped adopter
144+
guidance teaches, and it is also the remedy `0.24.0`'s own `ERR_COLLECTION_NOT_FOUND`
145+
prints (*"declare `<sourceDir>`/`<sources>` explicitly"*), so following the fix for a
146+
silently-empty model put you back into one. A directory now expands through the SAME
147+
`DirectorySource` walk the loader itself uses for a directory source, so "which files
148+
count as metadata" keeps one definition; a `<sourceDir>` that exists but holds no
149+
metadata is `ERR_COLLECTION_NOT_FOUND` rather than an empty model, since reaching the
150+
expensive outcome by a different road is still the expensive outcome. A nonexistent
151+
`<sourceDir>` already failed and still does. **Purely additive** — a pom naming
152+
`<sources>`, or naming neither, is unaffected.
153+
- **C#: `dotnet meta gen` anchored `.gen-state/.hashes.json` on the process working
154+
directory**, so generating from anywhere but the project root wrote a stray
155+
`.metaobjects/` into the caller's cwd and left the real project with no record of what
156+
was written — the entire reason that manifest is committed. The Python port already
157+
anchors on the metadata dir's parent and its docstring says why. The C# **test assembly
158+
had been doing this into its own `bin/`** for as long as the manifest has existed,
159+
invisible because `bin/` is ignored, and worse than untidy: every test in the assembly
160+
then shared ONE manifest keyed by bare filename — precisely the collision that docstring
161+
warns about. An explicit `<metadataDir>` now anchors on its parent; with it omitted the
162+
`.metaobjects/config.json` ladder has already resolved relative to cwd, so cwd is the
163+
project. **Not a behaviour change for the documented invocation** — only the
164+
cwd-is-not-the-project case moves, and that case was broken.
165+
132166

133167
## [0.24.4] — 2026-08-28
134168

‎examples/showcase/jvm/pom.xml‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,14 @@
4848
<artifactId>metaobjects-maven-plugin</artifactId>
4949
<version>${metaobjects.version}</version>
5050
<configuration>
51+
<!-- <sourceDir> with no <sources> is the documented shape: a directory
52+
names a directory, and the plugin expands it. Needs the plugin at or
53+
after the version that made that true — before it, naming <sourceDir>
54+
alone loaded nothing, ran the generators against an empty model and
55+
reported BUILD SUCCESS. `bun scripts/regen-showcase.ts` fails loudly
56+
on that now rather than emptying the committed trees. -->
5157
<loader>
5258
<sourceDir>../metaobjects</sourceDir>
53-
<sources>
54-
<source>meta.subscriber.yaml</source>
55-
</sources>
5659
</loader>
5760
<generators>
5861
<generator>

‎scripts/regen-showcase.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,17 @@ function selectPorts(): Port[] {
211211
function generateInto(project: string, ports: Port[]): void {
212212
for (const p of ports) {
213213
p.generate(project);
214+
// A generator that wrote nothing and exited 0 is the failure this whole corpus
215+
// exists to catch, and it is not hypothetical: the Maven plugin ran both JVM
216+
// generators against an empty model, wrote zero files, and reported BUILD
217+
// SUCCESS. In write mode that would silently EMPTY a committed tree, and `✓`
218+
// would still print. A port that produced nothing did not succeed.
219+
const empty = p.dirs.filter((d) => listFiles(join(project, "generated", d)).length === 0);
220+
if (empty.length) {
221+
throw new Error(
222+
`${p.name} exited 0 but wrote no files to ${empty.map((d) => `generated/${d}`).join(", ")}`,
223+
);
224+
}
214225
console.log(` ✓ ${p.name}`);
215226
}
216227
}

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,22 @@ protected MetaDataLoader createLoader(ClassLoader projectClassLoader) {
270270
List<String> neutralSources = resolveNeutralSourcesIfPomIsSilent(loaderConfig);
271271
if (!neutralSources.isEmpty()) {
272272
sources = neutralSources;
273+
} else if ((sources == null || sources.isEmpty()) && srcDir != null) {
274+
// <sourceDir> alone. The ladder above deliberately does not fire here —
275+
// naming EITHER key means the pom owns the concern — which used to leave
276+
// this branch with an empty source list: the generators ran against an
277+
// empty model, wrote zero files, and the build reported SUCCESS.
278+
//
279+
// That is the shape the adopter guidance teaches, and it is also the
280+
// remedy 0.24.0's own ERR_COLLECTION_NOT_FOUND prints ("declare
281+
// <sourceDir>/<sources> explicitly"), so following the fix for a silent
282+
// empty model landed you back in one. A directory names a directory:
283+
// expand it, through the SAME DirectorySource walk the loader itself uses
284+
// (via SourceResolver), so "which files count as metadata" keeps one
285+
// definition. The sourceDir base is dropped because the expansion is
286+
// already absolute.
287+
sources = expandSourceDir(srcDir);
288+
sourceDir = null;
273289
}
274290

275291
// An unknown <library> name is a HARD failure naming the ones this version ships,
@@ -534,6 +550,37 @@ protected File getProjectBaseDir() {
534550
* either {@code <sourceDir>} or {@code <sources>} (i.e. the neutral file is
535551
* not consulted at all)
536552
*/
553+
/**
554+
* Expand a {@code <loader><sourceDir>} that carries no {@code <sources>} into the
555+
* metadata files it holds.
556+
*
557+
* <p>Delegates to {@link com.metaobjects.config.SourceResolver}, so the extension
558+
* filter and ordering are {@code DirectorySource}'s — the same walk the loader
559+
* performs for a directory source, rather than a second definition of what counts
560+
* as metadata.
561+
*
562+
* <p>A directory holding NOTHING is a failure, not an empty model. The whole point
563+
* of this branch is that generating zero files while reporting success is the
564+
* expensive outcome, and an empty directory reaches it by a different road than an
565+
* unnamed one.
566+
*/
567+
protected List<String> expandSourceDir(File srcDir) {
568+
Path dir = srcDir.getAbsoluteFile().toPath().normalize();
569+
List<String> expanded = com.metaobjects.config.SourceResolver
570+
.resolveSources(getProjectBaseDir().toPath(),
571+
List.of(java.util.Map.of("path", dir.toString())))
572+
.stream()
573+
.map(p -> "model:file:" + p.toString().replace(java.io.File.separatorChar, '/'))
574+
.toList();
575+
if (expanded.isEmpty()) {
576+
throw new MetaDataException(
577+
"<loader><sourceDir> " + dir + " holds no metadata files. Point it at a "
578+
+ "directory containing metadata, or name the files with <sources>.",
579+
ErrorCode.ERR_COLLECTION_NOT_FOUND);
580+
}
581+
return expanded;
582+
}
583+
537584
protected List<String> resolveNeutralSourcesIfPomIsSilent(LoaderParam loaderConfig) {
538585
boolean pomNamesLocation =
539586
(loaderConfig.getSourceDir() != null && !loaderConfig.getSourceDir().isBlank())
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
package com.metaobjects.mojo;
2+
3+
import com.metaobjects.MetaDataException;
4+
import com.metaobjects.loader.MetaDataLoader;
5+
import org.apache.maven.project.MavenProject;
6+
import org.junit.Test;
7+
import org.mockito.Mockito;
8+
9+
import java.io.IOException;
10+
import java.nio.charset.StandardCharsets;
11+
import java.nio.file.Files;
12+
import java.nio.file.Path;
13+
import java.util.Collections;
14+
import java.util.Comparator;
15+
import java.util.stream.Stream;
16+
17+
import static org.junit.Assert.assertEquals;
18+
import static org.junit.Assert.assertTrue;
19+
import static org.junit.Assert.fail;
20+
21+
/**
22+
* A {@code <loader>} naming {@code <sourceDir>} and NOTHING else must load that
23+
* directory's metadata.
24+
*
25+
* <p>It used to load nothing at all — and say nothing about it. The precedence
26+
* ladder consults the port-neutral config only when the pom names NEITHER
27+
* {@code <sourceDir>} nor {@code <sources>}, so naming {@code <sourceDir>} alone
28+
* took the pom-owns-the-concern branch with an empty source list: the generators
29+
* ran against an empty model, wrote zero files, and the build reported SUCCESS.
30+
*
31+
* <p>Which matters because {@code <sourceDir>} alone is exactly what the shipped
32+
* adopter guidance teaches, and it is also the remedy 0.24.0's own
33+
* {@code ERR_COLLECTION_NOT_FOUND} prints ("declare {@code <sourceDir>}/{@code
34+
* <sources>} explicitly") — so following the fix for the silent-empty model put
35+
* you straight back into a silent-empty model.
36+
*
37+
* <p>Two neighbouring cases are pinned alongside it, because the fix makes this
38+
* branch tolerant and both are ways it could go quiet again: a {@code <sourceDir>}
39+
* that does not exist (already loud, an {@code IllegalArgumentException} naming the
40+
* path), and one that exists but holds no metadata at all.
41+
*
42+
* <p>Paths here are ABSOLUTE on purpose — {@code <sourceDir>} resolves against the
43+
* process working directory, not the module basedir, so a relative path in a test
44+
* would be measuring the runner's cwd rather than the mojo.
45+
*/
46+
public class SourceDirWithoutSourcesTest {
47+
48+
private static final String WIDGET_JSON = """
49+
{
50+
"metadata.root": {
51+
"package": "sourcedir::only",
52+
"children": [
53+
{ "object.entity": { "name": "Widget", "children": [
54+
{ "field.long": { "name": "id" } }
55+
] } }
56+
]
57+
}
58+
}
59+
""";
60+
61+
/** A pom naming <sourceDir> and no <sources> — the documented shape. */
62+
private MetaDataGeneratorMojo mojoWithSourceDirOnly(Path basedir, String sourceDir) {
63+
MavenProject mavenProject = Mockito.mock(MavenProject.class);
64+
Mockito.when(mavenProject.getBasedir()).thenReturn(basedir.toFile());
65+
66+
MetaDataGeneratorMojo mojo = new MetaDataGeneratorMojo();
67+
mojo.project = mavenProject;
68+
mojo.setLoader(LoaderParam.builder("sourcedir-only-test")
69+
.withClassname("com.metaobjects.loader.MetaDataLoader")
70+
.withSourceDir(sourceDir)
71+
.build());
72+
mojo.setGenerators(Collections.emptyList());
73+
mojo.setGlobals(Collections.emptyMap());
74+
return mojo;
75+
}
76+
77+
@Test
78+
public void sourceDirAloneLoadsThatDirectorysMetadata() throws IOException {
79+
Path root = Files.createTempDirectory("mo-mojo-srcdir-").toAbsolutePath().normalize();
80+
try {
81+
Path metaDir = root.resolve("src/main/metaobjects");
82+
Files.createDirectories(metaDir);
83+
Files.write(metaDir.resolve("meta.widget.json"), WIDGET_JSON.getBytes(StandardCharsets.UTF_8));
84+
85+
MetaDataGeneratorMojo mojo = mojoWithSourceDirOnly(root, metaDir.toString());
86+
MetaDataLoader loaded = mojo.createLoader(mojo.createProjectClassLoader());
87+
88+
assertEquals("a <sourceDir> with no <sources> must load the directory, not nothing",
89+
1, loaded.getMetaObjects().size());
90+
assertEquals("Widget", loaded.getMetaObjects().get(0).getShortName());
91+
} finally {
92+
deleteRecursive(root);
93+
}
94+
}
95+
96+
@Test
97+
public void aSourceDirThatDoesNotExistFailsRatherThanLoadingNothing() throws IOException {
98+
Path root = Files.createTempDirectory("mo-mojo-srcdir-missing-").toAbsolutePath().normalize();
99+
Path typo = root.resolve("src/main/typo");
100+
try {
101+
MetaDataGeneratorMojo mojo = mojoWithSourceDirOnly(root, typo.toString());
102+
try {
103+
MetaDataLoader loaded = mojo.createLoader(mojo.createProjectClassLoader());
104+
fail("expected a failure naming the missing directory, got a loader with "
105+
+ loaded.getMetaObjects().size() + " object(s)");
106+
} catch (RuntimeException expected) {
107+
assertTrue("the message must name the path that did not resolve, got: "
108+
+ expected.getMessage(),
109+
expected.getMessage().contains(typo.toString()));
110+
}
111+
} finally {
112+
deleteRecursive(root);
113+
}
114+
}
115+
116+
@Test
117+
public void aSourceDirHoldingNoMetadataFailsRatherThanLoadingNothing() throws IOException {
118+
Path root = Files.createTempDirectory("mo-mojo-srcdir-empty-").toAbsolutePath().normalize();
119+
try {
120+
Path metaDir = root.resolve("src/main/metaobjects");
121+
Files.createDirectories(metaDir);
122+
123+
MetaDataGeneratorMojo mojo = mojoWithSourceDirOnly(root, metaDir.toString());
124+
try {
125+
MetaDataLoader loaded = mojo.createLoader(mojo.createProjectClassLoader());
126+
fail("expected a failure naming the empty directory, got a loader with "
127+
+ loaded.getMetaObjects().size() + " object(s)");
128+
} catch (MetaDataException expected) {
129+
assertTrue("the message must name the directory that held nothing, got: "
130+
+ expected.getMessage(),
131+
expected.getMessage().contains(metaDir.toString()));
132+
}
133+
} finally {
134+
deleteRecursive(root);
135+
}
136+
}
137+
138+
private static void deleteRecursive(Path root) throws IOException {
139+
if (!Files.exists(root)) return;
140+
try (Stream<Path> walk = Files.walk(root)) {
141+
walk.sorted(Comparator.reverseOrder()).forEach(p -> p.toFile().delete());
142+
}
143+
}
144+
}

0 commit comments

Comments
 (0)