Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions server/csharp/MetaObjects.Cli.Tests/VerifyStrictTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
using MetaObjects.Cli;
using Xunit;

namespace MetaObjects.Cli.Tests;

/// <summary>
/// #96 — `dotnet meta verify` is strict-by-default (ADR-0023), with a `--lax`
/// escape. An undeclared / typo'd own `@attr` is <c>ERR_UNKNOWN_ATTR</c> and verify
/// surfaces it as a load error (non-zero exit); `--lax` restores the legacy
/// open-attr load so the same metadata passes.
///
/// This closes the port-dependent verdict where an unregistered attr silently
/// passed verify in C# (lax load) but was rejected by Java's force-strict Maven
/// goal. The loader-level error code/text is unchanged — only the verify CLI's
/// default strictness moves (and only verify; gen/docs/agent-docs stay lax).
/// </summary>
public sealed class VerifyStrictTests : IDisposable
{
private readonly string _tmp = Path.Combine(Path.GetTempPath(), "meta-verify-strict-" + Guid.NewGuid().ToString("N"));
private string MetaDir => Path.Combine(_tmp, "metaobjects");
private string TplDir => Path.Combine(_tmp, "templates");

// A registered node (object.entity / field.string) carrying ONE made-up own
// attribute (@madeUpAttr) that no metamodel provider declares. Strict load →
// ERR_UNKNOWN_ATTR; lax load → accepted.
private const string MetadataWithUnknownAttr = """
{ "metadata.root": { "package": "acme", "children": [
{ "object.entity": { "name": "Subscriber", "children": [
{ "source.rdb": { "@table": "subscribers" } },
{ "field.long": { "name": "id" } },
{ "field.string": { "name": "email", "@madeUpAttr": "oops" } },
{ "identity.primary": { "@fields": "id" } }
]}}
]}}
""";

public VerifyStrictTests()
{
Directory.CreateDirectory(MetaDir);
Directory.CreateDirectory(TplDir);
File.WriteAllText(Path.Combine(MetaDir, "meta.acme.json"), MetadataWithUnknownAttr);
}

public void Dispose() { try { Directory.Delete(_tmp, recursive: true); } catch { } }

private VerifyCommand.Options Opts(bool strict) =>
new()
{
MetadataDir = MetaDir,
TemplatesRoot = TplDir,
Templates = true,
Strict = strict,
};

[Fact]
public void Verify_strict_by_default_rejects_an_unregistered_attr()
{
// Default Options.Strict is true → the made-up @attr is ERR_UNKNOWN_ATTR.
var opts = new VerifyCommand.Options { MetadataDir = MetaDir, TemplatesRoot = TplDir, Templates = true };
Assert.True(opts.Strict); // strict is the default

var r = VerifyCommand.RunSubverbs(opts);
Assert.NotEqual(0, r.ExitCode);
Assert.NotNull(r.Templates);
Assert.Contains(ErrorCode.ERR_UNKNOWN_ATTR.ToString(), r.Templates!.LoadErrors);
}

[Fact]
public void Verify_lax_accepts_an_unregistered_attr()
{
var r = VerifyCommand.RunSubverbs(Opts(strict: false));
// No load errors under lax — the made-up attr is tolerated.
Assert.DoesNotContain(ErrorCode.ERR_UNKNOWN_ATTR.ToString(), r.Templates!.LoadErrors);
Assert.Empty(r.Templates!.LoadErrors);
}

[Fact]
public void Run_strict_surfaces_unknown_attr_as_a_load_error()
{
var strict = VerifyCommand.Run(MetaDir, TplDir, strict: true);
Assert.Contains(ErrorCode.ERR_UNKNOWN_ATTR.ToString(), strict.LoadErrors);
Assert.False(strict.Ok);

var lax = VerifyCommand.Run(MetaDir, TplDir, strict: false);
Assert.DoesNotContain(ErrorCode.ERR_UNKNOWN_ATTR.ToString(), lax.LoadErrors);
}

[Fact]
public void Codegen_gate_is_strict_by_default_and_reports_the_unknown_attr()
{
var opts = new VerifyCommand.Options
{
MetadataDir = MetaDir,
OutDir = Path.Combine(_tmp, "generated"),
Codegen = true,
// Strict defaults true.
};
var r = VerifyCommand.RunSubverbs(opts);
// Strict load fails before any diff → codegen error (exit 2), naming the code.
Assert.NotEqual(0, r.ExitCode);
Assert.NotNull(r.Codegen!.Error);
Assert.Contains(ErrorCode.ERR_UNKNOWN_ATTR.ToString(), r.Codegen!.Error);
}
}
22 changes: 19 additions & 3 deletions server/csharp/MetaObjects.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@
" [--generators <a,b,c>] [--template-root <dir>]\n" +
" generate EF Core code from metadata\n" +
" gen --list list available generators (stable names) and exit\n" +
" verify <metadataDir> [--templates <root>] [--codegen --out <dir> [--namespace <ns>]] [--db]\n" +
" verify <metadataDir> [--templates <root>] [--codegen --out <dir> [--namespace <ns>]] [--db] [--lax]\n" +
" drift gates (ADR-0021 D2 subverbs):\n" +
" --templates template/prompt drift (default)\n" +
" --codegen regen-to-temp vs committed --out\n" +
" --namespace codegen regen namespace; inferred\n" +
" from the committed --out when omitted\n" +
" --db NOT supported in C# (migrate engine)\n" +
" --lax load lax (legacy); strict-by-default\n" +
" rejects an unregistered @attr (ADR-0023)\n" +
" docs <metadataDir> --out <dir> [--namespace <ns>] [--project <name>] [--model-base-url <url>]\n" +
" emit the generated C# SDK api reference\n" +
" (the api/csharp surface: one page per\n" +
Expand Down Expand Up @@ -173,7 +175,7 @@ static int RunVerify(string[] rest)
bool nsExplicit = false;
string? generatorsCsv = null;
string? templateRoot = null;
bool templates = false, codegen = false, db = false;
bool templates = false, codegen = false, db = false, lax = false;

for (int i = 0; i < rest.Length; i++)
{
Expand All @@ -186,14 +188,17 @@ static int RunVerify(string[] rest)
}
else if (a == "--codegen") codegen = true;
else if (a == "--db") db = true;
// --lax (#96 / ADR-0023): restore the legacy open-attr load. verify is
// strict-by-default — an undeclared/typo'd own @attr is ERR_UNKNOWN_ATTR.
else if (a == "--lax") lax = true;
else if (a == "--out" && i + 1 < rest.Length) outDir = rest[++i];
else if (a == "--namespace" && i + 1 < rest.Length) { ns = rest[++i]; nsExplicit = true; }
else if (a == "--generators" && i + 1 < rest.Length) generatorsCsv = rest[++i];
else if (a == "--template-root" && i + 1 < rest.Length) templateRoot = rest[++i];
else if (a.StartsWith('-'))
{
Console.Error.WriteLine($"dotnet meta verify: unknown option \"{a}\"");
Console.Error.WriteLine("usage: dotnet meta verify <metadataDir> [--templates <root>] [--codegen --out <dir> [--namespace <ns>]] [--db]");
Console.Error.WriteLine("usage: dotnet meta verify <metadataDir> [--templates <root>] [--codegen --out <dir> [--namespace <ns>]] [--db] [--lax]");
return 2;
}
else if (metadataDir is null) metadataDir = a;
Expand Down Expand Up @@ -239,12 +244,23 @@ static int RunVerify(string[] rest)
Templates = templates,
Codegen = codegen,
Db = db,
// #96 / ADR-0023: verify is strict-by-default; --lax restores the legacy load.
Strict = !lax,
};

var result = VerifyCommand.RunSubverbs(opts);

if (result.EmittedDefaultNote) Console.WriteLine(VerifyCommand.SUBVERB_NOTE);

// #96 — when a strict load surfaced an unregistered @attr, print the actionable
// three-exit hint once (register / attr.properties bag / --lax). Suppressed in
// lax mode (the user already opted out of strict).
var unknownAttr = MetaObjects.ErrorCode.ERR_UNKNOWN_ATTR.ToString();
bool sawUnknownAttr =
(result.Templates?.LoadErrors.Contains(unknownAttr) ?? false) ||
(result.Codegen?.Error?.Contains(unknownAttr) ?? false);
if (sawUnknownAttr && !lax) Console.Error.WriteLine($" hint: {VerifyCommand.UNKNOWN_ATTR_HINT}");

// -- templates gate output --
if (result.RanTemplates && result.Templates is { } t)
{
Expand Down
27 changes: 23 additions & 4 deletions server/csharp/MetaObjects.Cli/VerifyCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ public sealed record Outcome(
"migrate engine (the TypeScript `meta verify --db`). Use 'dotnet meta verify --codegen' " +
"for generated-output drift, or '--templates' for template/prompt drift.";

/// <summary>
/// Actionable hint (#96 / ADR-0023) printed when a strict load rejects an
/// undeclared own <c>@attr</c> (<c>ERR_UNKNOWN_ATTR</c>) — names the three exits.
/// </summary>
public const string UNKNOWN_ATTR_HINT =
"verify loads strict by default (ADR-0023): an unregistered or typo'd @attr is " +
"ERR_UNKNOWN_ATTR. To fix: register the attr on a metadata provider, OR move " +
"arbitrary author-supplied properties into an `attr.properties` bag, OR run " +
"'dotnet meta verify --lax' to keep the legacy open-attr load.";

/// <summary>The one-line note bare <c>verify</c> prints advertising the subverbs.</summary>
public const string SUBVERB_NOTE =
"dotnet meta verify — running --templates (default). Explicit subverbs: " +
Expand Down Expand Up @@ -90,6 +100,15 @@ public sealed record Options
/// <summary><c>--db</c> requested (rejected in C#).</summary>
public bool Db { get; init; }

/// <summary>
/// Strict metadata load (ADR-0023 / #96). Default <c>true</c>: an undeclared or
/// typo'd own <c>@attr</c> is <c>ERR_UNKNOWN_ATTR</c> and verify fails — matching
/// Java's force-strict Maven goal and the strict TS/Python verify CLIs. <c>--lax</c>
/// sets this <c>false</c> to restore the legacy open-attr load. Only <c>verify</c>
/// defaults strict; <c>gen</c>/<c>docs</c>/<c>agent-docs</c> stay lax.
/// </summary>
public bool Strict { get; init; } = true;

/// <summary>True when no explicit subverb flag was passed (bare verify).</summary>
public bool NoExplicitSubverb => !Templates && !Codegen && !Db;
}
Expand Down Expand Up @@ -133,7 +152,7 @@ public static SubverbResult RunSubverbs(Options opts)
Outcome? templatesOutcome = null;
if (runTemplates)
{
templatesOutcome = Run(opts.MetadataDir, opts.TemplatesRoot ?? "");
templatesOutcome = Run(opts.MetadataDir, opts.TemplatesRoot ?? "", opts.Strict);
if (!templatesOutcome.Ok) exit = Math.Max(exit, 1);
}

Expand Down Expand Up @@ -181,7 +200,7 @@ private static Codegen.CodegenDrift.Result RunCodegenDrift(Options opts)
"generated output to diff against.",
};

var load = MetaDataLoader.FromDirectory(opts.MetadataDir);
var load = MetaDataLoader.FromDirectory(opts.MetadataDir, strict: opts.Strict);
if (load.Errors.Count > 0)
return new Codegen.CodegenDrift.Result
{
Expand Down Expand Up @@ -225,9 +244,9 @@ private static Codegen.CodegenDrift.Result RunCodegenDrift(Options opts)
_ => [],
};

public static Outcome Run(string metadataDir, string templatesRoot)
public static Outcome Run(string metadataDir, string templatesRoot, bool strict = true)
{
var load = MetaDataLoader.FromDirectory(metadataDir);
var load = MetaDataLoader.FromDirectory(metadataDir, strict: strict);
var loadErrors = load.Errors.Select(e => e.Code.ToString()).ToList();

var provider = new FilesystemProvider(templatesRoot);
Expand Down
8 changes: 5 additions & 3 deletions server/csharp/MetaObjects/Loader/MetaDataLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,12 @@ private static TypeRegistry DefaultRegistry() =>

/// <summary>
/// Convenience: build a <see cref="DirectorySource"/> for <paramref name="directory"/>
/// and load all discovered files in deterministic order.
/// and load all discovered files in deterministic order. <paramref name="strict"/>
/// (default false / lax) makes an undeclared own <c>@attr</c> an
/// <c>ERR_UNKNOWN_ATTR</c> load error (ADR-0023) — <c>verify</c> opts into strict.
/// </summary>
public static LoadResult FromDirectory(string directory, DirectorySource.Options? opts = null)
=> FromDirectory(directory, DefaultRegistry(), opts);
public static LoadResult FromDirectory(string directory, DirectorySource.Options? opts = null, bool strict = false)
=> FromDirectory(directory, DefaultRegistry(), opts, strict);

/// <summary>
/// Registry-aware overload: build a <see cref="DirectorySource"/> and load
Expand Down
Loading