From 62704b382dce8315a76f2c09cee7b3271413186a Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Fri, 30 Oct 2026 02:00:54 -0400 Subject: [PATCH 1/6] fix(csharp): derive namespaces from the metadata package when none is configured --- CHANGELOG.md | 16 +-- docs/features/cli.md | 6 +- docs/ports/csharp.md | 9 +- .../.metaobjects/.gen-state/.hashes.json | 12 +- .../generated/csharp/AppDbContext.g.cs | 2 +- .../showcase/generated/csharp/Subscriber.g.cs | 2 +- .../csharp/SubscriberBlurbPayload.g.cs | 2 +- .../csharp/SubscriberFilterAllowlist.g.cs | 2 +- .../generated/csharp/SubscriberNames.g.cs | 2 +- .../generated/csharp/SubscriberRoutes.g.cs | 2 +- .../CodegenNamespaceTests.cs | 16 ++- .../DerivedNamespaceTests.cs | 119 ++++++++++++++++++ .../MetaObjects.Codegen/CodegenRunner.cs | 1 + .../csharp/MetaObjects.Codegen/Generator.cs | 16 ++- .../Generators/ReverseFinders.cs | 4 + .../PackageBindingResolver.cs | 39 +++++- 16 files changed, 210 insertions(+), 40 deletions(-) create mode 100644 server/csharp/MetaObjects.Codegen.Tests/DerivedNamespaceTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 76f4c81bfe..5c66890f37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -249,13 +249,15 @@ here.** committed output, so output generated under another namespace is drift. A C# namespace comes from the package bindings (`GenConfig.TypeOverrides`, then `PackageNamespaces`, then the `Convention` rule, then `UnmappedStrategy`, each keyed by an object's own metadata package); - the default `Flatten` strategy places every type in one namespace, `--namespace` or else the - new `csharp.namespace` key in `.metaobjects/config.json`. There is no default value: the - constant `Generated` is gone, and a run that emits a file needing the namespace without one - set stops and asks for it (a run that emits nothing never does). A namespace that is not a - dotted path of C# identifiers fails generation. **Upgrade step:** set - `"csharp": { "namespace": "" }` to the namespace your committed output uses (`Generated` - if you never passed `--namespace`), so `gen` and a bare `dotnet meta verify` both use it. `dotnet meta gen`, `verify --codegen`, `init` + with nothing configured, each type's namespace is derived from its own metadata package (each + `::` segment PascalCased, joined with `.`: `acme::billing` is `Acme.Billing`), and the types that + belong to no single package (`AppDbContext`, routes) land in the namespace every package shares. + `csharp.namespace` in `.metaobjects/config.json` (or `--namespace`) replaces the derived default + with one flat namespace for types no binding places. A namespace that is not a dotted path of C# + identifiers fails generation, naming the metadata package it came from. **Upgrade step:** a + project that set no namespace now gets namespaces derived from its packages instead of + `Generated`; to keep the namespace you generated with before, set + `"csharp": { "namespace": "Generated" }` (or whatever your committed output uses). `dotnet meta gen`, `verify --codegen`, `init` and `eject` no longer check whether `codegen/Program.cs` runs `Generators.All`. **Upgrade step:** if `codegen/Program.cs` builds its own generator list, replace that list with `Generators.All`. Such a project still runs its own list and ignores diff --git a/docs/features/cli.md b/docs/features/cli.md index 52c6cd7344..e2b68fc18f 100644 --- a/docs/features/cli.md +++ b/docs/features/cli.md @@ -217,9 +217,9 @@ the committed `--out` tree, never touching it), and a **clean `--db` rejection directory and `codegen/Generators.cs` are present, `--codegen`. The output directory is `csharp.outDir` in `.metaobjects/config.json` (the same setting `gen` writes to); `--out ` wins over it. With neither, the codegen gate is skipped with a note that names the setting. The -namespace for types no package binding places is `--namespace`, else `csharp.namespace` there, -read the same way by `gen` and `verify`; a run that emits such a type with neither set stops and -asks for one. A bare run prints the subverb note. The **Java/Kotlin `mvn metaobjects:verify`** port expresses the same vocabulary as a +namespace of each type comes from its metadata package (`acme::billing` is `Acme.Billing`); +`csharp.namespace` there (or `--namespace`) replaces that with one flat namespace for types no +package binding places, read the same way by `gen` and `verify`. A bare run prints the subverb note. The **Java/Kotlin `mvn metaobjects:verify`** port expresses the same vocabulary as a `mode` parameter (Maven goals are parameter-driven, not flag-driven). With `mode` unset the goal runs both gates below, skipping the template gate with a note when no `templateRoot` is set. `-Dmeta.verify.mode=codegen` diff --git a/docs/ports/csharp.md b/docs/ports/csharp.md index dc128fc15f..ce588e6d71 100644 --- a/docs/ports/csharp.md +++ b/docs/ports/csharp.md @@ -207,10 +207,11 @@ directory holding `.metaobjects/`: Each generated type's namespace comes from the package bindings, keyed by that type's own metadata package: a type-level override (`GenConfig.TypeOverrides`), then a package-level one (`PackageNamespaces`), then the `Convention` rule (strip a package prefix, prepend a root, case -each `::` segment), then `UnmappedStrategy`. The default strategy, `Flatten`, puts every type in -one namespace: `--namespace` when given, else `csharp.namespace`. There is no built-in value, so -a run that emits a file needing it with neither set stops and names both; a run that emits -nothing never asks. The namespace must be a dotted path of C# identifiers. `dotnet meta gen` and +each `::` segment), then `UnmappedStrategy`. With none of those configured, the default +convention applies: each `::` segment PascalCased and joined with `.` (`acme::billing` is +`Acme.Billing`), and the artifacts that belong to no single package (`AppDbContext`, routes) land in +the namespace all the packages share. Setting `--namespace` or `csharp.namespace` switches the +default off and puts every type no binding places in that one flat namespace. The namespace must be a dotted path of C# identifiers. `dotnet meta gen` and `verify` set only that flat namespace; the per-package bindings are `GenConfig` properties, set when you run codegen from your own code with `CodegenRunner`. diff --git a/examples/showcase/.metaobjects/.gen-state/.hashes.json b/examples/showcase/.metaobjects/.gen-state/.hashes.json index 447c9713ae..8a7a4f6bb9 100644 --- a/examples/showcase/.metaobjects/.gen-state/.hashes.json +++ b/examples/showcase/.metaobjects/.gen-state/.hashes.json @@ -1,10 +1,10 @@ { - "generated/csharp/AppDbContext.g.cs": "314c6078030ba151687430b37e6b6ea936238bb5df02df238e4acce2f95a430d", - "generated/csharp/Subscriber.g.cs": "3ca160941378fbb8b6ad6acf080ff85573f463931e21b8756873a4476e5dd163", - "generated/csharp/SubscriberBlurbPayload.g.cs": "6b1ce5b5a4018c647586d86125718c4764ae5d6eeb94e9545e8006a7ec2b2b6f", - "generated/csharp/SubscriberFilterAllowlist.g.cs": "7428d47f6ccb64bb315266ee0564fa3803736623a34ab491435c042848c5d472", - "generated/csharp/SubscriberNames.g.cs": "91f6eba78657b31fceef7021c6ca6bd799524dcb5120acbc8fcbe18786787174", - "generated/csharp/SubscriberRoutes.g.cs": "acb153e2aac648a487d78d6d5321c0f250451f47b691f988e3b99ff410bcfa06", + "generated/csharp/AppDbContext.g.cs": "4d441b4283d074cd98d11385bd8c9a2294a9378d3b392b451aea3130b5d2ca28", + "generated/csharp/Subscriber.g.cs": "a7701fda82e3eb27dad5a01f326c0893ecd2ac418eea6e3afc5c4a2b81535bc2", + "generated/csharp/SubscriberBlurbPayload.g.cs": "29eb3a6ce64496ffe734cba55d0213994316b4281d3fa6791917393bae36c9ed", + "generated/csharp/SubscriberFilterAllowlist.g.cs": "3839b04884e4e393d4f7d7bf4c7d4fe5b3852bd3a92b1ecb282cb8935a3c98e5", + "generated/csharp/SubscriberNames.g.cs": "c2cf0248c2ecde155ec2623604771e39bed23783603d6f18a2ed82967227a732", + "generated/csharp/SubscriberRoutes.g.cs": "15450e230d396a739d789340c42488da354c22265193b5ac61b074b0ba6fb1ff", "generated/python/Subscriber.py": "9dd4c0acea47f99d1daf79930f9de2e400f30780ebce6c63f023be528a72b762", "generated/python/SubscriberBlurbPayload.py": "a5ef8a08e63efbe0f73e39a21a368cff5150c68cc8beeea6e8aab0c755f8bbdd", "generated/python/__init__.py": "daa04cec586768866d0996c779f62073e6773fe63750ff668327991fac2fd0f5", diff --git a/examples/showcase/generated/csharp/AppDbContext.g.cs b/examples/showcase/generated/csharp/AppDbContext.g.cs index ca2c9eb5ed..7c6b8821be 100644 --- a/examples/showcase/generated/csharp/AppDbContext.g.cs +++ b/examples/showcase/generated/csharp/AppDbContext.g.cs @@ -3,7 +3,7 @@ #nullable enable using Microsoft.EntityFrameworkCore; -namespace Generated; +namespace Acme; public class AppDbContext : DbContext { diff --git a/examples/showcase/generated/csharp/Subscriber.g.cs b/examples/showcase/generated/csharp/Subscriber.g.cs index 679535597f..d0135f44b2 100644 --- a/examples/showcase/generated/csharp/Subscriber.g.cs +++ b/examples/showcase/generated/csharp/Subscriber.g.cs @@ -6,7 +6,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -namespace Generated; +namespace Acme; [Table(SubscriberNames.SourcePrimaryTable)] public class Subscriber diff --git a/examples/showcase/generated/csharp/SubscriberBlurbPayload.g.cs b/examples/showcase/generated/csharp/SubscriberBlurbPayload.g.cs index 33d8e99647..7117a267f9 100644 --- a/examples/showcase/generated/csharp/SubscriberBlurbPayload.g.cs +++ b/examples/showcase/generated/csharp/SubscriberBlurbPayload.g.cs @@ -7,7 +7,7 @@ using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json.Serialization; -namespace Generated; +namespace Acme; public class SubscriberBlurbPayload { diff --git a/examples/showcase/generated/csharp/SubscriberFilterAllowlist.g.cs b/examples/showcase/generated/csharp/SubscriberFilterAllowlist.g.cs index 799d3039af..c34c35a0b5 100644 --- a/examples/showcase/generated/csharp/SubscriberFilterAllowlist.g.cs +++ b/examples/showcase/generated/csharp/SubscriberFilterAllowlist.g.cs @@ -3,7 +3,7 @@ #nullable enable using System.Collections.Generic; -namespace Generated; +namespace Acme; /// /// GENERATED — per-entity FR-009 filter allowlist for Subscriber. diff --git a/examples/showcase/generated/csharp/SubscriberNames.g.cs b/examples/showcase/generated/csharp/SubscriberNames.g.cs index 87bce520c2..75e7f02ed7 100644 --- a/examples/showcase/generated/csharp/SubscriberNames.g.cs +++ b/examples/showcase/generated/csharp/SubscriberNames.g.cs @@ -3,7 +3,7 @@ #nullable enable using System.Collections.Generic; -namespace Generated; +namespace Acme; /// /// GENERATED — per-object physical database names for Subscriber (spec A1/A2/A6). diff --git a/examples/showcase/generated/csharp/SubscriberRoutes.g.cs b/examples/showcase/generated/csharp/SubscriberRoutes.g.cs index abeeaf2841..ffb1ed369c 100644 --- a/examples/showcase/generated/csharp/SubscriberRoutes.g.cs +++ b/examples/showcase/generated/csharp/SubscriberRoutes.g.cs @@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore; using MetaObjects.Codegen.Runtime; -namespace Generated; +namespace Acme; public static class SubscriberRoutes { diff --git a/server/csharp/MetaObjects.Cli.Tests/CodegenNamespaceTests.cs b/server/csharp/MetaObjects.Cli.Tests/CodegenNamespaceTests.cs index f1b2892977..da09117bac 100644 --- a/server/csharp/MetaObjects.Cli.Tests/CodegenNamespaceTests.cs +++ b/server/csharp/MetaObjects.Cli.Tests/CodegenNamespaceTests.cs @@ -3,9 +3,9 @@ namespace MetaObjects.Cli.Tests; /// -/// The flat namespace gen and verify --codegen generate under: --namespace, else -/// csharp.namespace in .metaobjects/config.json; with neither, only a run that emits -/// a file needing one stops. Driven through the entry point a project's +/// The namespace gen and verify --codegen generate under: --namespace, else +/// csharp.namespace in .metaobjects/config.json; with neither, each type's namespace is +/// derived from its metadata package. Driven through the entry point a project's /// codegen/Program.cs calls (); the oracle is the /// namespace ...; declaration in the generated file, the output contract an adopter's code /// compiles against. @@ -65,18 +65,16 @@ public void A_config_namespace_is_used_by_gen_and_a_bare_verify_is_clean() } [Fact] - public void With_no_namespace_a_run_that_emits_code_stops_and_asks_for_one() + public void With_no_namespace_it_is_derived_from_the_metadata_package_and_a_bare_verify_is_clean() { WriteConfig("\"outDir\": \"Generated\""); var gen = Run("gen"); - Assert.NotEqual(0, gen.Exit); - Assert.Contains("\"csharp\": { \"namespace\": \"\" }", gen.Output); - Assert.False(File.Exists(Path.Combine(OutDir, "Subscriber.g.cs"))); + Assert.True(gen.Exit == 0, gen.Output); + Assert.Contains("namespace Acme.Shop;", EntityFile()); var verify = Run("verify", "--codegen"); - Assert.Equal(2, verify.Exit); - Assert.Contains("\"csharp\": { \"namespace\": \"\" }", verify.Output); + Assert.True(verify.Exit == 0, verify.Output); } [Fact] diff --git a/server/csharp/MetaObjects.Codegen.Tests/DerivedNamespaceTests.cs b/server/csharp/MetaObjects.Codegen.Tests/DerivedNamespaceTests.cs new file mode 100644 index 0000000000..41dceb6b89 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/DerivedNamespaceTests.cs @@ -0,0 +1,119 @@ +// With no namespace configured, a generated type's namespace comes from its own metadata +// package (the default convention), and the types that belong to no single package land in the +// namespace every package shares. Explicit settings keep winning, and a multi-package model +// compiles: the cross-namespace references need the usings the split-model machinery computes. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using MetaObjects.Codegen; +using MetaObjects.Codegen.Generators; +using MetaObjects.Loader; +using MetaObjects.Meta; +using Xunit; + +namespace MetaObjects.Codegen.Tests; + +public class DerivedNamespaceTests +{ + private static string Billing(string package = "acme::billing") => """ + { "metadata.root": { "package": "@PKG@", "children": [ + { "object.entity": { "name": "Invoice", "children": [ + { "source.rdb": { "@table": "invoices" } }, + { "field.long": { "name": "id" } }, + { "identity.primary": { "@fields": "id" } } + ]}} + ]}} + """.Replace("@PKG@", package); + + private const string Crm = """ + { "metadata.root": { "package": "acme::crm", "children": [ + { "object.entity": { "name": "Order", "children": [ + { "source.rdb": { "@table": "orders" } }, + { "field.long": { "name": "id" } }, + { "field.long": { "name": "invoiceId" } }, + { "relationship.association": { "name": "invoice", "@cardinality": "one", "@objectRef": "acme::billing::Invoice" } }, + { "identity.primary": { "@fields": "id" } }, + { "identity.reference": { "name": "refInvoice", "@fields": "invoiceId", "@references": "acme::billing::Invoice" } } + ]}} + ]}} + """; + + private static MetaRoot Load(params string[] docs) + { + var r = new MetaDataLoader().Load(docs.Select((d, i) => (IMetaDataSource)new InMemoryStringSource(d, id: $"m{i}.json")).ToList()); + Assert.Empty(r.Errors); + return r.Root; + } + + private static List Generate(MetaRoot root, GenConfig config) + { + config = PackageBindingResolver.WithDerivedHome(config, root); + var ctx = new GenContext { Entities = root.Objects(), Root = root, Config = config }; + return new IGenerator[] { new EntityGenerator(), new DbContextGenerator(), new FilterAllowlistGenerator() } + .SelectMany(g => g.Generate(ctx)).ToList(); + } + + private static GenConfig Bare() => new() { OutDir = "/tmp" }; + + private static string Content(List files, string name) => + files.Single(f => f.Path == name).Content; + + [Fact] + public void No_config_derives_each_namespace_from_its_package() + { + var files = Generate(Load(Billing(), Crm), Bare()); + Assert.Contains("namespace Acme.Billing;", Content(files, "Invoice.g.cs")); + Assert.Contains("namespace Acme.Crm;", Content(files, "Order.g.cs")); + // The package-less artifact lands in the namespace both packages share, and uses both. + var db = Content(files, "AppDbContext.g.cs"); + Assert.Contains("namespace Acme;", db); + Assert.Contains("using Acme.Billing;", db); + Assert.Contains("using Acme.Crm;", db); + } + + [Fact] + public void A_single_package_is_its_own_home_namespace() + { + var files = Generate(Load(Billing()), Bare()); + Assert.Contains("namespace Acme.Billing;", Content(files, "AppDbContext.g.cs")); + } + + [Fact] + public void Configured_namespace_overrides_the_derived_default() + { + var files = Generate(Load(Billing(), Crm), Bare() with { Namespace = "My.App" }); + Assert.Contains("namespace My.App;", Content(files, "Invoice.g.cs")); + Assert.Contains("namespace My.App;", Content(files, "Order.g.cs")); + } + + [Fact] + public void A_package_binding_overrides_the_derived_default() + { + var cfg = Bare() with { PackageNamespaces = new() { ["acme::billing"] = "Acme.Ledger" } }; + var files = Generate(Load(Billing(), Crm), cfg); + Assert.Contains("namespace Acme.Ledger;", Content(files, "Invoice.g.cs")); + Assert.Contains("namespace Acme.Crm;", Content(files, "Order.g.cs")); + } + + [Fact] + public void An_invalid_derived_segment_names_the_package() + { + var root = Load(Billing("acme::9lives")); + var ex = Assert.Throws(() => Generate(root, Bare())); + Assert.Contains("acme::9lives", ex.Message); + } + + [Fact] + public void A_multi_package_model_compiles() + { + var files = Generate(Load(Billing(), Crm), Bare()); + var comp = CSharpCompilation.Create( + "derived_ns_" + Guid.NewGuid().ToString("N"), + files.Select(f => CSharpSyntaxTree.ParseText(f.Content, new CSharpParseOptions(LanguageVersion.CSharp12), path: f.Path)), + DbContextCompileTests.BuildReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var errors = comp.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error) + .Select(d => $"{d.Location.GetLineSpan().Path} {d.Id}: {d.GetMessage()}").ToList(); + Assert.True(errors.Count == 0, string.Join("\n", errors)); + } +} diff --git a/server/csharp/MetaObjects.Codegen/CodegenRunner.cs b/server/csharp/MetaObjects.Codegen/CodegenRunner.cs index 71ab61ead5..4aa2ffa2f9 100644 --- a/server/csharp/MetaObjects.Codegen/CodegenRunner.cs +++ b/server/csharp/MetaObjects.Codegen/CodegenRunner.cs @@ -45,6 +45,7 @@ internal static Func OutRootFor(GenConfig config) => internal static RunResult Run( GenConfig config, MetaRoot root, IReadOnlyList generators, Func outRoot) { + config = PackageBindingResolver.WithDerivedHome(config, root); var warnings = new List(); var ctx = new GenContext { diff --git a/server/csharp/MetaObjects.Codegen/Generator.cs b/server/csharp/MetaObjects.Codegen/Generator.cs index 20f8d38c07..748cf34fbf 100644 --- a/server/csharp/MetaObjects.Codegen/Generator.cs +++ b/server/csharp/MetaObjects.Codegen/Generator.cs @@ -78,13 +78,25 @@ public sealed record GenConfig [System.Diagnostics.CodeAnalysis.AllowNull] public string Namespace { - get => PackageBindingResolver.ValidNamespace(_namespace ?? throw new InvalidOperationException( + get => PackageBindingResolver.ValidNamespace(_namespace ?? DerivedHome ?? throw new InvalidOperationException( "no C# namespace for the generated code: set \"csharp\": { \"namespace\": \"\" } in " + - ".metaobjects/config.json or pass --namespace .")); + ".metaobjects/config.json or pass --namespace (the metadata packages share no common " + + "root to derive one from).")); init => _namespace = value; } private readonly string? _namespace; + /// True when --namespace or csharp.namespace supplied a flat namespace. + /// Without one, a package no binding places takes its namespace from its own metadata package + /// (). + public bool HasNamespace => _namespace is not null; + + /// The home namespace derived from the model's own packages when none is configured: + /// where the artifacts that belong to no single package (AppDbContext, routes) land. + /// Set once per run by ; kept apart from the + /// configured namespace so deriving it does not switch the default convention off. + internal string? DerivedHome { get; init; } + /// /// Strategy applied to field names with no @column override. ADR-0058 — one /// project setting (naming.columns in .metaobjects/config.json), read by diff --git a/server/csharp/MetaObjects.Codegen/Generators/ReverseFinders.cs b/server/csharp/MetaObjects.Codegen/Generators/ReverseFinders.cs index d68a6965c6..1bbd72d328 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/ReverseFinders.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/ReverseFinders.cs @@ -116,6 +116,10 @@ private static string FkValueType(MetaObject entity, ReverseFk fk, MetaRoot root sb.AppendLine("using System.Collections.Generic;"); sb.AppendLine("using System.Linq;"); sb.AppendLine("using Microsoft.EntityFrameworkCore;"); + // The finders live beside AppDbContext; the entity (or its view read model) lives in its own + // package's namespace when the model is split. + var entityNs = PackageBindingResolver.Resolve(ctx.Config, PackageBindingResolver.EffectivePackage(entity), entity.Name, fallbackContext: entity.Name); + if (entityNs != ctx.Config.Namespace) sb.AppendLine($"using {entityNs};"); sb.AppendLine(); sb.AppendLine($"namespace {ctx.Config.Namespace};"); sb.AppendLine(); diff --git a/server/csharp/MetaObjects.Codegen/PackageBindingResolver.cs b/server/csharp/MetaObjects.Codegen/PackageBindingResolver.cs index 485f78af2b..b57c91e5a5 100644 --- a/server/csharp/MetaObjects.Codegen/PackageBindingResolver.cs +++ b/server/csharp/MetaObjects.Codegen/PackageBindingResolver.cs @@ -3,7 +3,8 @@ // // 1. Type-level override — config.TypeOverrides["::"] // 2. Package-level override — config.PackageNamespaces[] -// 3. Convention rule — config.Convention (strip + prepend + case + separator) +// 3. Convention rule — config.Convention (strip + prepend + case + separator); when +// none is set and no flat namespace is either, DefaultConvention // 4. Unmapped strategy fallback — config.UnmappedStrategy (Flatten | Error | Derive) // // All resolution happens via this single helper so the @provided enum resolver @@ -21,6 +22,38 @@ public static class PackageBindingResolver /// The metaobjects-standard package separator (cross-port invariant). Always ::. public const string MetaPackageSeparator = "::"; + /// The default pattern: each :: segment PascalCased, joined with . + /// (acme::billing → Acme.Billing). It applies only while the project configures no + /// and no flat namespace, so every explicit setting wins. + public static readonly PackageBindingConvention DefaultConvention = new() { Prepend = "" }; + + /// With no configured namespace, the home namespace the package-less artifacts need: the + /// longest dotted prefix every package namespace in the model shares (Acme.Billing and + /// Acme.Crm → Acme; a single package → its own). Left unset when the packages share + /// no root, so a run that needs one fails naming the setting. A configured namespace is returned + /// unchanged. + public static GenConfig WithDerivedHome(GenConfig config, MetaRoot root) + { + if (config.HasNamespace) return config; + var namespaces = root.Objects() + .Select(EffectivePackage) + .Where(p => !string.IsNullOrEmpty(p)) + .Distinct(StringComparer.Ordinal) + .Select(p => TryResolve(config, p)) + .OfType() + .Select(ns => ns.Split('.')) + .ToList(); + if (namespaces.Count == 0) return config; + var common = namespaces[0].AsEnumerable(); + foreach (var other in namespaces.Skip(1)) + common = common.Zip(other, (a, b) => (a, b)).TakeWhile(t => t.a == t.b).Select(t => t.a); + var home = string.Join('.', common); + return home.Length == 0 ? config : config with { DerivedHome = home }; + } + + private static PackageBindingConvention? EffectiveConvention(GenConfig config) => + config.Convention ?? (config.HasNamespace ? null : DefaultConvention); + /// The metadata node's EFFECTIVE package — the file-default package captured at parse /// time, NOT just the node's own (rarely-set) package: attr. Mirrors the /// PackageOf pattern in FR-019: derive from @@ -171,7 +204,7 @@ public static string ValidNamespace(string ns, string? package = null) return pkgTarget; } // 3. Convention rule. - if (!string.IsNullOrEmpty(package) && config.Convention is { } conv) + if (!string.IsNullOrEmpty(package) && EffectiveConvention(config) is { } conv) { // When useProvidedEnumPrepend is true, ONLY succeed if the convention actually has // a ProvidedEnumPrepend set; otherwise fall through so the caller can apply its @@ -206,7 +239,7 @@ private static string ResolveImpl( } // 3. Convention rule — derives the target from the package itself. - if (!string.IsNullOrEmpty(package) && config.Convention is { } conv) + if (!string.IsNullOrEmpty(package) && EffectiveConvention(config) is { } conv) { var derived = ApplyConvention(conv, package, applyStripPrepend: true, useProvidedEnumPrepend); if (derived is not null) return derived; From f69c23d3e402bc99fdd77c7301efdf7594c8e7c5 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Fri, 30 Oct 2026 02:05:55 -0400 Subject: [PATCH 2/6] no-mistakes(review): Add package usings to home-namespace C# generators --- .../DerivedNamespaceTests.cs | 41 +++++++++++++++++-- .../MetaObjects.Codegen/CSharpNaming.cs | 4 +- .../Generators/DbContextGenerator.cs | 4 +- .../Generators/ReportExportGenerator.cs | 1 + .../Generators/RoutesGenerator.cs | 22 ++++++++++ .../PackageBindingResolver.cs | 10 +++++ 6 files changed, 75 insertions(+), 7 deletions(-) diff --git a/server/csharp/MetaObjects.Codegen.Tests/DerivedNamespaceTests.cs b/server/csharp/MetaObjects.Codegen.Tests/DerivedNamespaceTests.cs index 41dceb6b89..fa5ab5697b 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/DerivedNamespaceTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/DerivedNamespaceTests.cs @@ -20,8 +20,19 @@ private static string Billing(string package = "acme::billing") => """ { "object.entity": { "name": "Invoice", "children": [ { "source.rdb": { "@table": "invoices" } }, { "field.long": { "name": "id" } }, + { "field.string": { "name": "label", "@maxLength": 40 } }, + { "identity.primary": { "@fields": "id" } }, + { "dimension.attribute": { "name": "label", "@of": "Invoice.label" } }, + { "measure.aggregate": { "name": "lines", "@agg": "count", "@of": "Invoice.id" } } + ]}}, + { "object.entity": { "name": "Series", "children": [ + { "source.rdb": { "@table": "series" } }, + { "field.long": { "name": "id" } }, + { "field.enum": { "name": "grade", "@values": ["Low", "High"], "@intValueMap": { "Low": 1, "High": 2 } } }, { "identity.primary": { "@fields": "id" } } - ]}} + ]}}, + { "object.report": { "name": "InvoiceTotals", "@from": "Invoice", "@dimensions": ["label"], "@measures": ["lines"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_invoice_totals" } } ] } } ]}} """.Replace("@PKG@", package); @@ -49,10 +60,17 @@ private static List Generate(MetaRoot root, GenConfig config) { config = PackageBindingResolver.WithDerivedHome(config, root); var ctx = new GenContext { Entities = root.Objects(), Root = root, Config = config }; - return new IGenerator[] { new EntityGenerator(), new DbContextGenerator(), new FilterAllowlistGenerator() } + return new IGenerator[] + { + new EntityGenerator(), new DbContextGenerator(), new FilterAllowlistGenerator(), + new NamesGenerator(), new ReportExportGenerator(), new RoutesGenerator(), + } .SelectMany(g => g.Generate(ctx)).ToList(); } + private static IEnumerable Usings(string source) => + CSharpSyntaxTree.ParseText(source).GetCompilationUnitRoot().Usings.Select(u => u.Name!.ToString()); + private static GenConfig Bare() => new() { OutDir = "/tmp" }; private static string Content(List files, string name) => @@ -69,6 +87,9 @@ public void No_config_derives_each_namespace_from_its_package() Assert.Contains("namespace Acme;", db); Assert.Contains("using Acme.Billing;", db); Assert.Contains("using Acme.Crm;", db); + // An already-plural owner is named by its own package's namespace, not the home's. + Assert.Contains("global::Acme.Billing.Series.", db); + Assert.DoesNotContain("global::Acme.Series", db); } [Fact] @@ -107,13 +128,27 @@ public void An_invalid_derived_segment_names_the_package() public void A_multi_package_model_compiles() { var files = Generate(Load(Billing(), Crm), Bare()); + // Routes import the ASP.NET Core shared framework, which an in-memory compilation does not + // carry (CodegenCompileConformanceTests draws the same line); their package usings are + // asserted by Routes_reach_the_package_namespaces_of_the_types_they_name. var comp = CSharpCompilation.Create( "derived_ns_" + Guid.NewGuid().ToString("N"), - files.Select(f => CSharpSyntaxTree.ParseText(f.Content, new CSharpParseOptions(LanguageVersion.CSharp12), path: f.Path)), + files.Where(f => !f.Path.EndsWith("Routes.g.cs", StringComparison.Ordinal)).Select(f => CSharpSyntaxTree.ParseText(f.Content, new CSharpParseOptions(LanguageVersion.CSharp12), path: f.Path)), DbContextCompileTests.BuildReferences(), new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); var errors = comp.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error) .Select(d => $"{d.Location.GetLineSpan().Path} {d.Id}: {d.GetMessage()}").ToList(); Assert.True(errors.Count == 0, string.Join("\n", errors)); } + + [Fact] + public void Routes_reach_the_package_namespaces_of_the_types_they_name() + { + var files = Generate(Load(Billing(), Crm), Bare()); + Assert.Contains("Acme.Billing", Usings(Content(files, "InvoiceRoutes.g.cs"))); + Assert.Contains("Acme.Crm", Usings(Content(files, "OrderRoutes.g.cs"))); + // Order navigates to an Invoice, and the home namespace is neither package's. + Assert.Contains("Acme", Content(files, "OrderRoutes.g.cs").Split('\n').Where(l => l.StartsWith("namespace ")).Select(l => l["namespace ".Length..].TrimEnd(';', '\r'))); + Assert.Contains("Acme.Billing", Usings(Content(files, "InvoiceTotalsExport.g.cs"))); + } } diff --git a/server/csharp/MetaObjects.Codegen/CSharpNaming.cs b/server/csharp/MetaObjects.Codegen/CSharpNaming.cs index 2d39b5ef6e..29e96e4ed4 100644 --- a/server/csharp/MetaObjects.Codegen/CSharpNaming.cs +++ b/server/csharp/MetaObjects.Codegen/CSharpNaming.cs @@ -1127,8 +1127,8 @@ public static string EnumTypeName(MetaObject entity, MetaField field) /// with ITSELF (the self-collision case), which the refusal does not and should not /// forbid. /// - public static string QualifiedOwnerTypeRef(string owner, GenConfig config) => - Pluralize(owner) == owner ? $"global::{config.Namespace}.{owner}" : owner; + public static string QualifiedOwnerTypeRef(string owner, MetaObject ownerObject, GenConfig config) => + Pluralize(owner) == owner ? $"global::{ValueObjectNames.Namespace(ownerObject, config)}.{owner}" : owner; /// /// Refuse a generation run in which two DISTINCT entities/projections pluralize to diff --git a/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs index ab4b56154c..aa604b2b1f 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs @@ -409,7 +409,7 @@ private static string HasDiscriminatorConfig(string owner, MetaObject baseEntity // this DbContext's own same-named DbSet property instead of the entity type // (CS1061), which an already-plural base entity name now reaches. var enumType = discField is not null && isEnum - ? $"{CSharpNaming.QualifiedOwnerTypeRef(owner, config)}.{CSharpNaming.EnumTypeName(baseEntity, discField)}" : null; + ? $"{CSharpNaming.QualifiedOwnerTypeRef(owner, baseEntity, config)}.{CSharpNaming.EnumTypeName(baseEntity, discField)}" : null; var sb = new StringBuilder(); sb.Append($" modelBuilder.Entity<{owner}>().HasDiscriminator(e => e.{discProp})"); @@ -946,7 +946,7 @@ private static string EnumConversionCall(string owner, MetaObject entity, MetaFi // name now reaches. var type = Fr019SharedEnum.SharedEnumForField(f) is { } shared ? Fr019SharedEnum.SharedEnumTypeReference(shared, config) - : $"{CSharpNaming.QualifiedOwnerTypeRef(owner, config)}.{CSharpNaming.EnumTypeName(entity, f)}"; + : $"{CSharpNaming.QualifiedOwnerTypeRef(owner, entity, config)}.{CSharpNaming.EnumTypeName(entity, f)}"; // Read the ints THROUGH the map, keyed by member, so @values stays the SSOT and a // member with no mapping cannot silently vanish from the conversion. var ints = new List(members.Count); diff --git a/server/csharp/MetaObjects.Codegen/Generators/ReportExportGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/ReportExportGenerator.cs index 60fdca3d03..223d5e3de9 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/ReportExportGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/ReportExportGenerator.cs @@ -99,6 +99,7 @@ private string ReportModule(MetaObject report, GenContext ctx) sb.AppendLine("using System.Collections.Generic;"); sb.AppendLine("using System.IO;"); sb.AppendLine("using System.Linq;"); + foreach (var u in PackageBindingResolver.HomeUsings(ctx.Config, [report])) sb.AppendLine(u); sb.AppendLine(); sb.AppendLine($"namespace {ctx.Config.Namespace};"); sb.AppendLine(); diff --git a/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs index 12f9b08bc5..f254e8ec63 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs @@ -183,6 +183,26 @@ private static void AppendReportExportBranch(StringBuilder sb, string cls, strin sb.AppendLine($" return ReportExportHttp.Xml(http, \"{route}\", w => {cls}Export.WriteXml(w, rows));"); } + // Every type the route file names bare — the entity itself, its value objects (owned and map + // values) and the targets and junctions of its M:N traversals — so a split-namespace model gets + // a `using` for each package namespace the home-namespace file reaches into. + private static IEnumerable ReferencedTypes(IEnumerable entities, MetaRoot root) + { + foreach (var entity in entities) + { + yield return entity; + foreach (var f in entity.Fields()) + if ((f.SubType == FIELD_SUBTYPE_OBJECT || f.SubType == FIELD_SUBTYPE_MAP) + && ValueObjectNames.ResolveFieldRef(f, root) is { } target && target.IsValue()) + yield return target; + foreach (var nav in M2MNavigationBuilder.For(entity, root, _ => { })) + { + yield return nav.Target; + yield return nav.Junction; + } + } + } + protected virtual EmittedFile GenerateStandardRoutes(MetaObject entity, GenContext ctx) { var cls = CSharpNaming.Pascal(entity.Name); @@ -286,6 +306,7 @@ protected virtual EmittedFile GenerateStandardRoutes(MetaObject entity, GenConte sb.AppendLine("using Microsoft.AspNetCore.Routing;"); sb.AppendLine("using Microsoft.EntityFrameworkCore;"); sb.AppendLine($"using {HelperRuntimeNamespace};"); + foreach (var u in PackageBindingResolver.HomeUsings(ctx.Config, ReferencedTypes([entity], ctx.Root))) sb.AppendLine(u); sb.AppendLine(); sb.AppendLine($"namespace {ctx.Config.Namespace};"); sb.AppendLine(); @@ -596,6 +617,7 @@ protected virtual EmittedFile GenerateTphRoutes(MetaObject baseEntity, TphPlan t sb.AppendLine("using Microsoft.AspNetCore.Routing;"); sb.AppendLine("using Microsoft.EntityFrameworkCore;"); sb.AppendLine($"using {HelperRuntimeNamespace};"); + foreach (var u in PackageBindingResolver.HomeUsings(ctx.Config, ReferencedTypes(tph.Subtypes.Select(st => st.Entity).Prepend(baseEntity), ctx.Root))) sb.AppendLine(u); sb.AppendLine(); sb.AppendLine($"namespace {ctx.Config.Namespace};"); sb.AppendLine(); diff --git a/server/csharp/MetaObjects.Codegen/PackageBindingResolver.cs b/server/csharp/MetaObjects.Codegen/PackageBindingResolver.cs index b57c91e5a5..9a4d1cc7f8 100644 --- a/server/csharp/MetaObjects.Codegen/PackageBindingResolver.cs +++ b/server/csharp/MetaObjects.Codegen/PackageBindingResolver.cs @@ -51,6 +51,16 @@ public static GenConfig WithDerivedHome(GenConfig config, MetaRoot root) return home.Length == 0 ? config : config with { DerivedHome = home }; } + /// The using directives a file in the home namespace needs to name + /// bare: one per distinct namespace they resolve to other than the home + /// itself, sorted ordinally. + public static IEnumerable HomeUsings(GenConfig config, IEnumerable types) => + types.Select(t => Resolve(config, EffectivePackage(t), t.Name, fallbackContext: t.Name)) + .Where(ns => ns != config.Namespace) + .Distinct(StringComparer.Ordinal) + .OrderBy(ns => ns, StringComparer.Ordinal) + .Select(ns => $"using {ns};"); + private static PackageBindingConvention? EffectiveConvention(GenConfig config) => config.Convention ?? (config.HasNamespace ? null : DefaultConvention); From 48d03ec4f9544c21e627e0c0273cab9afa2c19ce Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Fri, 30 Oct 2026 02:08:28 -0400 Subject: [PATCH 3/6] no-mistakes(review): Scope derived home to in-scope packages; widen compile test --- .../DerivedNamespaceTests.cs | 54 +++++++++++++++++-- 1 file changed, 49 insertions(+), 5 deletions(-) diff --git a/server/csharp/MetaObjects.Codegen.Tests/DerivedNamespaceTests.cs b/server/csharp/MetaObjects.Codegen.Tests/DerivedNamespaceTests.cs index fa5ab5697b..24cdbeace7 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/DerivedNamespaceTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/DerivedNamespaceTests.cs @@ -49,6 +49,34 @@ private static string Billing(string package = "acme::billing") => """ ]}} """; + private const string Ops = """ + { "metadata.root": { "package": "acme::ops", "children": [ + { "field.enum": { "name": "Grade", "abstract": true, "@values": ["Low", "High"] } }, + { "object.entity": { "name": "Ticket", "children": [ + { "source.rdb": { "@table": "tickets" } }, + { "field.long": { "name": "id" } }, + { "field.enum": { "name": "grade", "extends": "Grade" } }, + { "identity.primary": { "@fields": "id" } } + ]}}, + { "object.value": { "name": "TicketArgs", "children": [ + { "field.int": { "name": "ticketId", "@required": true } } + ]}}, + { "object.projection": { "name": "TicketSummary", "children": [ + { "source.rdb": { "@kind": "storedProc", "@proc": "fn_ticket_summary", "@parameterRef": "TicketArgs" } }, + { "field.long": { "name": "ticketId" } } + ]}}, + { "object.value": { "name": "TicketBrief", "children": [ + { "field.string": { "name": "title" } } + ]}}, + { "object.value": { "name": "TicketVerdict", "children": [ + { "field.enum": { "name": "verdict", "@required": true, "@values": ["GOOD", "POOR"] } }, + { "field.string": { "name": "rationale", "@required": true, "@maxLength": 480 } } + ]}}, + { "template.prompt": { "name": "triage", "@payloadRef": "TicketBrief", "@responseRef": "TicketVerdict", + "@textRef": "ops/triage", "@format": "text", "@responseFormat": "json", "@promptStyle": "guide" } } + ]}} + """; + private static MetaRoot Load(params string[] docs) { var r = new MetaDataLoader().Load(docs.Select((d, i) => (IMetaDataSource)new InMemoryStringSource(d, id: $"m{i}.json")).ToList()); @@ -64,10 +92,20 @@ private static List Generate(MetaRoot root, GenConfig config) { new EntityGenerator(), new DbContextGenerator(), new FilterAllowlistGenerator(), new NamesGenerator(), new ReportExportGenerator(), new RoutesGenerator(), + new CallableGenerator(), new RenderHelperGenerator(TemplateRoot.Value), new OutputPromptGenerator(), + new OutputParserGenerator(), new ExtractorGenerator(), } .SelectMany(g => g.Generate(ctx)).ToList(); } + private static readonly Lazy TemplateRoot = new(() => + { + var root = Path.Combine(Path.GetTempPath(), "derived-ns-prompts-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Combine(root, "ops")); + File.WriteAllText(Path.Combine(root, "ops", "triage.mustache"), "Ticket: {{title}}\n"); + return root; + }); + private static IEnumerable Usings(string source) => CSharpSyntaxTree.ParseText(source).GetCompilationUnitRoot().Usings.Select(u => u.Name!.ToString()); @@ -79,7 +117,7 @@ private static string Content(List files, string name) => [Fact] public void No_config_derives_each_namespace_from_its_package() { - var files = Generate(Load(Billing(), Crm), Bare()); + var files = Generate(Load(Billing(), Crm, Ops), Bare()); Assert.Contains("namespace Acme.Billing;", Content(files, "Invoice.g.cs")); Assert.Contains("namespace Acme.Crm;", Content(files, "Order.g.cs")); // The package-less artifact lands in the namespace both packages share, and uses both. @@ -102,7 +140,7 @@ public void A_single_package_is_its_own_home_namespace() [Fact] public void Configured_namespace_overrides_the_derived_default() { - var files = Generate(Load(Billing(), Crm), Bare() with { Namespace = "My.App" }); + var files = Generate(Load(Billing(), Crm, Ops), Bare() with { Namespace = "My.App" }); Assert.Contains("namespace My.App;", Content(files, "Invoice.g.cs")); Assert.Contains("namespace My.App;", Content(files, "Order.g.cs")); } @@ -111,7 +149,7 @@ public void Configured_namespace_overrides_the_derived_default() public void A_package_binding_overrides_the_derived_default() { var cfg = Bare() with { PackageNamespaces = new() { ["acme::billing"] = "Acme.Ledger" } }; - var files = Generate(Load(Billing(), Crm), cfg); + var files = Generate(Load(Billing(), Crm, Ops), cfg); Assert.Contains("namespace Acme.Ledger;", Content(files, "Invoice.g.cs")); Assert.Contains("namespace Acme.Crm;", Content(files, "Order.g.cs")); } @@ -127,7 +165,13 @@ public void An_invalid_derived_segment_names_the_package() [Fact] public void A_multi_package_model_compiles() { - var files = Generate(Load(Billing(), Crm), Bare()); + var files = Generate(Load(Billing(), Crm, Ops), Bare()); + foreach (var expected in new[] + { + "Enums.g.cs", "TicketSummary.callable.g.cs", "triage.render.cs", "triage.response.cs", + "triage.responseFormat.cs", "TicketVerdictExtracted.g.cs", "InvoiceTotalsExport.g.cs", + }) + Assert.Contains(files, f => f.Path == expected); // Routes import the ASP.NET Core shared framework, which an in-memory compilation does not // carry (CodegenCompileConformanceTests draws the same line); their package usings are // asserted by Routes_reach_the_package_namespaces_of_the_types_they_name. @@ -144,7 +188,7 @@ public void A_multi_package_model_compiles() [Fact] public void Routes_reach_the_package_namespaces_of_the_types_they_name() { - var files = Generate(Load(Billing(), Crm), Bare()); + var files = Generate(Load(Billing(), Crm, Ops), Bare()); Assert.Contains("Acme.Billing", Usings(Content(files, "InvoiceRoutes.g.cs"))); Assert.Contains("Acme.Crm", Usings(Content(files, "OrderRoutes.g.cs"))); // Order navigates to an Invoice, and the home namespace is neither package's. From 3abe18dd4c4b6ecb8ce98fcb1007c573002234cd Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Fri, 30 Oct 2026 02:09:50 -0400 Subject: [PATCH 4/6] no-mistakes(review): Derive home namespace from in-scope packages only --- .../DependencyCodegenTests.cs | 16 ++++++++++++++-- .../PackageBindingResolver.cs | 1 + 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/server/csharp/MetaObjects.Codegen.Tests/DependencyCodegenTests.cs b/server/csharp/MetaObjects.Codegen.Tests/DependencyCodegenTests.cs index dcaac174ca..a75364ab18 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/DependencyCodegenTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/DependencyCodegenTests.cs @@ -58,13 +58,13 @@ private void Write(string rel, string content) File.WriteAllText(path, content); } - private Dictionary GenerateAll() + private Dictionary GenerateAll(string? ns = "Acme.Generated") { var load = MetadataLocation.Resolve(null, _tmp).Load(); Assert.Empty(load.Errors); // The import is on the loaded root: that is what the generators read. Assert.NotNull(load.Root.ImportScope); - var config = new GenConfig { OutDir = OutDir, Namespace = "Acme.Generated" }; + var config = ns is null ? new GenConfig { OutDir = OutDir } : new GenConfig { OutDir = OutDir, Namespace = ns }; var names = GeneratorRegistry.Entries.Keys.Where(n => !Excluded.Contains(n)).ToList(); CodegenRunner.Run(config, load.Root, GeneratorRegistry.Resolve(names)); return Directory.EnumerateFiles(OutDir, "*", SearchOption.AllDirectories) @@ -94,6 +94,18 @@ public void An_imported_node_emits_nothing_from_any_registered_generator() } } + [Fact] + public void With_no_namespace_configured_an_imported_package_does_not_decide_the_home_namespace() + { + Project(); + var output = GenerateAll(ns: null); + + // The project's own package `app` and the imported `acme::common` share no root; only the + // project's own package is generated, so only it names the home namespace. + Assert.Contains("namespace App;", output["AppDbContext.g.cs"]); + Assert.Contains("namespace App;", output["Order.g.cs"]); + } + [Fact] public void An_imported_value_object_a_local_entity_holds_is_still_emitted() { diff --git a/server/csharp/MetaObjects.Codegen/PackageBindingResolver.cs b/server/csharp/MetaObjects.Codegen/PackageBindingResolver.cs index 9a4d1cc7f8..77457e902f 100644 --- a/server/csharp/MetaObjects.Codegen/PackageBindingResolver.cs +++ b/server/csharp/MetaObjects.Codegen/PackageBindingResolver.cs @@ -36,6 +36,7 @@ public static GenConfig WithDerivedHome(GenConfig config, MetaRoot root) { if (config.HasNamespace) return config; var namespaces = root.Objects() + .Where(root.InScope) .Select(EffectivePackage) .Where(p => !string.IsNullOrEmpty(p)) .Distinct(StringComparer.Ordinal) From d17434a8c1db4d5820c8d939a821fc8d97bddb31 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Fri, 30 Oct 2026 02:21:51 -0400 Subject: [PATCH 5/6] no-mistakes(document): Docs updated for derived C# namespace default. Two stale spots fixed: the port doc paragraph (added the no-common-root failure, rewrapped) and the codegen skill reference, which now points to docs/ports/csharp.md. CHANGELOG and docs/features/cli.md were already updated by the change. No executable code or tests touched. --- .../skills/metaobjects-codegen/references/csharp.md | 6 +++--- docs/ports/csharp.md | 9 +++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/agent-context/skills/metaobjects-codegen/references/csharp.md b/agent-context/skills/metaobjects-codegen/references/csharp.md index e782303a8d..e2a5371bb5 100644 --- a/agent-context/skills/metaobjects-codegen/references/csharp.md +++ b/agent-context/skills/metaobjects-codegen/references/csharp.md @@ -106,9 +106,9 @@ set and no separate docs command. `--generators ` narrows a run to entrie list already declares, by stable name; it never adds one, and naming an entry the list does not declare is refused. `dotnet meta gen --list` is the catalog of what the port registers, not what this project runs; under each name it prints the expression that lists -that generator in `codegen/Generators.cs` and its options. Output lands under `--namespace` in -`--out` unless a generator's own init properties say otherwise — documentation -does (below). +that generator in `codegen/Generators.cs` and its options. Output lands in `--out`, in the +namespace its metadata package maps to (see `docs/ports/csharp.md`), unless a generator's own +init properties say otherwise — documentation does (below). | Stable name | Entry in `codegen/Generators.cs` (`new MetaObjects.Codegen.Generators.…`) | Output | |---|---|---| diff --git a/docs/ports/csharp.md b/docs/ports/csharp.md index ce588e6d71..8c73e423c6 100644 --- a/docs/ports/csharp.md +++ b/docs/ports/csharp.md @@ -210,10 +210,11 @@ metadata package: a type-level override (`GenConfig.TypeOverrides`), then a pack each `::` segment), then `UnmappedStrategy`. With none of those configured, the default convention applies: each `::` segment PascalCased and joined with `.` (`acme::billing` is `Acme.Billing`), and the artifacts that belong to no single package (`AppDbContext`, routes) land in -the namespace all the packages share. Setting `--namespace` or `csharp.namespace` switches the -default off and puts every type no binding places in that one flat namespace. The namespace must be a dotted path of C# identifiers. `dotnet meta gen` and -`verify` set only that flat namespace; the per-package bindings are `GenConfig` properties, set -when you run codegen from your own code with `CodegenRunner`. +the namespace all the packages share. When they share no root, generation stops and asks for a +namespace. Setting `--namespace` or `csharp.namespace` switches the default off and puts every type +no binding places in that one flat namespace. The namespace must be a dotted path of C# +identifiers. `dotnet meta gen` and `verify` set only that flat namespace; the per-package bindings +are `GenConfig` properties, set when you run codegen from your own code with `CodegenRunner`. With that, `dotnet meta gen` writes there, `dotnet meta verify --codegen` diffs against it, and a plain `dotnet meta verify` in CI runs the codegen gate with no flags. `--out ` still works From ec512519e26ad9c7e470248d6798f198ec0eaabe Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Fri, 30 Oct 2026 02:24:26 -0400 Subject: [PATCH 6/6] no-mistakes(lint): Regenerate showcase site payload for derived C# namespace --- examples/showcase/site-payload.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/showcase/site-payload.json b/examples/showcase/site-payload.json index a3c26fd067..fd2b2670a6 100644 --- a/examples/showcase/site-payload.json +++ b/examples/showcase/site-payload.json @@ -33,7 +33,7 @@ "csharp-entity": { "lang": "csharp", "inline": "// <auto-generated/>\n…\n[Table(SubscriberNames.SourcePrimaryTable)]\npublic class Subscriber\n{\n public enum SubscriberStatus { active, paused, cancelled }\n [Key]\n [Column(SubscriberNames.IdColumn)]\n public long Id { get; set; }\n [Column(SubscriberNames.EmailColumn)]\n [Required(AllowEmptyStrings = true)]\n [MaxLength(320)]\n…\n public string Email { get; set; } = default!;\n [Column(SubscriberNames.NameColumn)]\n public string? Name { get; set; }\n [Column(SubscriberNames.StatusColumn)]\n public SubscriberStatus Status { get; set; }\n [Column(SubscriberNames.CreatedAtColumn)]\n public DateTimeOffset? CreatedAt { get; set; }\n}", - "full": "// <auto-generated/>\n// Generated by MetaObjects entity-generator. Do not edit by hand.\n#nullable enable\nusing System;\nusing System.Collections.Generic;\nusing System.ComponentModel.DataAnnotations;\nusing System.ComponentModel.DataAnnotations.Schema;\n\nnamespace Generated;\n\n[Table(SubscriberNames.SourcePrimaryTable)]\npublic class Subscriber\n{\n public enum SubscriberStatus { active, paused, cancelled }\n [Key]\n [Column(SubscriberNames.IdColumn)]\n public long Id { get; set; }\n [Column(SubscriberNames.EmailColumn)]\n [Required(AllowEmptyStrings = true)]\n [MaxLength(320)]\n [MinLength(1)]\n public string Email { get; set; } = default!;\n [Column(SubscriberNames.NameColumn)]\n public string? Name { get; set; }\n [Column(SubscriberNames.StatusColumn)]\n public SubscriberStatus Status { get; set; }\n [Column(SubscriberNames.CreatedAtColumn)]\n public DateTimeOffset? CreatedAt { get; set; }\n}\n", + "full": "// <auto-generated/>\n// Generated by MetaObjects entity-generator. Do not edit by hand.\n#nullable enable\nusing System;\nusing System.Collections.Generic;\nusing System.ComponentModel.DataAnnotations;\nusing System.ComponentModel.DataAnnotations.Schema;\n\nnamespace Acme;\n\n[Table(SubscriberNames.SourcePrimaryTable)]\npublic class Subscriber\n{\n public enum SubscriberStatus { active, paused, cancelled }\n [Key]\n [Column(SubscriberNames.IdColumn)]\n public long Id { get; set; }\n [Column(SubscriberNames.EmailColumn)]\n [Required(AllowEmptyStrings = true)]\n [MaxLength(320)]\n [MinLength(1)]\n public string Email { get; set; } = default!;\n [Column(SubscriberNames.NameColumn)]\n public string? Name { get; set; }\n [Column(SubscriberNames.StatusColumn)]\n public SubscriberStatus Status { get; set; }\n [Column(SubscriberNames.CreatedAtColumn)]\n public DateTimeOffset? CreatedAt { get; set; }\n}\n", "lineCount": 29 }, "java-dto": {