diff --git a/CHANGELOG.md b/CHANGELOG.md index 72a89c3b4..3a4d98db6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/server/csharp/MetaObjects.Codegen.Tests/PayloadGeneratorTests.cs b/server/csharp/MetaObjects.Codegen.Tests/PayloadGeneratorTests.cs new file mode 100644 index 000000000..c731b88ec --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/PayloadGeneratorTests.cs @@ -0,0 +1,102 @@ +using MetaObjects.Codegen; +using MetaObjects.Codegen.Generators; +using MetaObjects.Loader; +using MetaObjects.Meta; +using Xunit; + +namespace MetaObjects.Codegen.Tests; + +/// +/// 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. +/// +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); + } +} diff --git a/server/csharp/MetaObjects.Codegen/Generators/PayloadGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/PayloadGenerator.cs index 37a4818a3..3d2307366 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/PayloadGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/PayloadGenerator.cs @@ -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()); } } diff --git a/server/csharp/MetaObjects.Codegen/PayloadCodegen.cs b/server/csharp/MetaObjects.Codegen/PayloadCodegen.cs index b8d0f56ec..c521106ac 100644 --- a/server/csharp/MetaObjects.Codegen/PayloadCodegen.cs +++ b/server/csharp/MetaObjects.Codegen/PayloadCodegen.cs @@ -128,6 +128,14 @@ private static List CollectEnumDecls(MetaData vo) private static void EmitRecord(MetaData root, string voName, HashSet emitted, List 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;