Skip to content

Commit 8760128

Browse files
dmealingclaude
andcommitted
fix(codegen-spring): collision-scoped payload record naming (ADR-0044, #219 stage 3a)
The Java payload generator named every nested payload record after the VO's bare short name and wrote it to `<prompts-pkg>/<Short>Payload.java`. Dedupe is FQN-keyed (getName() is the FQN), so two `object.value` `Note`s in different packages both reached emission — but both were named `NotePayload` and written to the same file path, so the second silently CLOBBERED the first (last-wins), binding the wrong shape (the xpkg-collision fixture lost the alpha shape). Ports ADR-0044's collision-scoped naming (mirroring the TS/C#/Python reference), adapted for Java's one-record-per-file emit — the collision domain is the OUTPUT PACKAGE, not one file: - `computePayloadNameMap` — a pure function of the loaded templates: walk each template's nested-payload closure (`collectNestedClosure` / `nestedTargetOf`, the same two edge types the emitter resolves), assign each nested VO to its output prompts package, then per (package, short-name) group emit `<Short>Payload` when unique (byte-identical to before) or every colliding member as `<PkgSegmentsPascal><Short>Payload` (`AcmeAlphaNotePayload`). A still-colliding derived name fails loud with ERR_PAYLOAD_NAME_COLLISION. - The name map threads through the emit chain alongside the existing dedupe set, driving the record declaration, the file name, and every reference. The api-docs caller (JavaFieldShapes) passes an empty map → bare fallback (unchanged). ERR_PAYLOAD_NAME_COLLISION is a per-port local constant for now (as in TS/C#/Python), promoted to the shared ledger when the Kotlin half lands. Gated by a new SpringPayloadGeneratorTest case that loads the SHARED xpkg corpus and asserts two distinct package-qualified records (never a clobbered NotePayload.java). Non-colliding output byte-identical: full codegen-spring suite green (175). Kotlin (stage 3b), #220 render-helper coverage, the ledger promotion, and the coordinated release follow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeGSV3StPCcJGZNNJ4ZfAb
1 parent a59c471 commit 8760128

3 files changed

Lines changed: 264 additions & 16 deletions

File tree

server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaFieldShapes.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,11 @@ public static List<FieldShape> payloadFields(MetaData template, MetaDataLoader l
104104
String nestedPkg = SpringNaming.promptsPackage(split[0]);
105105

106106
List<FieldShape> out = new ArrayList<>();
107+
// api-docs documents field shapes only (bare nested names are fine here); pass
108+
// an empty ADR-0044 name map so emitNestedAndReturnType uses its bare fallback.
109+
java.util.Map<String, String> nameMap = java.util.Map.of();
107110
for (MetaField field : vo.getMetaFields()) {
108-
String type = gen.resolveFieldType(field, vo, loader, nestedPkg, scratch, emittedNestedFqns);
111+
String type = gen.resolveFieldType(field, vo, loader, nestedPkg, scratch, emittedNestedFqns, nameMap);
109112
// Optional iff the payload generator would emit a hasXxx() helper for
110113
// this component (String / List / reference → optional; bare scalar
111114
// primitive → not). Mirror that determination exactly.

server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringPayloadGenerator.java

Lines changed: 181 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,14 @@
2424
import java.nio.file.Paths;
2525
import java.util.ArrayList;
2626
import java.util.Collection;
27+
import java.util.Collections;
2728
import java.util.Comparator;
29+
import java.util.HashMap;
2830
import java.util.HashSet;
2931
import java.util.Iterator;
32+
import java.util.LinkedHashMap;
3033
import java.util.List;
34+
import java.util.Map;
3135
import java.util.Set;
3236

3337
/**
@@ -101,6 +105,12 @@
101105
*/
102106
public class SpringPayloadGenerator extends MultiFileDirectGeneratorBase<MetaObject> {
103107

108+
// ADR-0044 backstop error code — a codegen-time (not loader) error, peer of the
109+
// render tier's ERR_VAR_NOT_ON_PAYLOAD. Declared LOCALLY (as in the TS/C#/Python
110+
// ports) rather than in the shared cross-port ledger; promoted to the ledger once
111+
// the coordinated follow-up lands, so no port reddens on a code it doesn't emit.
112+
public static final String ERR_PAYLOAD_NAME_COLLISION = "ERR_PAYLOAD_NAME_COLLISION";
113+
104114
@Override
105115
protected Class<MetaObject> getFilterClass() {
106116
return MetaObject.class;
@@ -124,9 +134,153 @@ public void execute(MetaDataLoader loader) {
124134
new ArrayList<>(loader.getRoot().getChildren(MetaTemplate.class, true));
125135
templates.sort(Comparator.comparing(MetaTemplate::getName));
126136

137+
// ADR-0044 — collision-scoped nested-payload record names, computed once as a
138+
// pure function of the loaded templates (order-independent). Keyed by VO FQN.
139+
Map<String, String> nameMap = computePayloadNameMap(templates, loader);
140+
141+
for (MetaTemplate tmpl : templates) {
142+
emit(tmpl, loader, outRoot, emittedNestedFqns, nameMap);
143+
}
144+
}
145+
146+
/**
147+
* ADR-0044 pass 1/2 — the run's nested-payload name map, keyed by value-object
148+
* FQN ({@link MetaObject#getName()}), scoped per OUTPUT PACKAGE. Java is a
149+
* one-record-per-file emitter, so its collision domain is the output (prompts)
150+
* package: two value-objects sharing a bare short name written into the same
151+
* package would clobber one {@code NotePayload.java}. A nested VO whose bare
152+
* short name is UNIQUE in its output package emits {@code <Short>Payload}
153+
* (byte-identical to pre-ADR-0044 output); a COLLISION emits every member under
154+
* its package-qualified derived name ({@code acme::alpha::Note} ->
155+
* {@code AcmeAlphaNotePayload}). A still-colliding derived name fails loud with
156+
* {@link #ERR_PAYLOAD_NAME_COLLISION}. Pure function of the templates — never of
157+
* emission order.
158+
*/
159+
protected Map<String, String> computePayloadNameMap(List<MetaTemplate> templates, MetaDataLoader loader) {
160+
// FQN -> output package (first reaching template in sorted order wins, matching
161+
// the run-wide dedupe below). The primary VO is template-named, so excluded.
162+
Map<String, String> voOutPkg = new LinkedHashMap<>();
163+
List<String> orderedFqns = new ArrayList<>();
127164
for (MetaTemplate tmpl : templates) {
128-
emit(tmpl, loader, outRoot, emittedNestedFqns);
165+
MetaObject vo = resolveValueObject(loader, tmpl.getPayloadRef());
166+
if (vo == null) continue;
167+
String nestedPkg = SpringNaming.promptsPackage(SpringNaming.splitFqn(tmpl.getName())[0]);
168+
Set<String> seen = new HashSet<>();
169+
seen.add(vo.getName()); // primary is template-named — outside the VO-name collision domain
170+
collectNestedClosure(vo, loader, nestedPkg, voOutPkg, orderedFqns, seen);
171+
}
172+
// Group by (output package, bare short name).
173+
Map<String, List<String>> byPkgShort = new LinkedHashMap<>();
174+
for (String fqn : orderedFqns) {
175+
String key = voOutPkg.get(fqn) + "" + SpringNaming.splitFqn(fqn)[1];
176+
byPkgShort.computeIfAbsent(key, k -> new ArrayList<>()).add(fqn);
177+
}
178+
Map<String, String> nameMap = new LinkedHashMap<>();
179+
for (List<String> fqns : byPkgShort.values()) {
180+
if (fqns.size() == 1) {
181+
String fqn = fqns.get(0);
182+
nameMap.put(fqn, SpringNaming.payloadName(SpringNaming.splitFqn(fqn)[1]));
183+
} else {
184+
for (String fqn : fqns) {
185+
String[] sp = SpringNaming.splitFqn(fqn);
186+
nameMap.put(fqn, SpringNaming.payloadName(packageQualifiedName(sp[0], sp[1])));
187+
}
188+
}
189+
}
190+
// Backstop — per output package, two DISTINCT FQNs deriving the same record
191+
// name (e.g. acme::alpha and acmeAlpha both fold to AcmeAlpha). Sorted so the
192+
// named pair (and whether any collision fires) is order-independent.
193+
Map<String, String> ownerByPkgName = new HashMap<>();
194+
List<String> sortedFqns = new ArrayList<>(nameMap.keySet());
195+
Collections.sort(sortedFqns);
196+
for (String fqn : sortedFqns) {
197+
String pkgName = voOutPkg.get(fqn) + "" + nameMap.get(fqn);
198+
String prev = ownerByPkgName.putIfAbsent(pkgName, fqn);
199+
if (prev != null && !prev.equals(fqn)) {
200+
throw new GeneratorException(ERR_PAYLOAD_NAME_COLLISION
201+
+ ": payload record name collision: \"" + nameMap.get(fqn)
202+
+ "\" derives from both \"" + prev + "\" and \"" + fqn
203+
+ "\" — rename one value-object or move it to a package that derives a distinct name");
204+
}
205+
}
206+
return nameMap;
207+
}
208+
209+
/**
210+
* ADR-0044 pass 1 — walk {@code vo}'s transitive nested-payload closure (plain
211+
* {@code field.object @objectRef} + {@code origin.collection @via} edges),
212+
* assigning each not-yet-seen target VO to {@code outPkg} (first reaching
213+
* template wins) and recording it in {@code orderedFqns}. {@code seen} is seeded
214+
* with the primary VO's FQN and doubles as the cycle guard.
215+
*/
216+
protected void collectNestedClosure(MetaObject vo,
217+
MetaDataLoader loader,
218+
String outPkg,
219+
Map<String, String> voOutPkg,
220+
List<String> orderedFqns,
221+
Set<String> seen) {
222+
for (MetaField<?> field : vo.getMetaFields()) {
223+
MetaObject target = nestedTargetOf(field, loader);
224+
if (target == null) continue;
225+
String fqn = target.getName();
226+
if (!seen.add(fqn)) continue;
227+
if (!voOutPkg.containsKey(fqn)) {
228+
voOutPkg.put(fqn, outPkg);
229+
orderedFqns.add(fqn);
230+
}
231+
collectNestedClosure(target, loader, outPkg, voOutPkg, orderedFqns, seen);
232+
}
233+
}
234+
235+
/**
236+
* The nested-payload target VO a {@code field} contributes to the closure, or
237+
* {@code null} when it contributes no nested record. Mirrors the resolution in
238+
* {@link #resolveObjectFieldType} (plain {@code field.object @objectRef}) and
239+
* {@link #resolveCollectionType} ({@code origin.collection @via}) EXACTLY, so the
240+
* closure walk and the emission walk agree on the target set. Passthrough /
241+
* aggregate origins yield scalar types (no nested record).
242+
*/
243+
protected MetaObject nestedTargetOf(MetaField<?> field, MetaDataLoader loader) {
244+
MetaOrigin origin = firstOriginChild(field);
245+
if (origin instanceof CollectionOrigin co) {
246+
String via = co.getVia();
247+
if (via == null) return null;
248+
String[] split = splitDottedRef(via);
249+
if (split == null) return null;
250+
MetaObject parent = resolveObjectByShortOrFqn(loader, split[0]);
251+
if (parent == null) return null;
252+
for (MetaData child : parent.getChildren()) {
253+
if (!(child instanceof MetaRelationship rel)) continue;
254+
if (rel.getName().equals(split[1]) || shortName(rel.getName()).equals(split[1])) {
255+
String targetRef = rel.getObjectRef();
256+
if (targetRef == null) return null;
257+
return resolveObjectByShortOrFqn(loader, targetRef);
258+
}
259+
}
260+
return null;
261+
}
262+
if (origin != null) return null; // passthrough / aggregate -> scalar
263+
if (field instanceof ObjectField of) {
264+
MetaObject target = of.getObjectRef();
265+
if (target == null || !MetaObject.SUBTYPE_VALUE.equals(target.getSubType())) return null;
266+
return target;
267+
}
268+
return null;
269+
}
270+
271+
/**
272+
* ADR-0044 — PascalCase each dotted segment of {@code javaPkg} (already
273+
* {@code ::}->{@code .} converted by {@link SpringNaming#splitFqn}), concatenate,
274+
* append the bare {@code shortName} ({@code "acme.alpha"} + {@code "Note"} ->
275+
* {@code "AcmeAlphaNote"}). A root-level (empty-package) node keeps its bare name.
276+
*/
277+
protected static String packageQualifiedName(String javaPkg, String shortName) {
278+
if (javaPkg == null || javaPkg.isEmpty()) return shortName;
279+
StringBuilder sb = new StringBuilder();
280+
for (String seg : javaPkg.split("\\.")) {
281+
if (!seg.isEmpty()) sb.append(Character.toUpperCase(seg.charAt(0))).append(seg.substring(1));
129282
}
283+
return sb.append(shortName).toString();
130284
}
131285

132286
/**
@@ -145,7 +299,7 @@ public static boolean appliesTo(MetaData node, MetaDataLoader loader) {
145299
}
146300

147301
protected void emit(MetaTemplate template, MetaDataLoader loader, Path outRoot,
148-
Set<String> emittedNestedFqns) {
302+
Set<String> emittedNestedFqns, Map<String, String> nameMap) {
149303
if (!appliesTo(template, loader)) {
150304
return; // missing @payloadRef, or not a VO — same contract as Kotlin / C# / Python
151305
}
@@ -165,7 +319,8 @@ protected void emit(MetaTemplate template, MetaDataLoader loader, Path outRoot,
165319
payloadVo,
166320
loader,
167321
outRoot,
168-
emittedNestedFqns);
322+
emittedNestedFqns,
323+
nameMap);
169324
}
170325

171326
/**
@@ -181,7 +336,8 @@ protected void emitPayloadRecord(String outPkg,
181336
MetaObject voObject,
182337
MetaDataLoader loader,
183338
Path outRoot,
184-
Set<String> emittedNestedFqns) {
339+
Set<String> emittedNestedFqns,
340+
Map<String, String> nameMap) {
185341
StringBuilder src = new StringBuilder();
186342
src.append("package ").append(outPkg).append(";\n\n");
187343
src.append("/** ").append(banner).append(" */\n");
@@ -208,7 +364,7 @@ protected void emitPayloadRecord(String outPkg,
208364
Iterator<MetaField> it = voObject.getMetaFields().iterator();
209365
while (it.hasNext()) {
210366
MetaField field = it.next();
211-
String type = resolveFieldType(field, voObject, loader, outPkg, outRoot, emittedNestedFqns);
367+
String type = resolveFieldType(field, voObject, loader, outPkg, outRoot, emittedNestedFqns, nameMap);
212368
src.append(" ").append(type).append(' ').append(field.getName());
213369
if (it.hasNext()) src.append(',');
214370
src.append('\n');
@@ -275,7 +431,8 @@ public String resolveFieldType(MetaField<?> field,
275431
MetaDataLoader loader,
276432
String nestedPkg,
277433
Path outRoot,
278-
Set<String> emittedNestedFqns) {
434+
Set<String> emittedNestedFqns,
435+
Map<String, String> nameMap) {
279436
MetaOrigin origin = firstOriginChild(field);
280437
if (origin instanceof PassthroughOrigin pt) {
281438
return resolvePassthroughType(pt, loader, field);
@@ -284,7 +441,7 @@ public String resolveFieldType(MetaField<?> field,
284441
return resolveAggregateType(ag, loader, field);
285442
}
286443
if (origin instanceof CollectionOrigin co) {
287-
return resolveCollectionType(co, loader, nestedPkg, outRoot, emittedNestedFqns, field);
444+
return resolveCollectionType(co, loader, nestedPkg, outRoot, emittedNestedFqns, field, nameMap);
288445
}
289446
// field.enum (scalar or array): type the STRICT payload component as the generated
290447
// Java enum nested in this record. The enum name is unqualified here because the record
@@ -296,7 +453,7 @@ public String resolveFieldType(MetaField<?> field,
296453
return SpringTypeMapper.payloadJavaTypeName(field, owner, "");
297454
}
298455
if (field instanceof ObjectField of) {
299-
return resolveObjectFieldType(of, loader, nestedPkg, outRoot, emittedNestedFqns);
456+
return resolveObjectFieldType(of, loader, nestedPkg, outRoot, emittedNestedFqns, nameMap);
300457
}
301458
return SpringTypeMapper.javaTypeName(field);
302459
}
@@ -312,7 +469,8 @@ protected String resolveObjectFieldType(ObjectField field,
312469
MetaDataLoader loader,
313470
String nestedPkg,
314471
Path outRoot,
315-
Set<String> emittedNestedFqns) {
472+
Set<String> emittedNestedFqns,
473+
Map<String, String> nameMap) {
316474
MetaObject target = field.getObjectRef();
317475
if (target == null) return SpringTypeMapper.javaTypeName(field);
318476
if (!MetaObject.SUBTYPE_VALUE.equals(target.getSubType())) {
@@ -322,7 +480,7 @@ protected String resolveObjectFieldType(ObjectField field,
322480
return SpringTypeMapper.javaTypeName(field);
323481
}
324482
// ADR-0039: resolving array-ness (isArray() is the own-only native flag).
325-
return emitNestedAndReturnType(target, loader, nestedPkg, outRoot, emittedNestedFqns, field.isArrayType());
483+
return emitNestedAndReturnType(target, loader, nestedPkg, outRoot, emittedNestedFqns, field.isArrayType(), nameMap);
326484
}
327485

328486
/**
@@ -379,7 +537,8 @@ protected String resolveCollectionType(CollectionOrigin origin,
379537
String nestedPkg,
380538
Path outRoot,
381539
Set<String> emittedNestedFqns,
382-
MetaField<?> fallbackField) {
540+
MetaField<?> fallbackField,
541+
Map<String, String> nameMap) {
383542
String via = origin.getVia();
384543
if (via == null) return SpringTypeMapper.javaTypeName(fallbackField);
385544
String[] split = splitDottedRef(via);
@@ -406,7 +565,7 @@ protected String resolveCollectionType(CollectionOrigin origin,
406565

407566
MetaObject target = resolveObjectByShortOrFqn(loader, targetRef);
408567
if (target == null) return SpringTypeMapper.javaTypeName(fallbackField);
409-
return emitNestedAndReturnType(target, loader, nestedPkg, outRoot, emittedNestedFqns, true);
568+
return emitNestedAndReturnType(target, loader, nestedPkg, outRoot, emittedNestedFqns, true, nameMap);
410569
}
411570

412571
/**
@@ -424,8 +583,14 @@ protected String emitNestedAndReturnType(MetaObject target,
424583
String nestedPkg,
425584
Path outRoot,
426585
Set<String> emittedNestedFqns,
427-
boolean asList) {
428-
String nestedRecord = SpringNaming.payloadName(SpringNaming.splitFqn(target.getName())[1]);
586+
boolean asList,
587+
Map<String, String> nameMap) {
588+
// ADR-0044 — the record name (declaration, file name, and this reference)
589+
// comes from the collision-scoped name map (bare when unique in the output
590+
// package, package-qualified on a cross-package short-name collision). The
591+
// bare fallback guards callers that emit without a precomputed map (api-docs).
592+
String nestedRecord = nameMap.getOrDefault(
593+
target.getName(), SpringNaming.payloadName(SpringNaming.splitFqn(target.getName())[1]));
429594
if (emittedNestedFqns.add(target.getName())) {
430595
emitPayloadRecord(
431596
nestedPkg,
@@ -435,7 +600,8 @@ protected String emitNestedAndReturnType(MetaObject target,
435600
target,
436601
loader,
437602
outRoot,
438-
emittedNestedFqns);
603+
emittedNestedFqns,
604+
nameMap);
439605
}
440606
return asList ? "java.util.List<" + nestedRecord + ">" : nestedRecord;
441607
}

0 commit comments

Comments
 (0)