}.
// FR5b — pass the wrapper-key position so the scalar-body synthesis
// (Rule 2) can attribute the synthesized `name` slot to the wrapper.
- JsonObject body = DesugarBody(rawBody, registry, canonicalKey, errors, path, wrapperKeyPos, nodePkg);
+ JsonObject body = DesugarBody(rawBody, registry, canonicalKey, errors, path, wrapperKeyPos);
// Rule 3 (cont.): stamp isArray onto the canonical body.
if (isArray)
@@ -362,7 +184,7 @@ public static YamlDesugarResult Desugar(YamlNode? input, TypeRegistry registry)
for (int i = 0; i < seq.Children.Count; i++)
{
string childPath = $"{path}.{RESERVED_KEY_CHILDREN}[{i}]";
- JsonObject? child = DesugarNode(seq.Children[i], registry, errors, childPath, nodePkg);
+ JsonObject? child = DesugarNode(seq.Children[i], registry, errors, childPath);
children.Add(child ?? new JsonObject());
}
body[RESERVED_KEY_CHILDREN] = children;
@@ -434,10 +256,7 @@ private static JsonObject DesugarBody(
// to back-fill the `name` slot on Rule-2 scalar-body synthesis; for
// mapping bodies, we read each body-key's own position from the
// YAML AST (the per-kvp scan below).
- YamlPosition? wrapperKeyPos,
- // FR-032: this node's effective package context, used to FQN-expand
- // ref-bearing attr values (extends + @objectRef/@from/…).
- string nodePkg)
+ YamlPosition? wrapperKeyPos)
{
var output = new JsonObject();
if (rawBody is null)
@@ -533,9 +352,8 @@ private static JsonObject DesugarBody(
// ERR_RESERVED_ATTR and emits it with the proper FR5a envelope
// (format=json, jsonPath at the parent node). Mirrors TS
// yaml-desugar.ts:197-203 and Java YamlDesugar.java:340-349.
- // FR-032: expand a ref-bearing value (reserved `extends` or an already-
- // `@`-prefixed ref attr) to FQN; the `package` key is never expanded.
- output[key] = MaybeExpandRef(key, YamlToJsonNode(value), nodePkg, errors, path);
+ // ADR-0059 R1: a reference value is copied through exactly as written.
+ output[key] = YamlToJsonNode(value);
outKey = key;
if (key.StartsWith(ATTR_PREFIX, StringComparison.Ordinal))
{
@@ -551,9 +369,8 @@ private static JsonObject DesugarBody(
else
{
// Rule 4 — sigil-free attr: re-prefix with `@` when lowering to canonical.
- // FR-032: a bare ref-bearing attr is expanded to FQN too.
outKey = $"{ATTR_PREFIX}{key}";
- output[outKey] = MaybeExpandRef(outKey, YamlToJsonNode(value), nodePkg, errors, path);
+ output[outKey] = YamlToJsonNode(value);
CheckCoercion(key, value, schemaIndex, errors, path);
}
diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/GeneratedFileWriter.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/GeneratedFileWriter.java
index 8d77cc454..aa46ab1f6 100644
--- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/GeneratedFileWriter.java
+++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/GeneratedFileWriter.java
@@ -8,8 +8,14 @@
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.ArrayList;
import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
import java.util.regex.Pattern;
/**
@@ -21,20 +27,27 @@
* so the backstop was not implemented here at all. A file at a generated output path was
* overwritten unconditionally, whatever it contained and whoever wrote it.
*
- * What it does, and deliberately does not do. This is the marker floor, not
- * edit detection. A file that carries the {@code GENERATED} marker is this toolchain's
- * output and is overwritten; a file that does not is somebody's own and is refused. That
- * makes taking ownership of a generated file an explicit gesture: delete the marker
- * line, and regeneration will never touch the file again.
+ *
What it does. The write decision comes from a committed HASH MANIFEST of what
+ * {@code mvn metaobjects:generate} previously wrote — the same rule the TypeScript, Python
+ * and C# ports already share — not from the {@code GENERATED} marker alone. The marker
+ * rule it replaced was wrong in both directions: a hand-EDITED generated file keeps its
+ * marker, so the one case worth protecting was overwritten silently; and a file this
+ * generator wrote whose template later stopped emitting the marker was refused as though
+ * it were somebody's hand-written source. The marker still matters at the ONE moment the
+ * manifest cannot help: an existing file at a generated path that has never been recorded.
+ * There, carrying the marker is what lets a pre-manifest file (adopted from before this
+ * project committed {@code .metaobjects/.gen-state/.hashes.json}) be taken as this
+ * toolchain's own rather than refused as hand-written — see {@link #write} for the full
+ * decision tree. Absent any manifest configuration on the active {@link Run} (an embedder,
+ * or a test driving a generator directly), the marker alone still decides, exactly as
+ * before.
*
- *
It cannot detect an edit to a file that KEEPS its marker — for that you need a
- * record of what was written, which is what the TypeScript port's committed hash manifest
- * provides. That machinery is deliberately not replicated here: these ports' customization
- * model is build-config and template-spec (see {@code docs/features/own-your-codegen.md},
- * "Per port"), not editing emitted files in place, so the accuracy it buys is not worth a
- * committed state file, a migration and a new class of merge conflict on ports whose
- * workflow does not ask for it. The same reasoning ADR-0015 applies to schema migrations:
- * shared where the guarantee is shared, per-port where the workflow differs.
+ *
Each {@code } entry in the pom carries its own {@code handEdits} setting
+ * ({@code keep}, the default, or {@code overwrite}, set via {@link Run#attributeTo(String,
+ * String)}): {@code keep} refuses a hand-edited file by name; {@code overwrite} replaces it
+ * with fresh output and re-baselines its recorded hash, printing one line per file so the
+ * replacement is never silent. {@code overwrite} never applies to a file that was never
+ * recorded as ours (a stranger) — only to a file the manifest shows this toolchain wrote.
*
* Refusing is a WARNING, never a build failure. These generators run inside
* {@code mvn metaobjects:generate}, where failing the reactor over a file the user chose
@@ -120,16 +133,120 @@ public static final class Run implements AutoCloseable {
private final Map contentByPath = new HashMap<>();
private final Map writerByPath = new HashMap<>();
private String current = "(unattributed generator)";
+ private String currentHandEdits = HAND_EDITS_KEEP;
+ private HashManifest.Owner currentOwner;
+
+ // Manifest configuration — unset (null genStateDir/projectRoot) means "no manifest
+ // for this run", which keeps every write on the legacy marker-only rule. Set once,
+ // by the Mojo, right after beginRun().
+ private Path genStateDir;
+ private Path projectRoot;
+ // The manifest, read once when it is configured and written back once by flush().
+ private Map hashes;
+ private Map owners;
+ private boolean dirty;
+
+ // Every path write() was asked to produce this run, whatever the outcome — written,
+ // unchanged, refused or adopted. This is the "emitted" set the caller's orphan sweep
+ // must not touch: a refused or adopted path is still spoken for.
+ private final Set emittedPaths = new LinkedHashSet<>();
+ // Per path, the content hash each owner emitted, for every write that is ours to
+ // make: a file the adopter hand-owns is no generator's output, so it is left out.
+ private final Map> emissions = new LinkedHashMap<>();
+ // Aggregate state for the Mojo's end-of-run notices.
+ private final List adoptedPaths = new ArrayList<>();
+ private final List overwrittenHandEditedPaths = new ArrayList<>();
+ private int untrackedCount = 0;
private Run() {}
- /** Name the generator whose writes follow. Called once per generator by the runner. */
+ /** Name the generator whose writes follow, with {@code handEdits=keep}. Equivalent
+ * to {@code attributeTo(generatorName, "keep")}. */
public Run attributeTo(String generatorName) {
+ return attributeTo(generatorName, HAND_EDITS_KEEP, null);
+ }
+
+ /**
+ * Name the generator whose writes follow, its {@code handEdits} policy
+ * ({@link #HAND_EDITS_KEEP} or {@link #HAND_EDITS_OVERWRITE}) and the
+ * {@link HashManifest.Owner} its writes are recorded against ({@code null}: none).
+ * Called once per generator by the runner, before that generator executes.
+ */
+ public Run attributeTo(String generatorName, String handEdits, HashManifest.Owner owner) {
this.current = generatorName;
+ this.currentHandEdits = handEdits;
+ this.currentOwner = owner;
return this;
}
+ /**
+ * Turn on manifest-aware writes for this run: {@code genStateDir} is the directory
+ * holding {@code .hashes.json} (typically {@code /.metaobjects/.gen-state}),
+ * and {@code projectRoot} is what manifest keys are relative to (typically the
+ * module basedir). Called once, by the Mojo, right after {@link #beginRun}. Leaving
+ * this unset keeps every write on the legacy marker-only rule.
+ */
+ public Run configureManifest(Path genStateDir, Path projectRoot) {
+ this.genStateDir = genStateDir;
+ this.projectRoot = projectRoot;
+ this.hashes = HashManifest.load(genStateDir);
+ this.owners = HashManifest.loadOwners(genStateDir);
+ return this;
+ }
+
+ /** Write this run's manifest changes to disk, if any. The Mojo calls it once every
+ * generator has run, before its orphan sweep reads the manifest; {@link #close}
+ * calls it for whatever is still unwritten. */
+ public void flush() {
+ if (!dirty) return;
+ HashManifest.save(genStateDir, hashes, owners);
+ dirty = false;
+ }
+
+ private void record(String key, String hash) {
+ if (!hash.equals(hashes.put(key, hash))) dirty = true;
+ HashManifest.Owner previous = currentOwner == null ? owners.remove(key) : owners.put(key, currentOwner);
+ if (!Objects.equals(previous, currentOwner)) dirty = true;
+ }
+
+ /** The files written, left in place, or adopted this run — the orphan sweep's
+ * "do not touch" set. */
+ public Set emittedPaths() {
+ return emittedPaths;
+ }
+
+ /** Each path a generator emitted as its own output (not refused as hand-owned), with
+ * the hash of the content each owner emitted there. */
+ public Map> emissions() {
+ return emissions;
+ }
+
+ /** Project-relative paths adopted into a fresh manifest (or a manifest that had no
+ * record for them yet) this run — for the Mojo's one-time adoption notice. */
+ public List adoptedPaths() {
+ return adoptedPaths;
+ }
+
+ /** Paths overwritten under {@code handEdits=overwrite} despite having been
+ * hand-edited since this toolchain last wrote them. */
+ public List overwrittenHandEditedPaths() {
+ return overwrittenHandEditedPaths;
+ }
+
+ /** How many writes this run landed outside {@code projectRoot} and so could not be
+ * recorded in the manifest at all. */
+ public int untrackedCount() {
+ return untrackedCount;
+ }
+
+ private void note(Path outFile, String content) {
+ if (currentOwner == null) return;
+ emissions.computeIfAbsent(outFile.toAbsolutePath().normalize(), k -> new LinkedHashMap<>())
+ .putIfAbsent(currentOwner, HashManifest.contentHash(content));
+ }
+
private void claim(Path outFile, String content) {
+ note(outFile, content);
Path key = outFile.toAbsolutePath().normalize();
String previous = contentByPath.get(key);
if (previous == null) {
@@ -150,7 +267,11 @@ private void claim(Path outFile, String content) {
@Override
public void close() {
- ACTIVE_RUN.remove();
+ try {
+ flush();
+ } finally {
+ ACTIVE_RUN.remove();
+ }
}
}
@@ -173,27 +294,131 @@ public static Run currentRun() {
return ACTIVE_RUN.get();
}
+ /** {@link Run#attributeTo(String, String, HashManifest.Owner)}'s default: refuse a hand-edited file by name. */
+ public static final String HAND_EDITS_KEEP = "keep";
+ /** {@link Run#attributeTo(String, String, HashManifest.Owner)}: replace a hand-edited file with fresh output. */
+ public static final String HAND_EDITS_OVERWRITE = "overwrite";
+
/** What happened to one file. */
public enum Outcome {
- /** Written — the path was new, or held this toolchain's own output. */
+ /** Written — the path was new, held this toolchain's own output, or was adopted
+ * or overwritten per the active {@code handEdits} policy. */
WRITTEN,
- /** Left alone — the existing file carries no generated marker. */
- REFUSED
+ /** Left alone — hand-written (or hand-edited under {@code handEdits=keep}). */
+ REFUSED,
+ /** Nothing to do — the file already holds exactly this content. */
+ UNCHANGED
}
/**
* Write {@code content} to {@code outFile} unless an existing file there is not ours,
* creating parent directories as needed. Logs its own refusal.
*
+ * No manifest configured on the active {@link Run} (none open, or
+ * {@link Run#configureManifest} was never called — an embedder, or a test driving a
+ * generator directly): the legacy rule stands. A file that carries the
+ * {@code GENERATED} marker is overwritten; one that does not is refused.
+ *
+ *
With a manifest (the normal {@code mvn metaobjects:generate} path), per
+ * file:
+ *
+ * - absent → write, record its hash;
+ * - exists, no record for this key yet → carries the marker? adopt it
+ * (write fresh content, record the hash) — this is how a file generated before
+ * this project committed a manifest, or by a generator just added to one, is
+ * taken as ours without a human having to say so. No marker → refused, exactly
+ * as always (a stranger, never touched regardless of {@code handEdits});
+ * - exists, content already matches the fresh output → re-affirm the record,
+ * {@link Outcome#UNCHANGED};
+ * - exists, recorded hash matches the current content (pristine) → overwrite,
+ * record the new hash;
+ * - exists, recorded hash does NOT match (hand-edited since we wrote it) →
+ * {@code handEdits=keep} (default) refuses by name; {@code handEdits=overwrite}
+ * replaces it and re-baselines the record, logging the replacement.
+ *
+ *
+ * A path outside the run's configured project root is never recorded (nothing to
+ * key it by) and falls back to the marker-only rule, same as having no manifest at
+ * all — the Mojo counts these via {@link Run#untrackedCount()}.
+ *
*
Throws {@link IOException} rather than wrapping it: every call site already sits
* inside a {@code try/catch (IOException)} that raises a {@link GeneratorException}
* naming the specific artifact. Swallowing it here would make those catches
* unreachable — a compile error — and would replace each generator's precise message
* with a generic one.
- *
- * @return {@link Outcome#WRITTEN} or {@link Outcome#REFUSED}
*/
public static Outcome write(Path outFile, String content) throws IOException {
+ Run run = ACTIVE_RUN.get();
+ if (run != null) {
+ run.emittedPaths.add(outFile.toAbsolutePath().normalize());
+ }
+
+ if (run == null || run.genStateDir == null) {
+ return writeMarkerOnly(outFile, content, run);
+ }
+
+ String key = OrphanSweep.keyFor(run.projectRoot, outFile);
+ if (key == null) {
+ run.untrackedCount++;
+ return writeMarkerOnly(outFile, content, run);
+ }
+
+ if (!Files.exists(outFile)) {
+ performWrite(outFile, content, run);
+ run.record(key, HashManifest.contentHash(content));
+ return Outcome.WRITTEN;
+ }
+
+ String current = Files.readString(outFile, StandardCharsets.UTF_8);
+ String recorded = run.hashes.get(key);
+
+ if (recorded == null) {
+ // Never recorded under this key. Marker present ⇒ this toolchain's own prior
+ // output, from before a manifest existed (or before this generator's entry did)
+ // — adopt it. No marker ⇒ hand-written, refused exactly as always; `handEdits`
+ // never applies here, because a file never recorded as ours is not ours to
+ // replace under any policy.
+ if (looksGenerated(current)) {
+ performWrite(outFile, content, run);
+ run.record(key, HashManifest.contentHash(content));
+ run.adoptedPaths.add(outFile.toString());
+ return Outcome.WRITTEN;
+ }
+ LOG.warn(refusedMessage(outFile));
+ return Outcome.REFUSED;
+ }
+
+ if (current.equals(content)) {
+ // Nothing to write, but re-affirm the record: a first run over already-correct
+ // output should leave the file recognised as ours next time.
+ run.claim(outFile, content);
+ run.record(key, HashManifest.contentHash(content));
+ return Outcome.UNCHANGED;
+ }
+
+ if (recorded.equals(HashManifest.contentHash(current))) {
+ // Pristine: matches what we last wrote, safe to overwrite with fresh content.
+ performWrite(outFile, content, run);
+ run.record(key, HashManifest.contentHash(content));
+ return Outcome.WRITTEN;
+ }
+
+ // Edited: differs from both the fresh output and the last recorded hash.
+ if (HAND_EDITS_OVERWRITE.equals(run.currentHandEdits)) {
+ performWrite(outFile, content, run);
+ run.record(key, HashManifest.contentHash(content));
+ run.overwrittenHandEditedPaths.add(outFile.toString());
+ LOG.warn("overwrote hand-edited " + outFile + " (handEdits: overwrite)");
+ return Outcome.WRITTEN;
+ }
+ run.record(key, recorded);
+ run.note(outFile, content);
+ LOG.warn(refusedHandEditedMessage(outFile));
+ return Outcome.REFUSED;
+ }
+
+ /** The legacy, manifest-less decision: overwrite a marker-bearing file, refuse any other. */
+ private static Outcome writeMarkerOnly(Path outFile, String content, Run run) throws IOException {
// The marker question comes FIRST, and a refusal returns before anything is
// claimed. A path the adopter hand-owns is a path NO generator can write, so two
// generators emitting different content there is not an order-dependent output —
@@ -205,12 +430,16 @@ public static Outcome write(Path outFile, String content) throws IOException {
LOG.warn(refusedMessage(outFile));
return Outcome.REFUSED;
}
- // Now the ordering question, asked only of writes that will actually land: did a
- // sibling generator in this run already claim this path with different content?
- // That is a defect in the generator SELECTION, not in the file on disk, so it is
- // raised rather than warned — an order-dependent output is not something the user
- // can act on from a log line.
- Run run = ACTIVE_RUN.get();
+ performWrite(outFile, content, run);
+ return Outcome.WRITTEN;
+ }
+
+ /**
+ * Claim the path in the active run (the ordering/collision check — raised, never
+ * warned, since an order-dependent output is not something the user can act on from a
+ * log line) and write it to disk, creating parent directories as needed.
+ */
+ private static void performWrite(Path outFile, String content, Run run) throws IOException {
if (run != null) {
run.claim(outFile, content);
}
@@ -218,7 +447,6 @@ public static Outcome write(Path outFile, String content) throws IOException {
Files.createDirectories(outFile.getParent());
}
Files.writeString(outFile, content, StandardCharsets.UTF_8);
- return Outcome.WRITTEN;
}
/**
@@ -233,4 +461,16 @@ public static String refusedMessage(Path outFile) {
+ "' marker, so it is treated as hand-written and was left untouched. "
+ "If you want the generated version, move or delete this file and re-run.";
}
+
+ /**
+ * The message shown when a file this toolchain DID previously write is refused because
+ * it was edited since ({@code handEdits=keep}, the default).
+ */
+ public static String refusedHandEditedMessage(Path outFile) {
+ return "refusing to overwrite " + outFile + " — it was edited since metaobjects "
+ + "generated it, so it is treated as hand-edited and was left untouched "
+ + "(handEdits: keep). Move your edits into a file of your own, delete this file and "
+ + "re-run to accept the generated version, or set this generator's to "
+ + "'overwrite' to have it replace hand edits automatically.";
+ }
}
diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/HashManifest.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/HashManifest.java
new file mode 100644
index 000000000..8dcd96e76
--- /dev/null
+++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/HashManifest.java
@@ -0,0 +1,202 @@
+package com.metaobjects.generator.util;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParseException;
+import com.google.gson.JsonParser;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.TreeMap;
+
+/**
+ * The committed codegen hash manifest — how a run tells its own prior output from a hand
+ * edit, replacing the {@code GENERATED} marker as the sole overwrite signal.
+ *
+ * Mirrors the shape and algorithm the TypeScript, Python and C# ports already share:
+ * a JSON object {@code { "": "" }}, keys sorted,
+ * pretty-printed, one trailing newline, at
+ * {@code /.metaobjects/.gen-state/.hashes.json} where {@code } is the
+ * Maven project base dir. The KEYS are not meant to be portable between ports — each
+ * keys relative to its own notion of "the project" — only the shape and the hashing
+ * algorithm (sha-256 hex of the UTF-8 content) are shared, so identical content hashes
+ * identically everywhere.
+ *
+ * This file is meant to be COMMITTED: one hash per generated path, small and
+ * reviewable, where a full snapshot of every previously-generated file would be a second
+ * copy of everything. A hash already answers the only question the write decision needs:
+ * is this file byte-for-byte what we wrote last time?
+ *
+ *
An entry this port writes also names its {@link Owner}, what emitted the file: the
+ * generator's registry id and its output directory within the module, {@code "": {
+ * "hash": "", "generator": "", "outputDir": "" }}. The owner does
+ * not depend on which Maven execution ran, so a command-line run, a renamed execution and
+ * the lifecycle execution with the same configuration agree on it. An entry in the plain
+ * {@code "": ""} form, the form the other ports write, reads as recorded with
+ * no owner, and so does an entry carrying the execution-id {@code "owner"} field an
+ * earlier 8.1.0 build wrote.
+ */
+public final class HashManifest {
+
+ private HashManifest() {}
+
+ /** What emitted a recorded file: a generator's registry id and its module-relative
+ * output directory. */
+ public record Owner(String generator, String outputDir) {
+ @Override
+ public String toString() {
+ return generator + " -> " + outputDir;
+ }
+ }
+
+ private static final String FILE_NAME = ".hashes.json";
+ private static final Gson GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create();
+
+ private static Path pathFor(Path genStateDir) {
+ return genStateDir.resolve(FILE_NAME);
+ }
+
+ /** sha-256 hex (lowercase) of {@code content}'s UTF-8 bytes. */
+ public static String contentHash(String content) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ byte[] hash = digest.digest(content.getBytes(StandardCharsets.UTF_8));
+ StringBuilder sb = new StringBuilder(hash.length * 2);
+ for (byte b : hash) {
+ sb.append(Character.forDigit((b >> 4) & 0xF, 16));
+ sb.append(Character.forDigit(b & 0xF, 16));
+ }
+ return sb.toString();
+ } catch (NoSuchAlgorithmException e) {
+ // SHA-256 is mandated by every JDK provider; this cannot happen in practice.
+ throw new IllegalStateException("SHA-256 not available", e);
+ }
+ }
+
+ /**
+ * Whether this project has a manifest AT ALL — distinct from "the manifest has no
+ * entry for this path". A project with no manifest predates this toolchain's hash
+ * manifest, so a file at a generated path with no record deserves adoption (if it
+ * carries the marker) rather than a cold refusal.
+ */
+ public static boolean exists(Path genStateDir) {
+ return Files.isRegularFile(pathFor(genStateDir));
+ }
+
+ /**
+ * Load the manifest's hashes. An absent or corrupt one reads as EMPTY, which fails
+ * closed — every path then reads as unrecorded rather than being guessed at.
+ */
+ public static Map load(Path genStateDir) {
+ Map out = new LinkedHashMap<>();
+ for (Map.Entry e : entries(genStateDir).entrySet()) {
+ JsonElement value = e.getValue();
+ if (value.isJsonPrimitive() && value.getAsJsonPrimitive().isString()) {
+ out.put(e.getKey(), value.getAsString());
+ } else if (value.isJsonObject()) {
+ String v = stringField(value.getAsJsonObject(), "hash");
+ if (v != null) out.put(e.getKey(), v);
+ }
+ }
+ return out;
+ }
+
+ /** Load the owner of every entry that names one; an entry with no owner is absent. */
+ public static Map loadOwners(Path genStateDir) {
+ Map out = new LinkedHashMap<>();
+ for (Map.Entry e : entries(genStateDir).entrySet()) {
+ if (!e.getValue().isJsonObject()) continue;
+ String generator = stringField(e.getValue().getAsJsonObject(), "generator");
+ String outputDir = stringField(e.getValue().getAsJsonObject(), "outputDir");
+ if (generator != null && outputDir != null) out.put(e.getKey(), new Owner(generator, outputDir));
+ }
+ return out;
+ }
+
+ private static Map entries(Path genStateDir) {
+ Map out = new LinkedHashMap<>();
+ Path path = pathFor(genStateDir);
+ if (!Files.isRegularFile(path)) return out;
+ JsonElement parsed;
+ try {
+ parsed = JsonParser.parseString(Files.readString(path, StandardCharsets.UTF_8));
+ } catch (IOException | JsonParseException e) {
+ return out;
+ }
+ if (!parsed.isJsonObject()) return out;
+ for (Map.Entry e : parsed.getAsJsonObject().entrySet()) {
+ out.put(e.getKey(), e.getValue());
+ }
+ return out;
+ }
+
+ private static String stringField(JsonObject object, String field) {
+ JsonElement v = object.get(field);
+ return v != null && v.isJsonPrimitive() && v.getAsJsonPrimitive().isString() ? v.getAsString() : null;
+ }
+
+ /**
+ * Persist the manifest with SORTED keys, pretty-printed, one trailing newline. A key
+ * with an owner is written as {@code { "hash", "generator", "outputDir" }}, one without
+ * as the plain hash. Sorted because the file is committed: insertion order would make
+ * the diff — and any merge conflict between two people who both regenerated — depend
+ * on which generator happened to run first.
+ */
+ public static void save(Path genStateDir, Map hashes, Map owners) {
+ try {
+ Files.createDirectories(genStateDir);
+ JsonObject root = new JsonObject();
+ for (Map.Entry e : new TreeMap<>(hashes).entrySet()) {
+ Owner owner = owners.get(e.getKey());
+ if (owner == null) {
+ root.addProperty(e.getKey(), e.getValue());
+ } else {
+ JsonObject entry = new JsonObject();
+ entry.addProperty("hash", e.getValue());
+ entry.addProperty("generator", owner.generator());
+ entry.addProperty("outputDir", owner.outputDir());
+ root.add(e.getKey(), entry);
+ }
+ }
+ Files.writeString(pathFor(genStateDir), GSON.toJson(root) + "\n", StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ throw new UncheckedIOException("Could not write codegen hash manifest at " + genStateDir, e);
+ }
+ }
+
+ /** Record that {@code owner} wrote {@code key} with {@code content}: load, mutate, save. */
+ public static void record(Path genStateDir, String key, String content, Owner owner) {
+ recordHash(genStateDir, key, contentHash(content), owner);
+ }
+
+ /** Record {@code hash} for {@code key}, owned by {@code owner} ({@code null}: no owner). */
+ public static void recordHash(Path genStateDir, String key, String hash, Owner owner) {
+ Map hashes = load(genStateDir);
+ Map owners = loadOwners(genStateDir);
+ hashes.put(key, hash);
+ if (owner == null) owners.remove(key);
+ else owners.put(key, owner);
+ save(genStateDir, hashes, owners);
+ }
+
+ /** The hash recorded for {@code key}, or {@code null} if never recorded. */
+ public static String recordedHash(Path genStateDir, String key) {
+ return load(genStateDir).get(key);
+ }
+
+ /** Whether {@code current} is byte-for-byte what was recorded under {@code key}.
+ * FAILS CLOSED — {@code false} when it cannot be proven. */
+ public static boolean isPristine(Path genStateDir, String key, String current) {
+ String recorded = recordedHash(genStateDir, key);
+ return recorded != null && recorded.equals(contentHash(current));
+ }
+}
diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/OrphanSweep.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/OrphanSweep.java
new file mode 100644
index 000000000..021dbb0b7
--- /dev/null
+++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/OrphanSweep.java
@@ -0,0 +1,262 @@
+package com.metaobjects.generator.util;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+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.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+
+/**
+ * The orphan sweep: delete a file {@code mvn metaobjects:generate} wrote earlier and no
+ * longer generates.
+ *
+ * The rule every port shares (TypeScript's {@code runner.ts}/{@code orphan-sweep.ts},
+ * Python's {@code codegen/orphan_sweep.py}, C#'s {@code OrphanSweep.cs}):
+ *
+ *
+ * - A candidate is a file under an output directory of a FULL run that the hash
+ * manifest records writing and that no generator of the run emitted this time.
+ * A file the manifest does not record is not ours, whatever its name, and is
+ * never touched (a {@link Verdict#STRANGER}).
+ * - On the JVM every entry names its {@link HashManifest.Owner}, the generator and
+ * output directory that emitted it ({@link #isCandidate}). An entry is this run's
+ * to judge when this run's own generators own it, or when what owns it is configured
+ * in no {@code metaobjects:generate} execution of the module (a removed generator,
+ * or an entry with no owner in one of this run's output directories). An entry
+ * another configured generator owns is that generator's, even in a shared output
+ * directory.
+ * - A file is judged once, by the innermost swept directory that holds it, so a
+ * directory nested in another is judged by its own generators' policy; a file in
+ * none of them is judged as {@code keep}.
+ * - An untouched candidate ({@link Verdict#PRISTINE} — its content still hashes to
+ * what was recorded) is deleted and its manifest entry dropped.
+ * - An edited candidate ({@link Verdict#EDITED}) is, by default, REFUSED by name and
+ * stays recorded, so the refusal repeats until someone deletes the file or moves
+ * the edits — unless the directory it lives under is entirely owned by generators
+ * whose {@code handEdits} setting is {@code overwrite}, in which case it is deleted
+ * too (there being no record of which generator wrote an orphan, {@code overwrite}
+ * only applies when EVERY generator writing into that directory this run agrees).
+ * - A recorded path whose file is already gone is forgotten.
+ *
+ *
+ * {@code metaobjects:verify} convicts an extra committed file on the same evidence
+ * ({@link #classify}), so {@code generate} removes exactly the untouched files the gate
+ * reports, which is what makes the gate's "regenerate" remedy work.
+ */
+public final class OrphanSweep {
+
+ private OrphanSweep() {}
+
+ /** How a committed file relates to the manifest. */
+ public enum Verdict {
+ /** Not recorded: never ours, never touched. */
+ STRANGER,
+ /** Recorded and byte-for-byte what was recorded: safe to delete/overwrite. */
+ PRISTINE,
+ /** Recorded but its content has changed since: hand-edited. */
+ EDITED
+ }
+
+ /** One directory a run's generators wrote into. */
+ public static final class Entry {
+ public final Path dir;
+ /** Prefix shown before a dir-relative path when reporting a file under this dir. */
+ public final String label;
+ /** True iff every generator that writes into this dir this run has {@code handEdits=overwrite}. */
+ public final boolean allowOverwrite;
+
+ public Entry(Path dir, String label, boolean allowOverwrite) {
+ this.dir = dir;
+ this.label = label;
+ this.allowOverwrite = allowOverwrite;
+ }
+ }
+
+ /** The outcome of one sweep. */
+ public static final class Result {
+ /** Pristine orphans deleted, as shown (label + dir-relative path). */
+ public final List removed;
+ /** Edited orphans deleted because their directory's generators are all {@code overwrite}. */
+ public final List overwrittenDeleted;
+ /** Project-relative keys of edited orphans left in place (handEdits=keep). */
+ public final List refused;
+
+ public Result(List removed, List overwrittenDeleted, List refused) {
+ this.removed = removed;
+ this.overwrittenDeleted = overwrittenDeleted;
+ this.refused = refused;
+ }
+ }
+
+ /**
+ * The project-root-relative, forward-slash key for {@code file}, or {@code null} when
+ * {@code file} lies outside {@code projectRoot} — nothing outside the project is ever
+ * recorded, so nothing outside it can be classified.
+ */
+ public static String keyFor(Path projectRoot, Path file) {
+ Path root = projectRoot.toAbsolutePath().normalize();
+ Path full = file.toAbsolutePath().normalize();
+ Path rel;
+ try {
+ rel = root.relativize(full);
+ } catch (IllegalArgumentException e) {
+ return null; // different filesystem roots (Windows drives) — definitely outside
+ }
+ String key = rel.toString().replace(java.io.File.separatorChar, '/');
+ return key.startsWith("..") ? null : key;
+ }
+
+ /**
+ * Classify one committed file against the manifest: not recorded ({@link Verdict#STRANGER}),
+ * recorded and unchanged ({@link Verdict#PRISTINE}), or recorded and changed
+ * ({@link Verdict#EDITED}). An unreadable file that IS recorded reads as edited —
+ * fail closed, never silently dropped.
+ */
+ public static Verdict classify(Map hashes, Path projectRoot, Path file) {
+ String key = keyFor(projectRoot, file);
+ if (key == null) return Verdict.STRANGER;
+ String recorded = hashes.get(key);
+ if (recorded == null) return Verdict.STRANGER;
+ String current;
+ try {
+ current = Files.readString(file, StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ return Verdict.EDITED;
+ }
+ return recorded.equals(HashManifest.contentHash(current)) ? Verdict.PRISTINE : Verdict.EDITED;
+ }
+
+ /**
+ * Whether the sweep may judge an entry owned by {@code owner} ({@code null}: recorded
+ * with no owner): {@code runOwners} own it, or {@code configured} (every
+ * generator/output-directory pair configured in the module; {@code null} when that is
+ * unknown) does not hold it. An entry with no owner counts only inside one of this
+ * run's output directories ({@code inRunDir}), since outside them it may be another
+ * port's. {@code configured} is asked only when the answer depends on it.
+ */
+ public static boolean isCandidate(HashManifest.Owner owner, boolean inRunDir,
+ Set runOwners, Supplier> configured) {
+ if (owner != null && runOwners.contains(owner)) return true;
+ if (owner == null && !inRunDir) return false;
+ Set known = configured.get();
+ return known != null && (owner == null || !known.contains(owner));
+ }
+
+ /**
+ * Delete the untouched recorded files no generator of this run emitted ({@code emitted}:
+ * normalized absolute paths this run produced, refused hand-edits included) that the
+ * run may judge ({@link #isCandidate}), and forget every such entry whose file is
+ * already gone. No-op (empty result, manifest untouched) when the project has no
+ * manifest yet — there is nothing recorded, so nothing could be an orphan.
+ */
+ public static Result sweep(Path genStateDir, Path projectRoot, List dirs, Set emitted,
+ Set runOwners, Supplier> configured) {
+ if (!HashManifest.exists(genStateDir)) {
+ return new Result(List.of(), List.of(), List.of());
+ }
+ Map hashes = HashManifest.load(genStateDir);
+ Map owners = HashManifest.loadOwners(genStateDir);
+ Set forget = new LinkedHashSet<>();
+ List removed = new ArrayList<>();
+ List overwrittenDeleted = new ArrayList<>();
+ List refused = new ArrayList<>();
+ Path root = projectRoot.toAbsolutePath().normalize();
+
+ Map byDir = new LinkedHashMap<>();
+ for (Entry e : dirs) {
+ byDir.putIfAbsent(e.dir.toAbsolutePath().normalize(), e);
+ }
+
+ for (String key : hashes.keySet()) {
+ Path path = root.resolve(key).normalize();
+ if (!path.startsWith(root) || emitted.contains(path)) continue;
+ Path dir = innermost(byDir.keySet(), path);
+ if (!isCandidate(owners.get(key), dir != null, runOwners, configured)) continue;
+ if (!Files.exists(path)) {
+ // Already gone: forget it, so a stranger's file created later under that
+ // name is not mistaken for ours.
+ forget.add(key);
+ continue;
+ }
+ if (!Files.isRegularFile(path)) continue;
+
+ Entry e = dir != null ? byDir.get(dir) : null;
+ String shown = e != null
+ ? e.label + dir.relativize(path).toString().replace(java.io.File.separatorChar, '/')
+ : key;
+ switch (classify(hashes, root, path)) {
+ case PRISTINE:
+ delete(path);
+ forget.add(key);
+ removed.add(shown);
+ break;
+ case EDITED:
+ if (e != null && e.allowOverwrite) {
+ delete(path);
+ forget.add(key);
+ overwrittenDeleted.add(shown);
+ } else {
+ refused.add(key);
+ }
+ break;
+ case STRANGER:
+ default:
+ break;
+ }
+ }
+
+ if (!forget.isEmpty()) {
+ Map updated = new LinkedHashMap<>(hashes);
+ for (String key : forget) updated.remove(key);
+ HashManifest.save(genStateDir, updated, owners);
+ }
+
+ removed.sort(String::compareTo);
+ overwrittenDeleted.sort(String::compareTo);
+ List distinctRefused = refused.stream().distinct().sorted().collect(Collectors.toList());
+ return new Result(removed, overwrittenDeleted, distinctRefused);
+ }
+
+ /** The deepest of {@code dirs} holding {@code path}, or {@code null}. */
+ public static Path innermost(Iterable dirs, Path path) {
+ Path best = null;
+ for (Path d : dirs) {
+ if (path.startsWith(d) && (best == null || d.getNameCount() > best.getNameCount())) best = d;
+ }
+ return best;
+ }
+
+ private static void delete(Path path) {
+ try {
+ Files.delete(path);
+ } catch (IOException ex) {
+ throw new UncheckedIOException("Could not delete orphaned generated file [" + path + "]", ex);
+ }
+ }
+
+ /**
+ * The aggregate warning for edited orphans left in place (handEdits=keep). The same
+ * wording, shared verbatim with codegen-ts's {@code refusedOrphanMessage}, Python's
+ * {@code refused_orphan_message} and C#'s {@code OrphanSweep.RefusedMessage}: what
+ * happened, why nothing was deleted, and the two ways out.
+ */
+ public static String refusedMessage(List paths, String owner) {
+ return owner + ": " + paths.size() + " generated file(s) are no longer generated but have been "
+ + "edited by hand, so they were NOT deleted: " + String.join(", ", paths) + ". Delete each "
+ + "one, or, if what produced it was renamed, move your edits into the newly generated file "
+ + "first and then delete it (regeneration cannot follow a rename).";
+ }
+
+ /** The per-file notice for an edited orphan deleted under {@code handEdits=overwrite}. */
+ public static String overwrittenOrphanMessage(String path) {
+ return "deleted hand-edited " + path + ", which no generator emits any more (handEdits: overwrite)";
+ }
+}
diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/verify/TemplateVerify.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/verify/TemplateVerify.java
index 690068363..97f95fb9c 100644
--- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/verify/TemplateVerify.java
+++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/verify/TemplateVerify.java
@@ -12,7 +12,7 @@
import com.metaobjects.template.MetaTemplate;
import com.metaobjects.template.PromptTemplate;
import com.metaobjects.template.TemplateConstants;
-import com.metaobjects.validation.SymbolTable;
+import com.metaobjects.loader.References;
import java.nio.file.Path;
import java.util.ArrayList;
@@ -96,14 +96,6 @@ public static Outcome run(MetaDataLoader loader, Path templateRoot) {
List warnings = new ArrayList<>();
List unresolved = new ArrayList<>();
- // ADR-0042 — resolve @payloadRef through the loader's OWN package-local symbol table
- // (#228), so a bare ref binds the template's own package (else root-level) and an FQN
- // binds exactly — the same contract the loader validated the ref under. The prior
- // package-blind bare-tail scan bound a same-short-named value-object in the WRONG
- // package under a cross-package collision (load-order-dependent), deriving the wrong
- // field tree and mis-reporting {{field}} drift.
- SymbolTable symbols = SymbolTable.build(loader.getRoot());
-
for (com.metaobjects.MetaData child : loader.getRoot().getChildren()) {
if (!(child instanceof MetaTemplate tmpl)) continue;
@@ -116,7 +108,7 @@ public static Outcome run(MetaDataLoader loader, Path templateRoot) {
// Both subtypes: @payloadRef must resolve to a loaded object.value (a
// non-empty derived field tree). Catches a renamed VO before codegen.
- MetaObject payloadVo = resolveValueObject(symbols, payloadRef, tmpl.getPackage());
+ MetaObject payloadVo = valueObjectOrNull(References.refTarget(tmpl, "payloadRef"));
List fields = payloadVo == null
? List.of()
: derivePayloadFieldTree(loader, payloadVo, new LinkedHashSet<>());
@@ -207,32 +199,10 @@ private static List derivePayloadFieldTree(
}
/**
- * Resolve a {@code field.object}'s {@code @objectRef} to its target
- * {@code object.value} by BARE short-name (the cross-port render-helper
- * consensus; the raw attr value is read directly, NOT package-folded).
+ * The {@code object.value} a {@code field.object}'s {@code @objectRef} is bound to.
*/
private static MetaObject resolveNestedObjectRef(MetaDataLoader loader, ObjectField field) {
- if (!field.hasMetaAttr(MetaObject.ATTR_OBJECT_REF, false)) return null;
- String ref = field.getMetaAttr(MetaObject.ATTR_OBJECT_REF, false).getValueAsString();
- if (ref == null || ref.isEmpty()) return null;
- // ADR-0041: a FULLY-QUALIFIED @objectRef (contains "::") resolves EXACTLY on
- // the value-object's package-qualified name — never a bare-tail fallback. A
- // bare-tail match binds a same-named object.value in the WRONG package when
- // the short name collides across packages (e.g. two prompt trees each declare
- // their own `PlayerActionView`/`MemoryView`), yielding the wrong element
- // field-tree and spurious ERR_VAR_NOT_ON_PAYLOAD on the inner {{fields}}. A
- // bare ref (no "::") still matches the object.value's short name (the
- // cross-port render-helper consensus). Mirrors the Kotlin render helper.
- boolean fqn = ref.contains("::");
- String refShort = shortName(ref);
- for (MetaObject obj : loader.getMetaObjects()) {
- if (!MetaObject.SUBTYPE_VALUE.equals(obj.getSubType())) continue;
- boolean matches = fqn
- ? obj.getName().equals(ref)
- : shortName(obj.getName()).equals(refShort);
- if (matches) return obj;
- }
- return null;
+ return valueObjectOrNull(References.refTarget(field, MetaObject.ATTR_OBJECT_REF));
}
/**
@@ -249,20 +219,7 @@ private static void addIfPresent(List refs, String ref) {
if (ref != null && !ref.isEmpty()) refs.add(ref);
}
- /**
- * Resolve {@code @payloadRef} to its {@code object.value} target under the loader's ADR-0042
- * package-local contract (rejects entities). {@code referrerPkg} is the template's own package
- * ({@code ""} for a root-level template): a bare ref binds {@code ::[} first,
- * else a root-level object; an FQN binds exactly — no cross-package bare-name fallback.
- */
- private static MetaObject resolveValueObject(SymbolTable symbols, String ref, String referrerPkg) {
- MetaObject obj = symbols.resolveObject(ref, referrerPkg == null ? "" : referrerPkg);
+ private static MetaObject valueObjectOrNull(MetaObject obj) {
return (obj != null && MetaObject.SUBTYPE_VALUE.equals(obj.getSubType())) ? obj : null;
}
-
- /** Last {@code ::} segment of a (possibly packaged) metadata name. */
- private static String shortName(String fqn) {
- int sep = fqn.lastIndexOf("::");
- return sep < 0 ? fqn : fqn.substring(sep + 2);
- }
}
diff --git a/server/java/codegen-base/src/test/java/com/metaobjects/generator/template/TemplateDataTest.java b/server/java/codegen-base/src/test/java/com/metaobjects/generator/template/TemplateDataTest.java
index 40dd5ca39..f59bddf41 100644
--- a/server/java/codegen-base/src/test/java/com/metaobjects/generator/template/TemplateDataTest.java
+++ b/server/java/codegen-base/src/test/java/com/metaobjects/generator/template/TemplateDataTest.java
@@ -61,7 +61,7 @@ private MetaObject obj(MetaDataLoader loader, String bareName) {
assertEquals(1, rels.size());
assertEquals("product", rels.get(0).get("name"));
assertEquals("one", rels.get(0).get("cardinality"));
- assertEquals("Product", rels.get(0).get("targetRef"));
+ assertEquals("shop::Product", rels.get(0).get("targetRef"));
}
/**
diff --git a/server/java/codegen-base/src/test/java/com/metaobjects/generator/util/GeneratedFileWriterTest.java b/server/java/codegen-base/src/test/java/com/metaobjects/generator/util/GeneratedFileWriterTest.java
index 46661051e..e09e14925 100644
--- a/server/java/codegen-base/src/test/java/com/metaobjects/generator/util/GeneratedFileWriterTest.java
+++ b/server/java/codegen-base/src/test/java/com/metaobjects/generator/util/GeneratedFileWriterTest.java
@@ -7,8 +7,10 @@
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.List;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
@@ -243,4 +245,230 @@ public void closingARunEndsItsScope() throws Exception {
// The same path, different content, is now free again — a new build, not a collision.
GeneratedFileWriter.write(out, "/** GENERATED */\nclass Scoped { int b; }\n");
}
+
+ // === manifest-aware writes ================================================
+
+ @Test
+ public void absentFile_writesAndRecords() throws Exception {
+ Path project = tmp.newFolder().toPath();
+ Path genState = project.resolve(".metaobjects/.gen-state");
+ Path out = project.resolve("gen/X.java");
+
+ try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) {
+ run.configureManifest(genState, project);
+ assertEquals(GeneratedFileWriter.Outcome.WRITTEN, GeneratedFileWriter.write(out, GENERATED_BODY));
+ }
+ assertEquals(GENERATED_BODY, Files.readString(out));
+ assertEquals(HashManifest.contentHash(GENERATED_BODY), HashManifest.recordedHash(genState, "gen/X.java"));
+ }
+
+ @Test
+ public void manifestIsWrittenOncePerRun_onFlushNotOnEachWrite() throws Exception {
+ Path project = tmp.newFolder().toPath();
+ Path genState = project.resolve(".metaobjects/.gen-state");
+
+ try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) {
+ run.configureManifest(genState, project);
+ GeneratedFileWriter.write(project.resolve("gen/A.java"), GENERATED_BODY);
+ GeneratedFileWriter.write(project.resolve("gen/B.java"), GENERATED_BODY);
+ assertFalse(HashManifest.exists(genState));
+ run.flush();
+ assertEquals(HashManifest.contentHash(GENERATED_BODY), HashManifest.recordedHash(genState, "gen/A.java"));
+ assertEquals(HashManifest.contentHash(GENERATED_BODY), HashManifest.recordedHash(genState, "gen/B.java"));
+ }
+ }
+
+ @Test
+ public void runThatChangesNothing_leavesTheManifestFileAlone() throws Exception {
+ Path project = tmp.newFolder().toPath();
+ Path genState = project.resolve(".metaobjects/.gen-state");
+ Path out = project.resolve("gen/X.java");
+ Files.createDirectories(out.getParent());
+ Files.writeString(out, GENERATED_BODY);
+ Files.createDirectories(genState);
+ String manifest = "{\"gen/X.java\": \"" + HashManifest.contentHash(GENERATED_BODY) + "\"}";
+ Files.writeString(genState.resolve(".hashes.json"), manifest);
+
+ try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) {
+ run.configureManifest(genState, project);
+ assertEquals(GeneratedFileWriter.Outcome.UNCHANGED, GeneratedFileWriter.write(out, GENERATED_BODY));
+ }
+ assertEquals("not rewritten (a rewrite would pretty-print it)", manifest,
+ Files.readString(genState.resolve(".hashes.json")));
+ }
+
+ @Test
+ public void firstRunAdoption_markerFilePresentNoManifest_isAdoptedNotRefused() throws Exception {
+ Path project = tmp.newFolder().toPath();
+ Path genState = project.resolve(".metaobjects/.gen-state");
+ Path out = project.resolve("gen/X.java");
+ Files.createDirectories(out.getParent());
+ Files.writeString(out, GENERATED_BODY); // pre-existing, never recorded
+
+ try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) {
+ run.configureManifest(genState, project);
+ assertEquals(GeneratedFileWriter.Outcome.WRITTEN, GeneratedFileWriter.write(out, GENERATED_BODY));
+ assertEquals(List.of(out.toString()), run.adoptedPaths());
+ }
+ assertEquals(HashManifest.contentHash(GENERATED_BODY), HashManifest.recordedHash(genState, "gen/X.java"));
+ }
+
+ @Test
+ public void firstRunAdoption_noMarkerNoManifest_isRefusedNotAdopted() throws Exception {
+ Path project = tmp.newFolder().toPath();
+ Path genState = project.resolve(".metaobjects/.gen-state");
+ Path out = project.resolve("gen/X.java");
+ Files.createDirectories(out.getParent());
+ String mine = "// hand-written, no marker\npublic class X {}\n";
+ Files.writeString(out, mine);
+
+ try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) {
+ run.configureManifest(genState, project);
+ assertEquals(GeneratedFileWriter.Outcome.REFUSED, GeneratedFileWriter.write(out, GENERATED_BODY));
+ assertTrue(run.adoptedPaths().isEmpty());
+ }
+ assertEquals(mine, Files.readString(out));
+ assertNull(HashManifest.recordedHash(genState, "gen/X.java"));
+ }
+
+ @Test
+ public void pristineRecordedFile_isOverwrittenAndReRecorded() throws Exception {
+ Path project = tmp.newFolder().toPath();
+ Path genState = project.resolve(".metaobjects/.gen-state");
+ Path out = project.resolve("gen/X.java");
+
+ try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) {
+ run.configureManifest(genState, project);
+ GeneratedFileWriter.write(out, GENERATED_BODY);
+ }
+ String fresh = "/** GENERATED — REST controller for Council. */\npublic class X { int y; }\n";
+ try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) {
+ run.configureManifest(genState, project);
+ assertEquals(GeneratedFileWriter.Outcome.WRITTEN, GeneratedFileWriter.write(out, fresh));
+ }
+ assertEquals(fresh, Files.readString(out));
+ assertEquals(HashManifest.contentHash(fresh), HashManifest.recordedHash(genState, "gen/X.java"));
+ }
+
+ @Test
+ public void unchangedContent_isReaffirmedNotRewritten() throws Exception {
+ Path project = tmp.newFolder().toPath();
+ Path genState = project.resolve(".metaobjects/.gen-state");
+ Path out = project.resolve("gen/X.java");
+
+ try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) {
+ run.configureManifest(genState, project);
+ GeneratedFileWriter.write(out, GENERATED_BODY);
+ }
+ try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) {
+ run.configureManifest(genState, project);
+ assertEquals(GeneratedFileWriter.Outcome.UNCHANGED, GeneratedFileWriter.write(out, GENERATED_BODY));
+ }
+ }
+
+ @Test
+ public void unchangedWriteStillClaimsThePath_soADifferingSecondEmitterCollides() throws Exception {
+ Path project = tmp.newFolder().toPath();
+ Path genState = project.resolve(".metaobjects/.gen-state");
+ Path out = project.resolve("gen/X.java");
+
+ try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) {
+ run.configureManifest(genState, project);
+ GeneratedFileWriter.write(out, GENERATED_BODY);
+ }
+ try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) {
+ run.configureManifest(genState, project);
+ run.attributeTo("GeneratorA");
+ assertEquals(GeneratedFileWriter.Outcome.UNCHANGED, GeneratedFileWriter.write(out, GENERATED_BODY));
+ run.attributeTo("GeneratorB");
+ try {
+ GeneratedFileWriter.write(out, "/** GENERATED */\npublic class X { int other; }\n");
+ fail("expected an output path collision");
+ } catch (GeneratorException e) {
+ assertTrue(e.getMessage(), e.getMessage().contains("Output path collision")
+ && e.getMessage().contains("GeneratorA") && e.getMessage().contains("GeneratorB"));
+ }
+ }
+ assertEquals(GENERATED_BODY, Files.readString(out));
+ }
+
+ @Test
+ public void editedRecordedFile_handEditsKeep_isRefusedByNameAndStaysRecorded() throws Exception {
+ Path project = tmp.newFolder().toPath();
+ Path genState = project.resolve(".metaobjects/.gen-state");
+ Path out = project.resolve("gen/X.java");
+
+ try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) {
+ run.configureManifest(genState, project);
+ GeneratedFileWriter.write(out, GENERATED_BODY);
+ }
+ String edited = GENERATED_BODY + "// mine\n";
+ Files.writeString(out, edited);
+
+ String fresh = "/** GENERATED — REST controller for Council. */\npublic class X { int z; }\n";
+ try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) {
+ run.configureManifest(genState, project);
+ run.attributeTo("SomeGenerator", GeneratedFileWriter.HAND_EDITS_KEEP, null);
+ assertEquals(GeneratedFileWriter.Outcome.REFUSED, GeneratedFileWriter.write(out, fresh));
+ }
+ assertEquals(edited, Files.readString(out));
+ // Still recorded under its ORIGINAL hash — the refusal must repeat next run.
+ assertEquals(HashManifest.contentHash(GENERATED_BODY), HashManifest.recordedHash(genState, "gen/X.java"));
+ }
+
+ @Test
+ public void editedRecordedFile_handEditsOverwrite_isReplacedAndReBaselined() throws Exception {
+ Path project = tmp.newFolder().toPath();
+ Path genState = project.resolve(".metaobjects/.gen-state");
+ Path out = project.resolve("gen/X.java");
+
+ try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) {
+ run.configureManifest(genState, project);
+ GeneratedFileWriter.write(out, GENERATED_BODY);
+ }
+ Files.writeString(out, GENERATED_BODY + "// mine\n");
+
+ String fresh = "/** GENERATED — REST controller for Council. */\npublic class X { int z; }\n";
+ try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) {
+ run.configureManifest(genState, project);
+ run.attributeTo("SomeGenerator", GeneratedFileWriter.HAND_EDITS_OVERWRITE, null);
+ assertEquals(GeneratedFileWriter.Outcome.WRITTEN, GeneratedFileWriter.write(out, fresh));
+ assertEquals(List.of(out.toString()), run.overwrittenHandEditedPaths());
+ }
+ assertEquals(fresh, Files.readString(out));
+ assertEquals(HashManifest.contentHash(fresh), HashManifest.recordedHash(genState, "gen/X.java"));
+ }
+
+ @Test
+ public void strangerFile_isNeverTouchedByOverwritePolicy() throws Exception {
+ // A file never recorded as ours stays refused even when the generator's policy is
+ // `overwrite` — `overwrite` only ever replaces what the manifest shows we wrote.
+ Path project = tmp.newFolder().toPath();
+ Path genState = project.resolve(".metaobjects/.gen-state");
+ Path out = project.resolve("gen/X.java");
+ Files.createDirectories(out.getParent());
+ String mine = "// never ours\npublic class X {}\n";
+ Files.writeString(out, mine);
+
+ try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) {
+ run.configureManifest(genState, project);
+ run.attributeTo("SomeGenerator", GeneratedFileWriter.HAND_EDITS_OVERWRITE, null);
+ assertEquals(GeneratedFileWriter.Outcome.REFUSED, GeneratedFileWriter.write(out, GENERATED_BODY));
+ }
+ assertEquals(mine, Files.readString(out));
+ }
+
+ @Test
+ public void untrackedPathOutsideProjectRoot_fallsBackToMarkerOnlyRule() throws Exception {
+ Path project = tmp.newFolder().toPath();
+ Path genState = project.resolve(".metaobjects/.gen-state");
+ Path outside = tmp.newFolder().toPath().resolve("X.java"); // sibling, not under project
+
+ try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) {
+ run.configureManifest(genState, project);
+ assertEquals(GeneratedFileWriter.Outcome.WRITTEN, GeneratedFileWriter.write(outside, GENERATED_BODY));
+ assertEquals(1, run.untrackedCount());
+ }
+ assertEquals(GENERATED_BODY, Files.readString(outside));
+ }
}
diff --git a/server/java/codegen-base/src/test/java/com/metaobjects/generator/util/HashManifestTest.java b/server/java/codegen-base/src/test/java/com/metaobjects/generator/util/HashManifestTest.java
new file mode 100644
index 000000000..062bd337f
--- /dev/null
+++ b/server/java/codegen-base/src/test/java/com/metaobjects/generator/util/HashManifestTest.java
@@ -0,0 +1,119 @@
+package com.metaobjects.generator.util;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+/** The committed hash manifest: shape, merge semantics, fail-closed reads. */
+public class HashManifestTest {
+
+ private static final HashManifest.Owner OWNER = new HashManifest.Owner("entity", "gen");
+
+ @Rule
+ public TemporaryFolder tmp = new TemporaryFolder();
+
+ @Test
+ public void absentManifestDoesNotExistAndLoadsEmpty() throws Exception {
+ Path genState = tmp.getRoot().toPath().resolve(".metaobjects/.gen-state");
+ assertFalse(HashManifest.exists(genState));
+ assertTrue(HashManifest.load(genState).isEmpty());
+ assertNull(HashManifest.recordedHash(genState, "src/X.java"));
+ }
+
+ @Test
+ public void recordThenExistsThenRecordedHash() throws Exception {
+ Path genState = tmp.getRoot().toPath().resolve(".metaobjects/.gen-state");
+ String content = "public class X {}\n";
+ HashManifest.record(genState, "src/X.java", content, OWNER);
+
+ assertTrue(HashManifest.exists(genState));
+ assertEquals(HashManifest.contentHash(content), HashManifest.recordedHash(genState, "src/X.java"));
+ }
+
+ @Test
+ public void recordMergesRatherThanOverwritingOtherKeys() throws Exception {
+ Path genState = tmp.getRoot().toPath().resolve(".metaobjects/.gen-state");
+ HashManifest.record(genState, "src/A.java", "a", OWNER);
+ HashManifest.record(genState, "src/B.java", "b", OWNER);
+
+ Map loaded = HashManifest.load(genState);
+ assertEquals(2, loaded.size());
+ assertEquals(HashManifest.contentHash("a"), loaded.get("src/A.java"));
+ assertEquals(HashManifest.contentHash("b"), loaded.get("src/B.java"));
+ }
+
+ @Test
+ public void savedFileIsSortedPrettyAndTrailingNewline() throws Exception {
+ Path genState = tmp.getRoot().toPath().resolve(".metaobjects/.gen-state");
+ HashManifest.record(genState, "src/Z.java", "z", OWNER);
+ HashManifest.record(genState, "src/A.java", "a", OWNER);
+
+ String raw = Files.readString(genState.resolve(".hashes.json"));
+ assertTrue("expected a trailing newline", raw.endsWith("\n"));
+ // Sorted: A before Z.
+ assertTrue(raw.indexOf("src/A.java") < raw.indexOf("src/Z.java"));
+ // Pretty-printed: not a single line.
+ assertTrue(raw.contains("\n "));
+ }
+
+ @Test
+ public void isPristineFailsClosedWhenNeverRecorded() throws Exception {
+ Path genState = tmp.getRoot().toPath().resolve(".metaobjects/.gen-state");
+ assertFalse(HashManifest.isPristine(genState, "src/Never.java", "anything"));
+ }
+
+ @Test
+ public void isPristineTrueOnlyForExactRecordedContent() throws Exception {
+ Path genState = tmp.getRoot().toPath().resolve(".metaobjects/.gen-state");
+ HashManifest.record(genState, "src/X.java", "original", OWNER);
+
+ assertTrue(HashManifest.isPristine(genState, "src/X.java", "original"));
+ assertFalse(HashManifest.isPristine(genState, "src/X.java", "edited"));
+ }
+
+ @Test
+ public void contentHashIsStableAndLowercaseHex() {
+ String h1 = HashManifest.contentHash("hello");
+ String h2 = HashManifest.contentHash("hello");
+ assertEquals(h1, h2);
+ assertEquals(64, h1.length());
+ assertEquals(h1, h1.toLowerCase());
+ }
+
+ @Test
+ public void ownedEntryIsWrittenWithItsOwner_plainAndExecutionIdEntriesReadAsUnowned() throws Exception {
+ Path genState = tmp.getRoot().toPath().resolve(".metaobjects/.gen-state");
+ Files.createDirectories(genState);
+ // The plain form, as the TypeScript, Python and C# ports write it, and the
+ // execution-id owner an earlier 8.1.0 build wrote.
+ Files.writeString(genState.resolve(".hashes.json"), "{\n \"src/Plain.java\": \"abc\",\n"
+ + " \"src/Legacy.java\": { \"hash\": \"def\", \"owner\": \"app:codegen\" }\n}\n");
+
+ HashManifest.record(genState, "src/Owned.java", "owned", OWNER);
+
+ Map hashes = HashManifest.load(genState);
+ assertEquals("abc", hashes.get("src/Plain.java"));
+ assertEquals("def", hashes.get("src/Legacy.java"));
+ assertEquals(HashManifest.contentHash("owned"), hashes.get("src/Owned.java"));
+ Map owners = HashManifest.loadOwners(genState);
+ assertEquals(Map.of("src/Owned.java", OWNER), owners);
+
+ com.google.gson.JsonObject raw = com.google.gson.JsonParser.parseString(
+ Files.readString(genState.resolve(".hashes.json"))).getAsJsonObject();
+ assertEquals("abc", raw.get("src/Plain.java").getAsString());
+ assertEquals("def", raw.get("src/Legacy.java").getAsString());
+ com.google.gson.JsonObject owned = raw.getAsJsonObject("src/Owned.java");
+ assertEquals(HashManifest.contentHash("owned"), owned.get("hash").getAsString());
+ assertEquals("entity", owned.get("generator").getAsString());
+ assertEquals("gen", owned.get("outputDir").getAsString());
+ }
+}
diff --git a/server/java/codegen-base/src/test/java/com/metaobjects/generator/util/OrphanSweepTest.java b/server/java/codegen-base/src/test/java/com/metaobjects/generator/util/OrphanSweepTest.java
new file mode 100644
index 000000000..ec55953c9
--- /dev/null
+++ b/server/java/codegen-base/src/test/java/com/metaobjects/generator/util/OrphanSweepTest.java
@@ -0,0 +1,347 @@
+package com.metaobjects.generator.util;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/** The orphan sweep: classify, delete, refuse, forget — against a real manifest and tree. */
+public class OrphanSweepTest {
+
+ private static final HashManifest.Owner OWNER = new HashManifest.Owner("entity", "gen");
+
+ @Rule
+ public TemporaryFolder tmp = new TemporaryFolder();
+
+ @Rule
+ public TemporaryFolder elsewhere = new TemporaryFolder();
+
+ private Path projectRoot;
+ private Path genStateDir;
+ private Path outDir;
+
+ private void setUpProject() throws Exception {
+ projectRoot = tmp.getRoot().toPath();
+ genStateDir = projectRoot.resolve(".metaobjects/.gen-state");
+ outDir = projectRoot.resolve("gen");
+ Files.createDirectories(outDir);
+ }
+
+ // === classify ============================================================
+
+ @Test
+ public void classifyStrangerWhenNotRecorded() throws Exception {
+ setUpProject();
+ Path file = outDir.resolve("Stray.java");
+ Files.writeString(file, "// not ours\n");
+
+ assertEquals(OrphanSweep.Verdict.STRANGER,
+ OrphanSweep.classify(Collections.emptyMap(), projectRoot, file));
+ }
+
+ @Test
+ public void classifyPristineWhenContentMatchesRecordedHash() throws Exception {
+ setUpProject();
+ Path file = outDir.resolve("Telemetry.java");
+ String content = "/** GENERATED */\npublic class Telemetry {}\n";
+ Files.writeString(file, content);
+ HashManifest.record(genStateDir, "gen/Telemetry.java", content, OWNER);
+
+ assertEquals(OrphanSweep.Verdict.PRISTINE,
+ OrphanSweep.classify(HashManifest.load(genStateDir), projectRoot, file));
+ }
+
+ @Test
+ public void classifyEditedWhenContentDiffersFromRecordedHash() throws Exception {
+ setUpProject();
+ Path file = outDir.resolve("Telemetry.java");
+ HashManifest.record(genStateDir, "gen/Telemetry.java", "/** GENERATED */\npublic class Telemetry {}\n", OWNER);
+ Files.writeString(file, "/** GENERATED */\npublic class Telemetry { int extra; }\n");
+
+ assertEquals(OrphanSweep.Verdict.EDITED,
+ OrphanSweep.classify(HashManifest.load(genStateDir), projectRoot, file));
+ }
+
+ @Test
+ public void keyForIsNullOutsideProjectRoot() throws Exception {
+ setUpProject();
+ Path outside = elsewhere.getRoot().toPath().resolve("X.java");
+ assertEquals(null, OrphanSweep.keyFor(projectRoot, outside));
+ }
+
+ // === sweep: the rename case — pristine orphan deleted + forgotten =========
+
+ @Test
+ public void renameCase_pristineOrphanIsDeletedAndForgotten() throws Exception {
+ setUpProject();
+ String oldContent = "/** GENERATED */\npublic class Telemetry {}\n";
+ Path oldFile = outDir.resolve("Telemetry.java");
+ Files.writeString(oldFile, oldContent);
+ HashManifest.record(genStateDir, "gen/Telemetry.java", oldContent, OWNER);
+
+ Path newFile = outDir.resolve("TelemetryReading.java");
+ Files.writeString(newFile, "/** GENERATED */\npublic class TelemetryReading {}\n");
+ Set emitted = new LinkedHashSet<>(Set.of(newFile.toAbsolutePath().normalize()));
+
+ OrphanSweep.Entry entry = new OrphanSweep.Entry(outDir, "gen/", false);
+ OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), emitted, Set.of(OWNER), () -> null);
+
+ assertFalse("old file should be deleted", Files.exists(oldFile));
+ assertTrue(result.removed.contains("gen/Telemetry.java"));
+ assertTrue(result.refused.isEmpty());
+ assertTrue(result.overwrittenDeleted.isEmpty());
+ assertFalse(HashManifest.load(genStateDir).containsKey("gen/Telemetry.java"));
+ }
+
+ // === sweep: hand-edited orphan is refused, stays recorded =================
+
+ @Test
+ public void handEditedOrphan_isRefused_keptAndStillRecorded() throws Exception {
+ setUpProject();
+ Path oldFile = outDir.resolve("Telemetry.java");
+ Files.writeString(oldFile, "/** GENERATED */\npublic class Telemetry {}\n");
+ HashManifest.record(genStateDir, "gen/Telemetry.java", "/** GENERATED */\npublic class Telemetry {}\n", OWNER);
+ // Hand-edit after generation.
+ Files.writeString(oldFile, "/** GENERATED */\npublic class Telemetry { /* mine */ }\n");
+
+ OrphanSweep.Entry entry = new OrphanSweep.Entry(outDir, "gen/", false);
+ OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), Set.of(), Set.of(OWNER), () -> null);
+
+ assertTrue("edited orphan must be left in place", Files.exists(oldFile));
+ assertTrue(result.refused.contains("gen/Telemetry.java"));
+ assertTrue(result.removed.isEmpty());
+ assertTrue(result.overwrittenDeleted.isEmpty());
+ assertTrue("still recorded so the refusal repeats until acted on",
+ HashManifest.load(genStateDir).containsKey("gen/Telemetry.java"));
+ }
+
+ @Test
+ public void handEditedOrphan_underAllowOverwrite_isDeleted() throws Exception {
+ setUpProject();
+ Path oldFile = outDir.resolve("Telemetry.java");
+ Files.writeString(oldFile, "/** GENERATED */\npublic class Telemetry {}\n");
+ HashManifest.record(genStateDir, "gen/Telemetry.java", "/** GENERATED */\npublic class Telemetry {}\n", OWNER);
+ Files.writeString(oldFile, "/** GENERATED */\npublic class Telemetry { /* mine */ }\n");
+
+ OrphanSweep.Entry entry = new OrphanSweep.Entry(outDir, "gen/", true);
+ OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), Set.of(), Set.of(OWNER), () -> null);
+
+ assertFalse("overwrite policy deletes a hand-edited orphan", Files.exists(oldFile));
+ assertTrue(result.overwrittenDeleted.contains("gen/Telemetry.java"));
+ assertTrue(result.refused.isEmpty());
+ assertFalse(HashManifest.load(genStateDir).containsKey("gen/Telemetry.java"));
+ }
+
+ // === sweep: stranger file is never touched =================================
+
+ @Test
+ public void strangerFile_isNeverTouched() throws Exception {
+ setUpProject();
+ Path stray = outDir.resolve("HandWritten.java");
+ Files.writeString(stray, "// never generated, never recorded\n");
+ // Manifest exists (so sweep actually runs) but has nothing for this file.
+ HashManifest.record(genStateDir, "gen/Other.java", "x", OWNER);
+
+ OrphanSweep.Entry entry = new OrphanSweep.Entry(outDir, "gen/", false);
+ OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), Set.of(), Set.of(OWNER), () -> null);
+
+ assertTrue(Files.exists(stray));
+ assertFalse(result.removed.contains("gen/HandWritten.java"));
+ assertFalse(result.refused.contains("gen/HandWritten.java"));
+ }
+
+ @Test
+ public void noManifestAtAll_sweepIsANoOp() throws Exception {
+ setUpProject();
+ Path stray = outDir.resolve("Whatever.java");
+ Files.writeString(stray, "whatever\n");
+
+ OrphanSweep.Entry entry = new OrphanSweep.Entry(outDir, "gen/", false);
+ OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), Set.of(), Set.of(OWNER), () -> null);
+
+ assertTrue(result.removed.isEmpty());
+ assertTrue(result.refused.isEmpty());
+ assertTrue(result.overwrittenDeleted.isEmpty());
+ assertTrue(Files.exists(stray));
+ }
+
+ // === sweep: a recorded path whose file is already gone is forgotten ========
+
+ @Test
+ public void alreadyGoneRecordedFile_isForgotten() throws Exception {
+ setUpProject();
+ HashManifest.record(genStateDir, "gen/Deleted.java", "irrelevant", OWNER);
+
+ OrphanSweep.Entry entry = new OrphanSweep.Entry(outDir, "gen/", false);
+ OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), Set.of(), Set.of(OWNER), () -> null);
+
+ assertFalse(HashManifest.load(genStateDir).containsKey("gen/Deleted.java"));
+ }
+
+ // === sweep: two directories (two executions sharing one manifest) =========
+
+ @Test
+ public void twoDirectories_neitherSweepsTheOthersFiles() throws Exception {
+ setUpProject();
+ Path dirA = projectRoot.resolve("genA");
+ Path dirB = projectRoot.resolve("genB");
+ Files.createDirectories(dirA);
+ Files.createDirectories(dirB);
+
+ Path fileA = dirA.resolve("A.java");
+ Path fileB = dirB.resolve("B.java");
+ Files.writeString(fileA, "a");
+ Files.writeString(fileB, "b");
+ HashManifest.Owner ownerA = new HashManifest.Owner("entity", "genA");
+ HashManifest.record(genStateDir, "genA/A.java", "a", ownerA);
+ HashManifest.record(genStateDir, "genB/B.java", "b", new HashManifest.Owner("dto", "genB"));
+
+ // Only dirA's execution runs this sweep (dirB's execution's generators did not run
+ // this time), and dirA emitted nothing — A.java is now an orphan of dirA only.
+ OrphanSweep.Entry entryA = new OrphanSweep.Entry(dirA, "genA/", false);
+ OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entryA), Set.of(), Set.of(ownerA), () -> null);
+
+ assertFalse("A.java is dirA's orphan", Files.exists(fileA));
+ assertTrue("B.java is untouched — outside dirA's jurisdiction", Files.exists(fileB));
+ assertTrue(HashManifest.load(genStateDir).containsKey("genB/B.java"));
+ }
+
+ // === sweep: whose entries a run may judge ==================================
+
+ private static final HashManifest.Owner DTO = new HashManifest.Owner("dto", "gen");
+ private static final HashManifest.Owner REMOVED = new HashManifest.Owner("routes", "web");
+
+ private Path sharedLayout() throws Exception {
+ setUpProject();
+ Files.writeString(outDir.resolve("Mine.java"), "m");
+ Files.writeString(outDir.resolve("Theirs.java"), "t");
+ Files.writeString(outDir.resolve("Unowned.java"), "u");
+ Path web = projectRoot.resolve("web");
+ Files.createDirectories(web);
+ Files.writeString(web.resolve("Route.java"), "r");
+ Path other = projectRoot.resolve("other");
+ Files.createDirectories(other);
+ Files.writeString(other.resolve("Plain.java"), "p");
+ HashManifest.record(genStateDir, "gen/Mine.java", "m", OWNER);
+ HashManifest.record(genStateDir, "gen/Theirs.java", "t", DTO);
+ HashManifest.record(genStateDir, "gen/Unowned.java", "u", null);
+ HashManifest.record(genStateDir, "gen/TheirsGone.java", "g", DTO);
+ HashManifest.record(genStateDir, "web/Route.java", "r", REMOVED);
+ HashManifest.record(genStateDir, "other/Plain.java", "p", null);
+ return web;
+ }
+
+ @Test
+ public void anotherConfiguredGeneratorsEntries_areNeverCandidates_evenInASharedDirectory() throws Exception {
+ Path web = sharedLayout();
+
+ OrphanSweep.Entry entry = new OrphanSweep.Entry(outDir, "gen/", true);
+ OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), Set.of(),
+ Set.of(OWNER), () -> Set.of(OWNER, DTO, REMOVED));
+
+ assertEquals(List.of("gen/Mine.java", "gen/Unowned.java"), result.removed);
+ assertTrue(Files.exists(outDir.resolve("Theirs.java")));
+ assertTrue(Files.exists(web.resolve("Route.java")));
+ assertTrue("an entry with no owner outside this run's directories may be another port's",
+ Files.exists(projectRoot.resolve("other/Plain.java")));
+ assertTrue("another generator's gone file is that generator's to forget",
+ HashManifest.load(genStateDir).containsKey("gen/TheirsGone.java"));
+ assertEquals(DTO, HashManifest.loadOwners(genStateDir).get("gen/Theirs.java"));
+ }
+
+ @Test
+ public void anEntryWhoseOwnerIsConfiguredNowhere_isAnOrphan_whereverItLives() throws Exception {
+ Path web = sharedLayout();
+
+ OrphanSweep.Entry entry = new OrphanSweep.Entry(outDir, "gen/", false);
+ OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), Set.of(),
+ Set.of(OWNER), () -> Set.of(OWNER));
+
+ assertEquals(List.of("gen/Mine.java", "gen/Theirs.java", "gen/Unowned.java", "web/Route.java"),
+ result.removed);
+ assertFalse(Files.exists(web.resolve("Route.java")));
+ assertFalse(HashManifest.load(genStateDir).containsKey("gen/TheirsGone.java"));
+ assertTrue(Files.exists(projectRoot.resolve("other/Plain.java")));
+ }
+
+ @Test
+ public void withTheModulesConfigurationUnknown_onlyTheRunsOwnEntriesAreCandidates() throws Exception {
+ Path web = sharedLayout();
+
+ OrphanSweep.Entry entry = new OrphanSweep.Entry(outDir, "gen/", false);
+ OrphanSweep.Result result = OrphanSweep.sweep(genStateDir, projectRoot, List.of(entry), Set.of(),
+ Set.of(OWNER), () -> null);
+
+ assertEquals(List.of("gen/Mine.java"), result.removed);
+ assertTrue(Files.exists(outDir.resolve("Unowned.java")));
+ assertTrue(Files.exists(web.resolve("Route.java")));
+ }
+
+ // === sweep: a nested directory is judged by its own policy ==================
+
+ @Test
+ public void nestedDirectory_editedOrphanIsJudgedOnceByTheInnermostDirectory() throws Exception {
+ setUpProject();
+ Path inner = outDir.resolve("inner");
+ Files.createDirectories(inner);
+ Path orphan = inner.resolve("Old.java");
+ HashManifest.record(genStateDir, "gen/inner/Old.java", "original", OWNER);
+ Files.writeString(orphan, "edited");
+
+ OrphanSweep.Entry outerKeep = new OrphanSweep.Entry(outDir, "gen/", false);
+ OrphanSweep.Entry innerOverwrite = new OrphanSweep.Entry(inner, "inner/", true);
+ OrphanSweep.Result result = OrphanSweep.sweep(
+ genStateDir, projectRoot, List.of(outerKeep, innerOverwrite), Set.of(), Set.of(OWNER), () -> null);
+
+ assertFalse(Files.exists(orphan));
+ assertEquals(List.of("inner/Old.java"), result.overwrittenDeleted);
+ assertTrue(result.refused.isEmpty());
+ }
+
+ @Test
+ public void nestedDirectory_keepInsideOverwrite_isRefusedNotDeleted() throws Exception {
+ setUpProject();
+ Path inner = outDir.resolve("inner");
+ Files.createDirectories(inner);
+ Path orphan = inner.resolve("Old.java");
+ HashManifest.record(genStateDir, "gen/inner/Old.java", "original", OWNER);
+ Files.writeString(orphan, "edited");
+
+ OrphanSweep.Entry outerOverwrite = new OrphanSweep.Entry(outDir, "gen/", true);
+ OrphanSweep.Entry innerKeep = new OrphanSweep.Entry(inner, "inner/", false);
+ OrphanSweep.Result result = OrphanSweep.sweep(
+ genStateDir, projectRoot, List.of(outerOverwrite, innerKeep), Set.of(), Set.of(OWNER), () -> null);
+
+ assertTrue(Files.exists(orphan));
+ assertEquals(List.of("gen/inner/Old.java"), result.refused);
+ assertTrue(result.overwrittenDeleted.isEmpty());
+ }
+
+ // === messages ===============================================================
+
+ @Test
+ public void refusedMessageNamesEveryPathAndTheOwner() {
+ String msg = OrphanSweep.refusedMessage(List.of("gen/A.java", "gen/B.java"), "mvn metaobjects:generate");
+ assertTrue(msg.contains("mvn metaobjects:generate"));
+ assertTrue(msg.contains("gen/A.java"));
+ assertTrue(msg.contains("gen/B.java"));
+ assertTrue(msg.contains("NOT deleted"));
+ }
+
+ @Test
+ public void overwrittenOrphanMessageNamesThePathAndPolicy() {
+ String msg = OrphanSweep.overwrittenOrphanMessage("gen/Telemetry.java");
+ assertTrue(msg.contains("gen/Telemetry.java"));
+ assertTrue(msg.contains("handEdits: overwrite"));
+ }
+}
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/FindInbound.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/FindInbound.kt
index 0a59501bc..9c8d3811a 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/FindInbound.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/FindInbound.kt
@@ -71,10 +71,7 @@ object FindInbound {
@JvmStatic
fun responseShape(loader: MetaDataLoader, tmpl: MetaTemplate): InboundShape? {
val ref = responseRefOf(tmpl) ?: return null
- // ADR-0042/#228: resolve package-aware through the SAME value-object resolver
- // @payloadRef obeys, so the parser can never bind a record the payload tier refused to
- // emit (the C# defect this port is written to avoid).
- val vo = KotlinGenUtil.resolveValueObjectRef(loader, ref, tmpl.getPackage()) ?: return null
+ val vo = KotlinGenUtil.payloadTarget(tmpl, "responseRef") ?: return null
return InboundShape(vo, ref, responseFormatOf(tmpl))
}
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt
index dfebad248..67f73c239 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt
@@ -427,7 +427,7 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase() {
KotlinTypeMapper.enumTypeName(field, owner, fr019Config)?.let { return it }
if (field is ObjectField) {
val ref = readObjectRef(field)
- val target = ref?.let { KotlinGenUtil.resolveObjectByShortOrFqn(loader, it) }
+ val target = ref?.let { KotlinGenUtil.lookupCanonicalObject(loader, it) }
if (target != null) {
val (targetPkg, targetShort) = PackageMapping.splitFqn(target.name)
return ClassName(targetPkg, targetShort)
@@ -438,7 +438,7 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase() {
// which needs the loader to resolve, so build the Map here.
if (field is MapField) {
val ref = readObjectRef(field)
- val target = ref?.let { KotlinGenUtil.resolveObjectByShortOrFqn(loader, it) }
+ val target = ref?.let { KotlinGenUtil.lookupCanonicalObject(loader, it) }
if (target != null) {
val (targetPkg, targetShort) = PackageMapping.splitFqn(target.name)
return MAP.parameterizedBy(STRING, ClassName(targetPkg, targetShort))
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt
index ee31661e5..336b413fc 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt
@@ -540,7 +540,7 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase
f is ObjectField && readStorage(f) == STORAGE_FLATTENED &&
- (readObjectRef(f)?.let { KotlinGenUtil.resolveObjectByShortOrFqn(loader, it) }
+ (readObjectRef(f)?.let { KotlinGenUtil.lookupCanonicalObject(loader, it) }
?.metaFields?.any { KotlinTypeMapper.isJsonbOpenBag(it) } ?: false)
}
// Object/map jsonb columns → the Jackson metaJsonbMapper codec (a same-package `internal`
@@ -841,7 +841,7 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase
rel.through == null && rel.objectRef != null &&
- KotlinGenUtil.resolveObjectByShortOrFqn(loader, rel.objectRef) === target &&
+ KotlinGenUtil.lookupCanonicalObject(loader, rel.objectRef) === target &&
RelationshipReferences.resolveRelationshipReference(
- entity, rel.shortName, rel.objectRef, rel.sourceRefField) === ref
+ entity, rel.shortName, target, rel.sourceRefField) === ref
}
var rel = childSide
// When the tier-3 satisfiability guard fires, the reverse relationship's
@@ -2172,7 +2172,7 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase()
.filter { r ->
r.isEnforced && r.targetEntity != null &&
- KotlinGenUtil.resolveObjectByShortOrFqn(loader, r.targetEntity) === target
+ KotlinGenUtil.lookupCanonicalObject(loader, r.targetEntity) === target
}
if (enforcedRefsToTarget.singleOrNull() !== ref) return null
// #368: fail closed on ambiguity rather than taking the first match — when more
@@ -2185,7 +2185,7 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase
rel.through == null && rel.objectRef != null &&
- KotlinGenUtil.resolveObjectByShortOrFqn(loader, rel.objectRef) === entity
+ KotlinGenUtil.lookupCanonicalObject(loader, rel.objectRef) === entity
}.singleOrNull()
}
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtil.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtil.kt
index a0e411910..32a905c2a 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtil.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtil.kt
@@ -24,7 +24,6 @@ import com.metaobjects.reporting.ReportShape
import com.metaobjects.source.MetaSource
import com.metaobjects.source.RdbSource
import com.metaobjects.source.SourceResolution
-import com.metaobjects.validation.SymbolTable
/**
* Helpers shared by the codegen-kotlin generators. Extracted to keep
@@ -49,44 +48,46 @@ public object KotlinGenUtil {
const val AUTO_SET_ON_UPDATE = "onUpdate"
/**
- * Resolve a MetaObject (entity OR value) by exact FQN match or by short-name match
- * (the trailing segment after the last `::`). Returns null when neither matches.
+ * Resolve a MetaObject (entity OR value) from a CANONICAL reference value (ADR-0059 R7):
+ * an exact resolution-key match (`pkg::Name`, `::Name`, or a root-level `Name`), with any
+ * dotted child tail ignored. Never matches on a short name. Returns null when nothing matches.
*/
- fun resolveObjectByShortOrFqn(loader: MetaDataLoader, ref: String): MetaObject? {
- for (child in loader.metaObjects) {
- if (child.name == ref || child.name.substringAfterLast("::") == ref) return child
- }
- return null
- }
+ fun lookupCanonicalObject(loader: MetaDataLoader, ref: String): MetaObject? =
+ com.metaobjects.loader.References.lookupCanonical(loader.root, ref)
- // =========================================================================
- // ADR-0042 — canonical package-local object-ref resolution (@payloadRef).
- //
- // The loader validates a template's @payloadRef via the SAME package-local
- // contract (ValidationPhase.resolveRootObject, backed by SymbolTable): an FQN ref
- // binds EXACTLY; a bare ref binds the referrer's own package, else a root-level
- // object; NO cross-package bare-name / bare-tail fallback. Reusing the loader's own
- // public [SymbolTable] (rather than a divergent codegen copy) keeps codegen's
- // @payloadRef resolution identical to the loader's, so under a cross-package
- // short-name collision codegen binds the SAME value-object the loader validated —
- // never a load-order-dependent decoy (the #244 class). The bare-tail/first-match
- // [resolveObjectByShortOrFqn] above is deliberately left as-is: it backs the
- // @from/@of/@via dotted-ref navigation (a different ref kind, #244's own domain).
- // =========================================================================
+ /**
+ * The earlier name of [lookupCanonicalObject], kept so a generator ejected before 1.1.0
+ * still compiles unchanged. It now does the same exact canonical lookup; it no longer
+ * matches a short name, whatever its name says.
+ */
+ @Deprecated("Use lookupCanonicalObject", ReplaceWith("lookupCanonicalObject(loader, ref)"))
+ fun resolveObjectByShortOrFqn(loader: MetaDataLoader, ref: String): MetaObject? =
+ lookupCanonicalObject(loader, ref)
/**
- * Resolve a metadata OBJECT reference (bare or FQN) under the loader's ADR-0042
- * package-local contract, or null. [referrerPkg] is the effective package of the node
- * carrying the ref (a template's own `getPackage()` for a @payloadRef); "" for root-level.
+ * Resolve a CANONICAL object reference exactly (ADR-0059 R7), or null: `pkg::Name` binds
+ * that package's object, `::Name` and a bare `Name` bind the root-level object. The same
+ * lookup as [lookupCanonicalObject]; no short-name match and no package applied. Prefer
+ * [payloadTarget] on the node that carries the reference.
+ *
+ * [referrerPkg] is ignored: a canonical value is absolute. It is kept so existing callers
+ * and owned generator copies compile unchanged.
*/
- fun resolveObjectRef(loader: MetaDataLoader, ref: String?, referrerPkg: String?): MetaObject? {
- if (ref == null) return null
- return SymbolTable.build(loader.root).resolveObject(ref, referrerPkg ?: "")
- }
+ @Suppress("UNUSED_PARAMETER")
+ fun resolveObjectRef(loader: MetaDataLoader, ref: String?, referrerPkg: String?): MetaObject? =
+ if (ref.isNullOrEmpty()) null else lookupCanonicalObject(loader, ref)
+
+ /**
+ * The payload-shape target a reference attribute on [node] (`payloadRef` or `responseRef`) is
+ * bound to, as the loader recorded it, or null when it does not resolve or is not a legal
+ * payload target.
+ */
+ fun payloadTarget(node: com.metaobjects.MetaData, attr: String): MetaObject? =
+ com.metaobjects.loader.References.refTarget(node, attr)?.takeIf { isLegalPayloadTarget(it) }
/**
- * Resolve [ref] to its payload-shape target under the same ADR-0042 package-local
- * contract as [resolveObjectRef]. #210 — a template-level payload target is an
+ * Resolve the canonical [ref] to its payload-shape target exactly, as [resolveObjectRef]
+ * does ([referrerPkg] is ignored). Prefer [payloadTarget] on the template node. #210 — a template-level payload target is an
* `object.value` OR a SOURCELESS `object.projection` (rejects entities and sourced
* projections; the loader enforces the same set). Nested `field.object @objectRef`
* targets stay value-only ([KotlinExtractSchemaEmitter.objectRefValueObject]).
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinM2mSupport.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinM2mSupport.kt
index 9667f27b6..5f15e74ba 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinM2mSupport.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinM2mSupport.kt
@@ -85,8 +85,30 @@ object KotlinM2mSupport {
val targetScalarFields: List,
val targetDiscriminator: TargetDiscriminator?,
val symmetric: Boolean,
+ val junctionPackage: String = "",
)
+ /**
+ * The import lines a file in [sourcePkg] needs to name what each of [navs] reaches in ANOTHER
+ * package: the target's Exposed table (and the junction's, with [junction]) and, with
+ * [dataClass], the target data class when it is root-level (a packaged target is qualified
+ * inline). A root-level name is imported explicitly so it wins over a same-named declaration
+ * in [sourcePkg].
+ */
+ fun importLines(navs: Collection, sourcePkg: String, junction: Boolean, dataClass: Boolean): Set {
+ val out = sortedSetOf()
+ fun add(pkg: String, name: String) {
+ if (pkg == sourcePkg) return
+ out.add(if (pkg.isEmpty()) name else "$pkg.$name")
+ }
+ for (nav in navs) {
+ add(nav.targetPackage, nav.targetTableObj)
+ if (junction) add(nav.junctionPackage, nav.junctionTableObj)
+ if (dataClass && nav.targetPackage.isEmpty()) add(nav.targetPackage, nav.targetType)
+ }
+ return out
+ }
+
/**
* Resolve every M:N relationship declared on [entity]. Returns an empty list
* when the entity declares none. Each entry's junction FK fields are derived
@@ -105,15 +127,15 @@ object KotlinM2mSupport {
if (through.isNullOrEmpty()) continue
val fields = M2MFields.derive(rel, entity, root)
- val declaredTarget = KotlinGenUtil.resolveObjectByShortOrFqn(loader, rel.objectRef ?: continue) ?: continue
- val junction = KotlinGenUtil.resolveObjectByShortOrFqn(loader, through) ?: continue
+ val declaredTarget = com.metaobjects.loader.References.refTarget(rel, MetaRelationship.ATTR_OBJECT_REF) ?: continue
+ val junction = com.metaobjects.loader.References.refTarget(rel, MetaRelationship.ATTR_THROUGH) ?: continue
// FW-3: a TPH subtype (e.g. `BridgeAuth`) has no Exposed Table object and no data
// class of its own — its rows live in, and are shaped like, the discriminator base's
// (`AuthTable` / `Auth`). Redirect the target's PHYSICAL identity to that storage
// object; `targetShortName` above keeps naming the DECLARED target for diagnostics.
val target = KotlinTphPlan.storageObjectOf(declaredTarget)
- val junctionShort = PackageMapping.splitFqn(junction.name).second
+ val (junctionPkg, junctionShort) = PackageMapping.splitFqn(junction.name)
val (targetPkg, targetShort) = PackageMapping.splitFqn(target.name)
val declaredShort = PackageMapping.splitFqn(declaredTarget.name).second
// The STORAGE object's full column set — base + folded subtype columns when it is a
@@ -176,6 +198,7 @@ object KotlinM2mSupport {
targetScalarFields = targetScalars,
targetDiscriminator = targetDiscriminator,
symmetric = rel.isSymmetric,
+ junctionPackage = junctionPkg,
)
)
}
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRelationsGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRelationsGenerator.kt
index 4aac25532..8591caaa2 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRelationsGenerator.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRelationsGenerator.kt
@@ -151,13 +151,13 @@ open class KotlinRelationsGenerator : MultiFileDirectGeneratorBase()
// Resolve each to-many composition target → (relShortName, targetShort). Skip
// relationships whose @objectRef cannot be resolved (defensive — the loader's
// validation phase already gates the attr being present).
- data class Helper(val relShortName: String, val targetTable: String)
+ data class Helper(val relShortName: String, val targetTable: String, val targetPackage: String)
val helpers = manyRels.mapNotNull { rel ->
- val ref = rel.objectRef ?: return@mapNotNull null
- val target = KotlinGenUtil.resolveObjectByShortOrFqn(loader, ref) ?: return@mapNotNull null
- val targetShort = PackageMapping.splitFqn(target.name).second
+ val target = com.metaobjects.loader.References.refTarget(rel, MetaRelationship.ATTR_OBJECT_REF)
+ ?: return@mapNotNull null
+ val (targetPkg, targetShort) = PackageMapping.splitFqn(target.name)
val relShort = rel.shortName ?: rel.name
- Helper(relShort, targetShort + "Table")
+ Helper(relShort, targetShort + "Table", targetPkg)
}
if (helpers.isEmpty() && m2mNavs.isEmpty() && reverseFks.isEmpty()) return
@@ -207,6 +207,10 @@ open class KotlinRelationsGenerator : MultiFileDirectGeneratorBase()
if (pkParamSimpleName == "UUID" || reverseFks.any { it.valueType == "UUID" }) {
append("import java.util.UUID\n")
}
+ val crossPackageTables = KotlinM2mSupport.importLines(m2mNavs.map { it.nav }, pkg, junction = true, dataClass = false) +
+ helpers.filter { it.targetPackage != pkg }
+ .map { if (it.targetPackage.isEmpty()) it.targetTable else "${it.targetPackage}.${it.targetTable}" }
+ for (line in crossPackageTables.toSortedSet()) append("import $line\n")
append("\n")
append("/** GENERATED — extension fns for `$ownerShort` to-many relationships. Do not hand-edit. */\n")
var first = true
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRenderHelperGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRenderHelperGenerator.kt
index f0b9db157..13285e0e4 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRenderHelperGenerator.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRenderHelperGenerator.kt
@@ -113,8 +113,7 @@ open class KotlinRenderHelperGenerator : MultiFileDirectGeneratorBase::][` — correct for a `field.object` inherited via extends across
- * packages), else a root-level `object.value`. Package-local BEFORE root-level;
- * no cross-package short-name fallback.
- * Returns null when the field has no `@objectRef` or nothing resolves.
+ * The `object.value` a `field.object`'s `@objectRef` is bound to (ADR-0059 R7: the
+ * target the loader recorded, resolved in the package of the node that declared it).
+ * Returns null when the field has no `@objectRef` or it names no `object.value`.
*/
private fun resolveNestedObjectRef(loader: MetaDataLoader, field: ObjectField): MetaObject? {
- // ADR-0039: @objectRef is an inheritable effective field property — RESOLVE
- // (includeParentData=true) so a field.object inheriting @objectRef via extends
- // still resolves its target VO.
- if (!field.hasMetaAttr(MetaObject.ATTR_OBJECT_REF, true)) return null
- val ref = field.getMetaAttr(MetaObject.ATTR_OBJECT_REF, true).valueAsString
- if (ref.isNullOrEmpty()) return null
- val fqn = ref.contains("::")
- // The DECLARING field's package (walks parents; for an inherited field.object it is
- // the package where the field is DECLARED, not where it is used).
- val referrerPkg = com.metaobjects.util.MetaDataUtil.findPackageForMetaData(field) ?: ""
- val localKey = if (fqn || referrerPkg.isEmpty()) ref else "$referrerPkg::$ref"
- var own: MetaObject? = null
- var rootLevel: MetaObject? = null
- for (obj in loader.metaObjects) {
- if (MetaObject.SUBTYPE_VALUE != obj.subType) continue
- if (obj.name == localKey) own = obj
- if (!fqn && obj.name == ref) rootLevel = obj
- }
- return if (fqn) own else own ?: rootLevel
+ // ADR-0059 R7: read the target the loader bound for the declaring field's @objectRef
+ // (resolved where it was written, so an inherited field.object binds its own package).
+ val target = com.metaobjects.loader.References.refTarget(field, MetaObject.ATTR_OBJECT_REF)
+ return if (target != null && MetaObject.SUBTYPE_VALUE == target.subType) target else null
}
/**
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt
index 5842edb20..e745abb3d 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt
@@ -378,6 +378,9 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase(
val parameterRef = sourceRdb.takeIf { it.hasMetaAttr(RdbSource.ATTR_PARAMETER_REF) }
?.let { runCatching { it.getMetaAttr(RdbSource.ATTR_PARAMETER_REF).valueAsString }.getOrNull() }
?.takeIf { it.isNotEmpty() }
- // ADR-0042: a bare ref resolves package-locally against the referrer's METADATA
- // package — `entity.getPackage()` ("acme::demo"), never the mapped Kotlin package
- // `pkg` ("acme.demo") that PackageMapping.splitFqn hands back. Passing the Kotlin
- // one silently resolves NOTHING, which reads exactly like "this callable declares no
- // arguments" and emits a zero-argument wrapper — the same silent-shortfall shape as
- // the @param bug this replaces.
- val argsObject = KotlinGenUtil.resolveObjectRef(loader, parameterRef, entity.getPackage())
+ val argsObject = com.metaobjects.loader.References.refTarget(sourceRdb, RdbSource.ATTR_PARAMETER_REF)
if (parameterRef != null && argsObject == null) {
LOG.warn(
"KotlinStoredProcGenerator: callable \"{}\" @parameterRef \"{}\" did not " +
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModelBuilder.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModelBuilder.kt
index 842cf9565..1c62b600b 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModelBuilder.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModelBuilder.kt
@@ -537,10 +537,7 @@ class KotlinApiModelBuilder(
/** The `@payloadRef` value object a template resolves to (rejects entities), or null. */
private fun payloadValueObject(tmpl: MetaTemplate, loader: MetaDataLoader): MetaObject? {
- val ref = tmpl.payloadRef
- if (ref.isNullOrEmpty()) return null
- // ADR-0042 — resolve @payloadRef under the loader's package-local contract (#228).
- return KotlinGenUtil.resolveValueObjectRef(loader, ref, tmpl.getPackage())
+ return KotlinGenUtil.payloadTarget(tmpl, "payloadRef")
}
private fun isEmailKind(tmpl: MetaTemplate): Boolean {
diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinCrossPackageBindingConformanceTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinCrossPackageBindingConformanceTest.kt
new file mode 100644
index 000000000..e16400e39
--- /dev/null
+++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinCrossPackageBindingConformanceTest.kt
@@ -0,0 +1,215 @@
+package com.metaobjects.generator.kotlin
+
+import com.fasterxml.jackson.databind.JsonNode
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.metaobjects.identity.MetaIdentity
+import com.metaobjects.identity.ReferenceIdentity
+import com.metaobjects.loader.LoaderOptions
+import com.metaobjects.loader.MetaDataLoader
+import com.metaobjects.loader.References
+import com.metaobjects.loader.uri.URIHelper
+import com.metaobjects.metadata.ktx.loadUris
+import com.metaobjects.`object`.MetaObject
+import com.metaobjects.relationship.M2MFields
+import com.metaobjects.relationship.MetaRelationship
+import com.metaobjects.relationship.RelationshipReferences
+import com.tschuchort.compiletesting.KotlinCompilation
+import com.tschuchort.compiletesting.SourceFile
+import org.junit.jupiter.api.Test
+import java.nio.file.Files
+import java.nio.file.Path
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertSame
+import kotlin.test.assertTrue
+
+/**
+ * Cross-port conformance (Kotlin) for ADR-0059 Decision 7: codegen binds the target a reference
+ * names, never a same-short-named object in another package and never the package of the entity
+ * that inherits the attribute. Loads `fixtures/codegen-conformance/cross-package-binding/input/`,
+ * checks every entry of `expected-bindings.json`, then the generated Exposed tables, relations
+ * and controllers.
+ */
+class KotlinCrossPackageBindingConformanceTest {
+
+ private fun fixtureDir(): Path {
+ val dir = Path.of(System.getProperty("user.dir")).resolve("../../..").normalize()
+ .resolve("fixtures/codegen-conformance/cross-package-binding")
+ assertTrue(Files.isDirectory(dir), "fixture must exist at $dir")
+ return dir
+ }
+
+ private fun load(): MetaDataLoader {
+ val uris = Files.list(fixtureDir().resolve("input")).use { s ->
+ s.filter { it.toString().endsWith(".json") }.sorted().toList()
+ }.map { URIHelper.toURI("model:file:" + it.toAbsolutePath().toString().replace('\\', '/')) }
+ assertEquals(4, uris.size)
+ return loadUris("cross-package-binding", uris, LoaderOptions.create(false, false, true))
+ }
+
+ private fun expected(): JsonNode =
+ ObjectMapper().readTree(Files.readString(fixtureDir().resolve("expected-bindings.json")))
+
+ private fun objectOf(loader: MetaDataLoader, key: String): MetaObject =
+ assertNotNull(loader.getMetaObjectByName(key), "object $key must load")
+
+ private fun tableOf(o: MetaObject): String = o.findPrimaryWritableSource().orElseThrow().tableName
+
+ private fun member(key: String): Pair =
+ key.substring(0, key.lastIndexOf('.')) to key.substring(key.lastIndexOf('.') + 1)
+
+ @Test
+ fun `every identity reference is bound to the expected object`() {
+ val loader = load()
+ val refs = expected().get("references")
+ var checked = 0
+ for (key in refs.fieldNames().asSequence().toList()) {
+ val (owner, name) = member(key)
+ val ref = objectOf(loader, owner).identities
+ .filterIsInstance().firstOrNull { it.shortName == name }
+ assertNotNull(ref, "identity.reference $key")
+ val bound = References.refTarget(ref, MetaIdentity.ATTR_REFERENCES)
+ assertNotNull(bound, "unbound $key")
+ assertEquals(refs.get(key).asText(), bound.name, "binding of $key")
+ checked++
+ }
+ assertEquals(6, checked)
+ }
+
+ @Test
+ fun `every relationship resolves to the expected target tables and join fields`() {
+ val loader = load()
+ val relations = expected().get("relations")
+ var checked = 0
+ for (key in relations.fieldNames().asSequence().toList()) {
+ val want = relations.get(key)
+ val (ownerKey, name) = member(key)
+ val owner = objectOf(loader, ownerKey)
+ val rel = owner.relationships.firstOrNull { it.shortName == name }
+ assertNotNull(rel, "relationship $key")
+
+ val target = References.refTarget(rel, MetaRelationship.ATTR_OBJECT_REF)
+ assertNotNull(target, "unbound target of $key")
+ assertEquals(want.get("target").asText(), target.name, "target of $key")
+ assertEquals(want.get("targetTable").asText(), tableOf(target), "target table of $key")
+
+ if (want.get("cardinality").asText() == "many") {
+ val junction = References.refTarget(rel, MetaRelationship.ATTR_THROUGH)
+ assertNotNull(junction, "unbound junction of $key")
+ assertEquals(want.get("junction").asText(), junction.name, "junction of $key")
+ assertEquals(want.get("junctionTable").asText(), tableOf(junction), "junction table of $key")
+ val fields = M2MFields.derive(rel, owner, loader.root)
+ assertEquals(want.get("sourceJoinField").asText(), fields.sourceField, "source join field of $key")
+ assertEquals(want.get("targetJoinField").asText(), fields.targetField, "target join field of $key")
+
+ val nav = KotlinM2mSupport.resolve(owner, loader).firstOrNull { it.relationName == name }
+ assertNotNull(nav, "codegen M:N navigation for $key")
+ assertEquals(want.get("sourceJoinField").asText(), nav.sourceField)
+ assertEquals(want.get("targetJoinField").asText(), nav.targetField)
+ assertEquals(PackageMapping.splitFqn(target.name).second + "Table", nav.targetTableObj)
+ assertEquals(PackageMapping.splitFqn(target.name).first, nav.targetPackage)
+ assertEquals(PackageMapping.splitFqn(junction.name).second + "Table", nav.junctionTableObj)
+ assertEquals(target.getMetaField("id").name, nav.targetPkField)
+ } else {
+ val fk = RelationshipReferences.resolveRelationshipReference(
+ owner, rel.shortName, target, rel.sourceRefField)
+ assertNotNull(fk, "fk reference of $key")
+ assertEquals(want.get("fkField").asText(), fk.fields[0], "fk field of $key")
+ }
+ checked++
+ }
+ assertEquals(4, checked)
+ }
+
+ @Test
+ fun `inherited root references bind the root object not the inheriting packages`() {
+ val loader = load()
+ val order = objectOf(loader, "acme::Order")
+ val customer = loader.getMetaObjectByName("Customer")
+ val label = loader.getMetaObjectByName("Label")
+ val customerRel = order.relationships.first { it.shortName == "customer" }
+ val labelsRel = order.relationships.first { it.shortName == "labels" }
+ assertSame(customer, References.refTarget(customerRel, MetaRelationship.ATTR_OBJECT_REF))
+ assertSame(label, References.refTarget(labelsRel, MetaRelationship.ATTR_OBJECT_REF))
+ assertSame(customer, References.lookupCanonical(loader.root,
+ customerRel.getMetaAttr(MetaRelationship.ATTR_OBJECT_REF).valueAsString))
+ }
+
+ private fun generateAll(loader: MetaDataLoader, out: Path, withControllers: Boolean): Map {
+ val args = mapOf("outputDir" to out.toString())
+ KotlinEntityGenerator().apply { setArgs(args) }.execute(loader)
+ KotlinExposedTableGenerator().apply { setArgs(args) }.execute(loader)
+ KotlinRelationsGenerator().apply { setArgs(args) }.execute(loader)
+ if (withControllers) KotlinSpringControllerGenerator().apply { setArgs(args) }.execute(loader)
+ return Files.walk(out).use { s ->
+ s.filter { it.toString().endsWith(".kt") }.toList()
+ }.associate { out.relativize(it).toString().replace('\\', '/') to Files.readString(it) }
+ }
+
+ @Test
+ fun `generated tables relations and controllers use the bound targets`() {
+ val out = Files.createTempDirectory("kcross-")
+ try {
+ val files = generateAll(load(), out, withControllers = true)
+ fun file(path: String) = assertNotNull(files[path], "expected $path; saw ${files.keys.sorted()}")
+
+ val postTable = file("a/PostTable.kt")
+ assertTrue("import b.TagTable" in postTable, "Post.featuredTag must bind b::Tag; saw:\n$postTable")
+ assertTrue("references(TagTable.id" in postTable, "saw:\n$postTable")
+ val postRelations = file("a/PostRelations.kt")
+ assertTrue("import b.TagTable" in postRelations, "saw:\n$postRelations")
+ assertTrue("TagTable.join(PostTagTable, JoinType.INNER) { TagTable.id eq PostTagTable.tagId }" in postRelations,
+ "saw:\n$postRelations")
+ val postController = file("a/PostController.kt")
+ assertTrue("import b.TagTable" in postController, "saw:\n$postController")
+ assertTrue("ResponseEntity]>" in postController, "saw:\n$postController")
+
+ val orderTable = file("acme/OrderTable.kt")
+ assertTrue("\nimport CustomerTable\n" in orderTable,
+ "Order.customer must bind the root Customer, not acme::Customer; saw:\n$orderTable")
+ assertTrue("references(CustomerTable.id" in orderTable, "saw:\n$orderTable")
+ val orderRelations = file("acme/OrderRelations.kt")
+ assertTrue("\nimport LabelTable\n" in orderRelations, "saw:\n$orderRelations")
+ assertTrue("\nimport OrderLabelTable\n" in orderRelations, "saw:\n$orderRelations")
+ assertTrue("LabelTable.join(OrderLabelTable, JoinType.INNER) { LabelTable.id eq OrderLabelTable.labelId }" in orderRelations,
+ "saw:\n$orderRelations")
+ val orderController = file("acme/OrderController.kt")
+ assertTrue("\nimport LabelTable\n" in orderController, "saw:\n$orderController")
+ assertTrue("\nimport Label\n" in orderController, "saw:\n$orderController")
+
+ for ((path, src) in files) {
+ if (!path.startsWith("a/Post") && !path.startsWith("acme/Order")) continue
+ for (wrong in listOf("a_tags", "acme_labels", "acme_customers", "a.TagTable", "acme.LabelTable", "acme.CustomerTable")) {
+ assertFalse(wrong in src, "$path must not mention $wrong")
+ }
+ }
+ } finally {
+ out.toFile().deleteRecursively()
+ }
+ }
+
+ @OptIn(org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi::class)
+ @Test
+ fun `generated entities tables and relations compile against exposed`() {
+ val out = Files.createTempDirectory("kcross-compile-")
+ try {
+ generateAll(load(), out, withControllers = false)
+ val sources = Files.walk(out).use { s -> s.filter { Files.isRegularFile(it) }.sorted().toList() }
+ .map { SourceFile.kotlin(out.relativize(it).toString().replace('/', '_'), Files.readString(it)) }
+ val exposed = Path.of(System.getProperty("user.home"), ".m2", "repository",
+ "org/jetbrains/exposed/exposed-core/0.55.0/exposed-core-0.55.0.jar")
+ assertTrue(Files.exists(exposed), "expected jar on local m2: $exposed")
+ val result = KotlinCompilation().apply {
+ this.sources = sources
+ inheritClassPath = true
+ classpaths = listOf(exposed.toFile())
+ messageOutputStream = System.out
+ }.compile()
+ assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode,
+ "generated cross-package tree failed to compile:\n${result.messages}")
+ } finally {
+ out.toFile().deleteRecursively()
+ }
+ }
+}
diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtilCanonicalRefTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtilCanonicalRefTest.kt
new file mode 100644
index 000000000..2455aadc7
--- /dev/null
+++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtilCanonicalRefTest.kt
@@ -0,0 +1,53 @@
+package com.metaobjects.generator.kotlin
+
+import com.metaobjects.loader.InMemoryStringSource
+import com.metaobjects.loader.LoaderOptions
+import com.metaobjects.loader.MetaDataLoader
+import com.metaobjects.`object`.MetaObject
+import org.junit.jupiter.api.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+/**
+ * ADR-0059 R7 — [KotlinGenUtil.resolveObjectRef] and [KotlinGenUtil.resolveValueObjectRef] resolve a
+ * CANONICAL value exactly: `pkg::Name` binds that package's object, `::Name` and a bare `Name` bind
+ * the root-level one, and the referrer package is never applied. Three value objects share the short
+ * name `Report`, so a short-name scan or a package-local resolve would bind a packaged copy instead.
+ */
+class KotlinGenUtilCanonicalRefTest {
+
+ private fun doc(pkg: String?, field: String): String {
+ val pkgPart = if (pkg == null) "" else "\"package\": \"$pkg\", "
+ return "{ \"metadata.root\": { $pkgPart\"children\": [" +
+ "{ \"object.value\": { \"name\": \"Report\", \"children\": [" +
+ "{ \"field.string\": { \"name\": \"$field\" } } ] } } ] } }"
+ }
+
+ private fun load(vararg docs: String): MetaDataLoader {
+ val loader = MetaDataLoader(LoaderOptions.create(false, false, true), MetaDataLoader.SUBTYPE_MANUAL, "kotlin-canonical")
+ loader.init()
+ loader.load(docs.mapIndexed { i, d -> InMemoryStringSource(d, "kotlin-canonical/meta.$i.json") })
+ return loader
+ }
+
+ private fun fieldOf(obj: MetaObject?): String? = obj?.metaFields?.first()?.name
+
+ @Test
+ fun canonicalValuesResolveExactly() {
+ val loader = load(doc("acme::alpha", "alphaVal"), doc("acme::beta", "betaVal"), doc(null, "rootVal"))
+
+ assertEquals("alphaVal", fieldOf(KotlinGenUtil.resolveObjectRef(loader, "acme::alpha::Report", "")))
+ assertEquals("betaVal", fieldOf(KotlinGenUtil.resolveValueObjectRef(loader, "acme::beta::Report", "acme::alpha")))
+ assertEquals("rootVal", fieldOf(KotlinGenUtil.resolveValueObjectRef(loader, "::Report", "acme::alpha")))
+ assertEquals("rootVal", fieldOf(KotlinGenUtil.resolveObjectRef(loader, "Report", "acme::alpha")))
+ assertNull(KotlinGenUtil.resolveObjectRef(loader, "acme::gamma::Report", ""))
+ }
+
+ @Test
+ fun bareValueDoesNotBindAPackagedObject() {
+ val loader = load(doc("acme::alpha", "alphaVal"))
+
+ assertNull(KotlinGenUtil.resolveObjectRef(loader, "Report", "acme::alpha"))
+ assertEquals("alphaVal", fieldOf(KotlinGenUtil.resolveObjectRef(loader, "acme::alpha::Report", "")))
+ }
+}
diff --git a/server/java/codegen-plantuml/src/main/java/com/metaobjects/generator/direct/plantuml/PlantUMLWriter.java b/server/java/codegen-plantuml/src/main/java/com/metaobjects/generator/direct/plantuml/PlantUMLWriter.java
index 087892a7d..386f3ddf2 100644
--- a/server/java/codegen-plantuml/src/main/java/com/metaobjects/generator/direct/plantuml/PlantUMLWriter.java
+++ b/server/java/codegen-plantuml/src/main/java/com/metaobjects/generator/direct/plantuml/PlantUMLWriter.java
@@ -369,18 +369,7 @@ protected List getAssociationRelationships(MetaObject m
}
protected MetaObject getTargetObject(AssociationRelationship relationship) {
- // Get the target object name from the relationship
- if (relationship.hasMetaAttr(MetaRelationship.ATTR_OBJECT_REF)) {
- String targetObjectName = relationship.getMetaAttr(MetaRelationship.ATTR_OBJECT_REF).getValueAsString();
- if (targetObjectName != null) {
- try {
- return MetaDataUtil.findMetaObjectByName(targetObjectName, this);
- } catch (Exception e) {
- if (debug) log.warn("Could not find target object: " + targetObjectName, e);
- }
- }
- }
- return null;
+ return com.metaobjects.loader.References.refTarget(relationship, MetaRelationship.ATTR_OBJECT_REF);
}
protected void writeObjectKeyRelationships(MetaObject mo ) throws IOException {
@@ -605,7 +594,8 @@ protected String getAttrValue( MetaAttribute attr ) {
}
// TODO: This should be handled differently
else if (attr.getName().equals(ObjectField.ATTR_OBJECTREF)) {
- return "\""+MetaDataUtil.expandPackageForPath(MetaDataUtil.findPackageForMetaData( attr ), attr.getValueAsString() )+"\"";
+ // ADR-0059: the value is already canonical (pkg::Name); never re-expand it.
+ return "\""+attr.getValueAsString()+"\"";
}
else if ( attr.getDataType() == DataTypes.STRING_ARRAY ) {
return "\""+val.toString()+"\"";
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaApiModelBuilder.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaApiModelBuilder.java
index 9bc854ebd..6c08f9f44 100644
--- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaApiModelBuilder.java
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaApiModelBuilder.java
@@ -360,10 +360,7 @@ parser, ApiSymbolKind.OUTPUT_PARSER, fqn(promptsPkg, parser),
/** The template's resolvable {@code @payloadRef} value object, or {@code null}. */
private static MetaObject payloadValueObject(MetaTemplate tmpl, MetaDataLoader loader) {
- String ref = tmpl.getPayloadRef();
- if (ref == null || ref.isEmpty()) return null;
- return SpringNaming.resolveValueObjectRef(
- loader, ref, com.metaobjects.util.MetaDataUtil.findPackageForMetaData(tmpl));
+ return SpringNaming.payloadTarget(tmpl, TemplateConstants.ATTR_PAYLOAD_REF);
}
/**
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/FindInbound.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/FindInbound.java
index e8f6aa966..e405d06c2 100644
--- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/FindInbound.java
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/FindInbound.java
@@ -87,12 +87,7 @@ public static boolean isInbound(com.metaobjects.MetaData node, MetaDataLoader lo
public static InboundShape responseShape(MetaDataLoader loader, MetaTemplate tmpl) {
String ref = responseRefOf(tmpl);
if (ref == null) return null;
- // ADR-0042/#228: resolve package-aware — a bare ref binds the template's OWN
- // package first, never a bare-tail/global scan that could bind the WRONG package's
- // same-short-named object. Enforces the SAME "must be an object.value" target rule
- // @payloadRef obeys, so the two refs cannot diverge on what counts as a legal target.
- MetaObject vo = SpringNaming.resolveValueObjectRef(
- loader, ref, MetaDataUtil.findPackageForMetaData(tmpl));
+ MetaObject vo = SpringNaming.payloadTarget(tmpl, "responseRef");
if (vo == null) return null;
return new InboundShape(vo, ref, responseFormatOf(tmpl));
}
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/LlmTraceHelperGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/LlmTraceHelperGenerator.java
index 72308aaf4..f83c47cae 100644
--- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/LlmTraceHelperGenerator.java
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/LlmTraceHelperGenerator.java
@@ -174,11 +174,7 @@ protected void emit(MetaObject entity, MetaDataLoader loader, Path outRoot) {
String responseRef = prompt.getResponseRef();
if (responseRef == null || responseRef.isEmpty()) return; // @responseRef gates the helper
- // #228 — referrer is the PROMPT (the @responseRef is authored on it), not the
- // entity: findPackageForMetaData walks parents, so a nested prompt still
- // resolves the entity's effective package when the prompt itself carries none.
- MetaObject responseVo = resolveValueObject(loader, responseRef,
- com.metaobjects.util.MetaDataUtil.findPackageForMetaData(prompt));
+ MetaObject responseVo = SpringNaming.payloadTarget(prompt, "responseRef");
if (responseVo == null) {
throw new GeneratorException(
"trace-helper: entity \"" + entity.getName() + "\" prompt @responseRef \""
@@ -329,14 +325,10 @@ protected static PromptTemplate firstPrompt(MetaObject entity) {
}
/**
- * Resolve a {@code @responseRef} to its {@code object.value} target under the
- * ADR-0042 package-local contract (#228). Was a package-BLIND bare-name scan that,
- * worse, reduced the REF ITSELF to its trailing {@code ::} segment before matching
- * — the #219/#244 bare-tail-fallback pattern: an FQN {@code @responseRef} that
- * failed an exact match would still bind ANY same-bare-named {@code object.value}
- * in a WRONG package (first match, load-order-dependent), not just a genuinely
- * bare ref. Now delegates to the shared {@link SpringNaming#resolveValueObjectRef}
- * (FQN matches exactly, never a bare-tail fallback).
+ * Resolve a canonical {@code @responseRef} value to its {@code object.value} target
+ * exactly (ADR-0059 R7), through {@link SpringNaming#resolveValueObjectRef}. Prefer
+ * {@link SpringNaming#payloadTarget} on the template node, which returns the target the
+ * loader bound. {@code referrerPkg} is ignored.
*/
protected static MetaObject resolveValueObject(MetaDataLoader loader, String ref, String referrerPkg) {
return SpringNaming.resolveValueObjectRef(loader, ref, referrerPkg);
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringM2mSupport.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringM2mSupport.java
index 4adc64a9b..a641fc32f 100644
--- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringM2mSupport.java
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringM2mSupport.java
@@ -2,6 +2,7 @@
import com.metaobjects.MetaRoot;
import com.metaobjects.loader.MetaDataLoader;
+import com.metaobjects.loader.References;
import com.metaobjects.object.MetaObject;
import com.metaobjects.relationship.M2MFields;
import com.metaobjects.relationship.MetaRelationship;
@@ -90,8 +91,8 @@ public static List resolve(MetaObject entity, MetaDataLoader loader) {
M2MFields fields = M2MFields.derive(rel, entity, root);
- MetaObject target = findEntity(root, rel.getObjectRef());
- MetaObject junction = findEntity(root, through);
+ MetaObject target = References.refTarget(rel, MetaRelationship.ATTR_OBJECT_REF);
+ MetaObject junction = References.refTarget(rel, MetaRelationship.ATTR_THROUGH);
if (target == null || junction == null) continue;
String[] targetSplit = SpringNaming.splitFqn(target.getName());
@@ -128,21 +129,4 @@ public static List resolve(MetaObject entity, MetaDataLoader loader) {
}
return out;
}
-
- private static MetaObject findEntity(MetaRoot root, String name) {
- if (name == null) return null;
- // ADR-0041: a FULLY-QUALIFIED ref (contains "::") resolves EXACTLY on the
- // object's package-qualified name — it never falls back to a bare-tail match
- // (the closed bug: an FQN @objectRef / @through binding a same-named object
- // in the WRONG package). A bare ref matches the object's short name (first
- // match wins; cross-package same-package-preference is the deferred codegen
- // follow-up, mirroring the TS residual note).
- boolean fqn = name.indexOf("::") >= 0;
- // ADR-0039: root-level entity scan — root is never extended, so own children
- // is correct (entities are declared directly under root, not inherited into it).
- for (MetaObject mo : root.getChildren(MetaObject.class, false)) {
- if (fqn ? name.equals(mo.getName()) : name.equals(mo.getShortName())) return mo;
- }
- return null;
- }
}
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringNaming.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringNaming.java
index abf5b5ae6..f3ceb638c 100644
--- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringNaming.java
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringNaming.java
@@ -65,62 +65,45 @@ public static String[] splitFqn(String fqn) {
}
/**
- * ADR-0042 — resolve a metadata OBJECT reference (bare or FQN) to a {@link MetaObject}
- * under the package-local contract, or {@code null} when nothing matches:
- *
- * - FQN {@code ref} (contains {@code "::"}) → EXACT match on
- * {@link MetaData#getName()}. No bare-tail fallback, so an FQN pointing at one
- * package never binds a same-named object in another.
- * - bare {@code ref} (no {@code "::"}) → the referrer's OWN package
- * ({@code ::
[}) first, else a root-level (unpackaged) object
- * whose name IS {@code ref}. Package-local BEFORE root-level; no cross-package
- * short-name scan, no bare-tail fallback.]
- *
- * Mirrors the loader's own {@code ValidationPhase#resolveRootObject} (the same contract
- * the TS/Python/C# ports' canonical resolvers implement), so a codegen-time
- * {@code @payloadRef}/{@code @responseRef} resolution agrees with the loader's own
- * validation of the same ref under a cross-package short-name collision.
+ * ADR-0059 R7 — resolve a CANONICAL object reference exactly, or {@code null} when
+ * nothing matches: {@code pkg::Name} binds that package's object, {@code ::Name} and a
+ * bare {@code Name} bind the root-level object. No short-name scan and no package is
+ * applied, so a value read from a loaded model means the same thing wherever it is read.
+ * Prefer {@link #payloadTarget} (or {@code References.refTarget}) on the node that
+ * carries the reference: it returns the target the loader bound.
*
- * @param referrerPkg the effective package of the node carrying the ref ("" for root-level)
+ * @param referrerPkg ignored; a canonical value is absolute. Kept so existing callers
+ * and owned generator copies compile unchanged.
*/
public static MetaObject resolveObjectRef(MetaDataLoader loader, String ref, String referrerPkg) {
- if (ref == null) return null;
- String pkg = referrerPkg == null ? "" : referrerPkg;
- if (ref.contains("::")) {
- for (MetaObject obj : loader.getMetaObjects()) {
- if (ref.equals(obj.getName())) return obj;
- }
- return null;
- }
- String localKey = pkg.isEmpty() ? ref : pkg + "::" + ref;
- MetaObject own = null;
- MetaObject rootLevel = null;
- for (MetaObject obj : loader.getMetaObjects()) {
- String key = obj.getName();
- if (key == null) continue;
- if (key.equals(localKey)) own = obj;
- if (key.equals(ref)) rootLevel = obj;
- }
- if (own != null) return own;
- return localKey.equals(ref) ? null : rootLevel;
+ if (ref == null || ref.isEmpty()) return null;
+ return com.metaobjects.loader.References.lookupCanonical(loader.getRoot(), ref);
}
/**
- * Resolve {@code ref} to its payload-shape target under the same ADR-0042
- * package-local contract as {@link #resolveObjectRef}. #210 — a template-level
- * payload target is an {@code object.value} OR a SOURCELESS
- * {@code object.projection} (rejects entities and sourced projections; the
- * loader enforces the same set). The shared home for every
- * {@code @payloadRef}/{@code @responseRef} resolution in this package — callers
- * derive {@code referrerPkg} via
- * {@code MetaDataUtil.findPackageForMetaData(referrerNode)} (walks parents, so it
- * works for both a root-level template and a nested {@code template.prompt}).
+ * Resolve the canonical {@code ref} to its payload-shape target exactly, as
+ * {@link #resolveObjectRef} does. #210 — a template-level payload target is an
+ * {@code object.value} OR a SOURCELESS {@code object.projection} (rejects entities and
+ * sourced projections; the loader enforces the same set). Prefer {@link #payloadTarget}
+ * on the template node.
+ *
+ * @param referrerPkg ignored; a canonical value is absolute.
*/
public static MetaObject resolveValueObjectRef(MetaDataLoader loader, String ref, String referrerPkg) {
MetaObject obj = resolveObjectRef(loader, ref, referrerPkg);
return (obj != null && isLegalPayloadTarget(obj)) ? obj : null;
}
+ /**
+ * The payload-shape target a reference attribute on {@code node} is bound to (the target the
+ * loader recorded; {@code attr} is {@code payloadRef} or {@code responseRef}), or {@code null}
+ * when it does not resolve or is not a legal payload target.
+ */
+ public static MetaObject payloadTarget(MetaData node, String attr) {
+ MetaObject obj = com.metaobjects.loader.References.refTarget(node, attr);
+ return (obj != null && isLegalPayloadTarget(obj)) ? obj : null;
+ }
+
/**
* #210 — a template-level payload target ({@code @payloadRef} / {@code @responseRef})
* is an {@code object.value} OR a SOURCELESS {@code object.projection}.
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringOutputParserGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringOutputParserGenerator.java
index f0811d2ac..1eabd32c1 100644
--- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringOutputParserGenerator.java
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringOutputParserGenerator.java
@@ -755,12 +755,10 @@ private static String escapeJava(String value) {
}
/**
- * Resolve {@code @payloadRef} to its {@code object.value} target (rejects entities)
- * under the ADR-0042 package-local contract (#228) — was a package-BLIND bare-name
- * scan over every loaded {@code object.value} (first match wins, load-order-dependent);
- * now delegates to the shared {@link SpringNaming#resolveValueObjectRef} so a bare
- * {@code @payloadRef} binds the referrer's OWN package first, agreeing with the
- * loader's own {@code ValidationPhase} validation of the same ref.
+ * Resolve a canonical {@code @payloadRef} value to its {@code object.value} target
+ * exactly (ADR-0059 R7), through {@link SpringNaming#resolveValueObjectRef}. Prefer
+ * {@link SpringNaming#payloadTarget} on the template node, which returns the target the
+ * loader bound. {@code referrerPkg} is ignored.
*/
protected static MetaObject resolveValueObject(MetaDataLoader loader, String ref, String referrerPkg) {
return SpringNaming.resolveValueObjectRef(loader, ref, referrerPkg);
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringOutputPromptGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringOutputPromptGenerator.java
index e7f98e5d9..33f4419c3 100644
--- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringOutputPromptGenerator.java
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringOutputPromptGenerator.java
@@ -168,10 +168,10 @@ protected void emit(MetaTemplate template, MetaDataLoader loader, Path outRoot)
}
/**
- * Resolve {@code @payloadRef} to its {@code object.value} target (rejects entities)
- * under the ADR-0042 package-local contract (#228) — was a package-BLIND bare-name
- * scan (first match wins, load-order-dependent); now delegates to the shared
- * {@link SpringNaming#resolveValueObjectRef}.
+ * Resolve a canonical {@code @payloadRef} value to its {@code object.value} target
+ * exactly (ADR-0059 R7), through {@link SpringNaming#resolveValueObjectRef}. Prefer
+ * {@link SpringNaming#payloadTarget} on the template node, which returns the target the
+ * loader bound. {@code referrerPkg} is ignored.
*/
protected static MetaObject resolveValueObject(MetaDataLoader loader, String ref, String referrerPkg) {
return SpringNaming.resolveValueObjectRef(loader, ref, referrerPkg);
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringRenderHelperGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringRenderHelperGenerator.java
index 056aa6fda..0d59e38eb 100644
--- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringRenderHelperGenerator.java
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringRenderHelperGenerator.java
@@ -146,8 +146,7 @@ public static boolean appliesTo(MetaData node, MetaDataLoader loader) {
if (!isRenderable(template)) return false;
String payloadRef = template.getPayloadRef();
if (payloadRef == null || payloadRef.isEmpty()) return false;
- return resolveValueObject(loader, payloadRef,
- com.metaobjects.util.MetaDataUtil.findPackageForMetaData(template)) != null;
+ return SpringNaming.payloadTarget(template, TemplateConstants.ATTR_PAYLOAD_REF) != null;
}
/**
@@ -172,8 +171,7 @@ protected void emit(MetaTemplate template, MetaDataLoader loader, Path outRoot,
if (!appliesTo(template, loader)) {
return; // missing @payloadRef, or not a value shape
}
- MetaObject payloadVo = resolveValueObject(loader, template.getPayloadRef(),
- com.metaobjects.util.MetaDataUtil.findPackageForMetaData(template));
+ MetaObject payloadVo = SpringNaming.payloadTarget(template, TemplateConstants.ATTR_PAYLOAD_REF);
String[] split = SpringNaming.splitFqn(template.getName());
String templatePkg = split[0];
@@ -330,14 +328,6 @@ private static List derivePayloadFieldTree(
// own-only isArray() flag would read false for a field that inherits @isArray.
boolean isArray = field.isArrayType();
if (field instanceof ObjectField of) {
- // Resolve the nested @objectRef by BARE short-name — the cross-port
- // render-helper consensus (TS findObject: c.type === OBJECT &&
- // c.name === ref). Do NOT use of.getObjectRef() here: that runs the
- // JVM package-FOLDING resolver, which expands a bare ref relative to
- // the FIELD's package and diverges from TS/C#/Python/Kotlin for a
- // PACKAGED payload VO. The render-helper field-tree must resolve
- // identically across all five ports; only this walk changes —
- // MetaDataUtil.getObjectRef and other generators are untouched.
MetaObject target = resolveNestedObjectRef(loader, of);
if (target != null && MetaObject.SUBTYPE_VALUE.equals(target.getSubType())) {
List children =
@@ -352,42 +342,13 @@ private static List derivePayloadFieldTree(
}
/**
- * Resolve a {@code field.object}'s {@code @objectRef} to its target
- * {@code object.value} under the ADR-0042 package-local contract — mirroring the
- * TS render-helper's {@code resolveObjectRef(root, ref, declaringFieldPkg)}:
- *
- * - an FQN {@code @objectRef} (contains {@code ::}) resolves EXACTLY on
- * the object's package-qualified name — never a bare-tail fallback that would
- * bind a same-named {@code object.value} in the WRONG package;
- * - a bare {@code @objectRef} resolves in the DECLARING field's package
- * ({@code ::
[} — correct for a {@code field.object} inherited via
- * extends across packages), else a root-level {@code object.value}. Package-local
- * BEFORE root-level; no cross-package short-name fallback.]
- *
- * Returns {@code null} if the field has no {@code @objectRef} or nothing resolves.
+ * The {@code object.value} a {@code field.object}'s {@code @objectRef} is bound to (the
+ * target the loader recorded, read resolving so an inherited ref works), or {@code null}
+ * when the field has no ref, nothing resolves, or the target is not an {@code object.value}.
*/
protected static MetaObject resolveNestedObjectRef(MetaDataLoader loader, ObjectField field) {
- // ADR-0039: @objectRef is an inheritable effective field property — a concrete
- // field.object may inherit it from an abstract parent via extends. RESOLVE
- // (default includeParentData=true); own-only would miss the inherited ref.
- if (!field.hasMetaAttr(MetaObject.ATTR_OBJECT_REF)) return null;
- String ref = field.getMetaAttr(MetaObject.ATTR_OBJECT_REF).getValueAsString();
- if (ref == null || ref.isEmpty()) return null;
- boolean fqn = ref.contains("::");
- // The DECLARING field's package (walks parents; for an inherited field.object it is
- // the package where the field is DECLARED, not where it is used).
- String referrerPkg = com.metaobjects.util.MetaDataUtil.findPackageForMetaData(field);
- if (referrerPkg == null) referrerPkg = "";
- String localKey = (fqn || referrerPkg.isEmpty()) ? ref : referrerPkg + "::" + ref;
- MetaObject own = null;
- MetaObject rootLevel = null;
- for (MetaObject obj : loader.getMetaObjects()) {
- if (!MetaObject.SUBTYPE_VALUE.equals(obj.getSubType())) continue;
- if (obj.getName().equals(localKey)) own = obj;
- if (!fqn && obj.getName().equals(ref)) rootLevel = obj;
- }
- if (fqn) return own;
- return own != null ? own : rootLevel;
+ MetaObject target = com.metaobjects.loader.References.refTarget(field, MetaObject.ATTR_OBJECT_REF);
+ return target != null && MetaObject.SUBTYPE_VALUE.equals(target.getSubType()) ? target : null;
}
/**
@@ -444,12 +405,11 @@ private static String attr(MetaTemplate template, String attr) {
}
/**
- * Resolve {@code @payloadRef} to its {@code object.value} target (rejects entities)
- * under the ADR-0042 package-local contract (#228) — was a package-BLIND bare-name
- * scan (first match wins, load-order-dependent); now delegates to the shared
- * {@link SpringNaming#resolveValueObjectRef}. Distinct from this file's OWN
- * {@link #resolveNestedObjectRef} (the {@code @objectRef} field-tree walk), which was
- * ALREADY package-local-correct.
+ * Resolve a canonical {@code @payloadRef} value to its {@code object.value} target
+ * exactly (ADR-0059 R7), through {@link SpringNaming#resolveValueObjectRef}. Prefer
+ * {@link SpringNaming#payloadTarget} on the template node, which returns the target the
+ * loader bound. {@code referrerPkg} is ignored. Distinct from this file's OWN
+ * {@link #resolveNestedObjectRef} (the {@code @objectRef} field-tree walk).
*/
protected static MetaObject resolveValueObject(MetaDataLoader loader, String ref, String referrerPkg) {
return SpringNaming.resolveValueObjectRef(loader, ref, referrerPkg);
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringRepositoryGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringRepositoryGenerator.java
index 60657956d..e2bd22cd9 100644
--- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringRepositoryGenerator.java
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringRepositoryGenerator.java
@@ -426,8 +426,9 @@ public static List reverseFksFor(MetaObject entity) {
List fields = ref.getFields();
if (fields.size() != 1) continue; // single-column FKs only
String fkField = fields.get(0);
+ MetaObject bound = com.metaobjects.loader.References.refTarget(ref, "references");
String target = ref.getTargetEntity();
- String targetShort = target == null ? "" : stripPackage(target);
+ String targetShort = bound != null ? bound.getShortName() : target == null ? "" : stripPackage(target);
String valueType = "Long";
if (entity.hasMetaField(fkField)) {
MetaField> f = entity.getMetaField(fkField);
diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/CrossPackageBindingConformanceTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/CrossPackageBindingConformanceTest.java
new file mode 100644
index 000000000..a08e2bafb
--- /dev/null
+++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/CrossPackageBindingConformanceTest.java
@@ -0,0 +1,226 @@
+package com.metaobjects.generator.spring;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.metaobjects.generator.direct.MultiFileDirectGeneratorBase;
+import com.metaobjects.identity.MetaIdentity;
+import com.metaobjects.identity.ReferenceIdentity;
+import com.metaobjects.loader.LoaderOptions;
+import com.metaobjects.loader.MetaDataLoader;
+import com.metaobjects.loader.References;
+import com.metaobjects.loader.uri.URIHelper;
+import com.metaobjects.object.MetaObject;
+import com.metaobjects.registry.SharedRegistryTestBase;
+import com.metaobjects.relationship.M2MFields;
+import com.metaobjects.relationship.MetaRelationship;
+import com.metaobjects.relationship.RelationshipReferences;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.IOException;
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+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.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Cross-port conformance (Java/Spring) for ADR-0059 Decision 7: codegen binds the target a
+ * reference names, never a same-short-named object in another package and never the package
+ * of the entity that inherits the attribute. Loads
+ * {@code fixtures/codegen-conformance/cross-package-binding/input/} and checks every entry of
+ * {@code expected-bindings.json}, then the generated controller and repository text.
+ */
+public class CrossPackageBindingConformanceTest extends SharedRegistryTestBase {
+
+ @Rule
+ public TemporaryFolder tmp = new TemporaryFolder();
+
+ private static Path fixtureDir() {
+ Path repoRoot = Path.of(System.getProperty("user.dir")).resolve("../../..").normalize();
+ Path dir = repoRoot.resolve("fixtures/codegen-conformance/cross-package-binding");
+ assertTrue("fixture must exist at " + dir, Files.isDirectory(dir));
+ return dir;
+ }
+
+ private static MetaDataLoader load() throws IOException {
+ List uris = new ArrayList<>();
+ try (Stream files = Files.list(fixtureDir().resolve("input"))) {
+ for (Path p : files.filter(f -> f.toString().endsWith(".json")).sorted().collect(Collectors.toList())) {
+ uris.add(URIHelper.toURI("model:file:" + p.toAbsolutePath().toString().replace('\\', '/')));
+ }
+ }
+ assertEquals(4, uris.size());
+ MetaDataLoader loader = new MetaDataLoader(
+ LoaderOptions.create(false, false, true), MetaDataLoader.SUBTYPE_MANUAL, "cross-package-binding");
+ loader.setSourceURIs(uris);
+ loader.init();
+ return loader;
+ }
+
+ private static JsonNode expected() throws IOException {
+ return new ObjectMapper().readTree(Files.readString(fixtureDir().resolve("expected-bindings.json")));
+ }
+
+ private static MetaObject objectOf(MetaDataLoader loader, String key) {
+ MetaObject o = loader.getMetaObjectByName(key);
+ assertNotNull("object " + key + " must load", o);
+ return o;
+ }
+
+ private static String tableOf(MetaObject o) {
+ return o.findPrimaryWritableSource().orElseThrow().getTableName();
+ }
+
+ private static String[] splitMember(String key) {
+ int dot = key.lastIndexOf('.');
+ return new String[] { key.substring(0, dot), key.substring(dot + 1) };
+ }
+
+ @Test
+ public void everyIdentityReferenceIsBoundToTheExpectedObject() throws Exception {
+ MetaDataLoader loader = load();
+ JsonNode refs = expected().get("references");
+ Iterator keys = refs.fieldNames();
+ int checked = 0;
+ while (keys.hasNext()) {
+ String key = keys.next();
+ String[] m = splitMember(key);
+ MetaObject holder = objectOf(loader, m[0]);
+ ReferenceIdentity ref = null;
+ for (MetaIdentity id : holder.getIdentities()) {
+ if (id instanceof ReferenceIdentity && m[1].equals(id.getShortName())) ref = (ReferenceIdentity) id;
+ }
+ assertNotNull("identity.reference " + key, ref);
+ MetaObject bound = References.refTarget(ref, MetaIdentity.ATTR_REFERENCES);
+ assertNotNull("unbound " + key, bound);
+ assertEquals("binding of " + key, refs.get(key).asText(), bound.getName());
+ checked++;
+ }
+ assertEquals(6, checked);
+ }
+
+ @Test
+ public void everyRelationshipResolvesToTheExpectedTargetTablesAndJoinFields() throws Exception {
+ MetaDataLoader loader = load();
+ JsonNode relations = expected().get("relations");
+ Iterator keys = relations.fieldNames();
+ int checked = 0;
+ while (keys.hasNext()) {
+ String key = keys.next();
+ JsonNode want = relations.get(key);
+ String[] m = splitMember(key);
+ MetaObject owner = objectOf(loader, m[0]);
+ MetaRelationship rel = null;
+ for (MetaRelationship r : owner.getRelationships()) {
+ if (m[1].equals(r.getShortName())) rel = r;
+ }
+ assertNotNull("relationship " + key, rel);
+
+ MetaObject target = References.refTarget(rel, MetaRelationship.ATTR_OBJECT_REF);
+ assertNotNull("unbound target of " + key, target);
+ assertEquals("target of " + key, want.get("target").asText(), target.getName());
+ assertEquals("target table of " + key, want.get("targetTable").asText(), tableOf(target));
+
+ if ("many".equals(want.get("cardinality").asText())) {
+ MetaObject junction = References.refTarget(rel, MetaRelationship.ATTR_THROUGH);
+ assertNotNull("unbound junction of " + key, junction);
+ assertEquals("junction of " + key, want.get("junction").asText(), junction.getName());
+ assertEquals("junction table of " + key, want.get("junctionTable").asText(), tableOf(junction));
+ M2MFields fields = M2MFields.derive(rel, owner, loader.getRoot());
+ assertEquals("source join field of " + key, want.get("sourceJoinField").asText(), fields.getSourceField());
+ assertEquals("target join field of " + key, want.get("targetJoinField").asText(), fields.getTargetField());
+
+ SpringM2mSupport.M2mNav nav = SpringM2mSupport.resolve(owner, loader).stream()
+ .filter(n -> n.relationName().equals(m[1])).findFirst().orElse(null);
+ assertNotNull("codegen M:N navigation for " + key, nav);
+ assertEquals(want.get("sourceJoinField").asText(), nav.sourceField());
+ assertEquals(want.get("targetJoinField").asText(), nav.targetField());
+ } else {
+ ReferenceIdentity fk = RelationshipReferences.resolveRelationshipReference(
+ owner, rel.getShortName(), target, rel.getSourceRefField());
+ assertNotNull("fk reference of " + key, fk);
+ assertEquals("fk field of " + key, want.get("fkField").asText(), fk.getFields().get(0));
+ }
+ checked++;
+ }
+ assertEquals(4, checked);
+ }
+
+ @Test
+ public void inheritedRootReferencesBindTheRootObjectNotTheInheritingPackages() throws Exception {
+ MetaDataLoader loader = load();
+ MetaObject order = objectOf(loader, "acme::Order");
+ MetaObject customer = loader.getMetaObjectByName("Customer");
+ MetaObject label = loader.getMetaObjectByName("Label");
+ MetaRelationship customerRel = null;
+ MetaRelationship labelsRel = null;
+ for (MetaRelationship r : order.getRelationships()) {
+ if ("customer".equals(r.getShortName())) customerRel = r;
+ if ("labels".equals(r.getShortName())) labelsRel = r;
+ }
+ assertTrue(References.refTarget(customerRel, MetaRelationship.ATTR_OBJECT_REF) == customer);
+ assertTrue(References.refTarget(labelsRel, MetaRelationship.ATTR_OBJECT_REF) == label);
+ assertTrue(References.lookupCanonical(loader.getRoot(),
+ customerRel.getMetaAttr(MetaRelationship.ATTR_OBJECT_REF).getValueAsString()) == customer);
+ }
+
+ private Map generateAll(MetaDataLoader loader) throws IOException {
+ Path out = tmp.newFolder().toPath();
+ for (MultiFileDirectGeneratorBase> gen : List.of(
+ new SpringDtoGenerator(), new SpringRepositoryGenerator(), new SpringControllerGenerator())) {
+ Map args = new HashMap<>();
+ args.put("outputDir", out.toString());
+ gen.setArgs(args);
+ gen.execute(loader);
+ }
+ Map files = new HashMap<>();
+ try (Stream walk = Files.walk(out)) {
+ for (Path p : walk.filter(f -> f.toString().endsWith(".java")).collect(Collectors.toList())) {
+ files.put(out.relativize(p).toString().replace('\\', '/'), Files.readString(p));
+ }
+ }
+ return files;
+ }
+
+ @Test
+ public void generatedControllerAndRepositoryUseTheBoundTargets() throws Exception {
+ Map files = generateAll(load());
+
+ String postController = files.get("a/PostController.java");
+ String postRepo = files.get("a/PostRepository.java");
+ assertNotNull(postController);
+ assertNotNull(postRepo);
+ assertTrue("Post.tags must traverse to b::Tag; saw:\n" + postController,
+ postController.contains("ResponseEntity> findTags(@PathVariable Long id)"));
+ assertTrue("Post.tags finder must return b::Tag rows; saw:\n" + postRepo,
+ postRepo.contains("List findTags(Long sourceId);"));
+ assertFalse("Post must not bind a::Tag; saw:\n" + postController,
+ postController.replace("b.TagDto", "").contains("TagDto"));
+
+ String orderController = files.get("acme/OrderController.java");
+ String orderRepo = files.get("acme/OrderRepository.java");
+ assertNotNull(orderController);
+ assertNotNull(orderRepo);
+ assertTrue("Order.labels must traverse via the root OrderLabel junction; saw:\n" + orderController,
+ orderController.contains("findLabels(@PathVariable Long id)"));
+ assertTrue("saw:\n" + orderRepo, orderRepo.contains("findLabels(Long sourceId);"));
+
+ for (Map.Entry f : files.entrySet()) {
+ for (String wrong : List.of("a_tags", "acme_labels", "acme_customers")) {
+ assertFalse(f.getKey() + " must not mention " + wrong, f.getValue().contains(wrong));
+ }
+ }
+ }
+}
diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringNamingCanonicalRefTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringNamingCanonicalRefTest.java
new file mode 100644
index 000000000..0639a50a3
--- /dev/null
+++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringNamingCanonicalRefTest.java
@@ -0,0 +1,64 @@
+package com.metaobjects.generator.spring;
+
+import com.metaobjects.loader.InMemoryStringSource;
+import com.metaobjects.loader.LoaderOptions;
+import com.metaobjects.loader.MetaDataLoader;
+import com.metaobjects.object.MetaObject;
+import org.junit.Test;
+
+import java.util.List;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+/**
+ * ADR-0059 R7 — {@link SpringNaming#resolveObjectRef} and {@link SpringNaming#resolveValueObjectRef}
+ * resolve a CANONICAL value exactly: {@code pkg::Name} binds that package's object, {@code ::Name}
+ * and a bare {@code Name} bind the root-level one, and the referrer package is never applied.
+ * Three value objects share the short name {@code Report}, so a short-name scan or a package-local
+ * resolve would bind one of the packaged copies instead.
+ */
+public class SpringNamingCanonicalRefTest {
+
+ private static String doc(String pkg, String field) {
+ String pkgPart = pkg == null ? "" : "\"package\": \"" + pkg + "\", ";
+ return "{ \"metadata.root\": { " + pkgPart + "\"children\": ["
+ + "{ \"object.value\": { \"name\": \"Report\", \"children\": ["
+ + "{ \"field.string\": { \"name\": \"" + field + "\" } } ] } } ] } }";
+ }
+
+ private static MetaDataLoader load(String... docs) {
+ MetaDataLoader loader = new MetaDataLoader(
+ LoaderOptions.create(false, false, true), MetaDataLoader.SUBTYPE_MANUAL, "spring-naming-canonical");
+ loader.init();
+ List sources = new java.util.ArrayList<>();
+ for (int i = 0; i < docs.length; i++) {
+ sources.add(new InMemoryStringSource(docs[i], "spring-naming-canonical/meta." + i + ".json"));
+ }
+ loader.load(sources);
+ return loader;
+ }
+
+ private static String fieldOf(MetaObject obj) {
+ return obj == null ? null : obj.getMetaFields().iterator().next().getName();
+ }
+
+ @Test
+ public void canonicalValuesResolveExactly() {
+ MetaDataLoader loader = load(doc("acme::alpha", "alphaVal"), doc("acme::beta", "betaVal"), doc(null, "rootVal"));
+
+ assertEquals("alphaVal", fieldOf(SpringNaming.resolveObjectRef(loader, "acme::alpha::Report", "")));
+ assertEquals("betaVal", fieldOf(SpringNaming.resolveValueObjectRef(loader, "acme::beta::Report", "acme::alpha")));
+ assertEquals("rootVal", fieldOf(SpringNaming.resolveValueObjectRef(loader, "::Report", "acme::alpha")));
+ assertEquals("rootVal", fieldOf(SpringNaming.resolveObjectRef(loader, "Report", "acme::alpha")));
+ assertNull(SpringNaming.resolveObjectRef(loader, "acme::gamma::Report", ""));
+ }
+
+ @Test
+ public void bareValueDoesNotBindAPackagedObject() {
+ MetaDataLoader loader = load(doc("acme::alpha", "alphaVal"));
+
+ assertNull(SpringNaming.resolveObjectRef(loader, "Report", "acme::alpha"));
+ assertEquals("alphaVal", fieldOf(SpringNaming.resolveObjectRef(loader, "acme::alpha::Report", "")));
+ }
+}
diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java
index 258e99c4a..718383b02 100644
--- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java
+++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java
@@ -16,12 +16,24 @@
import com.metaobjects.loader.LoaderOptions;
import com.metaobjects.generator.EmitsPhysicalNameConstants;
import com.metaobjects.loader.MetaDataLoader;
+import com.metaobjects.generator.util.HashManifest;
+import org.apache.maven.model.Plugin;
+import org.apache.maven.model.PluginExecution;
+import org.apache.maven.execution.MavenSession;
+import org.apache.maven.model.Profile;
+import org.apache.maven.model.building.ModelBuildingRequest;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecution;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugin.MojoFailureException;
+import org.apache.maven.plugins.annotations.Component;
import org.apache.maven.plugins.annotations.Parameter;
+import org.apache.maven.project.DefaultProjectBuildingRequest;
import org.apache.maven.project.MavenProject;
+import org.apache.maven.project.ProjectBuilder;
+import org.apache.maven.project.ProjectBuildingException;
+import org.apache.maven.project.ProjectBuildingRequest;
+import org.codehaus.plexus.util.xml.Xpp3Dom;
import java.io.File;
import java.lang.reflect.Constructor;
@@ -33,7 +45,9 @@
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
+import java.util.HashSet;
import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -54,9 +68,263 @@ public abstract class AbstractMetaDataMojo extends AbstractMojo
@Parameter( defaultValue = "${project}", readonly = true, required = true )
protected MavenProject project;
+ @Parameter( defaultValue = "${session}", readonly = true )
+ protected MavenSession session;
+
+ @Component
+ protected ProjectBuilder projectBuilder;
+
@Parameter( defaultValue = "${mojoExecution}", readonly = true, required = true )
protected MojoExecution execution;
+ /** This plugin's key in a module's build model. */
+ static final String PLUGIN_KEY = "com.metaobjects:metaobjects-maven-plugin";
+
+ /**
+ * What a codegen hash manifest entry {@code g} writes is recorded against: the
+ * generator's registry id and its output directory relative to the module basedir.
+ * It names the configuration, not the Maven execution, so a command-line run, a
+ * renamed execution and the lifecycle execution with the same configuration agree.
+ * {@code null} for a generator with no {@code outputDir}.
+ */
+ protected HashManifest.Owner ownerOf(GeneratorParam g) {
+ return ownerOf(g.getClassname(), mergeAndOverwriteArgs(g).get(GeneratorBase.ARG_OUTPUTDIR));
+ }
+
+ private HashManifest.Owner ownerOf(String classname, String outputDir) {
+ if (classname == null || outputDir == null) return null;
+ return new HashManifest.Owner(RequiresCheck.registryId(classname), moduleDir(Path.of(outputDir)));
+ }
+
+ /** {@code dir} as an owner names it: relative to the module basedir when inside it. */
+ private String moduleDir(Path outputDir) {
+ Path dir = outputDir.toAbsolutePath().normalize();
+ String shown = dir.toString();
+ if (project != null && project.getBasedir() != null) {
+ Path base = project.getBasedir().toPath().toAbsolutePath().normalize();
+ if (dir.startsWith(base)) shown = base.relativize(dir).toString();
+ }
+ return shown.replace(File.separatorChar, '/');
+ }
+
+ /**
+ * Whether no generator the module configures for {@code dir}, in any execution or the
+ * plugin-level configuration, keeps hand edits: an edited orphan there may be deleted
+ * only when every writer of the directory says {@code handEdits=overwrite}. {@code false}
+ * when what is configured is unknown, so an edited orphan is never deleted on a guess.
+ */
+ protected boolean noConfiguredWriterKeeps(Path dir) {
+ ConfiguredOwners c = configured();
+ return c.owners() != null && !c.keepDirs().contains(moduleDir(dir));
+ }
+
+ /**
+ * Every generator/output-directory pair the module configures for
+ * {@code metaobjects:generate}: in each of its executions and in the plugin-level
+ * configuration a command-line run uses. It is the union over the module built with
+ * no profile, with each profile of the module and its parents active alone, and with
+ * this build's own active profiles, so a profile switched off for one build does not
+ * make its generators' files look stranded, and two profiles that set the same
+ * property or execution differently each count. Maven builds each model, so profile
+ * properties, inherited profiles and basedirs resolve as they do when the profile is
+ * on. {@code null} when a model cannot be built or none declares this plugin, so what
+ * is configured is unknown. Computed once per module per build and kept in the
+ * project's build context, since every execution of the module reads the same POMs.
+ */
+ protected Set configuredOwners() {
+ return configured().owners();
+ }
+
+ /**
+ * Fail before anything is written when two {@code metaobjects:generate} executions of
+ * the module, in any of the models {@link #configuredOwners} reads, configure the same
+ * generator into the same output directory. Their files would share one owner, so
+ * neither execution could tell the other's output from its own orphans. The
+ * plugin-level configuration is no execution of its own: a command-line run of it and
+ * the one execution that inherits it are the same configuration. An execution disabled
+ * with {@code none} is not counted here, though its pairs still count as
+ * configured, so the output of an on-demand run is never stranded; run explicitly
+ * ({@code mvn metaobjects:generate@}), it is refused when an enabled execution owns
+ * one of its generator/output-directory pairs.
+ */
+ protected void refuseSharedGeneratorDirs() {
+ List shared = new ArrayList<>();
+ Map> executions = configured().executions();
+ executions.forEach((owner, by) -> {
+ if (by.size() > 1) shared.add("'" + owner + "' in executions " + String.join(" and ", by));
+ });
+ String self = execution != null ? execution.getExecutionId() : null;
+ if (isDisabledGenerateExecution(self) && getGenerators() != null) {
+ for (GeneratorParam g : getGenerators()) {
+ HashManifest.Owner owner = ownerOf(g);
+ List by = owner != null ? executions.get(owner) : null;
+ if (by != null && !by.isEmpty()) {
+ shared.add("'" + owner + "' in executions " + String.join(" and ", by) + " and '" + self
+ + "' (none, invoked explicitly)");
+ }
+ }
+ }
+ if (shared.isEmpty()) return;
+ shared.sort(String::compareTo);
+ throw new MetaDataException("Two metaobjects:generate executions of this module configure the same "
+ + "generator into the same output directory: " + String.join("; ", shared) + ". Give each "
+ + "execution its own , or merge them into one execution.");
+ }
+
+ private ConfiguredOwners configured() {
+ if (project == null || project.getFile() == null || projectBuilder == null || session == null) {
+ return ConfiguredOwners.UNKNOWN;
+ }
+ if (project.getContextValue(CONFIGURED_OWNERS_CONTEXT_KEY) instanceof ConfiguredOwners known) {
+ return known;
+ }
+ ConfiguredOwners read = readConfiguredOwners();
+ project.setContextValue(CONFIGURED_OWNERS_CONTEXT_KEY, read);
+ return read;
+ }
+
+ private record ConfiguredOwners(Set owners, Map> executions,
+ Set keepDirs) {
+ static final ConfiguredOwners UNKNOWN = new ConfiguredOwners(null, Map.of(), Set.of());
+ }
+
+ private static final String CONFIGURED_OWNERS_CONTEXT_KEY =
+ AbstractMetaDataMojo.class.getName() + ".configuredOwners";
+
+ private ConfiguredOwners readConfiguredOwners() {
+ Set profileIds = new LinkedHashSet<>();
+ for (MavenProject p = project; p != null; p = p.getParent()) {
+ if (p.getModel() == null) continue;
+ for (Profile profile : p.getModel().getProfiles()) profileIds.add(profile.getId());
+ }
+ List models = new ArrayList<>();
+ models.add(project);
+ MavenProject base = projectWith(null, profileIds);
+ if (base == null) return ConfiguredOwners.UNKNOWN;
+ models.add(base);
+ for (String id : profileIds) {
+ MavenProject alone = projectWith(id, profileIds);
+ if (alone == null) return ConfiguredOwners.UNKNOWN;
+ models.add(alone);
+ }
+ Set out = new HashSet<>();
+ Set keepDirs = new HashSet<>();
+ Map> executions = new HashMap<>();
+ Map profileOf = new HashMap<>();
+ Set baseIds = generateExecutionIds(base);
+ List profiles = new ArrayList<>(profileIds);
+ for (int i = 0; i < profiles.size(); i++) {
+ for (String id : generateExecutionIds(models.get(i + 2))) {
+ if (!baseIds.contains(id)) profileOf.putIfAbsent(id, profiles.get(i));
+ }
+ }
+ boolean declared = false;
+ for (MavenProject model : models) {
+ Plugin plugin = model.getPlugin(PLUGIN_KEY);
+ if (plugin == null) continue;
+ declared = true;
+ Xpp3Dom pluginLevel = plugin.getConfiguration() instanceof Xpp3Dom dom ? dom : null;
+ out.addAll(configuredIn(pluginLevel, keepDirs));
+ for (PluginExecution e : plugin.getExecutions()) {
+ if (!e.getGoals().contains("generate")) continue;
+ if (!(e.getConfiguration() instanceof Xpp3Dom execution)) continue;
+ for (HashManifest.Owner owner : configuredIn(pluginLevel == null ? execution
+ : Xpp3Dom.mergeXpp3Dom(new Xpp3Dom(execution), pluginLevel), keepDirs)) {
+ out.add(owner);
+ if (isEnabledGenerate(e)) {
+ executions.computeIfAbsent(owner, k -> new LinkedHashSet<>()).add(e.getId());
+ }
+ }
+ }
+ }
+ if (!declared) return ConfiguredOwners.UNKNOWN;
+ Map> byOwner = new HashMap<>();
+ executions.forEach((owner, ids) -> byOwner.put(owner, ids.stream()
+ .map(id -> "'" + id + "'" + (profileOf.containsKey(id) ? " (profile '" + profileOf.get(id) + "')" : ""))
+ .toList()));
+ return new ConfiguredOwners(out, byOwner, keepDirs);
+ }
+
+ private static Set generateExecutionIds(MavenProject model) {
+ Set ids = new HashSet<>();
+ Plugin plugin = model.getPlugin(PLUGIN_KEY);
+ if (plugin == null) return ids;
+ for (PluginExecution e : plugin.getExecutions()) {
+ if (isEnabledGenerate(e)) ids.add(e.getId());
+ }
+ return ids;
+ }
+
+ /** A {@code metaobjects:generate} execution not switched off with {@code none}
+ * (how a module disables an execution it inherits). */
+ private static boolean isEnabledGenerate(PluginExecution e) {
+ return e.getGoals().contains("generate") && !"none".equals(e.getPhase());
+ }
+
+ /** Whether {@code id} names a generate execution of this module disabled with
+ * {@code none}, as {@code mvn metaobjects:generate@} can still run. */
+ private boolean isDisabledGenerateExecution(String id) {
+ Plugin plugin = project != null && id != null ? project.getPlugin(PLUGIN_KEY) : null;
+ if (plugin == null) return false;
+ for (PluginExecution e : plugin.getExecutions()) {
+ if (id.equals(e.getId()) && e.getGoals().contains("generate") && !isEnabledGenerate(e)) return true;
+ }
+ return false;
+ }
+
+ /** This module built by Maven with only profile {@code active} on ({@code null}: none of
+ * {@code all}); {@code null} when the build fails. */
+ private MavenProject projectWith(String active, Set all) {
+ List inactive = new ArrayList<>(all);
+ inactive.remove(active);
+ ProjectBuildingRequest request = new DefaultProjectBuildingRequest(session.getProjectBuildingRequest());
+ request.setActiveProfileIds(active == null ? List.of() : List.of(active));
+ request.setInactiveProfileIds(inactive);
+ request.setResolveDependencies(false);
+ request.setValidationLevel(ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL);
+ request.setProject(null);
+ try {
+ return projectBuilder.build(project.getFile(), request).getProject();
+ } catch (ProjectBuildingException e) {
+ getLog().warn("MetaData Mojo > Could not read every profile's configuration of this module, so files "
+ + "only a removed generator wrote are not cleaned up this run: " + e.getMessage());
+ return null;
+ }
+ }
+
+ /** The generator/output-directory pairs {@code dom} configures; adds to {@code keepDirs}
+ * the output directory of each one that keeps hand edits. */
+ private List configuredIn(Xpp3Dom dom, Set keepDirs) {
+ List out = new ArrayList<>();
+ if (dom == null) return out;
+ Map domGlobals = childValues(dom.getChild("globals"));
+ Xpp3Dom generators = dom.getChild("generators");
+ if (generators == null) return out;
+ for (Xpp3Dom generator : generators.getChildren()) {
+ Xpp3Dom classname = generator.getChild("classname");
+ if (classname == null) continue;
+ Map args = mergeArgs(domGlobals, childValues(generator.getChild("args")));
+ HashManifest.Owner owner = ownerOf(classname.getValue(), args.get(GeneratorBase.ARG_OUTPUTDIR));
+ if (owner == null) continue;
+ out.add(owner);
+ Xpp3Dom handEdits = generator.getChild("handEdits");
+ if (handEdits == null || handEdits.getValue() == null
+ || !GeneratorParam.HAND_EDITS_OVERWRITE.equals(handEdits.getValue().trim())) {
+ keepDirs.add(owner.outputDir());
+ }
+ }
+ return out;
+ }
+
+ private static Map childValues(Xpp3Dom dom) {
+ Map out = new HashMap<>();
+ if (dom == null) return out;
+ for (Xpp3Dom child : dom.getChildren()) {
+ if (child.getValue() != null) out.put(child.getName(), child.getValue().trim());
+ }
+ return out;
+ }
+
@Parameter(name="loader")
private LoaderParam loaderConfig = null;
public LoaderParam getLoader() {
@@ -257,9 +525,24 @@ protected List buildGenerators(ClassLoader projectClassLoader,
public Map mergeAndOverwriteArgs(GeneratorParam g) {
+ Map allargs = mergeArgs(globals, g.getArgs());
+
+ // Dump the args to debug
+ getLog().debug( "-- Generator ["+g.getClass().getSimpleName()+"] merged Args");
+ for ( String key : allargs.keySet()) {
+ getLog().debug( " "+key+" = '"+allargs.get(key) +
+ ((globals!=null && allargs.get(key).equals(globals.get(key)))?" #GLOBAL#":""));
+ }
+
+ return allargs;
+ }
+
+ /** {@code args} over {@code globalArgs}, with every path arg resolved against the
+ * module basedir. */
+ private Map mergeArgs(Map globalArgs, Map args) {
Map allargs = new HashMap<>();
- if ( globals != null ) allargs.putAll(globals);
- if ( g.getArgs() != null ) allargs.putAll(g.getArgs());
+ if ( globalArgs != null ) allargs.putAll(globalArgs);
+ if ( args != null ) allargs.putAll(args);
File basedir = project != null ? project.getBasedir() : null;
if ( basedir != null ) {
@@ -269,14 +552,6 @@ public Map mergeAndOverwriteArgs(GeneratorParam g) {
allargs.put(key, basedir.toPath().resolve(value).normalize().toString());
}
}
-
- // Dump the args to debug
- getLog().debug( "-- Generator ["+g.getClass().getSimpleName()+"] merged Args");
- for ( String key : allargs.keySet()) {
- getLog().debug( " "+key+" = '"+allargs.get(key) +
- ((globals!=null && allargs.get(key).equals(globals.get(key)))?" #GLOBAL#":""));
- }
-
return allargs;
}
diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/FmtSupport.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/FmtSupport.java
index 001d01062..01ea9c164 100644
--- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/FmtSupport.java
+++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/FmtSupport.java
@@ -158,7 +158,7 @@ public static FormatFileResult formatFile(
return FormatFileResult.failure(overlay, message);
}
- return FormatFileResult.success(CanonicalJsonSerializer.canonicalSerialize(loader.getRoot()));
+ return FormatFileResult.success(CanonicalJsonSerializer.canonicalSerialize(loader.getRoot(), CanonicalJsonSerializer.RefSpelling.AUTHORED));
}
/**
diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/GeneratorParam.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/GeneratorParam.java
index 8e3d1e9eb..74b1babea 100644
--- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/GeneratorParam.java
+++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/GeneratorParam.java
@@ -12,10 +12,16 @@
*/
public class GeneratorParam {
+ /** Refuse a hand-edited generated file by name (the default). */
+ public static final String HAND_EDITS_KEEP = "keep";
+ /** Replace a hand-edited generated file with fresh output and re-baseline its hash. */
+ public static final String HAND_EDITS_OVERWRITE = "overwrite";
+
private String classname = null;
private Map args = null;
private List filters = null;
private List