Skip to content

Commit 6186583

Browse files
dmealingclaude
andcommitted
feat(maven-plugin): meta:verify codegen-drift goal; confirm+document Kotlin codegen via meta:gen (SP-E Unit 3)
Adds a generator-neutral meta:verify goal (regenerate to temp + fail on drift vs committed output) to parallel meta:gen — the Java/Kotlin port's codegen-drift check. Confirms (with a test) that codegen-kotlin generators run through the existing meta:gen Mojo via the shared MultiFileDirectGeneratorBase SPI; documented in the codegen-kotlin README. No schema/migrate goal (schema is Node-only, ADR-0015). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 93c233c commit 6186583

7 files changed

Lines changed: 559 additions & 5 deletions

File tree

server/java/codegen-kotlin/README.md

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,33 @@ so consumers can write `AuthorTable.postsQuery(author.id).toList()` (or chain `.
122122
</plugin>
123123
```
124124

125+
### Running via Maven
126+
127+
Kotlin codegen runs through the **existing `meta:gen` goal** — there is no Kotlin-specific
128+
Mojo. Each Kotlin generator extends `MultiFileDirectGeneratorBase` (a `Generator`) with a
129+
no-arg constructor and reads its `outputDir` from `<args>`, which is exactly the SPI the
130+
plugin uses: it loads the `<classname>` off the project classpath, invokes the no-arg
131+
constructor, calls `setArgs(...)`, and runs `execute(loader)`. So wiring a
132+
`KotlinEntityGenerator` (etc.) as a `<generator>` above is all it takes:
133+
134+
```bash
135+
mvn metaobjects:generate # runs the configured Kotlin generators → emits .kt files
136+
```
137+
138+
Codegen drift is covered by the **`meta:verify` goal** (added alongside `meta:gen`). It is
139+
generator-neutral — it regenerates whatever generators you have configured into a throwaway
140+
temp directory and fails the build if the result differs from the committed output (a file
141+
whose content differs, a committed file the generator no longer produces, or a newly produced
142+
file that isn't committed). Because it reuses the same generator wiring, it drift-checks the
143+
Kotlin generators above without any Kotlin-specific knowledge:
144+
145+
```bash
146+
mvn metaobjects:verify # fails the build if generated Kotlin is stale vs metadata
147+
```
148+
149+
`meta:verify` here is **codegen drift only** — it is not the FR-004 prompt/template `verify`
150+
surface, and there is deliberately no schema/migrate goal (schema is Node-owned, ADR-0015).
151+
125152
## Spring Boot + Exposed wiring (auto-generated)
126153

127154
The `KotlinSpringConfigGenerator` emits a `@Configuration` class that wires `Database.connect()` from the Spring `DataSource` bean and runs the validator at `ApplicationReadyEvent`:
@@ -147,6 +174,7 @@ No hand-written Exposed wiring needed.
147174
| Drift source | Where caught | When |
148175
|---|---|---|
149176
| Code-vs-DB | Codegen (`KotlinEntityGenerator` + `KotlinExposedTableGenerator`) | Build time |
177+
| Generated-code-vs-metadata (codegen drift) | `meta:verify` goal (regenerate-to-temp + compare) | Build time / CI |
150178
| Code-vs-API-doc | Cross-port codegen from same metadata | Build time |
151179
| DB-vs-metadata, Migration-vs-metadata | TypeScript toolchain (`@metaobjectsdev/cli migrate`) — schema migrations and live-DB schema-drift verification are TS-only | Build time / CI |
152180
| Generated-edited | `@generated` headers in KotlinPoet output | Code review |
@@ -157,8 +185,10 @@ No hand-written Exposed wiring needed.
157185

158186
Schema migrations (and live-DB schema-drift verification) are owned by the
159187
TypeScript toolchain (`@metaobjectsdev/cli migrate`). The JVM-side Maven plugin's
160-
`meta:migrate` / `meta:verify` goals were removed along with the Java
161-
diff-and-converge engine; the Maven plugin ships `meta:gen` / `meta:editor` only.
188+
`meta:migrate` and the old live-DB-drift `meta:verify` goals were removed along with
189+
the Java diff-and-converge engine. The Maven plugin ships `meta:gen` / `meta:editor`
190+
plus a `meta:verify` goal that is **codegen drift only** (regenerate-to-temp +
191+
compare against committed output) — not schema drift.
162192

163193
## Cross-port codegen conformance (deferred)
164194

server/java/maven-plugin/pom.xml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,15 @@
106106
<version>3.9.11</version>
107107
<scope>provided</scope>
108108
</dependency>
109+
<!-- Test-only: prove a codegen-kotlin Generator loads + runs through the same
110+
reflective SPI the meta:gen / meta:verify Mojos use (Generator no-arg ctor +
111+
setArgs + execute). Not a runtime dep of the plugin. -->
112+
<dependency>
113+
<groupId>com.metaobjects</groupId>
114+
<artifactId>metaobjects-codegen-kotlin</artifactId>
115+
<version>${project.version}</version>
116+
<scope>test</scope>
117+
</dependency>
109118
<dependency>
110119
<groupId>org.apache.maven.plugin-testing</groupId>
111120
<artifactId>maven-plugin-testing-harness</artifactId>

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

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import org.apache.maven.plugin.AbstractMojo;
99
import org.apache.maven.plugin.MojoExecution;
1010
import org.apache.maven.plugin.MojoExecutionException;
11+
import org.apache.maven.plugin.MojoFailureException;
1112
import org.apache.maven.plugins.annotations.Parameter;
1213
import org.apache.maven.project.MavenProject;
1314

@@ -67,7 +68,7 @@ public void setGenerators(List<GeneratorParam> generators) {
6768
this.generators = generators;
6869
}
6970

70-
public void execute() throws MojoExecutionException
71+
public void execute() throws MojoExecutionException, MojoFailureException
7172
{
7273
if ( getLoader() == null ) {
7374
throw new MojoExecutionException( "No <loader> element was defined");
@@ -76,18 +77,45 @@ public void execute() throws MojoExecutionException
7677

7778
MetaDataLoader loader = createLoader(projectClassLoader);
7879

80+
List<Generator> generatorImpls = buildGenerators( projectClassLoader, null );
81+
82+
executeGenerators( loader, generatorImpls );
83+
}
84+
85+
/**
86+
* Reflectively instantiate and configure each declared generator, mirroring the exact
87+
* mechanism {@code meta:gen} uses. This is the single source of truth for generator
88+
* wiring so that sibling goals (e.g. {@code meta:verify}) drive identical behavior —
89+
* including loading generators from any module on the project classpath
90+
* (e.g. a {@code codegen-spring} or {@code codegen-kotlin} {@link Generator}).
91+
*
92+
* @param projectClassLoader the classloader that can see the project's generator deps
93+
* @param argOverridesByGenerator optional per-generator arg overrides keyed by
94+
* {@link GeneratorParam} identity (e.g. redirecting {@code outputDir} to a temp
95+
* dir for drift verification). May be {@code null} for no overrides.
96+
* @return the configured {@link Generator} list, in declaration order
97+
*/
98+
protected List<Generator> buildGenerators(ClassLoader projectClassLoader,
99+
Map<GeneratorParam, Map<String, String>> argOverridesByGenerator) {
79100
List<Generator> generatorImpls = new ArrayList<>();
80101

81102
if ( getGenerators() != null ) {
82103
for ( GeneratorParam g : getGenerators() ) {
83104
try {
84-
// Fixed: Replaced deprecated newInstance() with proper constructor usage
105+
// Reflective no-arg instantiation — the same SPI for every Generator
106+
// impl regardless of source module (codegen-spring, codegen-kotlin, ...).
85107
Class<?> generatorClass = projectClassLoader.loadClass(g.getClassname());
86108
Constructor<?> constructor = generatorClass.getDeclaredConstructor();
87109
Generator impl = (Generator) constructor.newInstance();
88110

89111
// Merge generator args and global args
90112
Map<String, String> allargs = mergeAndOverwriteArgs(g);
113+
114+
// Apply any per-generator overrides (verify redirects outputDir here).
115+
if ( argOverridesByGenerator != null ) {
116+
Map<String, String> overrides = argOverridesByGenerator.get(g);
117+
if ( overrides != null ) allargs.putAll(overrides);
118+
}
91119
impl.setArgs(allargs);
92120

93121
// Merge loader filters and generator filters
@@ -107,7 +135,7 @@ public void execute() throws MojoExecutionException
107135
}
108136
}
109137

110-
executeGenerators( loader, generatorImpls );
138+
return generatorImpls;
111139
}
112140

113141
public Map<String, String> mergeAndOverwriteArgs(GeneratorParam g) {
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
package com.metaobjects.mojo;
2+
3+
import com.metaobjects.generator.Generator;
4+
import com.metaobjects.generator.GeneratorBase;
5+
import com.metaobjects.loader.MetaDataLoader;
6+
import org.apache.maven.plugin.MojoExecutionException;
7+
import org.apache.maven.plugin.MojoFailureException;
8+
import org.apache.maven.plugins.annotations.LifecyclePhase;
9+
import org.apache.maven.plugins.annotations.Mojo;
10+
import org.apache.maven.plugins.annotations.ResolutionScope;
11+
12+
import java.io.IOException;
13+
import java.io.UncheckedIOException;
14+
import java.nio.file.FileVisitResult;
15+
import java.nio.file.Files;
16+
import java.nio.file.Path;
17+
import java.nio.file.SimpleFileVisitor;
18+
import java.nio.file.attribute.BasicFileAttributes;
19+
import java.util.ArrayList;
20+
import java.util.Arrays;
21+
import java.util.Collections;
22+
import java.util.HashMap;
23+
import java.util.LinkedHashSet;
24+
import java.util.List;
25+
import java.util.Map;
26+
import java.util.Set;
27+
import java.util.TreeSet;
28+
29+
/**
30+
* The {@code meta:verify} goal — a generator-neutral codegen-drift check, the Java/Kotlin
31+
* port's parallel to {@code meta:gen}.
32+
*
33+
* <p>It regenerates every declared generator into a throwaway temp directory (by overriding
34+
* each generator's {@code outputDir} arg) and then compares the freshly generated tree
35+
* against the committed output under the configured (real) {@code outputDir}. Any drift —
36+
* a file whose content differs, a committed file that the generator no longer produces, or
37+
* a newly produced file that is not committed — fails the build with a
38+
* {@link MojoFailureException} listing the drifted files.</p>
39+
*
40+
* <p>Because it reuses the exact generator-wiring of {@link AbstractMetaDataMojo#buildGenerators}
41+
* (reflective no-arg instantiation of any {@link Generator} on the project classpath), it is
42+
* generator-neutral: it covers {@code codegen-spring} generators and {@code codegen-kotlin}
43+
* generators (which run through the same {@code meta:gen} SPI) without any per-generator
44+
* knowledge.</p>
45+
*
46+
* <p>This is a codegen-drift gate, NOT the FR-004 prompt/template {@code verify} surface, and
47+
* there is deliberately no schema/migrate goal — schema is Node-owned (ADR-0015).</p>
48+
*/
49+
@Mojo(name = "verify",
50+
requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME,
51+
defaultPhase = LifecyclePhase.VERIFY)
52+
public class MetaDataVerifyMojo extends AbstractMetaDataMojo {
53+
54+
/** Arg used by {@link GeneratorBase} to locate each generator's output root. */
55+
static final String ARG_OUTPUT_DIR = GeneratorBase.ARG_OUTPUTDIR;
56+
57+
@Override
58+
public void execute() throws MojoExecutionException, MojoFailureException {
59+
if (getLoader() == null) {
60+
throw new MojoExecutionException("No <loader> element was defined");
61+
}
62+
63+
ClassLoader projectClassLoader = createProjectClassLoader();
64+
MetaDataLoader loader = createLoader(projectClassLoader);
65+
66+
// Per-generator: resolve its committed (real) outputDir from the merged args, then
67+
// mint a unique temp output dir and stage an arg-override so the regenerate writes
68+
// to temp instead. Keep the real<->temp mapping for the post-run comparison.
69+
Path tempRoot;
70+
try {
71+
tempRoot = Files.createTempDirectory("metaobjects-verify");
72+
} catch (IOException e) {
73+
throw new MojoExecutionException("Could not create temp dir for verify", e);
74+
}
75+
76+
Map<GeneratorParam, Map<String, String>> overrides = new HashMap<>();
77+
List<GenTarget> targets = new ArrayList<>();
78+
79+
if (getGenerators() != null) {
80+
int idx = 0;
81+
for (GeneratorParam g : getGenerators()) {
82+
Map<String, String> merged = mergeAndOverwriteArgs(g);
83+
String realDir = merged.get(ARG_OUTPUT_DIR);
84+
if (realDir == null) {
85+
// A generator with no outputDir cannot be drift-checked by file tree.
86+
// This is a configuration error for a file-emitting generator.
87+
throw new MojoExecutionException(
88+
"Generator [" + g.getClassname() + "] has no '" + ARG_OUTPUT_DIR
89+
+ "' arg; meta:verify can only drift-check file-emitting generators");
90+
}
91+
Path tempDir = tempRoot.resolve("gen-" + (idx++));
92+
overrides.put(g, Collections.singletonMap(ARG_OUTPUT_DIR, tempDir.toString()));
93+
targets.add(new GenTarget(g.getClassname(), Path.of(realDir), tempDir));
94+
}
95+
}
96+
97+
// Build + run generators with the temp-dir overrides applied.
98+
List<Generator> generatorImpls = buildGenerators(projectClassLoader, overrides);
99+
try {
100+
for (Generator gen : generatorImpls) {
101+
getLog().info("MetaData Verify Mojo > Regenerating (temp): " + gen.getClass().getName());
102+
gen.execute(loader);
103+
}
104+
105+
// Compare each generator's temp output against its committed output.
106+
List<String> drift = new ArrayList<>();
107+
for (GenTarget t : targets) {
108+
drift.addAll(compareTrees(t));
109+
}
110+
111+
if (!drift.isEmpty()) {
112+
StringBuilder sb = new StringBuilder();
113+
sb.append("generated code is stale — run `mvn ").append(genGoalName())
114+
.append("` and commit. Drifted files:");
115+
for (String d : drift) {
116+
sb.append("\n ").append(d);
117+
}
118+
throw new MojoFailureException(sb.toString());
119+
}
120+
121+
getLog().info("MetaData Verify Mojo > No codegen drift detected across "
122+
+ targets.size() + " generator(s).");
123+
} finally {
124+
deleteRecursively(tempRoot);
125+
}
126+
}
127+
128+
/** The gen goal to suggest in the failure message ({@code groupId:artifactId:goal}). */
129+
private String genGoalName() {
130+
return "metaobjects:generate";
131+
}
132+
133+
/**
134+
* Compare a single generator's freshly-generated temp tree against its committed tree.
135+
* Returns a list of human-readable drift descriptions (empty if in sync).
136+
*/
137+
private List<String> compareTrees(GenTarget t) throws MojoExecutionException {
138+
Set<String> generated = relativeFiles(t.tempDir);
139+
Set<String> committed = relativeFiles(t.realDir);
140+
141+
// Union of relative paths, sorted for stable, deterministic reporting.
142+
Set<String> all = new TreeSet<>();
143+
all.addAll(generated);
144+
all.addAll(committed);
145+
146+
List<String> drift = new ArrayList<>();
147+
for (String rel : all) {
148+
boolean inGen = generated.contains(rel);
149+
boolean inCommitted = committed.contains(rel);
150+
Path committedFile = t.realDir.resolve(rel);
151+
if (inGen && !inCommitted) {
152+
drift.add("[missing-from-repo] " + committedFile + " (generator produces it; not committed)");
153+
} else if (!inGen && inCommitted) {
154+
drift.add("[stale-in-repo] " + committedFile + " (committed; generator no longer produces it)");
155+
} else {
156+
// Present in both — compare bytes.
157+
if (!contentEquals(t.tempDir.resolve(rel), committedFile)) {
158+
drift.add("[content-differs] " + committedFile);
159+
}
160+
}
161+
}
162+
return drift;
163+
}
164+
165+
/** All regular files under {@code root}, as paths relative to {@code root}; empty if absent. */
166+
private Set<String> relativeFiles(Path root) throws MojoExecutionException {
167+
Set<String> out = new LinkedHashSet<>();
168+
if (!Files.isDirectory(root)) {
169+
return out;
170+
}
171+
try {
172+
Files.walkFileTree(root, new SimpleFileVisitor<>() {
173+
@Override
174+
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
175+
out.add(root.relativize(file).toString());
176+
return FileVisitResult.CONTINUE;
177+
}
178+
});
179+
} catch (IOException e) {
180+
throw new MojoExecutionException("Could not walk directory [" + root + "]", e);
181+
}
182+
return out;
183+
}
184+
185+
private boolean contentEquals(Path a, Path b) throws MojoExecutionException {
186+
try {
187+
return Arrays.equals(Files.readAllBytes(a), Files.readAllBytes(b));
188+
} catch (IOException e) {
189+
throw new MojoExecutionException("Could not compare files [" + a + "] vs [" + b + "]", e);
190+
}
191+
}
192+
193+
private void deleteRecursively(Path root) {
194+
if (root == null || !Files.exists(root)) return;
195+
try {
196+
Files.walkFileTree(root, new SimpleFileVisitor<>() {
197+
@Override
198+
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
199+
Files.delete(file);
200+
return FileVisitResult.CONTINUE;
201+
}
202+
203+
@Override
204+
public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
205+
Files.delete(dir);
206+
return FileVisitResult.CONTINUE;
207+
}
208+
});
209+
} catch (IOException e) {
210+
throw new UncheckedIOException(e);
211+
}
212+
}
213+
214+
/** {@code executeGenerators} from the base is unused — verify drives generators itself. */
215+
@Override
216+
protected void executeGenerators(MetaDataLoader loader, List<Generator> generatorImpls) {
217+
// No-op: execute() above orchestrates the regenerate+compare directly.
218+
}
219+
220+
/** One generator's committed-vs-temp output-dir mapping. */
221+
private static final class GenTarget {
222+
final String classname;
223+
final Path realDir;
224+
final Path tempDir;
225+
226+
GenTarget(String classname, Path realDir, Path tempDir) {
227+
this.classname = classname;
228+
this.realDir = realDir;
229+
this.tempDir = tempDir;
230+
}
231+
}
232+
}

0 commit comments

Comments
 (0)