Skip to content

Commit 1f80149

Browse files
committed
Merge worktree-fr6-csharp-2026-05-26 — FR6 outputParser generator + verify extension for template.output [C#]
2 parents 8f1c27e + a5f1946 commit 1f80149

5 files changed

Lines changed: 385 additions & 14 deletions

File tree

server/csharp/MetaObjects.Cli.Tests/VerifyCommandTests.cs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,57 @@ public void Missing_required_tag_is_caught()
8585
Assert.False(o.Ok);
8686
Assert.Contains(o.Errors, d => d.Code == Render.Verify.ERR_OUTPUT_TAG_MISSING && d.Path == "answer");
8787
}
88+
89+
[Fact]
90+
public void Prompt_findings_are_tagged_with_prompt_kind()
91+
{
92+
WriteTemplate("Hi {{notAField}}.");
93+
var o = VerifyCommand.Run(MetaDir, TplDir);
94+
Assert.False(o.Ok);
95+
Assert.All(o.Errors, d => Assert.Equal(VerifyCommand.KIND_PROMPT, d.Kind));
96+
}
97+
98+
// -------------------- template.output (FR6, ADR-0010) --------------------
99+
100+
[Fact]
101+
public void Output_with_resolved_payload_ref_passes_without_a_template_file()
102+
{
103+
// template.output's parser is schema-derived from the payload VO; it does
104+
// not require a @textRef-resolved template body. A clean payload-VO should
105+
// pass even when no template file is on disk.
106+
const string outputModel = """
107+
{ "metadata.root": { "package": "acme::ai", "children": [
108+
{ "object.value": { "name": "NpcResponsePayload", "children": [
109+
{ "field.string": { "name": "name" } },
110+
{ "field.int": { "name": "age" } }
111+
]}},
112+
{ "template.output": { "name": "NpcResponseOutput", "@format": "json",
113+
"@payloadRef": "NpcResponsePayload", "@textRef": "npc/output" } }
114+
]}}
115+
""";
116+
File.WriteAllText(Path.Combine(MetaDir, "meta.ai.json"), outputModel);
117+
var o = VerifyCommand.Run(MetaDir, TplDir);
118+
Assert.True(o.Ok, string.Join("; ",
119+
o.LoadErrors.Concat(o.UnresolvedText).Concat(o.Errors.Select(e => $"{e.Kind}/{e.Code}({e.Path})"))));
120+
}
121+
122+
[Fact]
123+
public void Output_with_unresolved_payload_ref_is_flagged_as_output_drift()
124+
{
125+
// The @payloadRef names an object.value that doesn't exist.
126+
const string outputModel = """
127+
{ "metadata.root": { "package": "acme::ai", "children": [
128+
{ "object.value": { "name": "RealPayload", "children": [ { "field.string": { "name": "x" } } ] } },
129+
{ "template.output": { "name": "OutputOne", "@format": "json",
130+
"@payloadRef": "NoSuchPayload", "@textRef": "x/y" } }
131+
]}}
132+
""";
133+
File.WriteAllText(Path.Combine(MetaDir, "meta.ai.json"), outputModel);
134+
var o = VerifyCommand.Run(MetaDir, TplDir);
135+
Assert.False(o.Ok);
136+
Assert.Contains(o.Errors, d =>
137+
d.Kind == VerifyCommand.KIND_OUTPUT &&
138+
d.Code == VerifyCommand.ERR_PAYLOAD_REF_UNRESOLVED &&
139+
d.Path == "NoSuchPayload");
140+
}
88141
}

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
{

server/csharp/MetaObjects.Cli/VerifyCommand.cs

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
1-
// `meta verify` — the build-time prompt drift gate (FR-004 Plan #3, T6).
1+
// `meta verify` — the build-time template drift gate (FR-004 Plan #3, T6;
2+
// extended to template.output by FR6, ADR-0010).
23
//
3-
// Loads metadata from a directory; for each template node resolves its @textRef
4-
// text via a filesystem provider, derives the @payloadRef view-object's field
5-
// tree from the loaded metadata, and runs the engine's verify() — reporting drift
6-
// (a template variable the payload doesn't declare, an unresolved partial) so CI
7-
// fails before a renamed field can silently break a prompt. Runs at the last
8-
// fixed point before serve, never on the request path.
4+
// Loads metadata from a directory; for each template node derives the
5+
// @payloadRef view-object's field tree and dispatches on subtype:
6+
//
7+
// template.prompt — resolves @textRef via a filesystem provider and runs the
8+
// engine's Verify (template variable ↔ payload field drift,
9+
// unresolved partials, missing output tags).
10+
// template.output — payload-VO resolution check only (the generator derives
11+
// the parser schema from the same VO, so any field-tree
12+
// drift surfaces here as well as at gen time).
13+
//
14+
// Runs at the last fixed point before serve, never on the request path.
915

1016
using MetaObjects.Codegen;
1117
using MetaObjects.Loader;
@@ -18,7 +24,8 @@ namespace MetaObjects.Cli;
1824
/// <summary>The verify command's pure logic — no console I/O, so it is testable.</summary>
1925
public static class VerifyCommand
2026
{
21-
public sealed record Drift(string Template, string Code, string Path);
27+
/// <summary><c>Kind</c> distinguishes prompt vs. output drift findings (ADR-0010).</summary>
28+
public sealed record Drift(string Template, string Kind, string Code, string Path);
2229

2330
public sealed record Outcome(
2431
IReadOnlyList<string> LoadErrors,
@@ -30,6 +37,16 @@ public sealed record Outcome(
3037
public bool Ok => LoadErrors.Count == 0 && Errors.Count == 0 && UnresolvedText.Count == 0;
3138
}
3239

40+
/// <summary>Drift-finding kind constant — set on every <see cref="Drift"/>.</summary>
41+
public const string KIND_PROMPT = "prompt";
42+
/// <summary>Drift-finding kind constant — set on every <see cref="Drift"/>.</summary>
43+
public const string KIND_OUTPUT = "output";
44+
45+
/// <summary>A drift error code emitted when a template's @payloadRef does not resolve
46+
/// to a loaded object.value. Both subtypes raise this; the codegen would otherwise
47+
/// fail at gen time, but verify catches it at the build-time fixed point.</summary>
48+
public const string ERR_PAYLOAD_REF_UNRESOLVED = "ERR_PAYLOAD_REF_UNRESOLVED";
49+
3350
/// <summary>Coerce a string-array attr (array, or a single string) into a string list.</summary>
3451
private static IReadOnlyList<string> AsStringList(object? attr) => attr switch
3552
{
@@ -51,9 +68,32 @@ public static Outcome Run(string metadataDir, string templatesRoot)
5168

5269
foreach (var tmpl in load.Root.OwnChildren().Where(c => c.Type == TYPE_TEMPLATE))
5370
{
54-
// Missing @textRef / @payloadRef are already load errors (Pass 9 + schema).
55-
if (tmpl.OwnAttr(TEMPLATE_ATTR_TEXT_REF) is not string textRef ||
56-
tmpl.OwnAttr(TEMPLATE_ATTR_PAYLOAD_REF) is not string payloadRef)
71+
// Missing @payloadRef is already a load error (template schema requires it).
72+
if (tmpl.OwnAttr(TEMPLATE_ATTR_PAYLOAD_REF) is not string payloadRef)
73+
continue;
74+
75+
var kind = tmpl.SubType == TEMPLATE_SUBTYPE_OUTPUT ? KIND_OUTPUT : KIND_PROMPT;
76+
77+
// Both subtypes: @payloadRef must resolve to a loaded object.value (=>
78+
// non-empty derived field tree). Catches a renamed VO before codegen.
79+
var fields = PayloadCodegen.BuildPayloadFieldTree(load.Root, payloadRef);
80+
if (fields.Count == 0)
81+
{
82+
errors.Add(new Drift(tmpl.Name, kind, ERR_PAYLOAD_REF_UNRESOLVED, payloadRef));
83+
continue;
84+
}
85+
86+
if (tmpl.SubType == TEMPLATE_SUBTYPE_OUTPUT)
87+
{
88+
// Output's parser schema is derived from the same VO that drives prompt
89+
// rendering — payload-VO resolution above covers FR6's drift contract.
90+
// No @textRef walk: output templates may not carry one (the parser is
91+
// schema-driven), and the generator surfaces gen-time issues directly.
92+
continue;
93+
}
94+
95+
// template.prompt branch — existing Mustache + tag/slot checks.
96+
if (tmpl.OwnAttr(TEMPLATE_ATTR_TEXT_REF) is not string textRef)
5797
continue;
5898

5999
var text = provider.Resolve(textRef);
@@ -63,7 +103,6 @@ public static Outcome Run(string metadataDir, string templatesRoot)
63103
continue;
64104
}
65105

66-
var fields = PayloadCodegen.BuildPayloadFieldTree(load.Root, payloadRef);
67106
var requiredSlots = AsStringList(tmpl.OwnAttr(TEMPLATE_ATTR_REQUIRED_SLOTS));
68107
var requiredTags = AsStringList(tmpl.OwnAttr(TEMPLATE_ATTR_REQUIRED_TAGS));
69108

@@ -75,7 +114,7 @@ public static Outcome Run(string metadataDir, string templatesRoot)
75114
};
76115
foreach (var e in Verify.Check(text, fields, verifyOptions))
77116
{
78-
var drift = new Drift(tmpl.Name, e.Code, e.Path);
117+
var drift = new Drift(tmpl.Name, kind, e.Code, e.Path);
79118
if (e.Code == Verify.ERR_REQUIRED_SLOT_UNUSED) warnings.Add(drift);
80119
else errors.Add(drift);
81120
}
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+
}

0 commit comments

Comments
 (0)