Skip to content

Commit ef8dcb1

Browse files
dmealingclaude
andcommitted
feat(codegen-csharp): FR-019 — bind @provided enum namespace to declaring package
A @provided shared enum's C# namespace now resolves from the enum's declaring metadata package via GenConfig.PackageNamespaces (package→namespace), with the single ProvidedEnumNamespace as a fallback. SharedEnum carries its declaring package; SharedEnumTypeReference resolves package-first and, when unresolved, errors naming the enum AND its package. Why: an adopter with N external enums across M namespaces now configures M package entries (one per namespace) instead of N per-enum entries, and adding an enum to an already-mapped package needs no config change. The package is metadata-native; only the binding is per-port config (ADR-0001) — mirroring how every generated type relates to its package. - GenConfig.PackageNamespaces (Dictionary<package, namespace>) - Fr019SharedEnum: SharedEnum.Package + PackageOf(decl) + package-first resolution - 4 new codegen tests (package resolve / map precedence / fallback / error names package) - ADR-0026 §3 + FR-019 spec + C# codegen skill: record the package-binding model Codegen.Tests 187/0 (drift gate green); solution builds clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b1733e9 commit ef8dcb1

6 files changed

Lines changed: 163 additions & 12 deletions

File tree

agent-context/skills/metaobjects-codegen/references/csharp.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,17 @@ plus finer hooks — `EmitClassHeader` / `EmitClassDeclarationLine` (class decla
6969
generator and override only the seam you need rather than forking. `@default` on a
7070
scalar emits a literal initializer; an `object.value`'s default storage is jsonb.
7171

72+
**Shared + externally-provided enums (FR-019).** A package-level abstract
73+
`field.enum` (`abstract: true`, `@values`) extended by concrete entity fields is
74+
materialized **once** (`Enums.g.cs`) and referenced — no per-entity nested enum.
75+
Adding `@provided: true` to that declaration suppresses materialization entirely:
76+
consuming fields reference a hand-written/third-party enum, and the C# namespace
77+
**binds to the enum's declaring metadata package** via
78+
`GenConfig.PackageNamespaces["<pkg>"] = "Your.Namespace"` (one entry per namespace;
79+
`ProvidedEnumNamespace` is the single fallback). The `@values` still drive the DB
80+
`CHECK` + validation. This replaces the retired C#-only `@csEnumType` FQN attr
81+
(ADR-0026) — no language FQN ever lives in metadata (ADR-0001).
82+
7283
## Re-scaffold this context
7384

7485
`dotnet meta agent-docs --server csharp [--out <dir>]` (re)scaffolds the slim

docs/superpowers/specs/2026-06-06-fr-019-shared-and-provided-enums-design.md

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,22 @@ values `[…]`**, materialize ONE standalone type and reference it from consumer
7575

7676
For **`@provided: true`**: materialize **nothing** for `E`; each consuming field
7777
references `E` resolved to the port's configured namespace/package + `E` (C#
78-
`<cfg.enumNamespace>.E`, Kotlin/Java `<cfg.enumPackage>.E`, Python an import of
79-
`E` from `<cfg.enumModule>`, TS an import of `E`). A missing config for a
80-
referenced `@provided` enum is a **codegen-time error** (clear message naming the
81-
enum + the config key), not a metadata error.
78+
`<ns>.E`, Kotlin/Java `<pkg>.E`, Python an import of `E` from `<module>`, TS an
79+
import of `E`). The namespace **binds to `E`'s declaring metadata package** via a
80+
per-port **package→namespace map** (C# `GenConfig.PackageNamespaces`, keyed by the
81+
metadata package `E` is declared under), with a single
82+
`ProvidedEnumNamespace`/`enumNamespace` **fallback** for the one-namespace case.
83+
This keeps config proportional to the number of namespaces (one entry per package),
84+
not the number of enums, and mirrors how every generated type relates to its
85+
package (ADR-0001 — the package is metadata; only the binding is config). A
86+
referenced `@provided` enum whose package resolves to no namespace (no map entry
87+
**and** no fallback) is a **codegen-time error** naming the enum + its package, not
88+
a metadata error.
89+
90+
> **C# status:** shipped — `GenConfig.PackageNamespaces` (package→namespace) with
91+
> the `ProvidedEnumNamespace` fallback; `SharedEnum` carries its declaring package;
92+
> `Fr019SharedEnum.SharedEnumTypeReference` resolves package-first. The other ports
93+
> adopt the same package-binding shape during graduation.
8294
8395
**Byte-identical default:** an enum that is NOT a package-level shared/abstract
8496
enum (the common inline case) emits exactly as today — the per-entity nested

server/csharp/MetaObjects.Codegen.Tests/Fr019SharedEnumCodegenTests.cs

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,18 @@ private static MetaRoot Load(string json)
2828
return r.Root;
2929
}
3030

31-
private static GenContext Ctx(MetaRoot root, string? providedNs = null) => new()
31+
private static MetaRoot LoadMany(params string[] jsons)
32+
{
33+
var srcs = jsons.Select((j, i) => (IMetaDataSource)new InMemoryStringSource(j, id: $"fr019-{i}.json")).ToList();
34+
var r = new MetaDataLoader().Load(srcs);
35+
Assert.Empty(r.Errors);
36+
return r.Root;
37+
}
38+
39+
private static GenContext Ctx(
40+
MetaRoot root,
41+
string? providedNs = null,
42+
Dictionary<string, string>? packageNamespaces = null) => new()
3243
{
3344
Entities = root.Objects(),
3445
Root = root,
@@ -37,6 +48,7 @@ private static MetaRoot Load(string json)
3748
OutDir = "/tmp",
3849
Namespace = "Acme.Generated",
3950
ProvidedEnumNamespace = providedNs,
51+
PackageNamespaces = packageNamespaces ?? new(),
4052
},
4153
};
4254

@@ -140,6 +152,76 @@ public void Provided_enum_without_configured_namespace_is_a_codegen_error()
140152
Assert.Contains("ProvidedEnumNamespace", ex.Message);
141153
}
142154

155+
// ── FR-019 (package-binding) — a @provided enum's namespace maps to its declaring
156+
// metadata package via GenConfig.PackageNamespaces, NOT a single global namespace.
157+
// The package is metadata-native (ADR-0001); the package→C# namespace map is
158+
// per-port codegen config. This lets one model reference provided enums living in
159+
// several namespaces (the multi-namespace adopter shape). ──
160+
161+
private const string ExtEnumSrc = """
162+
{ "metadata.root": { "package": "acme::ext::auth", "children": [
163+
{ "field.enum": { "name": "ContactMethod", "abstract": true, "@values": ["EMAIL", "PHONE"], "@provided": true } }
164+
]}}
165+
""";
166+
private const string ExtConsumerSrc = """
167+
{ "metadata.root": { "package": "acme", "children": [
168+
{ "object.entity": { "name": "Customer", "children": [
169+
{ "source.rdb": { "@table": "customers" } },
170+
{ "field.long": { "name": "id" } },
171+
{ "field.enum": { "name": "preferred", "extends": "acme::ext::auth::ContactMethod" } },
172+
{ "identity.primary": { "@fields": "id" } }
173+
]}}
174+
]}}
175+
""";
176+
177+
[Fact]
178+
public void Provided_enum_namespace_resolves_from_its_declaring_package()
179+
{
180+
var root = LoadMany(ExtEnumSrc, ExtConsumerSrc);
181+
var files = new EntityGenerator().Generate(
182+
Ctx(root, packageNamespaces: new() { ["acme::ext::auth"] = "Acme.External.Auth" })).ToList();
183+
184+
Assert.DoesNotContain(files, f => f.Path == "Enums.g.cs");
185+
var customer = Assert.Single(files, f => f.Path == "Customer.g.cs");
186+
Assert.Contains("public Acme.External.Auth.ContactMethod? Preferred { get; set; }", customer.Content);
187+
}
188+
189+
[Fact]
190+
public void Package_namespace_map_takes_precedence_over_the_single_fallback()
191+
{
192+
var root = LoadMany(ExtEnumSrc, ExtConsumerSrc);
193+
var files = new EntityGenerator().Generate(
194+
Ctx(root,
195+
providedNs: "Acme.Fallback",
196+
packageNamespaces: new() { ["acme::ext::auth"] = "Acme.External.Auth" })).ToList();
197+
198+
var customer = Assert.Single(files, f => f.Path == "Customer.g.cs");
199+
Assert.Contains("public Acme.External.Auth.ContactMethod? Preferred { get; set; }", customer.Content);
200+
Assert.DoesNotContain("Acme.Fallback", customer.Content);
201+
}
202+
203+
[Fact]
204+
public void Single_namespace_fallback_applies_when_package_is_unmapped()
205+
{
206+
var root = LoadMany(ExtEnumSrc, ExtConsumerSrc);
207+
var files = new EntityGenerator().Generate(
208+
Ctx(root, providedNs: "Acme.Fallback")).ToList(); // no PackageNamespaces entry
209+
210+
var customer = Assert.Single(files, f => f.Path == "Customer.g.cs");
211+
Assert.Contains("public Acme.Fallback.ContactMethod? Preferred { get; set; }", customer.Content);
212+
}
213+
214+
[Fact]
215+
public void Provided_enum_with_no_package_mapping_and_no_fallback_names_the_package()
216+
{
217+
var root = LoadMany(ExtEnumSrc, ExtConsumerSrc);
218+
var ex = Assert.Throws<InvalidOperationException>(() =>
219+
new EntityGenerator().Generate(Ctx(root)).ToList());
220+
221+
Assert.Contains("ContactMethod", ex.Message);
222+
Assert.Contains("acme::ext::auth", ex.Message); // the declaring package, to guide the config
223+
}
224+
143225
[Fact]
144226
public void Inline_enum_still_emits_nested_declaration_unchanged()
145227
{

server/csharp/MetaObjects.Codegen/Generator.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,18 @@ public sealed record GenConfig
4444
/// codegen-time error naming the enum + this key.
4545
/// </summary>
4646
public string? ProvidedEnumNamespace { get; init; }
47+
48+
/// <summary>
49+
/// FR-019 — maps a metadata <b>package</b> (e.g. <c>acme::ext::auth</c>) to the C#
50+
/// namespace its <c>@provided</c> shared enums are referenced from. The namespace
51+
/// binds to the enum's <i>declaring package</i> (metadata-native, ADR-0001); the
52+
/// package→namespace map is per-port codegen config. This lets a single model
53+
/// reference <c>@provided</c> enums that live in several namespaces (one entry per
54+
/// namespace, not per enum). When a referenced provided enum's package has no entry
55+
/// here, <see cref="ProvidedEnumNamespace"/> is used as the single fallback; if
56+
/// neither resolves, it is a codegen-time error naming the enum + its package.
57+
/// </summary>
58+
public Dictionary<string, string> PackageNamespaces { get; init; } = new();
4759
}
4860

4961
/// <summary>Per-run state handed to every generator.</summary>

server/csharp/MetaObjects.Codegen/Generators/Fr019SharedEnum.cs

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,12 @@
2828

2929
namespace MetaObjects.Codegen.Generators;
3030

31-
/// <summary>A shared (root-level abstract) enum declaration codegen must reason about.</summary>
32-
public sealed record SharedEnum(string Name, IReadOnlyList<string> Values, bool Provided);
31+
/// <summary>A shared (root-level abstract) enum declaration codegen must reason about.
32+
/// <paramref name="Package"/> is the declaration's effective metadata package (folded
33+
/// file-default package), used to resolve a <c>@provided</c> enum's C# namespace via
34+
/// <see cref="GenConfig.PackageNamespaces"/> (ADR-0001: bind the namespace to the
35+
/// metadata package, never an FQN in metadata).</summary>
36+
public sealed record SharedEnum(string Name, IReadOnlyList<string> Values, bool Provided, string Package);
3337

3438
/// <summary>
3539
/// FR-019 — resolves the shared/provided status of an enum field and collects the
@@ -63,7 +67,20 @@ public static class Fr019SharedEnum
6367
return new SharedEnum(
6468
Name: CSharpNaming.Pascal(decl.Name),
6569
Values: values,
66-
Provided: decl.OwnAttr(FIELD_ATTR_PROVIDED) is true);
70+
Provided: decl.OwnAttr(FIELD_ATTR_PROVIDED) is true,
71+
Package: PackageOf(decl));
72+
}
73+
74+
/// <summary>
75+
/// The declaration's effective metadata package — its package-folded
76+
/// <see cref="MetaData.ResolutionKey"/> with the trailing <c>::Name</c> stripped.
77+
/// Empty string when the declaration carries no package (root-level, no file default).
78+
/// </summary>
79+
private static string PackageOf(MetaField decl)
80+
{
81+
var key = decl.ResolutionKey();
82+
var idx = key.LastIndexOf(Structural.PACKAGE_SEPARATOR, StringComparison.Ordinal);
83+
return idx < 0 ? "" : key[..idx];
6784
}
6885

6986
/// <summary>
@@ -100,12 +117,17 @@ public static IReadOnlyList<SharedEnum> MaterializedSharedEnums(MetaRoot root) =
100117
public static string SharedEnumTypeReference(SharedEnum shared, GenConfig config)
101118
{
102119
if (!shared.Provided) return shared.Name;
103-
var ns = config.ProvidedEnumNamespace;
120+
// Bind the namespace to the enum's declaring metadata package (ADR-0001):
121+
// PackageNamespaces[pkg] first, then the single ProvidedEnumNamespace fallback.
122+
var ns = config.PackageNamespaces.TryGetValue(shared.Package, out var mapped) && !string.IsNullOrEmpty(mapped)
123+
? mapped
124+
: config.ProvidedEnumNamespace;
104125
if (string.IsNullOrEmpty(ns))
105126
throw new InvalidOperationException(
106-
$"provided enum \"{shared.Name}\" is marked @provided but no namespace is configured " +
107-
$"to reference it from. Set \"ProvidedEnumNamespace\" in your codegen config (e.g. " +
108-
$"ProvidedEnumNamespace = \"YourApp.Enums\") so the generated code can reference \"{shared.Name}\".");
127+
$"provided enum \"{shared.Name}\" (declared in package \"{shared.Package}\") is marked " +
128+
$"@provided but no C# namespace is configured to reference it from. Map its package in your " +
129+
$"codegen config — PackageNamespaces[\"{shared.Package}\"] = \"YourApp.Enums\" — or set the " +
130+
$"single ProvidedEnumNamespace fallback, so the generated code can reference \"{shared.Name}\".");
109131
return $"{ns}.{shared.Name}";
110132
}
111133
}

spec/decisions/ADR-0026-shared-and-provided-named-types.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,18 @@ resolves to is **codegen config** per port, never a metadata attr. `@csEnumType`
8181
(FQN-in-metadata, on the field) is **rejected and removed** from the adopter
8282
surface.
8383

84+
The namespace **binds to the type's declaring metadata package**, not to the
85+
individual type. A `@provided` enum declared under `package: acme::ext::auth`
86+
resolves its namespace from a per-port **package→namespace map** (C#:
87+
`GenConfig.PackageNamespaces["acme::ext::auth"] = "Acme.External.Auth"`), so an
88+
adopter with N external enums spread across M namespaces configures M package
89+
entries, not N per-type entries — and adding an enum to an already-mapped package
90+
needs no config change. A single fallback (`ProvidedEnumNamespace`) covers the
91+
common one-namespace case; an unresolved package is a codegen-time error naming
92+
the enum **and its package**. The package is metadata-native; only the
93+
package→namespace binding is per-port config — consistent with how every other
94+
generated type relates to its package.
95+
8496
### 4. Migration path (enum reuse vocabulary)
8597

8698
Shared enums use the existing D6 abstract-`field.enum` + `extends` vocabulary

0 commit comments

Comments
 (0)