Skip to content
Open
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

## [Unreleased]

### Fixed

- **C#** — `PayloadGenerator`/`PayloadCodegen.EmitRecord` failed to emit a payload record at
all (silently produced a header-only file) for any `template.output` in a **named package**
whose `@payloadRef` was authored bare. `MetaData.Attr()` resolves a bare ref to its
fully-qualified form per ADR-0042, but `PayloadCodegen.FindObject`'s bare short-name match
never accounted for that — the top-level `voName` needed the same `CSharpNaming.StripPkg`
treatment `FieldType` already applies to nested `@objectRef` fields. Adopters outside the
default/root package hit this on every `template.output`.

## [0.17.0] — 2026-07-18

Coordinated additive **minor** across all four registries: **npm `0.17.0`** · **PyPI `0.17.0`** · **NuGet `0.17.0`** · **Maven Central `7.9.0`** (Java/Kotlin). Bundles the accumulated projection/view + read-model + prompt work below, plus a full documentation + agent-context skills refresh (the seven `meta init` skills were accuracy-passed and Fable-reviewed, closing a class of stale-vocabulary and calibration defects; the runtime-ui skill gained its missing Python + C# language references). No breaking changes.
Expand Down
102 changes: 102 additions & 0 deletions server/csharp/MetaObjects.Codegen.Tests/PayloadGeneratorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
using MetaObjects.Codegen;
using MetaObjects.Codegen.Generators;
using MetaObjects.Loader;
using MetaObjects.Meta;
using Xunit;

namespace MetaObjects.Codegen.Tests;

/// <summary>
/// PayloadGenerator emission tests. Mirrors OutputParserGeneratorTests' shape (file-per-
/// template.output, GenContext wiring) plus the regression this generator specifically needed:
/// a template.output declared in a NAMED package with a BARE @payloadRef. ADR-0042 resolves
/// that bare attr to its fully-qualified form (MetaData.Attr() returns "pkg::Name", not the
/// literal string the author typed) — PayloadCodegen.EmitRecord's FindObject only matched a
/// bare short name, so the top-level payload record (and its file) silently emitted empty.
/// Nested @objectRef fields never hit this because FieldType already stripped them; only the
/// generator's own top-level payloadRef was unstripped.
/// </summary>
public sealed class PayloadGeneratorTests
{
private static MetaRoot Load(string model)
{
var r = new MetaDataLoader().Load([new InMemoryStringSource(model, id: "payload-gen.json")]);
Assert.Empty(r.Errors);
return r.Root;
}

private static GenContext Ctx(MetaRoot root) => new()
{
Entities = root.Objects(),
Root = root,
Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Generated" },
};

[Fact]
public void Emits_no_files_when_no_template_output_nodes()
{
const string m = """
{ "metadata.root": { "package": "acme::ai", "children": [
{ "object.value": { "name": "Payload", "children": [ { "field.string": { "name": "x" } } ] } },
{ "template.prompt": { "name": "promptOnly", "@payloadRef": "Payload", "@textRef": "p/x", "@format": "text" } }
]}}
""";
var files = new PayloadGenerator().Generate(Ctx(Load(m))).ToList();
Assert.Empty(files);
}

// Regression: a template.output in a NAMED package (not the default/root package) with a
// BARE @payloadRef. Before the fix, this emitted a file with a header comment and NOTHING
// else — no record, because FindObject's bare-name match failed against the FQN attr value.
[Fact]
public void Emits_full_record_for_bare_payloadRef_in_a_named_package()
{
const string m = """
{ "metadata.root": { "package": "acme::intake", "children": [
{ "object.value": { "name": "NoteEntry", "children": [
{ "field.string": { "name": "value" } },
{ "field.string": { "name": "reasoning" } }
]}},
{ "object.value": { "name": "ClassificationResponse", "children": [
{ "field.string": { "name": "documentType" } },
{ "field.decimal": { "name": "confidence" } },
{ "field.object": { "name": "note", "@objectRef": "NoteEntry" } }
]}},
{ "template.output": { "name": "ClassificationResponseTemplate",
"@payloadRef": "ClassificationResponse", "@textRef": "ai/classification-response", "@format": "text" } }
]}}
""";
var files = new PayloadGenerator().Generate(Ctx(Load(m))).ToList();

var file = Assert.Single(files);
Assert.Equal("ClassificationResponse.payload.cs", file.Path);
Assert.Contains("public sealed record ClassificationResponse", file.Content);
Assert.Contains("public required string documentType { get; init; }", file.Content);
Assert.Contains("public required double confidence { get; init; }", file.Content);
Assert.Contains("public required NoteEntry note { get; init; }", file.Content);
Assert.Contains("public sealed record NoteEntry", file.Content);
Assert.Contains("public required string value { get; init; }", file.Content);
// The FQN must not leak into the file name or the record/type names.
Assert.DoesNotContain("acme::intake::", file.Content);
Assert.DoesNotContain("::", file.Path);
}

[Fact]
public void Emits_one_file_per_template_output_with_expected_path_and_class()
{
const string m = """
{ "metadata.root": { "package": "acme::ai", "children": [
{ "object.value": { "name": "AlphaPayload", "children": [ { "field.string": { "name": "name" } } ] } },
{ "object.value": { "name": "BetaPayload", "children": [ { "field.int": { "name": "n" } } ] } },
{ "template.output": { "name": "Alpha", "@payloadRef": "AlphaPayload", "@textRef": "a/x", "@format": "json" } },
{ "template.output": { "name": "Beta", "@payloadRef": "BetaPayload", "@textRef": "b/x", "@format": "json" } }
]}}
""";
var files = new PayloadGenerator().Generate(Ctx(Load(m))).OrderBy(f => f.Path).ToList();
Assert.Equal(2, files.Count);
Assert.Equal("AlphaPayload.payload.cs", files[0].Path);
Assert.Equal("BetaPayload.payload.cs", files[1].Path);
Assert.Contains("public sealed record AlphaPayload", files[0].Content);
Assert.Contains("public sealed record BetaPayload", files[1].Content);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ protected virtual EmittedFile EmitPayload(string payloadRef, GenContext ctx)
sb.AppendLine($"namespace {ctx.Config.Namespace};");
sb.AppendLine();
sb.Append(records);
return new EmittedFile($"{payloadRef}.payload.cs", sb.ToString());
// payloadRef may be the FQN form (ADR-0042 attr resolution) — the file name is the
// bare record name, matching PayloadCodegen.EmitRecord's own stripping.
return new EmittedFile($"{CSharpNaming.StripPkg(payloadRef)}.payload.cs", sb.ToString());
}
}
8 changes: 8 additions & 0 deletions server/csharp/MetaObjects.Codegen/PayloadCodegen.cs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,14 @@ private static List<string> CollectEnumDecls(MetaData vo)

private static void EmitRecord(MetaData root, string voName, HashSet<string> emitted, List<string> output)
{
// A @payloadRef may reach here fully-qualified — ADR-0042 resolves a bare template
// attr to the FQN form, and MetaData.Attr() returns that resolved (possibly
// package-qualified) value, not the literal string the author typed. FindObject and
// the emitted record's own type name both need the bare short name (a C# record
// identifier can't contain "::" and isn't package-scoped); nested @objectRef fields
// already arrive here pre-stripped via FieldType's CSharpNaming.StripPkg call, so
// stripping here is a no-op for them and the fix for the top-level payloadRef case.
voName = CSharpNaming.StripPkg(voName);
if (!emitted.Add(voName)) return;
var vo = FindObject(root, voName);
if (vo is null) return;
Expand Down
Loading