|
| 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