Skip to content

Commit 12e4cae

Browse files
committed
merge: C# render engine (MetaObjects.Render) [FR-004 Plan #2, phase 1]
C# render engine renders the shared render-conformance corpus byte-identical to TS. First C# codegen/runtime-tier component; CLAUDE.md scope reconciled.
2 parents 6c529ae + e7e83ab commit 12e4cae

10 files changed

Lines changed: 484 additions & 1 deletion

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Equal weight. Three ship per-language today; the fourth is committed for 7.0.0:
1717

1818
## Status
1919

20-
TypeScript reference implementation is **published to npm at `0.5.0`** (11 `@metaobjectsdev/*` packages on the `latest` tag; `cli` at `0.5.1`) — Projects D–G shipped end-to-end with 2124+ tests passing. Java port is in progress: H3a (loader restructure) shipped 2026-05-19; H3b (conformance harness) is active. **C# loader + conformance shipped** (loader, canonical serializer, and a `dotnet test` conformance runner that runs the full shared corpus; codegen + runtime remain out of scope for C#). Python is planned post-H3.
20+
TypeScript reference implementation is **published to npm at `0.5.0`** (11 `@metaobjectsdev/*` packages on the `latest` tag; `cli` at `0.5.1`) — Projects D–G shipped end-to-end with 2124+ tests passing. Java port is in progress: H3a (loader restructure) shipped 2026-05-19; H3b (conformance harness) is active. **C# loader + conformance shipped** (loader, canonical serializer, and a `dotnet test` conformance runner that runs the full shared corpus; metadata layer fully caught up — empty expected-failures ledger, incl. `template.*` + `origin.collection`). C# is now also growing the FR-004 codegen/runtime tier: the **render engine (`MetaObjects.Render`) ships** and renders the shared `fixtures/render-conformance` corpus byte-identical to TS; payload-VO codegen + `verify` are in progress. (This supersedes the earlier "codegen + runtime out of scope for C#" boundary, which dated from when C# was the stale laggard.) Python is planned post-H3.
2121

2222
The 7.0.0 line is specced: FR-003 brings the Java OMDB persistence engine, metadata-driven schema migration, and dynamic projections onto current core; FR-004 builds the fourth pillar (cross-language prompt construction) on top of FR-003's projections. Both are design-stage plan-of-record (`docs/superpowers/specs/2026-05-22-fr-003-*` and `*-fr-004-*`), not yet implemented.
2323

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
using System.Text.Json;
2+
3+
namespace MetaObjects.Render.Tests;
4+
5+
/// <summary>
6+
/// Converts a payload.json document into the plain object graph the render engine
7+
/// (Stubble) consumes: objects -> Dictionary&lt;string, object&gt;, arrays ->
8+
/// List&lt;object&gt;, integral numbers -> long (so "2" renders as "2", not "2.0"),
9+
/// fractional -> double, plus string/bool/null.
10+
/// </summary>
11+
public static class JsonPayload
12+
{
13+
public static object? Parse(string json)
14+
{
15+
using var doc = JsonDocument.Parse(json);
16+
return Convert(doc.RootElement);
17+
}
18+
19+
private static object? Convert(JsonElement el) => el.ValueKind switch
20+
{
21+
JsonValueKind.Object => ToDict(el),
22+
JsonValueKind.Array => ToList(el),
23+
JsonValueKind.String => el.GetString(),
24+
JsonValueKind.Number => el.TryGetInt64(out var l) ? l : el.GetDouble(),
25+
JsonValueKind.True => true,
26+
JsonValueKind.False => false,
27+
_ => null,
28+
};
29+
30+
private static Dictionary<string, object> ToDict(JsonElement el)
31+
{
32+
var d = new Dictionary<string, object>(StringComparer.Ordinal);
33+
foreach (var p in el.EnumerateObject())
34+
d[p.Name] = Convert(p.Value)!;
35+
return d;
36+
}
37+
38+
private static List<object> ToList(JsonElement el)
39+
{
40+
var list = new List<object>();
41+
foreach (var e in el.EnumerateArray())
42+
list.Add(Convert(e)!);
43+
return list;
44+
}
45+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
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+
</ItemGroup>
13+
<ItemGroup>
14+
<ProjectReference Include="../MetaObjects.Render/MetaObjects.Render.csproj" />
15+
</ItemGroup>
16+
</Project>
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
using System.Text.Json;
2+
using Xunit;
3+
4+
namespace MetaObjects.Render.Tests;
5+
6+
/// <summary>
7+
/// Render-conformance corpus runner — the cross-language render guarantee.
8+
/// Each fixture dir under fixtures/render-conformance/ holds:
9+
/// meta.json { "format": "&lt;RenderFormat&gt;" }
10+
/// template.mustache the entry template
11+
/// partials/*.mustache optional partials, referenced as `partials/&lt;name&gt;`
12+
/// payload.json the render payload (pre-formatted primitives)
13+
/// expected.txt byte-exact expected output
14+
/// The same shared corpus is rendered byte-for-byte identically by TS and C#.
15+
/// Mirrors typescript/packages/render/test/render-conformance.test.ts.
16+
/// </summary>
17+
public class RenderConformanceTests
18+
{
19+
private static string CorpusRoot()
20+
{
21+
string root = AppContext.BaseDirectory;
22+
while (!Directory.Exists(Path.Combine(root, "fixtures", "render-conformance")))
23+
{
24+
var parent = Directory.GetParent(root)?.FullName;
25+
if (parent is null || parent == root)
26+
throw new InvalidOperationException("fixtures/render-conformance not found");
27+
root = parent;
28+
}
29+
return Path.Combine(root, "fixtures", "render-conformance");
30+
}
31+
32+
public static IEnumerable<object[]> Fixtures()
33+
{
34+
var corpus = CorpusRoot();
35+
foreach (var dir in Directory.GetDirectories(corpus).OrderBy(d => d, StringComparer.Ordinal))
36+
yield return new object[] { Path.GetFileName(dir) };
37+
}
38+
39+
private static InMemoryProvider ProviderFromPartials(string dir)
40+
{
41+
var map = new Dictionary<string, string>(StringComparer.Ordinal);
42+
var pdir = Path.Combine(dir, "partials");
43+
if (Directory.Exists(pdir))
44+
{
45+
foreach (var f in Directory.GetFiles(pdir, "*.mustache"))
46+
{
47+
var name = Path.GetFileNameWithoutExtension(f);
48+
map[$"partials/{name}"] = File.ReadAllText(f);
49+
}
50+
}
51+
return new InMemoryProvider(map);
52+
}
53+
54+
[Theory]
55+
[MemberData(nameof(Fixtures))]
56+
public void RenderMatchesExpected(string name)
57+
{
58+
var dir = Path.Combine(CorpusRoot(), name);
59+
60+
var metaRaw = File.ReadAllText(Path.Combine(dir, "meta.json"));
61+
var format = JsonDocument.Parse(metaRaw).RootElement.GetProperty("format").GetString()!;
62+
var template = File.ReadAllText(Path.Combine(dir, "template.mustache"));
63+
var payload = JsonPayload.Parse(File.ReadAllText(Path.Combine(dir, "payload.json")));
64+
var expected = File.ReadAllText(Path.Combine(dir, "expected.txt"));
65+
var provider = ProviderFromPartials(dir);
66+
67+
var actual = Renderer.Render(new RenderRequest
68+
{
69+
Template = template,
70+
Payload = payload,
71+
Provider = provider,
72+
Format = format,
73+
});
74+
75+
Assert.Equal(expected, actual);
76+
77+
// Determinism: identical across runs.
78+
var again = Renderer.Render(new RenderRequest
79+
{
80+
Template = template,
81+
Payload = payload,
82+
Provider = provider,
83+
Format = format,
84+
});
85+
Assert.Equal(actual, again);
86+
}
87+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
using Xunit;
2+
3+
namespace MetaObjects.Render.Tests;
4+
5+
/// <summary>
6+
/// Core render-engine unit tests, mirroring the engine block of
7+
/// typescript/packages/render/test/render.test.ts. (The verify-on-resolve guard
8+
/// tests are deferred to Phase 3 with the verify port.)
9+
/// </summary>
10+
public class RenderTests
11+
{
12+
private static InMemoryProvider P(Dictionary<string, string>? m = null) =>
13+
new(m ?? new Dictionary<string, string>(StringComparer.Ordinal));
14+
15+
private static string Render(string format, object? payload, IProvider provider,
16+
string? template = null, string? @ref = null) =>
17+
Renderer.Render(new RenderRequest
18+
{
19+
Template = template,
20+
Ref = @ref,
21+
Payload = payload,
22+
Provider = provider,
23+
Format = format,
24+
});
25+
26+
private static Dictionary<string, object> Obj(params (string k, object? v)[] kv)
27+
{
28+
var d = new Dictionary<string, object>(StringComparer.Ordinal);
29+
foreach (var (k, v) in kv) d[k] = v!;
30+
return d;
31+
}
32+
33+
[Fact]
34+
public void Renders_a_variable_text_raw() =>
35+
Assert.Equal("Hi Ada.", Render("text", Obj(("name", "Ada")), P(), template: "Hi {{name}}."));
36+
37+
[Fact]
38+
public void Resolves_a_template_by_2layer_ref() =>
39+
Assert.Equal("Hi Ada.", Render("text", Obj(("name", "Ada")),
40+
P(new() { ["g/main"] = "Hi {{name}}." }), @ref: "g/main"));
41+
42+
[Fact]
43+
public void Iterates_arrays_in_order() =>
44+
Assert.Equal("a,b,c,", Render("text", Obj(("xs", new List<object> { "a", "b", "c" })), P(),
45+
template: "{{#xs}}{{.}},{{/xs}}"));
46+
47+
[Fact]
48+
public void Inverted_section_when_empty() =>
49+
Assert.Equal("none", Render("text", Obj(("xs", new List<object>())), P(),
50+
template: "{{^xs}}none{{/xs}}"));
51+
52+
[Fact]
53+
public void Resolves_partials_through_the_provider() =>
54+
Assert.Equal("A [z] B", Render("text", Obj(("x", "z")),
55+
P(new() { ["g/main"] = "A {{> g/frag }} B", ["g/frag"] = "[{{x}}]" }), @ref: "g/main"));
56+
57+
[Fact]
58+
public void Renders_a_partial_once_per_loop_iteration_in_item_context() =>
59+
Assert.Equal("<1><2>", Render("text",
60+
Obj(("items", new List<object> { Obj(("n", 1L)), Obj(("n", 2L)) })),
61+
P(new() { ["g/main"] = "{{#items}}{{> g/row }}{{/items}}", ["g/row"] = "<{{n}}>" }),
62+
@ref: "g/main"));
63+
64+
[Fact]
65+
public void Detects_partial_cycles()
66+
{
67+
var ex = Assert.Throws<RenderException>(() =>
68+
Render("text", Obj(), P(new() { ["g/a"] = "{{> g/b }}", ["g/b"] = "{{> g/a }}" }), @ref: "g/a"));
69+
Assert.Contains("cycle", ex.Message, StringComparison.OrdinalIgnoreCase);
70+
}
71+
72+
[Fact]
73+
public void Html_format_escapes_markup() =>
74+
Assert.Equal("&lt;b&gt;&amp;", Render("html", Obj(("x", "<b>&")), P(), template: "{{x}}"));
75+
76+
[Fact]
77+
public void Text_format_does_not_escape() =>
78+
Assert.Equal("<b>&", Render("text", Obj(("x", "<b>&")), P(), template: "{{x}}"));
79+
80+
[Fact]
81+
public void Triple_mustache_bypasses_escaping() =>
82+
Assert.Equal("<b>", Render("html", Obj(("x", "<b>")), P(), template: "{{{x}}}"));
83+
84+
[Fact]
85+
public void Csv_format_neutralizes_formula_injection() =>
86+
Assert.Equal("'=cmd()", Render("csv", Obj(("x", "=cmd()")), P(), template: "{{x}}"));
87+
88+
[Fact]
89+
public void Csv_format_quotes_values_with_commas() =>
90+
Assert.Equal("\"a,b\"", Render("csv", Obj(("x", "a,b")), P(), template: "{{x}}"));
91+
92+
[Fact]
93+
public void Spreadsheet_format_xml_escapes_and_guards_injection() =>
94+
Assert.Equal("'=1&lt;2", Render("spreadsheet", Obj(("x", "=1<2")), P(), template: "{{x}}"));
95+
96+
[Fact]
97+
public void Throws_on_unresolved_ref()
98+
{
99+
var ex = Assert.Throws<RenderException>(() => Render("text", Obj(), P(), @ref: "g/missing"));
100+
Assert.Contains("unresolved", ex.Message, StringComparison.OrdinalIgnoreCase);
101+
}
102+
103+
[Fact]
104+
public void Deterministic_same_inputs_identical_output()
105+
{
106+
var payload = Obj(("xs", new List<object> { 1L, 2L, 3L }));
107+
var a = Render("text", payload, P(), template: "{{#xs}}{{.}}|{{/xs}}");
108+
var b = Render("text", payload, P(), template: "{{#xs}}{{.}}|{{/xs}}");
109+
Assert.Equal(a, b);
110+
Assert.Equal("1|2|3|", a);
111+
}
112+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Format-driven escaping (FR-004 R7/R8). The render engine OWNS escaping (it does
2+
// not rely on the Mustache lib's HTML-only default), so behavior is identical
3+
// across language ports. `{{var}}` is escaped per format; `{{{var}}}` is raw.
4+
//
5+
// RenderFormat values are kept independent of the metadata package's
6+
// TEMPLATE_FORMATS to keep this engine a zero-core-dependency module. The two
7+
// sets are kept in lock-step by the render-conformance corpus.
8+
//
9+
// Ported from typescript/packages/render/src/escapers.ts.
10+
11+
using System.Text;
12+
13+
namespace MetaObjects.Render;
14+
15+
/// <summary>Format-keyed escaper registry. Engine-owned; identical across ports.</summary>
16+
public static class Escapers
17+
{
18+
public const string FORMAT_TEXT = "text";
19+
public const string FORMAT_HTML = "html";
20+
public const string FORMAT_XML = "xml";
21+
public const string FORMAT_CSV = "csv";
22+
public const string FORMAT_JSON = "json";
23+
public const string FORMAT_MARKDOWN = "markdown";
24+
public const string FORMAT_SPREADSHEET = "spreadsheet";
25+
26+
private static string Raw(string s) => s;
27+
28+
private static string Xml(string s) =>
29+
s.Replace("&", "&amp;")
30+
.Replace("<", "&lt;")
31+
.Replace(">", "&gt;")
32+
.Replace("\"", "&quot;")
33+
.Replace("'", "&#39;");
34+
35+
// OWASP CSV/Excel formula-injection guard: neutralize a leading active char.
36+
private static string InjectionGuard(string s) =>
37+
s.Length > 0 && "=+-@\t\r".IndexOf(s[0]) >= 0 ? "'" + s : s;
38+
39+
private static string Csv(string s)
40+
{
41+
var v = InjectionGuard(s);
42+
return v.IndexOfAny(['"', ',', '\n', '\r']) >= 0
43+
? "\"" + v.Replace("\"", "\"\"") + "\""
44+
: v;
45+
}
46+
47+
// Mirrors JS JSON.stringify(s).slice(1, -1): escape ", \, and control chars;
48+
// nothing else (no HTML-safety escaping of < > &).
49+
private static string Json(string s)
50+
{
51+
var sb = new StringBuilder(s.Length + 8);
52+
foreach (char c in s)
53+
{
54+
switch (c)
55+
{
56+
case '"': sb.Append("\\\""); break;
57+
case '\\': sb.Append("\\\\"); break;
58+
case '\b': sb.Append("\\b"); break;
59+
case '\f': sb.Append("\\f"); break;
60+
case '\n': sb.Append("\\n"); break;
61+
case '\r': sb.Append("\\r"); break;
62+
case '\t': sb.Append("\\t"); break;
63+
default:
64+
if (c < 0x20) sb.Append("\\u").Append(((int)c).ToString("x4"));
65+
else sb.Append(c);
66+
break;
67+
}
68+
}
69+
return sb.ToString();
70+
}
71+
72+
private static readonly Dictionary<string, Func<string, string>> Registry = new(StringComparer.Ordinal)
73+
{
74+
[FORMAT_TEXT] = Raw,
75+
[FORMAT_MARKDOWN] = Raw,
76+
[FORMAT_HTML] = Xml,
77+
[FORMAT_XML] = Xml,
78+
[FORMAT_JSON] = Json,
79+
[FORMAT_CSV] = Csv,
80+
// XML-escape the content first, then guard — so the guard's leading quote is a
81+
// literal apostrophe in the XML (which is what tells Excel "treat as text"),
82+
// not itself escaped to &#39;.
83+
[FORMAT_SPREADSHEET] = s => InjectionGuard(Xml(s)),
84+
};
85+
86+
/// <summary>
87+
/// The escaper for a format. Unknown formats throw (callers pass a value from
88+
/// the closed TEMPLATE_FORMATS set). Defaults are not silently applied here —
89+
/// the Renderer applies "text" when no format is given.
90+
/// </summary>
91+
public static Func<string, string> For(string format) =>
92+
Registry.TryGetValue(format, out var fn)
93+
? fn
94+
: throw new ArgumentException($"unknown render format \"{format}\"", nameof(format));
95+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
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.Render</RootNamespace>
9+
</PropertyGroup>
10+
<ItemGroup>
11+
<PackageReference Include="Stubble.Core" Version="1.10.8" />
12+
</ItemGroup>
13+
</Project>

0 commit comments

Comments
 (0)