Skip to content

Commit cc9162a

Browse files
committed
merge: C# CLI host + meta verify drift gate [FR-004 Plan #3 T6]; C# scope opened to full-stack
2 parents 60fe26d + c3a900f commit cc9162a

9 files changed

Lines changed: 295 additions & 4 deletions

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; 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.
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# is a first-class full-stack target.** Loader + conformance shipped (loader, canonical serializer, and a `dotnet test` conformance runner over the full shared corpus; metadata layer fully caught up — empty expected-failures ledger, incl. `template.*` + `origin.collection`). The FR-004 tier ships: the **render engine (`MetaObjects.Render`)** renders the shared `fixtures/render-conformance` corpus byte-identical to TS, plus payload-VO codegen (`MetaObjects.Codegen`) and `verify`. The remaining tiers — CLI, full codegen, runtime, and migrate — are being built out (no part of MetaObjects is out of scope for C#). 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: 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.Cli/MetaObjects.Cli.csproj" />
15+
</ItemGroup>
16+
</Project>
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
using MetaObjects.Cli;
2+
using Xunit;
3+
4+
namespace MetaObjects.Cli.Tests;
5+
6+
/// <summary>
7+
/// End-to-end `meta verify`: a metadata dir + a filesystem templates dir, run
8+
/// through VerifyCommand.Run. Proves the build-time drift gate catches a template
9+
/// variable the payload doesn't declare, and passes a clean template.
10+
/// </summary>
11+
public sealed class VerifyCommandTests : IDisposable
12+
{
13+
private readonly string _tmp = Path.Combine(Path.GetTempPath(), "meta-verify-" + Guid.NewGuid().ToString("N"));
14+
private string MetaDir => Path.Combine(_tmp, "metaobjects");
15+
private string TplDir => Path.Combine(_tmp, "templates");
16+
17+
// object.value Payload { name } + template.prompt greeting -> Payload, @textRef t/main
18+
private const string Metadata = """
19+
{ "metadata.root": { "package": "acme::ai", "children": [
20+
{ "object.value": { "name": "Payload", "children": [ { "field.string": { "name": "name" } } ] } },
21+
{ "template.prompt": { "name": "greeting", "@payloadRef": "Payload", "@textRef": "t/main", "@format": "text" } }
22+
]}}
23+
""";
24+
25+
public VerifyCommandTests()
26+
{
27+
Directory.CreateDirectory(MetaDir);
28+
Directory.CreateDirectory(TplDir);
29+
File.WriteAllText(Path.Combine(MetaDir, "meta.ai.json"), Metadata);
30+
}
31+
32+
public void Dispose()
33+
{
34+
try { Directory.Delete(_tmp, recursive: true); } catch { /* best effort */ }
35+
}
36+
37+
private void WriteTemplate(string body)
38+
{
39+
var dir = Path.Combine(TplDir, "t");
40+
Directory.CreateDirectory(dir);
41+
File.WriteAllText(Path.Combine(dir, "main.mustache"), body);
42+
}
43+
44+
[Fact]
45+
public void Clean_template_passes()
46+
{
47+
WriteTemplate("Hi {{name}}.");
48+
var o = VerifyCommand.Run(MetaDir, TplDir);
49+
Assert.True(o.Ok, string.Join("; ",
50+
o.LoadErrors.Concat(o.UnresolvedText).Concat(o.Errors.Select(e => $"{e.Code}({e.Path})"))));
51+
Assert.Empty(o.Errors);
52+
}
53+
54+
[Fact]
55+
public void Drifted_variable_is_caught()
56+
{
57+
WriteTemplate("Hi {{name}}, you have {{notAField}}.");
58+
var o = VerifyCommand.Run(MetaDir, TplDir);
59+
Assert.False(o.Ok);
60+
Assert.Contains(o.Errors, d => d.Code == Render.Verify.ERR_VAR_NOT_ON_PAYLOAD && d.Path == "notAField");
61+
}
62+
63+
[Fact]
64+
public void Unresolved_textref_fails()
65+
{
66+
// No template file written → @textRef "t/main" does not resolve.
67+
var o = VerifyCommand.Run(MetaDir, TplDir);
68+
Assert.False(o.Ok);
69+
Assert.NotEmpty(o.UnresolvedText);
70+
}
71+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<OutputType>Exe</OutputType>
4+
<TargetFramework>net8.0</TargetFramework>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<Nullable>enable</Nullable>
7+
<LangVersion>12</LangVersion>
8+
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
9+
<RootNamespace>MetaObjects.Cli</RootNamespace>
10+
<AssemblyName>meta</AssemblyName>
11+
</PropertyGroup>
12+
<ItemGroup>
13+
<ProjectReference Include="../MetaObjects/MetaObjects.csproj" />
14+
<ProjectReference Include="../MetaObjects.Render/MetaObjects.Render.csproj" />
15+
<ProjectReference Include="../MetaObjects.Codegen/MetaObjects.Codegen.csproj" />
16+
</ItemGroup>
17+
</Project>
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// `meta` — the MetaObjects C# CLI host. Subcommands hang off here; `verify` is
2+
// the first (the FR-004 build-time prompt drift gate). `gen` / `migrate` follow.
3+
4+
using MetaObjects.Cli;
5+
6+
if (args.Length == 0)
7+
{
8+
Console.Error.WriteLine(
9+
"usage: meta <command> [options]\n" +
10+
" commands:\n" +
11+
" verify <metadataDir> --templates <root> drift-check templates against their payloads");
12+
return 2;
13+
}
14+
15+
return args[0] switch
16+
{
17+
"verify" => RunVerify(args[1..]),
18+
_ => Unknown(args[0]),
19+
};
20+
21+
static int Unknown(string cmd)
22+
{
23+
Console.Error.WriteLine($"meta: unknown command \"{cmd}\"");
24+
return 2;
25+
}
26+
27+
static int RunVerify(string[] rest)
28+
{
29+
string? metadataDir = null;
30+
string? templatesRoot = null;
31+
for (int i = 0; i < rest.Length; i++)
32+
{
33+
if (rest[i] == "--templates" && i + 1 < rest.Length) templatesRoot = rest[++i];
34+
else if (!rest[i].StartsWith('-')) metadataDir ??= rest[i];
35+
}
36+
if (metadataDir is null || templatesRoot is null)
37+
{
38+
Console.Error.WriteLine("usage: meta verify <metadataDir> --templates <templatesRoot>");
39+
return 2;
40+
}
41+
42+
var outcome = VerifyCommand.Run(metadataDir, templatesRoot);
43+
foreach (var e in outcome.LoadErrors) Console.Error.WriteLine($" load error: {e}");
44+
foreach (var u in outcome.UnresolvedText) Console.Error.WriteLine($" {u}");
45+
foreach (var d in outcome.Errors) Console.Error.WriteLine($" drift {d.Template}: {d.Code} ({d.Path})");
46+
foreach (var w in outcome.Warnings) Console.WriteLine($" warning {w.Template}: {w.Code} ({w.Path})");
47+
48+
if (outcome.Ok)
49+
{
50+
Console.WriteLine("meta verify: OK");
51+
return 0;
52+
}
53+
Console.Error.WriteLine("meta verify: FAILED");
54+
return 1;
55+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// `meta verify` — the build-time prompt drift gate (FR-004 Plan #3, T6).
2+
//
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.
9+
10+
using MetaObjects.Codegen;
11+
using MetaObjects.Loader;
12+
using MetaObjects.Render;
13+
using static MetaObjects.Shared.BaseTypes;
14+
using static MetaObjects.Template.TemplateConstants;
15+
16+
namespace MetaObjects.Cli;
17+
18+
/// <summary>The verify command's pure logic — no console I/O, so it is testable.</summary>
19+
public static class VerifyCommand
20+
{
21+
public sealed record Drift(string Template, string Code, string Path);
22+
23+
public sealed record Outcome(
24+
IReadOnlyList<string> LoadErrors,
25+
IReadOnlyList<Drift> Errors,
26+
IReadOnlyList<Drift> Warnings,
27+
IReadOnlyList<string> UnresolvedText)
28+
{
29+
/// <summary>Clean iff no load errors, no drift errors, and every @textRef resolved.</summary>
30+
public bool Ok => LoadErrors.Count == 0 && Errors.Count == 0 && UnresolvedText.Count == 0;
31+
}
32+
33+
public static Outcome Run(string metadataDir, string templatesRoot)
34+
{
35+
var load = new FileMetaDataLoader().LoadDirectory(metadataDir);
36+
var loadErrors = load.Errors.Select(e => e.Code.ToString()).ToList();
37+
38+
var provider = new FilesystemProvider(templatesRoot);
39+
var errors = new List<Drift>();
40+
var warnings = new List<Drift>();
41+
var unresolved = new List<string>();
42+
43+
foreach (var tmpl in load.Root.OwnChildren().Where(c => c.Type == TYPE_TEMPLATE))
44+
{
45+
// Missing @textRef / @payloadRef are already load errors (Pass 9 + schema).
46+
if (tmpl.OwnAttr(TEMPLATE_ATTR_TEXT_REF) is not string textRef ||
47+
tmpl.OwnAttr(TEMPLATE_ATTR_PAYLOAD_REF) is not string payloadRef)
48+
continue;
49+
50+
var text = provider.Resolve(textRef);
51+
if (text is null)
52+
{
53+
unresolved.Add($"template \"{tmpl.Name}\": @textRef \"{textRef}\" did not resolve under {templatesRoot}");
54+
continue;
55+
}
56+
57+
var fields = PayloadCodegen.BuildPayloadFieldTree(load.Root, payloadRef);
58+
IReadOnlyList<string> requiredSlots = tmpl.OwnAttr(TEMPLATE_ATTR_REQUIRED_SLOTS) switch
59+
{
60+
IReadOnlyList<string> ss => ss.ToList(),
61+
IReadOnlyList<object?> os => os.OfType<string>().ToList(),
62+
string s => [s],
63+
_ => [],
64+
};
65+
66+
var verifyOptions = new VerifyOptions { Provider = provider, RequiredSlots = requiredSlots };
67+
foreach (var e in Verify.Check(text, fields, verifyOptions))
68+
{
69+
var drift = new Drift(tmpl.Name, e.Code, e.Path);
70+
if (e.Code == Verify.ERR_REQUIRED_SLOT_UNUSED) warnings.Add(drift);
71+
else errors.Add(drift);
72+
}
73+
}
74+
75+
return new Outcome(loadErrors, errors, warnings, unresolved);
76+
}
77+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Filesystem-backed provider: resolves a `group/source` reference to
2+
// <root>/<group>/<source>.mustache. The render engine + verify delegate all I/O
3+
// to a provider, so this is the only file-touching piece of the render tier.
4+
//
5+
// Mirrors the FilesystemProvider contract noted in
6+
// typescript/packages/render/src/provider.ts.
7+
8+
namespace MetaObjects.Render;
9+
10+
/// <summary>
11+
/// Resolves <c>group/source</c> references to text files under a root directory:
12+
/// <c>resolve("npc/turn")</c> → <c>&lt;root&gt;/npc/turn.mustache</c>. Returns null
13+
/// when the file is absent. Refs that escape the root (via <c>..</c>) are rejected.
14+
/// </summary>
15+
public sealed class FilesystemProvider : IProvider
16+
{
17+
private readonly string _root;
18+
private readonly string _extension;
19+
20+
public FilesystemProvider(string root, string extension = ".mustache")
21+
{
22+
_root = Path.GetFullPath(root);
23+
_extension = extension;
24+
}
25+
26+
public string? Resolve(string reference)
27+
{
28+
// Build <root>/<seg>/<seg>.<ext> from the slash-separated ref.
29+
var segments = reference.Split('/', StringSplitOptions.RemoveEmptyEntries);
30+
if (segments.Length == 0) return null;
31+
32+
var combined = Path.GetFullPath(Path.Combine([_root, .. segments])) + _extension;
33+
34+
// Path-traversal guard: the resolved file must stay under the root.
35+
if (!combined.StartsWith(_root + Path.DirectorySeparatorChar, StringComparison.Ordinal) &&
36+
!combined.StartsWith(_root, StringComparison.Ordinal))
37+
return null;
38+
39+
return File.Exists(combined) ? File.ReadAllText(combined) : null;
40+
}
41+
}

server/csharp/MetaObjects.sln

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MetaObjects.Codegen", "Meta
1515
EndProject
1616
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MetaObjects.Codegen.Tests", "MetaObjects.Codegen.Tests\MetaObjects.Codegen.Tests.csproj", "{142B4D99-6673-4598-98B2-36EFC47492F9}"
1717
EndProject
18+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MetaObjects.Cli", "MetaObjects.Cli\MetaObjects.Cli.csproj", "{0BCBEBC2-7196-449D-B041-2F56FEE2714A}"
19+
EndProject
20+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MetaObjects.Cli.Tests", "MetaObjects.Cli.Tests\MetaObjects.Cli.Tests.csproj", "{5B55ECEC-72F4-4344-8629-69C0C79893F6}"
21+
EndProject
1822
Global
1923
GlobalSection(SolutionConfigurationPlatforms) = preSolution
2024
Debug|Any CPU = Debug|Any CPU
@@ -48,5 +52,13 @@ Global
4852
{142B4D99-6673-4598-98B2-36EFC47492F9}.Debug|Any CPU.Build.0 = Debug|Any CPU
4953
{142B4D99-6673-4598-98B2-36EFC47492F9}.Release|Any CPU.ActiveCfg = Release|Any CPU
5054
{142B4D99-6673-4598-98B2-36EFC47492F9}.Release|Any CPU.Build.0 = Release|Any CPU
55+
{0BCBEBC2-7196-449D-B041-2F56FEE2714A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
56+
{0BCBEBC2-7196-449D-B041-2F56FEE2714A}.Debug|Any CPU.Build.0 = Debug|Any CPU
57+
{0BCBEBC2-7196-449D-B041-2F56FEE2714A}.Release|Any CPU.ActiveCfg = Release|Any CPU
58+
{0BCBEBC2-7196-449D-B041-2F56FEE2714A}.Release|Any CPU.Build.0 = Release|Any CPU
59+
{5B55ECEC-72F4-4344-8629-69C0C79893F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
60+
{5B55ECEC-72F4-4344-8629-69C0C79893F6}.Debug|Any CPU.Build.0 = Debug|Any CPU
61+
{5B55ECEC-72F4-4344-8629-69C0C79893F6}.Release|Any CPU.ActiveCfg = Release|Any CPU
62+
{5B55ECEC-72F4-4344-8629-69C0C79893F6}.Release|Any CPU.Build.0 = Release|Any CPU
5163
EndGlobalSection
5264
EndGlobal

spec/roadmap.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77
- **H2 — Shared conformance fixtures** (2026-05-15)
88
Fixtures extracted into `fixtures/conformance/`; TS conformance runner; canonical
99
serializer (fused-key form); format documented in `spec/conformance-tests.md`.
10-
- **C# loader + conformance** (shipped at v0.3 parity)
10+
- **C# full-stack target** (loader + conformance shipped; codegen/runtime in progress)
1111
C# Loader at `csharp/MetaObjects/` plus full conformance corpus green via
12-
`csharp/MetaObjects.Conformance.Tests/` (`dotnet test`). C# codegen + runtime are out
13-
of scope at this stage.
12+
`csharp/MetaObjects.Conformance.Tests/` (`dotnet test`) — metadata layer fully caught
13+
up (empty expected-failures ledger). C# is a first-class full-stack port: the FR-004
14+
render engine (`MetaObjects.Render`) + payload-VO codegen (`MetaObjects.Codegen`) +
15+
`verify` ship; the CLI, full codegen, runtime, and migrate tiers are being built out.
1416
- **Per-target output directories (TS codegen)** (2026-05-22)
1517
Each generator routes to a named output target (`{ outDir, importBase?, outputLayout?,
1618
dbImport? }`), so generated code lands with its runtime concern (model → database

0 commit comments

Comments
 (0)