Skip to content

Commit 7d3a1c7

Browse files
dmealingclaude
andcommitted
feat(csharp): outputParser generator — template.output parser-on-receipt codegen [FR6]
For every template.output declaration, the meta gen pipeline now emits a <TemplateName>.output.cs file declaring a static <TemplateName>Parser class with the .NET BCL Parse/TryParse dual API (ADR-0010): TName Parse(string text) // throws JsonException bool TryParse(string, out TName?, out string?) // Result-style The parser returns the payload-VO record already emitted by PayloadCodegen — it does not redeclare the payload shape. Validation is System.Text.Json deserialization plus the C# required keyword on the payload record (.NET 7+); no DataAnnotations layer, no extra deps. Wired into GenCommand.DefaultGenerators() alongside Entity/DbContext/Routes. Mirrors typescript/packages/codegen-ts/src/generators/output-parser-file.ts functionally, with BCL idioms instead of Zod. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ad5c2cc commit 7d3a1c7

3 files changed

Lines changed: 280 additions & 1 deletion

File tree

server/csharp/MetaObjects.Cli/GenCommand.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public sealed record Outcome(IReadOnlyList<string> LoadErrors, CodegenRunner.Run
2020

2121
/// <summary>The default generator set. Extended as more generators land.</summary>
2222
public static IReadOnlyList<IGenerator> DefaultGenerators() =>
23-
[new EntityGenerator(), new DbContextGenerator(), new RoutesGenerator()];
23+
[new EntityGenerator(), new DbContextGenerator(), new RoutesGenerator(), new OutputParserGenerator()];
2424

2525
public static Outcome Run(string metadataDir, string outDir, string ns)
2626
{
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
using Microsoft.CodeAnalysis;
2+
using Microsoft.CodeAnalysis.CSharp;
3+
using MetaObjects.Codegen;
4+
using MetaObjects.Codegen.Generators;
5+
using MetaObjects.Loader;
6+
using MetaObjects.Meta;
7+
using Xunit;
8+
9+
namespace MetaObjects.Codegen.Tests;
10+
11+
/// <summary>
12+
/// OutputParserGenerator emission tests (FR6, ADR-0010). Mirrors
13+
/// server/typescript/packages/codegen-ts/test/generators/output-parser-file.test.ts —
14+
/// asserts the file-per-template emit, the Parse / TryParse dual-API shape, and
15+
/// that the generator stays out of template.prompt's lane.
16+
/// </summary>
17+
public sealed class OutputParserGeneratorTests
18+
{
19+
private static MetaRoot Load(string model)
20+
{
21+
var r = new MetaDataLoader().Load([new InMemoryStringSource(model, id: "outputs.json")]);
22+
Assert.Empty(r.Errors);
23+
return r.Root;
24+
}
25+
26+
private static GenContext Ctx(MetaRoot root) => new()
27+
{
28+
Entities = root.Objects(),
29+
Root = root,
30+
Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Generated" },
31+
};
32+
33+
[Fact]
34+
public void Emits_no_files_when_no_template_output_nodes()
35+
{
36+
// A model with only a template.prompt; the output-parser generator stays silent.
37+
const string m = """
38+
{ "metadata.root": { "package": "acme::ai", "children": [
39+
{ "object.value": { "name": "Payload", "children": [ { "field.string": { "name": "x" } } ] } },
40+
{ "template.prompt": { "name": "promptOnly", "@payloadRef": "Payload", "@textRef": "p/x", "@format": "text" } }
41+
]}}
42+
""";
43+
var files = new OutputParserGenerator().Generate(Ctx(Load(m))).ToList();
44+
Assert.Empty(files);
45+
}
46+
47+
[Fact]
48+
public void Emits_one_file_per_template_output_with_expected_path_and_class()
49+
{
50+
const string m = """
51+
{ "metadata.root": { "package": "acme::ai", "children": [
52+
{ "object.value": { "name": "AlphaPayload", "children": [ { "field.string": { "name": "name" } } ] } },
53+
{ "object.value": { "name": "BetaPayload", "children": [ { "field.int": { "name": "n" } } ] } },
54+
{ "template.output": { "name": "Alpha", "@payloadRef": "AlphaPayload", "@textRef": "a/x", "@format": "json" } },
55+
{ "template.output": { "name": "Beta", "@payloadRef": "BetaPayload", "@textRef": "b/x", "@format": "json" } }
56+
]}}
57+
""";
58+
var files = new OutputParserGenerator().Generate(Ctx(Load(m))).OrderBy(f => f.Path).ToList();
59+
Assert.Equal(2, files.Count);
60+
Assert.Equal("Alpha.output.cs", files[0].Path);
61+
Assert.Equal("Beta.output.cs", files[1].Path);
62+
Assert.Contains("public static class AlphaParser", files[0].Content);
63+
Assert.Contains("public static class BetaParser", files[1].Content);
64+
}
65+
66+
[Fact]
67+
public void Emits_dual_api_returning_the_payload_ref_type()
68+
{
69+
const string m = """
70+
{ "metadata.root": { "package": "acme::ai", "children": [
71+
{ "object.value": { "name": "NpcResponsePayload", "children": [
72+
{ "field.string": { "name": "name" } },
73+
{ "field.int": { "name": "age" } }
74+
]}},
75+
{ "template.output": { "name": "NpcResponseOutput", "@payloadRef": "NpcResponsePayload",
76+
"@textRef": "npc/output", "@format": "json" } }
77+
]}}
78+
""";
79+
var file = Assert.Single(new OutputParserGenerator().Generate(Ctx(Load(m))));
80+
Assert.Equal("NpcResponseOutput.output.cs", file.Path);
81+
82+
var src = file.Content;
83+
Assert.Contains("using System.Text.Json;", src);
84+
Assert.Contains("namespace Acme.Generated;", src);
85+
Assert.Contains("public static class NpcResponseOutputParser", src);
86+
87+
// Parse returns the payload-VO type and throws on failure.
88+
Assert.Contains("public static NpcResponsePayload Parse(string text)", src);
89+
Assert.Contains("throw new JsonException", src);
90+
91+
// TryParse returns bool + nullable out + error out.
92+
Assert.Contains("public static bool TryParse(string text,", src);
93+
Assert.Contains("[NotNullWhen(true)] out NpcResponsePayload? value", src);
94+
Assert.Contains("[NotNullWhen(false)] out string? error", src);
95+
}
96+
97+
[Fact]
98+
public void Emitted_source_compiles_alongside_the_payload_record()
99+
{
100+
// End-to-end: payload-VO codegen + output-parser codegen should compile
101+
// together with no errors. Guards against C# language-level regressions
102+
// in the emitted shape (required keyword, nullable annotations, etc.).
103+
const string m = """
104+
{ "metadata.root": { "package": "acme::ai", "children": [
105+
{ "object.value": { "name": "NpcResponsePayload", "children": [
106+
{ "field.string": { "name": "name" } },
107+
{ "field.int": { "name": "age" } }
108+
]}},
109+
{ "template.output": { "name": "NpcResponseOutput", "@payloadRef": "NpcResponsePayload",
110+
"@textRef": "npc/output", "@format": "json" } }
111+
]}}
112+
""";
113+
var root = Load(m);
114+
var parserSrc = Assert.Single(new OutputParserGenerator().Generate(Ctx(root))).Content;
115+
var payloadSrc = PayloadCodegen.GeneratePayloadRecords(root, "NpcResponsePayload");
116+
117+
// Wrap the payload record in a namespace matching the parser's.
118+
var wrappedPayload = "namespace Acme.Generated;\n" + payloadSrc;
119+
120+
var trees = new[]
121+
{
122+
CSharpSyntaxTree.ParseText(parserSrc, new CSharpParseOptions(LanguageVersion.CSharp12)),
123+
CSharpSyntaxTree.ParseText(wrappedPayload, new CSharpParseOptions(LanguageVersion.CSharp12)),
124+
};
125+
var refs = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!)
126+
.Split(Path.PathSeparator).Where(p => p.Length > 0)
127+
.Select(p => (MetadataReference)MetadataReference.CreateFromFile(p)).ToList();
128+
var comp = CSharpCompilation.Create("outparse_" + Guid.NewGuid().ToString("N"),
129+
trees, refs, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
130+
var errors = comp.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error)
131+
.Select(d => $"{d.Id}: {d.GetMessage()}").ToList();
132+
Assert.True(errors.Count == 0, "parser + payload should compile, got: " + string.Join("; ", errors));
133+
}
134+
135+
[Fact]
136+
public void Emitted_source_matches_the_template_output_simple_fixture()
137+
{
138+
// Conformance-adjacent check: the same fixture used by the TS port should
139+
// drive the C# emit (different output shape — TS = Zod, C# = STJ — but
140+
// same metadata in).
141+
var repoRoot = LocateRepoRoot();
142+
var fixtureDir = Path.Combine(repoRoot, "fixtures", "conformance", "template-output-simple", "input");
143+
Assert.True(Directory.Exists(fixtureDir), $"fixture dir not found at {fixtureDir}");
144+
145+
var load = MetaDataLoader.FromDirectory(fixtureDir);
146+
Assert.Empty(load.Errors);
147+
148+
var ctx = new GenContext
149+
{
150+
Entities = load.Root.Objects(),
151+
Root = load.Root,
152+
Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Generated" },
153+
};
154+
var file = Assert.Single(new OutputParserGenerator().Generate(ctx));
155+
Assert.Equal("NpcResponseOutput.output.cs", file.Path);
156+
Assert.Contains("public static NpcResponsePayload Parse(string text)", file.Content);
157+
Assert.Contains("public static bool TryParse(string text,", file.Content);
158+
}
159+
160+
// Walk upward from the test assembly to the repo root (contains a fixtures/ dir).
161+
private static string LocateRepoRoot()
162+
{
163+
var dir = new DirectoryInfo(AppContext.BaseDirectory);
164+
while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, "fixtures")))
165+
dir = dir.Parent;
166+
Assert.NotNull(dir);
167+
return dir!.FullName;
168+
}
169+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// output-parser-generator — for each `template.output` declaration, emits a
2+
// `<TemplateName>.output.cs` file declaring a static `<TemplateName>Parser`
3+
// class with the .NET BCL `Parse`/`TryParse` dual API (ADR-0010). `Parse`
4+
// throws on bad input; `TryParse` returns a bool and an out-error.
5+
//
6+
// The emitted parser RETURNS the payload-VO record already emitted by
7+
// PayloadCodegen (same metadata model). It does NOT redeclare the payload
8+
// shape; this generator emits parser glue only.
9+
//
10+
// Ported from typescript/packages/codegen-ts/src/generators/output-parser-file.ts
11+
// (renderer in typescript/packages/codegen-ts/src/templates/output-parser.ts).
12+
// TS uses Zod; C# uses System.Text.Json + the `required` keyword for
13+
// presence enforcement (.NET 7+). No DataAnnotations / Validator pass —
14+
// `required`-keyword construction + STJ's strict deserialization cover
15+
// presence + type in BCL-native form.
16+
17+
using System.Text;
18+
using MetaObjects.Meta;
19+
using static MetaObjects.Shared.BaseTypes;
20+
using static MetaObjects.Template.TemplateConstants;
21+
22+
namespace MetaObjects.Codegen.Generators;
23+
24+
/// <summary>
25+
/// Emits one parser file per <c>template.output</c> node, with a static class
26+
/// exposing <c>Parse(string)</c> + <c>TryParse(string, out T?, out string?)</c>.
27+
/// </summary>
28+
public sealed class OutputParserGenerator : IGenerator
29+
{
30+
public string Name => "output-parser-generator";
31+
32+
public IEnumerable<EmittedFile> Generate(GenContext ctx)
33+
{
34+
var outputs = ctx.Root.OwnChildren()
35+
.Where(c => c.Type == TYPE_TEMPLATE && c.SubType == TEMPLATE_SUBTYPE_OUTPUT)
36+
.OrderBy(t => t.Name, StringComparer.Ordinal)
37+
.ToList();
38+
39+
var files = new List<EmittedFile>();
40+
foreach (var tmpl in outputs)
41+
{
42+
if (tmpl.OwnAttr(TEMPLATE_ATTR_PAYLOAD_REF) is not string payloadRef)
43+
{
44+
// Loader passes treat missing @payloadRef as a load error; defensively skip.
45+
ctx.Warn($"{Name}: template.output \"{tmpl.Name}\" missing @payloadRef — skipped.");
46+
continue;
47+
}
48+
files.Add(EmitParser(tmpl.Name, payloadRef, ctx));
49+
}
50+
return files;
51+
}
52+
53+
private static EmittedFile EmitParser(string templateName, string payloadRef, GenContext ctx)
54+
{
55+
var parserClass = $"{templateName}Parser";
56+
var payloadType = payloadRef;
57+
58+
var sb = new StringBuilder();
59+
sb.AppendLine("// <auto-generated/>");
60+
sb.AppendLine("// Generated by MetaObjects output-parser-generator. Do not edit by hand.");
61+
sb.AppendLine("#nullable enable");
62+
sb.AppendLine("using System;");
63+
sb.AppendLine("using System.Diagnostics.CodeAnalysis;");
64+
sb.AppendLine("using System.Text.Json;");
65+
sb.AppendLine();
66+
sb.AppendLine($"namespace {ctx.Config.Namespace};");
67+
sb.AppendLine();
68+
sb.AppendLine($"/// <summary>Parser for LLM responses matching the <c>{templateName}</c> template.output.</summary>");
69+
sb.AppendLine($"public static class {parserClass}");
70+
sb.AppendLine("{");
71+
sb.AppendLine(" private static readonly JsonSerializerOptions Options = new()");
72+
sb.AppendLine(" {");
73+
// Case-sensitive (default) — the payload-VO property names are emitted as
74+
// the exact metadata field names (typically camelCase), which matches the
75+
// JSON the LLM is expected to produce.
76+
sb.AppendLine(" PropertyNameCaseInsensitive = false,");
77+
sb.AppendLine(" };");
78+
sb.AppendLine();
79+
sb.AppendLine($" /// <summary>Parse an LLM response into a typed <see cref=\"{payloadType}\"/>.</summary>");
80+
sb.AppendLine($" /// <param name=\"text\">The raw response text — expected to be JSON.</param>");
81+
sb.AppendLine(" /// <returns>The deserialized, validated payload.</returns>");
82+
sb.AppendLine(" /// <exception cref=\"JsonException\">JSON is malformed, missing a <c>required</c> property, or has a type mismatch.</exception>");
83+
sb.AppendLine($" public static {payloadType} Parse(string text) =>");
84+
sb.AppendLine($" JsonSerializer.Deserialize<{payloadType}>(text, Options)");
85+
sb.AppendLine($" ?? throw new JsonException(\"deserialized to null\");");
86+
sb.AppendLine();
87+
sb.AppendLine($" /// <summary>Parse with explicit error handling — does not throw on validation failure.</summary>");
88+
sb.AppendLine(" /// <param name=\"text\">The raw response text — expected to be JSON.</param>");
89+
sb.AppendLine(" /// <param name=\"value\">On success, the deserialized payload; otherwise null.</param>");
90+
sb.AppendLine(" /// <param name=\"error\">On failure, a human-readable error message; otherwise null.</param>");
91+
sb.AppendLine(" /// <returns><c>true</c> on success; <c>false</c> on validation failure.</returns>");
92+
sb.AppendLine($" public static bool TryParse(string text, [NotNullWhen(true)] out {payloadType}? value, [NotNullWhen(false)] out string? error)");
93+
sb.AppendLine(" {");
94+
sb.AppendLine(" try");
95+
sb.AppendLine(" {");
96+
sb.AppendLine(" value = Parse(text);");
97+
sb.AppendLine(" error = null;");
98+
sb.AppendLine(" return true;");
99+
sb.AppendLine(" }");
100+
sb.AppendLine(" catch (JsonException ex)");
101+
sb.AppendLine(" {");
102+
sb.AppendLine(" value = null;");
103+
sb.AppendLine(" error = ex.Message;");
104+
sb.AppendLine(" return false;");
105+
sb.AppendLine(" }");
106+
sb.AppendLine(" }");
107+
sb.AppendLine("}");
108+
return new EmittedFile($"{templateName}.output.cs", sb.ToString());
109+
}
110+
}

0 commit comments

Comments
 (0)