Skip to content

Commit 06f65a8

Browse files
committed
merge: C# payload-VO codegen + verify + two-failure demo [FR-004 Plan #3]
Completes the C# fourth pillar (render -> codegen -> verify): payload-VO + render-handle codegen, template-side verify drift check, and the Roslyn-backed two-failure demo (wrong-shaped caller fails to compile; drifted template variable caught by verify).
2 parents eac195d + 5c0aab7 commit 06f65a8

9 files changed

Lines changed: 776 additions & 0 deletions

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
using Microsoft.CodeAnalysis;
2+
using Microsoft.CodeAnalysis.CSharp;
3+
using MetaObjects;
4+
using MetaObjects.Codegen;
5+
using MetaObjects.Loader;
6+
using MetaObjects.Meta;
7+
using MetaObjects.Render;
8+
using Xunit;
9+
10+
namespace MetaObjects.Codegen.Tests;
11+
12+
/// <summary>
13+
/// THE DEMO (FR-004 Plan #3, T7) — the acceptance criterion for the fourth pillar.
14+
/// Proves BOTH enforcement mechanisms close the loop for the C# port:
15+
/// (a) compile-time: a payload VO codegen'd from the projection metadata makes a
16+
/// wrong-shaped caller fail to COMPILE (verified here with Roslyn).
17+
/// (b) build-time: verify() parses the opaque template text and catches a
18+
/// variable the payload doesn't declare ("a renamed field broke a prompt").
19+
/// </summary>
20+
public class DemoTests
21+
{
22+
private const string Model = """
23+
{
24+
"metadata.root": {
25+
"package": "acme::ai",
26+
"children": [
27+
{ "object.value": { "name": "PostBrief", "children": [
28+
{ "field.string": { "name": "title" } }
29+
]}},
30+
{ "object.value": { "name": "AuthorBrief", "children": [
31+
{ "field.string": { "name": "displayName" } },
32+
{ "field.int": { "name": "postCount" } },
33+
{ "field.object": { "name": "posts", "@isArray": true, "@objectRef": "PostBrief",
34+
"children": [ { "origin.collection": { "@via": "Author.posts" } } ] } }
35+
]}},
36+
{ "template.prompt": { "name": "contentStrategyPrompt",
37+
"@payloadRef": "AuthorBrief", "@textRef": "prompt/strategy", "@format": "xml" } }
38+
]
39+
}
40+
}
41+
""";
42+
43+
private static MetaRoot Load()
44+
{
45+
var result = new MetaDataLoader().Load([new InMemorySource(Model, id: "demo.json")]);
46+
Assert.Empty(result.Errors);
47+
return result.Root;
48+
}
49+
50+
// Compile a source string against the framework + MetaObjects.Render; return error diagnostics.
51+
private static IReadOnlyList<string> CompileErrors(string source)
52+
{
53+
var tree = CSharpSyntaxTree.ParseText(source, new CSharpParseOptions(LanguageVersion.CSharp12));
54+
var refs = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!)
55+
.Split(Path.PathSeparator)
56+
.Where(p => p.Length > 0)
57+
.Select(p => (MetadataReference)MetadataReference.CreateFromFile(p))
58+
.ToList();
59+
refs.Add(MetadataReference.CreateFromFile(typeof(Renderer).Assembly.Location));
60+
61+
var compilation = CSharpCompilation.Create(
62+
"demo_" + Guid.NewGuid().ToString("N"),
63+
[tree], refs,
64+
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
65+
66+
return compilation.GetDiagnostics()
67+
.Where(d => d.Severity == DiagnosticSeverity.Error)
68+
.Select(d => $"{d.Id}: {d.GetMessage()}")
69+
.ToList();
70+
}
71+
72+
private static string GeneratedSource(MetaRoot root)
73+
{
74+
var records = PayloadCodegen.GeneratePayloadRecords(root, "AuthorBrief");
75+
// Hoist the handle's `using` to the top so it can sit after the record decls.
76+
var handle = PayloadCodegen.GenerateRenderHandle(root, "contentStrategyPrompt")
77+
.Replace("using MetaObjects.Render;", "").TrimStart();
78+
return "using System.Collections.Generic;\nusing MetaObjects.Render;\n\n"
79+
+ records + "\n" + handle + "\n";
80+
}
81+
82+
[Fact]
83+
public void Compile_time__a_correctly_shaped_caller_compiles()
84+
{
85+
var source = GeneratedSource(Load()) + """
86+
public static class GoodCaller
87+
{
88+
public static string Go(IProvider p)
89+
{
90+
var good = new AuthorBrief { displayName = "Ada", postCount = 1, posts = new List<PostBrief>() };
91+
return RenderHandles.RenderContentStrategyPrompt(good, p);
92+
}
93+
}
94+
""";
95+
var errors = CompileErrors(source);
96+
Assert.True(errors.Count == 0, "expected the generated payload + a correct caller to compile, got: "
97+
+ string.Join("; ", errors));
98+
}
99+
100+
[Fact]
101+
public void Compile_time__a_wrong_shaped_caller_fails_to_compile()
102+
{
103+
// The caller omits required members (postCount, posts) — the codegen'd shape
104+
// contract makes this a compile error, not a silent runtime mismatch.
105+
var source = GeneratedSource(Load()) + """
106+
public static class BadCaller
107+
{
108+
public static string Go(IProvider p)
109+
{
110+
var bad = new AuthorBrief { displayName = "Ada" };
111+
return RenderHandles.RenderContentStrategyPrompt(bad, p);
112+
}
113+
}
114+
""";
115+
var errors = CompileErrors(source);
116+
Assert.True(errors.Count > 0, "expected a wrong-shaped caller to FAIL compilation, but it compiled clean");
117+
}
118+
119+
[Fact]
120+
public void Build_time__verify_catches_a_drifted_template_variable()
121+
{
122+
var fields = PayloadCodegen.BuildPayloadFieldTree(Load(), "AuthorBrief");
123+
var drift = Verify.Check("Hi {{displayName}}, you have {{notARealField}} posts.", fields);
124+
Assert.Contains(Verify.ERR_VAR_NOT_ON_PAYLOAD, drift.Select(e => e.Code));
125+
}
126+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<TargetFramework>net8.0</TargetFramework>
4+
<ImplicitUsings>enable</ImplicitUsings>
5+
<Nullable>enable</Nullable>
6+
<IsPackable>false</IsPackable>
7+
</PropertyGroup>
8+
<ItemGroup>
9+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
10+
<PackageReference Include="xunit" Version="2.9.2" />
11+
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
12+
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.11.0" />
13+
</ItemGroup>
14+
<ItemGroup>
15+
<ProjectReference Include="../MetaObjects/MetaObjects.csproj" />
16+
<ProjectReference Include="../MetaObjects.Codegen/MetaObjects.Codegen.csproj" />
17+
<ProjectReference Include="../MetaObjects.Render/MetaObjects.Render.csproj" />
18+
</ItemGroup>
19+
</Project>
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
using MetaObjects;
2+
using MetaObjects.Codegen;
3+
using MetaObjects.Loader;
4+
using MetaObjects.Meta;
5+
using Xunit;
6+
7+
namespace MetaObjects.Codegen.Tests;
8+
9+
/// <summary>
10+
/// Payload-VO + render-handle codegen tests. Mirrors
11+
/// typescript/packages/codegen-ts/test/payload-codegen.test.ts (asserts the
12+
/// emitted C# source; compilation + render of the output is the Phase 3 demo).
13+
/// </summary>
14+
public class PayloadCodegenTests
15+
{
16+
// PostBrief; AuthorBrief { displayName, postCount, posts: PostBrief[] via collection };
17+
// contentStrategyPrompt template over AuthorBrief.
18+
private const string Model = """
19+
{
20+
"metadata.root": {
21+
"package": "acme::ai",
22+
"children": [
23+
{ "object.value": { "name": "PostBrief", "children": [
24+
{ "field.string": { "name": "title" } }
25+
]}},
26+
{ "object.value": { "name": "AuthorBrief", "children": [
27+
{ "field.string": { "name": "displayName" } },
28+
{ "field.int": { "name": "postCount" } },
29+
{ "field.object": { "name": "posts", "@isArray": true, "@objectRef": "PostBrief",
30+
"children": [ { "origin.collection": { "@via": "Author.posts" } } ] } }
31+
]}},
32+
{ "template.prompt": { "name": "contentStrategyPrompt",
33+
"@payloadRef": "AuthorBrief", "@textRef": "prompt/strategy", "@format": "xml" } }
34+
]
35+
}
36+
}
37+
""";
38+
39+
private static MetaRoot Load()
40+
{
41+
var result = new MetaDataLoader().Load([new InMemorySource(Model, id: "codegen.json")]);
42+
Assert.Empty(result.Errors);
43+
return result.Root;
44+
}
45+
46+
[Fact]
47+
public void Emits_payload_record_with_scalar_and_nested_array_fields()
48+
{
49+
var src = PayloadCodegen.GeneratePayloadRecords(Load(), "AuthorBrief");
50+
Assert.Contains("public sealed record AuthorBrief", src);
51+
Assert.Contains("public required string displayName { get; init; }", src);
52+
Assert.Contains("public required int postCount { get; init; }", src);
53+
Assert.Contains("public required IReadOnlyList<PostBrief> posts { get; init; }", src);
54+
Assert.Contains("public sealed record PostBrief", src);
55+
Assert.Contains("public required string title { get; init; }", src);
56+
}
57+
58+
[Fact]
59+
public void Emits_render_handle_binding_textRef_and_format()
60+
{
61+
var src = PayloadCodegen.GenerateRenderHandle(Load(), "contentStrategyPrompt");
62+
Assert.Contains("public static string RenderContentStrategyPrompt(AuthorBrief payload, IProvider provider)", src);
63+
Assert.Contains("Ref = \"prompt/strategy\"", src);
64+
Assert.Contains("Format = \"xml\"", src);
65+
Assert.Contains("using MetaObjects.Render;", src);
66+
}
67+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<TargetFramework>net8.0</TargetFramework>
4+
<ImplicitUsings>enable</ImplicitUsings>
5+
<Nullable>enable</Nullable>
6+
<LangVersion>12</LangVersion>
7+
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
8+
<RootNamespace>MetaObjects.Codegen</RootNamespace>
9+
</PropertyGroup>
10+
<ItemGroup>
11+
<ProjectReference Include="../MetaObjects/MetaObjects.csproj" />
12+
<ProjectReference Include="../MetaObjects.Render/MetaObjects.Render.csproj" />
13+
</ItemGroup>
14+
</Project>
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// Payload-type + render-handle codegen for prompt construction (FR-004 Plan #3, B).
2+
//
3+
// Emits the TYPED PAYLOAD as an idiomatic C# record (no runtime ValueObject; the
4+
// render engine consumes the record's properties, and the record type gives the
5+
// caller-side compile-time guarantee) plus a typed render handle. Property names
6+
// are kept as the exact metadata field names so the render engine resolves
7+
// `{{field}}` against the record.
8+
//
9+
// Ported from typescript/packages/codegen-ts/src/payload-codegen.ts. The
10+
// assembler (RDB materialization + host overlay) is out of scope — this only
11+
// emits the contract.
12+
13+
using System.Text;
14+
using MetaObjects.Meta;
15+
using MetaObjects.Render;
16+
using static MetaObjects.Shared.BaseTypes;
17+
using static MetaObjects.Shared.Structural;
18+
using static MetaObjects.Core.Field.FieldConstants;
19+
using static MetaObjects.Template.TemplateConstants;
20+
21+
namespace MetaObjects.Codegen;
22+
23+
/// <summary>Emits typed payload records + render handles from view-object / template metadata.</summary>
24+
public static class PayloadCodegen
25+
{
26+
// Field subtype -> idiomatic C# scalar type. Mirrors the TS SCALAR map
27+
// (number split into int/long/double; dates are ISO strings on the wire).
28+
private static readonly IReadOnlyDictionary<string, string> ScalarType =
29+
new Dictionary<string, string>(StringComparer.Ordinal)
30+
{
31+
[FIELD_SUBTYPE_STRING] = "string",
32+
[FIELD_SUBTYPE_CLASS] = "string",
33+
[FIELD_SUBTYPE_INT] = "int",
34+
[FIELD_SUBTYPE_SHORT] = "int",
35+
[FIELD_SUBTYPE_BYTE] = "int",
36+
[FIELD_SUBTYPE_LONG] = "long",
37+
[FIELD_SUBTYPE_CURRENCY] = "long",
38+
[FIELD_SUBTYPE_DOUBLE] = "double",
39+
[FIELD_SUBTYPE_FLOAT] = "double",
40+
[FIELD_SUBTYPE_DECIMAL] = "double",
41+
[FIELD_SUBTYPE_BOOLEAN] = "bool",
42+
[FIELD_SUBTYPE_DATE] = "string",
43+
[FIELD_SUBTYPE_TIME] = "string",
44+
[FIELD_SUBTYPE_TIMESTAMP] = "string",
45+
};
46+
47+
private static MetaData? FindObject(MetaData root, string name) =>
48+
root.OwnChildren().FirstOrDefault(c => c.Type == TYPE_OBJECT && c.Name == name);
49+
50+
private static bool IsArrayField(MetaData field) =>
51+
field.OwnAttr(RESERVED_KEY_IS_ARRAY) is true || field.IsArray;
52+
53+
private static (string Type, string? RefVo) FieldType(MetaData field)
54+
{
55+
if (field.SubType == FIELD_SUBTYPE_OBJECT)
56+
{
57+
var refAttr = field.OwnAttr(FIELD_ATTR_OBJECT_REF);
58+
string refName = refAttr is string s ? s : "object";
59+
string? refVo = refAttr is string r ? r : null;
60+
return (IsArrayField(field) ? $"IReadOnlyList<{refName}>" : refName, refVo);
61+
}
62+
return (ScalarType.GetValueOrDefault(field.SubType, "object"), null);
63+
}
64+
65+
private static void EmitRecord(MetaData root, string voName, HashSet<string> emitted, List<string> output)
66+
{
67+
if (!emitted.Add(voName)) return;
68+
var vo = FindObject(root, voName);
69+
if (vo is null) return;
70+
71+
var lines = new List<string> { $"public sealed record {voName}", "{" };
72+
var refs = new List<string>();
73+
// Use Children() (effective) so inherited projection fields are included.
74+
foreach (var f in vo.Children().Where(c => c.Type == TYPE_FIELD))
75+
{
76+
var (type, refVo) = FieldType(f);
77+
lines.Add($" public required {type} {f.Name} {{ get; init; }}");
78+
if (refVo is not null) refs.Add(refVo);
79+
}
80+
lines.Add("}");
81+
output.Add(string.Join("\n", lines));
82+
83+
foreach (var r in refs) EmitRecord(root, r, emitted, output);
84+
}
85+
86+
/// <summary>
87+
/// Emit the payload record (+ nested element records) for an object.value view-object.
88+
/// </summary>
89+
public static string GeneratePayloadRecords(MetaData root, string voName)
90+
{
91+
var output = new List<string>();
92+
EmitRecord(root, voName, new HashSet<string>(StringComparer.Ordinal), output);
93+
return string.Join("\n\n", output) + "\n";
94+
}
95+
96+
/// <summary>
97+
/// Derive the verify field tree (the input to <c>Verify.Check</c>) from an
98+
/// object.value view-object: scalars become leaves, object-ref fields recurse
99+
/// into nested element trees. This is the metadata→verify bridge a `meta verify`
100+
/// command uses to drift-check a template against its @payloadRef.
101+
/// </summary>
102+
public static IReadOnlyList<PayloadField> BuildPayloadFieldTree(MetaData root, string voName) =>
103+
BuildTree(root, voName, new HashSet<string>(StringComparer.Ordinal));
104+
105+
private static IReadOnlyList<PayloadField> BuildTree(MetaData root, string voName, HashSet<string> visiting)
106+
{
107+
var vo = FindObject(root, voName);
108+
if (vo is null || !visiting.Add(voName)) return [];
109+
var fields = new List<PayloadField>();
110+
foreach (var f in vo.Children().Where(c => c.Type == TYPE_FIELD))
111+
{
112+
if (f.SubType == FIELD_SUBTYPE_OBJECT && f.OwnAttr(FIELD_ATTR_OBJECT_REF) is string refName)
113+
fields.Add(new PayloadField(f.Name, BuildTree(root, refName, visiting)));
114+
else
115+
fields.Add(new PayloadField(f.Name));
116+
}
117+
visiting.Remove(voName);
118+
return fields;
119+
}
120+
121+
private static string Pascal(string s) =>
122+
s.Length > 0 ? char.ToUpperInvariant(s[0]) + s[1..] : s;
123+
124+
/// <summary>
125+
/// Emit a typed render handle binding a template's @textRef + @format and typing
126+
/// its payload to the @payloadRef record. The generated code's only MetaObjects
127+
/// dependency is MetaObjects.Render (framework philosophy: generated code is
128+
/// idiomatic and runtime-light).
129+
/// </summary>
130+
public static string GenerateRenderHandle(MetaData root, string templateName)
131+
{
132+
var tmpl = root.OwnChildren()
133+
.FirstOrDefault(c => c.Type == TYPE_TEMPLATE && c.Name == templateName)
134+
?? throw new ArgumentException($"template \"{templateName}\" not found", nameof(templateName));
135+
136+
var payloadRef = tmpl.OwnAttr(TEMPLATE_ATTR_PAYLOAD_REF) as string
137+
?? throw new InvalidOperationException($"template \"{templateName}\" has no @payloadRef");
138+
var textRef = tmpl.OwnAttr(TEMPLATE_ATTR_TEXT_REF) as string ?? "";
139+
var format = tmpl.OwnAttr(TEMPLATE_ATTR_FORMAT) as string ?? "text";
140+
var fn = $"Render{Pascal(templateName)}";
141+
142+
var sb = new StringBuilder();
143+
sb.AppendLine("using MetaObjects.Render;");
144+
sb.AppendLine();
145+
sb.AppendLine("public static class RenderHandles");
146+
sb.AppendLine("{");
147+
sb.AppendLine($" public static string {fn}({payloadRef} payload, IProvider provider) =>");
148+
sb.AppendLine(" Renderer.Render(new RenderRequest");
149+
sb.AppendLine(" {");
150+
sb.AppendLine($" Ref = {Quote(textRef)},");
151+
sb.AppendLine(" Payload = payload,");
152+
sb.AppendLine($" Format = {Quote(format)},");
153+
sb.AppendLine(" Provider = provider,");
154+
sb.AppendLine(" });");
155+
sb.AppendLine("}");
156+
return sb.ToString();
157+
}
158+
159+
private static string Quote(string s) => "\"" + s.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
160+
}

0 commit comments

Comments
 (0)