Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions fixtures/template-output-render-conformance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ resolves to `templates/emails/welcome.subject.mustache`, and so on.
- `nested/meta.json` — a **no-package** sub-corpus carrying the nested/array
email case (`Order` over `Customer` + `Item[]`); see "Nested + array email"
below. It shares the same `templates/` dir.
- `xpkg-collision/` — a **multi-package** sub-corpus (`meta.alpha.json` +
`meta.beta.json` + `meta.app.json`) gating **FQN-exact** nested `@objectRef`
resolution across a cross-package short-name collision (ADR-0041); see
"Cross-package short-name collision" below. It also shares `templates/`.
- `templates/emails/order.subject.mustache` = `Order for {{customer.name}}`
- `templates/emails/order.html.mustache` =
`<h1>{{customer.name}}</h1><ul>{{#items}}<li>{{sku}} x{{qty}}</li>{{/items}}</ul>{{> shared/footer}}`
Expand Down Expand Up @@ -74,8 +78,10 @@ the XML entity set (`<`→`&lt;`, `>`→`&gt;`, `&`→`&amp;`, `"`→`&quot;`, `
@objectRef, isArray) }`, plus an `email` template `OrderEmail`
(`@payloadRef=Order`) over the `order.*` part-refs. It has **no package** on
purpose: a bare `@objectRef` resolves identically across ports only when there is
no package (TS resolves an objectRef by short name; the JVM expands a *packaged*
ref to an FQN, so bare == FQN only at the root package). The shared
no package: a bare `@objectRef` matches the value-object's short name in every
port, so this sub-corpus isolates the nested/array/partial shape from any
package-resolution concern. (The *packaged*/fully-qualified `@objectRef` case is
gated separately by `xpkg-collision/` below, per ADR-0041.) The shared
`templates/emails/order.*` + `templates/shared/footer` mustaches are reused.

Rendered with `{ customer: { name: "Ada" }, items: [ { sku: "A1", qty: 2 },
Expand All @@ -95,6 +101,38 @@ section-context drift (a `{{bogus}}` not on the `Item` element type the section
pushes) FAILS codegen with `ERR_VAR_NOT_ON_PAYLOAD`, proving the gate walks the
nested/section context, not just the root.

## Cross-package short-name collision — `xpkg-collision/`, `DigestDoc`

A **multi-package** sub-corpus (loaded as three sources — `meta.alpha.json` +
`meta.beta.json` + `meta.app.json`, mirroring
`fixtures/conformance/loader-same-name-distinct-packages`) that gates **FQN-exact**
nested `@objectRef` resolution (ADR-0041):

- `acme::alpha` declares `object.value Note { alphaText: string }`.
- `acme::beta` declares `object.value Note { betaText: string }` — a **colliding
short name** in a different package.
- `acme::app` declares payload `object.value Digest` with two `field.object`
children referencing the two Notes by **fully-qualified** `@objectRef`
(`acme::alpha::Note`, `acme::beta::Note`), and a `document` `template.output`
`DigestDoc` (`@format=html`, `@textRef="xpkg/digest"`, `@payloadRef="Digest"`).
- `templates/xpkg/digest.mustache` = `Alpha={{fromAlpha.alphaText}} Beta={{fromBeta.betaText}}`

Each port must resolve a fully-qualified `@objectRef` **exactly** on the
package-qualified name — never a bare-tail fallback that binds whichever `Note`
loads first. A bare-tail resolver collapses BOTH refs to one package's `Note`, so
one of `{{fromAlpha.alphaText}}`/`{{fromBeta.betaText}}` lands on the wrong element
type and the build-time drift gate throws `ERR_VAR_NOT_ON_PAYLOAD`. FQN-exact
resolution binds each ref to its own package, so the clean template passes.

Rendered with `{ fromAlpha: { alphaText: "AA" }, fromBeta: { betaText: "BB" } }`:

- `renderDigestDoc(...)` = `"Alpha=AA Beta=BB"`

(The two colliding VOs share the BARE payload-record type name `Note`; each port's
runner hand-authors — or otherwise reconciles — the payload record for that reason.
The record-name collision is an orthogonal concern; this sub-corpus gates the
`@objectRef` **resolver**, not payload-record naming.)

## Expected build-time drift FAILURE — `drift/`

`drift/meta.json` declares the same `Welcome` VO and a `document`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Alpha={{fromAlpha.alphaText}} Beta={{fromBeta.betaText}}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"metadata.root": {
"package": "acme::alpha",
"children": [
{
"object.value": {
"name": "Note",
"children": [
{ "field.string": { "name": "alphaText", "@required": true } }
]
}
}
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"metadata.root": {
"package": "acme::app",
"children": [
{
"object.value": {
"name": "Digest",
"children": [
{ "field.object": { "name": "fromAlpha", "@objectRef": "acme::alpha::Note" } },
{ "field.object": { "name": "fromBeta", "@objectRef": "acme::beta::Note" } }
]
}
},
{
"template.output": {
"name": "DigestDoc",
"@kind": "document",
"@payloadRef": "Digest",
"@textRef": "xpkg/digest",
"@format": "html"
}
}
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"metadata.root": {
"package": "acme::beta",
"children": [
{
"object.value": {
"name": "Note",
"children": [
{ "field.string": { "name": "betaText", "@required": true } }
]
}
}
]
}
}
39 changes: 39 additions & 0 deletions server/csharp/MetaObjects.Codegen.Tests/PayloadCodegenTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,45 @@ public void Fully_qualified_objectRef_strips_to_bare_record_type_and_resolves_ne
Assert.DoesNotContain("acme::ai::", src);
}

// ADR-0041: cross-package short-name collision. Two object.value `Note`s
// (acme::alpha with alphaText, acme::beta with betaText) and a payload `Digest`
// referencing BOTH by FULLY-QUALIFIED @objectRef. The BuildPayloadFieldTree walk (the
// `dotnet meta verify` field-tree) must bind each ref to its OWN package — not
// bare-tail-collapse both to whichever Note loads first (pre-fix: both got alphaText).
// This is the CLI-verify half of the fix; the render-helper half is gated by
// RenderHelperConformanceTests.Document_DigestDoc_resolves_fqn_nested_objectRef_across_collision.
[Fact]
public void BuildPayloadFieldTree_resolves_fqn_nested_objectRef_across_package_collision()
{
const string alpha = """
{ "metadata.root": { "package": "acme::alpha", "children": [
{ "object.value": { "name": "Note", "children": [ { "field.string": { "name": "alphaText" } } ] } } ] } }
""";
const string beta = """
{ "metadata.root": { "package": "acme::beta", "children": [
{ "object.value": { "name": "Note", "children": [ { "field.string": { "name": "betaText" } } ] } } ] } }
""";
const string app = """
{ "metadata.root": { "package": "acme::app", "children": [
{ "object.value": { "name": "Digest", "children": [
{ "field.object": { "name": "fromAlpha", "@objectRef": "acme::alpha::Note" } },
{ "field.object": { "name": "fromBeta", "@objectRef": "acme::beta::Note" } } ] } } ] } }
""";
var root = new MetaDataLoader().Load([
new InMemoryStringSource(alpha, id: "a.json"),
new InMemoryStringSource(beta, id: "b.json"),
new InMemoryStringSource(app, id: "c.json"),
]).Root;

var tree = PayloadCodegen.BuildPayloadFieldTree(root, "Digest");

var fromAlpha = Assert.Single(tree, f => f.Name == "fromAlpha");
var fromBeta = Assert.Single(tree, f => f.Name == "fromBeta");
// FQN-exact: each ref binds to its OWN package's Note.
Assert.Equal("alphaText", Assert.Single(fromAlpha.Fields!).Name);
Assert.Equal("betaText", Assert.Single(fromBeta.Fields!).Name);
}

[Fact]
public void Emits_render_handle_binding_textRef_and_format()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,18 @@ private static MetaRoot LoadFromFile(string metaJsonPath)
return r.Root;
}

// Multi-file (multi-package) load — one InMemoryStringSource per file, merged into a
// single root (mirrors fixtures/conformance/loader-same-name-distinct-packages).
private static MetaRoot LoadFromFiles(params string[] metaJsonPaths)
{
var sources = metaJsonPaths
.Select((p, i) => (IMetaDataSource)new InMemoryStringSource(File.ReadAllText(p), id: $"corpus{i}.json"))
.ToArray();
var r = new MetaDataLoader().Load(sources);
Assert.Empty(r.Errors);
return r.Root;
}

private static GenContext Ctx(MetaRoot root) => new()
{
Entities = root.Objects(),
Expand Down Expand Up @@ -192,6 +204,53 @@ public void Email_OrderEmail_renders_nested_array_loop_and_partial()
Assert.Contains("<hr/>Sent by Acme", email.HtmlBody);
}

// ---------------------------------------------------------------------
// xpkg-collision/ — cross-package short-name collision (ADR-0041). Two packages
// each declare an object.value `Note` (alpha: alphaText, beta: betaText); the
// payload `Digest` references BOTH by FULLY-QUALIFIED @objectRef. A bare-tail
// resolver binds both refs to whichever Note loads first → one of
// {{fromAlpha.alphaText}}/{{fromBeta.betaText}} lands on the wrong element type and
// the build-time drift gate throws ERR_VAR_NOT_ON_PAYLOAD. FQN-exact resolution binds
// each ref to its own package. The two colliding VOs share the BARE record type name
// `Note`, so (like the TS payloads.ts) the payload record is hand-authored with both
// fields — the record-name collision is an orthogonal concern; this test gates the
// render-helper's FQN-exact @objectRef resolver.
// ---------------------------------------------------------------------

[Fact]
public void Document_DigestDoc_resolves_fqn_nested_objectRef_across_collision()
{
var corpus = Corpus();
var dir = Path.Combine(corpus, "xpkg-collision");
var root = LoadFromFiles(
Path.Combine(dir, "meta.alpha.json"),
Path.Combine(dir, "meta.beta.json"),
Path.Combine(dir, "meta.app.json"));
var templates = Path.Combine(corpus, "templates");

// Must NOT throw: the FQN refs resolve to their own package's Note.
var file = Assert.Single(new RenderHelperGenerator(templates).Generate(Ctx(root)), f => f.Path == "DigestDoc.render.cs");

var payloadSrc = "namespace Acme.Generated;\n"
+ "public sealed record Note { public string? alphaText { get; init; } public string? betaText { get; init; } }\n"
+ "public sealed record Digest { public Note fromAlpha { get; init; } public Note fromBeta { get; init; } }\n";
var asm = CompileToAssembly(file.Content, payloadSrc);

var noteType = asm.GetType("Acme.Generated.Note")!;
var digestType = asm.GetType("Acme.Generated.Digest")!;
var fromAlpha = Activator.CreateInstance(noteType)!;
noteType.GetProperty("alphaText")!.SetValue(fromAlpha, "AA");
var fromBeta = Activator.CreateInstance(noteType)!;
noteType.GetProperty("betaText")!.SetValue(fromBeta, "BB");
var digest = Activator.CreateInstance(digestType)!;
digestType.GetProperty("fromAlpha")!.SetValue(digest, fromAlpha);
digestType.GetProperty("fromBeta")!.SetValue(digest, fromBeta);

var helper = asm.GetType("Acme.Generated.DigestDocRenderHelper")!;
var outText = (string)helper.GetMethod("Render")!.Invoke(null, [digest, new FilesystemProvider(templates)])!;
Assert.Equal("Alpha=AA Beta=BB", outText);
}

// ---------------------------------------------------------------------
// drift/ → the generator THROWS ERR_VAR_NOT_ON_PAYLOAD (fails codegen).
// ---------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@
// Reuse, not reimplementation: Renderer.Render (the emitted runtime call), Verify
// (the build-time gate), FilesystemProvider (build-time ref resolution), and
// EmailDocument (email return type). The payload field tree is walked from the VO
// the same way the other generators walk it — but a nested field.object's
// @objectRef is resolved by BARE short-name (cross-port render-helper consensus:
// TS findObject / Java resolveNestedObjectRef), only recursing into object.value
// (SUBTYPE_VALUE) targets, cycle-guarded.
// the same way the other generators walk it — a nested field.object's @objectRef is
// resolved FQN-exact when fully-qualified (ADR-0041) else by short name (cross-port
// render-helper consensus: TS refMatchesObject / Java+Kotlin resolveNestedObjectRef),
// only recursing into object.value (SUBTYPE_VALUE) targets, cycle-guarded.
//
// The emitted <fieldTree> literal is baked into the RenderRequest.Verify argument
// so Renderer's runtime drift check matches the build-time gate that ran here.
Expand Down Expand Up @@ -231,16 +231,17 @@ private void GateRef(string templateName, string reference, IReadOnlyList<Payloa
}

// -------------------------------------------------------------------------
// Payload field-tree walk — nested @objectRef resolved by BARE short-name
// (cross-port render-helper consensus: TS findObject / Java
// resolveNestedObjectRef). Object-ref fields recurse into their target
// object.value (SUBTYPE_VALUE only); a `seen` set guards reference cycles.
// Payload field-tree walk — nested @objectRef resolved FQN-exact when
// fully-qualified (ADR-0041) else by short name (cross-port render-helper
// consensus: TS refMatchesObject / Java+Kotlin resolveNestedObjectRef). Object-ref
// fields recurse into their target object.value (SUBTYPE_VALUE only); a `seen` set
// guards reference cycles.
// -------------------------------------------------------------------------

private static IReadOnlyList<PayloadField> DerivePayloadFieldTree(
MetaRoot root, MetaData vo, HashSet<string> seen)
{
if (vo is null || !seen.Add(vo.Name)) return [];
if (vo is null || !seen.Add(vo.ResolutionKey())) return [];
var fields = new List<PayloadField>();
foreach (var f in vo.Children().Where(c => c.Type == TYPE_FIELD))
{
Expand All @@ -263,20 +264,24 @@ private static IReadOnlyList<PayloadField> DerivePayloadFieldTree(

/// <summary>
/// Resolve a <c>field.object</c>'s <c>@objectRef</c> to its target
/// <c>object.value</c> by BARE short-name — mirroring the TS render-helper's
/// <c>findObject(root, ref)</c> (<c>c.type === OBJECT &amp;&amp; c.name === ref</c>)
/// and the Java <c>resolveNestedObjectRef</c>. If the ref carries a package, only
/// the segment after the last <c>::</c> is compared, against each
/// <c>object.value</c>'s own short name. Returns null when no match is found.
/// <c>object.value</c>. ADR-0041: a FULLY-QUALIFIED ref (contains <c>::</c>) resolves
/// EXACTLY on the package-qualified name (<c>ResolutionKey()</c>/<c>Fqn()</c>) — never
/// a bare-tail fallback that would bind a same-named <c>object.value</c> in the WRONG
/// package on a cross-package short-name collision. A bare ref still matches by short
/// name (first-wins). Mirrors the Java/Kotlin <c>resolveNestedObjectRef</c> + TS
/// <c>refMatchesObject</c>. Returns null when no match is found.
/// </summary>
private static MetaData? ResolveNestedObjectRef(MetaRoot root, string reference)
{
if (string.IsNullOrEmpty(reference)) return null;
bool fqn = reference.Contains("::");
var refShort = CSharpNaming.StripPkg(reference);
// ADR-0039: Children() — resolving root scan (behavior-identical; root has no super).
return root.Children().FirstOrDefault(c =>
c.Type == TYPE_OBJECT && c.SubType == OBJECT_SUBTYPE_VALUE &&
CSharpNaming.StripPkg(c.Name) == refShort);
(fqn
? c.ResolutionKey() == reference || c.Fqn() == reference
: CSharpNaming.StripPkg(c.Name) == refShort));
}

/// <summary>
Expand Down
29 changes: 24 additions & 5 deletions server/csharp/MetaObjects.Codegen/PayloadCodegen.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,23 @@ public static class PayloadCodegen

private static MetaData? FindObject(MetaData root, string name) =>
// ADR-0039: Children() — resolving root scan (behavior-identical; root has no super).
// Record emission resolves the payload VO by BARE short name (a C# record identifier
// is always bare, and EmitRecord derives the record name from this same voName).
root.Children().FirstOrDefault(c => c.Type == TYPE_OBJECT && c.Name == name);

// ADR-0041: the verify field-tree resolver — a FULLY-QUALIFIED ref (contains ::) resolves
// EXACTLY on the package-qualified name (ResolutionKey()/Fqn()), never a bare-tail fallback
// that would bind a same-named object.value in the WRONG package on a cross-package
// short-name collision. A bare ref matches by short name (first-wins). Kept SEPARATE from
// FindObject so record emission (bare identifiers) is unaffected. Mirrors the render-helper
// ResolveNestedObjectRef + Java/Kotlin + TS refMatchesObject.
private static MetaData? ResolveObjectRef(MetaData root, string reference)
{
bool fqn = reference.Contains("::");
return root.Children().FirstOrDefault(c => c.Type == TYPE_OBJECT &&
(fqn ? c.ResolutionKey() == reference || c.Fqn() == reference : c.Name == reference));
}

// ADR-0039: resolve array-ness through the super chain (isArray is a native
// property, not an attr; the former OwnAttr("isArray") clause was dead code).
private static bool IsArrayField(MetaData field) => field.ResolvedIsArray();
Expand All @@ -55,9 +70,10 @@ private static (string Type, string? RefVo) FieldType(MetaData owner, MetaData f
{
// ADR-0039: resolving — @objectRef may be inherited via extends.
var refAttr = field.Attr(FIELD_ATTR_OBJECT_REF);
// @objectRef may be authored fully-qualified (acme::sales::Brief) or bare;
// the generated record type + the nested-record lookup key are the BARE
// short name (FindObject matches bare names — every other caller strips too).
// @objectRef may be authored fully-qualified (acme::sales::Brief) or bare; the
// generated record TYPE name is the BARE short name (StripPkg). (The verify
// field-tree path — BuildTree — resolves the FULL ref FQN-exact via ResolveObjectRef
// per ADR-0041; record emission here stays bare: one C# type per short name.)
string refName = refAttr is string s ? CSharpNaming.StripPkg(s) : "object";
string? refVo = refAttr is string r ? CSharpNaming.StripPkg(r) : null;
return (IsArrayField(field) ? $"IReadOnlyList<{refName}>" : refName, refVo);
Expand Down Expand Up @@ -155,14 +171,17 @@ public static IReadOnlyList<PayloadField> BuildPayloadFieldTree(MetaData root, s

private static IReadOnlyList<PayloadField> BuildTree(MetaData root, string voName, HashSet<string> visiting)
{
var vo = FindObject(root, voName);
// ADR-0041: FQN-exact resolution for the verify field-tree (NOT the bare FindObject
// used by record emission) so a fully-qualified nested @objectRef binds its own package.
var vo = ResolveObjectRef(root, voName);
if (vo is null || !visiting.Add(voName)) return [];
var fields = new List<PayloadField>();
foreach (var f in vo.Children().Where(c => c.Type == TYPE_FIELD))
{
// ADR-0039: resolving — @objectRef may be inherited via extends (TS reads f.attr).
// ADR-0041: pass the FULL (possibly FQN) ref — ResolveObjectRef resolves it exactly.
if (f.SubType == FIELD_SUBTYPE_OBJECT && f.Attr(FIELD_ATTR_OBJECT_REF) is string refName)
fields.Add(new PayloadField(f.Name, BuildTree(root, CSharpNaming.StripPkg(refName), visiting)));
fields.Add(new PayloadField(f.Name, BuildTree(root, refName, visiting)));
else
fields.Add(new PayloadField(f.Name));
}
Expand Down
Loading
Loading