Skip to content

Commit 4255c2a

Browse files
committed
feat(csharp): eject hands over the emitters a generator writes through; api-docs is ejectable
An ejected C# generator used to be one file over packaged emitters, so a defect in the enum, navigation, doc-comment or prompt-tier writers still waited for a release. Eject now copies those emitters with the generator, into codegen/generators/ in the same Codegen.Generators namespace: Fr019SharedEnum, XmlDocBuilder, M2MNavigation and ReverseFinders with entity, db-context and routes as each calls them; ExtractDelegateEmitter, Fr010FieldMapping and OutputFormatSpecEmitter with output-parser, extractor and output-prompt. The list is each registry entry's Companions. A file two generators share is copied once and never overwritten. Naming and type-mapping tables, the model walks and the generator base stay in the package as the engine. api-docs is registered for C# and ejectable. The api reference is now a generator (IDocsGenerator) that `dotnet meta docs` runs; `gen` and `verify --codegen` skip it and say so. `docs` hands off to an owned codegen project once it owns api-docs, and what the reference documents comes from the generators in that project's list (GeneratorApplicability.From), so it describes an owned copy. Moved to the engine so an owned copy needs no emitter: CSharpStringLiteral to CSharpNaming, ValueObjectNames.ResolveValueObject out of RenderHelperGenerator. EjectedGeneratorsAloneTests derives its list from the registry: each ejectable generator is ejected alone, compiled against the public API, checked against a written engine surface, and compared byte for byte with the packaged generator. Packaged output is unchanged.
1 parent 0f8a14b commit 4255c2a

39 files changed

Lines changed: 1588 additions & 370 deletions

‎CHANGELOG.md‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,48 @@ generator catalog and the Atlas evaluation are 1.2 work.
138138
`GeneratorRegistry.GeneratorInfo.ejectWith()`, the classes eject copies with a
139139
generator.
140140

141+
- **C#: an ejected generator owns the emitters it writes through, and `api-docs` can be
142+
ejected.** `dotnet meta eject` used to copy one file per generator, so an owned `entity`
143+
still called four packaged writers, and an owned `output-parser`, `extractor` or
144+
`output-prompt` still called the prompt tier's field mapping and format-spec writer. A
145+
defect in any of them waited for a release. Eject now hands
146+
over the writers with the generator, into `codegen/generators/`, in the same
147+
`Codegen.Generators` namespace: `Fr019SharedEnum`, `XmlDocBuilder`, `M2MNavigation` and
148+
`ReverseFinders` with `entity`, `db-context` and `routes` as each calls them;
149+
`ExtractDelegateEmitter`, `Fr010FieldMapping` and `OutputFormatSpecEmitter` with
150+
`output-parser`, `extractor` and `output-prompt`. A file two generators share is copied once
151+
and never overwritten (`--force` replaces it, as it replaces the generator). What stays in
152+
the package is the engine: the generator base, `CSharpNaming`, `ValueObjectNames` and
153+
`PackageBindingResolver` (the naming and type-mapping tables), and the questions asked of
154+
the model (`FindInbound`, `TphPlan`, `InstanceArtifacts`, `ReportRows`). `template` is not
155+
ejectable: it has no emit logic of its own. Packaged output is unchanged and gated to be.
156+
- **`api-docs` is a generator you can own.** `dotnet meta eject api-docs` copies the page
157+
generator with its page model, builder, renderer and path math. `dotnet meta docs` hands
158+
off to `dotnet run --project codegen -- docs` once `codegen/generators/ApiDocsGenerator.cs`
159+
exists, so your copy writes the reference. What the reference documents comes from the
160+
generators in your `Program.cs` list: an owned `routes` whose `AppliesTo` you changed is
161+
documented as it behaves, not as the packaged one does (`GeneratorApplicability.From`).
162+
`dotnet meta gen` skips a docs generator and says so, and `verify --codegen` leaves it out.
163+
New public API in `MetaObjects.Codegen`: `IDocsGenerator`, `DocsRunFacts`,
164+
`GeneratorApplicability`, `GenContext.Applicability` and `GenContext.Docs`,
165+
`CodegenCli.RunDocs`, and `ValueObjectNames.ResolveValueObject` (moved from
166+
`RenderHelperGenerator`, where it was `internal`). `CSharpStringLiteral` moved from
167+
`Fr010FieldMapping` to `CSharpNaming`, so `names` needs no emitter; the old member stays as an
168+
`[Obsolete]` forwarder (see **Deprecated**).
169+
- **A copy you ejected before this release** keeps compiling and running: it finds the
170+
packaged emitters as it always did. `dotnet meta gen --list` now marks it as missing the
171+
ones it was not handed (`Fr019SharedEnum.cs missing`). To own them, eject the same name
172+
into a scratch directory (`dotnet meta eject entity --root <scratch>`) and copy the emitter
173+
files from its `codegen/generators/` into yours; your copy and your edits are untouched,
174+
and it resolves the new files by namespace.
175+
- **A gate for the rule.** For every ejectable generator the registry lists, a test ejects it
176+
alone, compiles it against the public API, fails on any reference to a packaged emitter,
177+
generator or docs writer outside a written engine surface, fails on a handed-over file
178+
nothing calls, and compares its output with the packaged generator's over the shared
179+
corpora. A registered generator with no eject source and no written exemption fails it, as
180+
does a generator class the package ships that no registry entry builds.
181+
The cross-port manifest lists `api-docs` for `csharp`.
182+
141183
- **The stock `entity`, `queries`, `routes` and `barrel` generators are importable again
142184
(TypeScript).** `entityFile`, `queriesFile`, `routesFile` and `barrel` are exported from
143185
`@metaobjectsdev/codegen-ts/generators` once more, beside `namesFile`, `routesFileHono`
@@ -744,6 +786,12 @@ until you regenerate.
744786
One rule is relaxed: an authored `validator.length @min: 0` on a `@required` string now
745787
admits the empty string, as the generated schema already did.
746788

789+
### Deprecated
790+
791+
- **C#: `Fr010FieldMapping.CSharpStringLiteral(string)` is `[Obsolete]`.** It forwards to
792+
`CSharpNaming.CSharpStringLiteral(string)`, which every generator now calls (so an ejected
793+
`names` needs no emitter). Same signature and result; switch the call when you next touch it.
794+
747795
### Removed
748796

749797
- **The standalone `mermaid-er` generator (TypeScript).** `mermaidErDiagram()`, its

‎docs/features/own-your-codegen.md‎

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1014,7 +1014,9 @@ ejectable generator's `.cs` file ships embedded in `MetaObjects.Codegen`, and a
10141014
byte-identical to the file the package compiles. Eject writes it to
10151015
`codegen/generators/<Name>Generator.cs`, renaming its namespace to `Codegen.Generators` and
10161016
adding the `using` lines that rename needs; nothing else changes, except in `routes` (see
1017-
[below](#ejecting-routes-hands-over-the-helper-runtime-too)).
1017+
[below](#ejecting-routes-hands-over-the-helper-runtime-too)). The emitters the generator
1018+
writes its output through come with it
1019+
([below](#ejecting-a-generator-hands-over-the-emitters-it-writes-through)).
10181020

10191021
On first use eject also writes `codegen/Codegen.csproj`, a console project referencing
10201022
`MetaObjects.Codegen` at the tool's version, and `codegen/Program.cs`, which lists your
@@ -1031,14 +1033,88 @@ them. So ejecting `names` from a nine-generator selection changes one generator'
10311033
not the other eight. The owned project finds metadata exactly as the tool does, including
10321034
`.metaobjects/config.json`'s `sources` and `libraries`. Eject never overwrites a copy without `--force`, and
10331035
`dotnet meta gen --list` marks owned copies `identical` or `DIFFERS: N behind, M of your
1034-
own`. The `template` primitive is not ejectable; it has no emit logic of its own.
1036+
own`, and names an emitter that was edited or removed after eject
1037+
(`Fr019SharedEnum.cs DIFFERS: 0 behind, 1 of your own`). The `template` primitive is not
1038+
ejectable; it has no emit logic of its own.
10351039
10361040
`dotnet meta eject requirement-tests` copies `RequirementTestsGenerator.cs`, which holds the
10371041
generator and its default rendering. Its options are public properties you set in
10381042
`codegen/Program.cs` (`new RequirementTestsGenerator { WitnessClass = "…" }`); the hook
10391043
types (`IRequirementTestRenderer`, `IRequirementTestFilter`), the identity function and the
10401044
digest stay in the package.
10411045
1046+
### Ejecting a generator hands over the emitters it writes through
1047+
1048+
A generator that calls an emitter writes part of its output through it. If eject left the
1049+
emitter in the package, the copy would own the orchestration and the package would still own
1050+
the text, and a defect in that text would wait for a release. So eject copies the emitters
1051+
too, next to the generator in `codegen/generators/`, in the same `Codegen.Generators`
1052+
namespace:
1053+
1054+
| Eject | Also writes |
1055+
|---|---|
1056+
| `entity` | `Fr019SharedEnum.cs` (shared and provided enums), `M2MNavigation.cs` (the many-to-many navigation), `ReverseFinders.cs` (the reverse FK finders), `XmlDocBuilder.cs` (doc comments) |
1057+
| `db-context` | `Fr019SharedEnum.cs`, `M2MNavigation.cs`, `XmlDocBuilder.cs` |
1058+
| `routes` | `M2MNavigation.cs` |
1059+
| `output-parser`, `extractor` | `ExtractDelegateEmitter.cs`, `Fr010FieldMapping.cs`; `extractor` also `Fr019SharedEnum.cs` |
1060+
| `output-prompt` | `OutputFormatSpecEmitter.cs`, `Fr010FieldMapping.cs` |
1061+
| `api-docs` | the page model, builder, renderer and path math (below), `Fr010FieldMapping.cs`, `M2MNavigation.cs` |
1062+
1063+
The copies are the packaged source with the same namespace edit as the generator and no
1064+
other change. A file two generators share is written once: a later eject finds it, says
1065+
`[kept — already yours]` and leaves your edits alone, and `--force` replaces it with the
1066+
reference as it replaces the generator. The copies see one another as members of their own
1067+
namespace, and a member of the namespace you are in wins over a name a `using` brings in, so
1068+
an owned generator reaches its owned emitters and never the packaged ones of the same name.
1069+
1070+
**What stays in the package is the engine**, public and unchanged: the generator contract
1071+
(`IGenerator`, `GenContext`, `GenConfig`, `EmittedFile`), the naming and type-mapping tables
1072+
(`CSharpNaming`, `ValueObjectNames`, `PackageBindingResolver`), and the questions a generator
1073+
asks of the model (`FindInbound`, `TphPlan`, `InstanceArtifacts`, `ReportRows`). Anything that
1074+
returns emitted text is handed over; a table or a model question is not. The line is written
1075+
down and checked: a test ejects each generator the registry lists into an empty project,
1076+
compiles it against the public API and fails on any reference to a packaged emitter, generator
1077+
or docs writer that is not on that list.
1078+
1079+
**A copy you ejected before the emitters were handed over** keeps compiling and running, with
1080+
the packaged emitters. To own them, eject the same name into a scratch directory and copy the
1081+
emitter files into your project; your copy and your edits are untouched:
1082+
1083+
```bash
1084+
scratch=$(mktemp -d)
1085+
dotnet meta eject entity --root "$scratch"
1086+
cp "$scratch"/codegen/generators/{Fr019SharedEnum,M2MNavigation,ReverseFinders,XmlDocBuilder}.cs codegen/generators/
1087+
dotnet meta gen --list # no longer reports an emitter missing
1088+
```
1089+
1090+
### `api-docs`: the api reference as an owned generator
1091+
1092+
`dotnet meta docs` writes the C# api reference. `dotnet meta eject api-docs` copies the
1093+
generator that writes it, with the page model (`CSharpApiModel.cs`), the builder that decides
1094+
what to document (`CSharpApiModelBuilder.cs`), the renderer (`CSharpApiDocsRenderer.cs`) and
1095+
the page-path math (`DocsPaths.cs`). Change which nodes get a page, how a page reads, or
1096+
where it lands, by editing them.
1097+
1098+
Once `codegen/generators/ApiDocsGenerator.cs` exists, `dotnet meta docs` hands off to
1099+
`dotnet run --project codegen -- docs` with the same arguments, and the reference is written
1100+
by your copy. A project that owns `codegen/` but not `api-docs` keeps the packaged reference:
1101+
the hand-off for `docs` is keyed to the generator, not to the project, because an owned
1102+
project pinned to an older `MetaObjects.Codegen` does not know the `docs` command.
1103+
1104+
**It describes the generators you run.** The reference documents a route, an allowlist, a
1105+
render helper only if the generator that emits it would. The packaged generators answer that
1106+
through their static `AppliesTo`; when `docs` runs, the answers come from the generators in
1107+
your `Program.cs` list instead, so an owned `routes` whose `AppliesTo` you changed is
1108+
documented as it behaves. A generator you left in the package keeps answering as the package
1109+
does. The builder asks through `GenContext.Applicability`
1110+
(`GeneratorApplicability.From(list)` builds it), never through a generator class, which is
1111+
why the copy depends on nothing but the engine.
1112+
1113+
`Program.cs` lists `new ApiDocsGenerator()` beside the code generators. `dotnet meta gen` and
1114+
`verify --codegen` skip it, and `gen` says so (`"api-docs" writes the api reference, so
1115+
gen skipped it. Run it with dotnet meta docs.`): it writes pages under the docs directory,
1116+
not code under `--out`.
1117+
10421118
### Ejecting routes hands over the helper runtime too
10431119
10441120
Generated routes call a handful of helpers: the filter parser, the EF Core filter

‎docs/ports/csharp.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ for a worked example.
8181

8282
## Generate
8383

84-
**Own a generator.** `dotnet meta eject <name>...` copies a reference generator into `codegen/generators/` and scaffolds an owned `codegen/` console project that `dotnet meta gen` then runs. See [Own your codegen → C#](../features/own-your-codegen.md#c-dotnet-meta-eject).
84+
**Own a generator.** `dotnet meta eject <name>...` copies a reference generator into `codegen/generators/` and scaffolds an owned `codegen/` console project that `dotnet meta gen` then runs. Every generator `dotnet meta gen --list` shows is ejectable except `template`, the Mustache primitive, which has no emit logic of its own to own. An ejected generator comes with the emitters it writes its output through (the enum, navigation, doc-comment and prompt-tier writers), so the copy owns the code that writes the file and not a shell around the package's; the naming and type-mapping tables (`CSharpNaming`, `ValueObjectNames`) stay in the package as the engine. `dotnet meta eject api-docs` does the same for the api reference `dotnet meta docs` writes, and the reference then describes the generators you own. See [Own your codegen → C#](../features/own-your-codegen.md#c-dotnet-meta-eject).
8585

8686
```bash
8787
# Generate EF Core entities + AppDbContext + CRUD minimal-API routes

‎fixtures/generator-registry-conformance/registry.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,8 @@
9090
"concept": "Per-entity/template SDK API reference (generated-code API; human + agent forms).",
9191
"tier": "native",
9292
"layer": "docs",
93-
"note": "Run by `meta docs` as its api surface (ADR-0025), not by `meta gen`: a `meta gen` config that lists it is warned about and skipped.",
94-
"ports": ["typescript"]
93+
"note": "Run by `meta docs` as its api surface (ADR-0025), not by `meta gen`: a `meta gen` config that lists it is warned about and skipped. On csharp it is run by `dotnet meta docs`, and `dotnet meta gen` skips it with a warning.",
94+
"ports": ["typescript", "csharp"]
9595
},
9696
"trace-helper": {
9797
"concept": "Per-entity typed record<Entity>/call<Entity> trace helpers (extract + buildLlmCallRow + persist; LlmCallBase-derived entities only).",
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// ADR-0034 Amendment 3 — eject hands over the emitters a generator writes its output through
2+
// (GeneratorRegistryEntry.Companions), not just the generator. The compile-and-engine-surface
3+
// proof is MetaObjects.Codegen.Tests' EjectedGeneratorsAloneTests; this is the command's half:
4+
// what `dotnet meta eject <name>` actually writes, derived from the registry for every name.
5+
6+
using MetaObjects.Cli;
7+
using MetaObjects.Codegen;
8+
using Xunit;
9+
10+
namespace MetaObjects.Cli.Tests;
11+
12+
public sealed class EjectCompanionsTests : IDisposable
13+
{
14+
private readonly string _root = Path.Combine(Path.GetTempPath(), "meta-eject-companions-" + Guid.NewGuid().ToString("N"));
15+
private string Owned(string relative) => Path.Combine(_root, relative.Replace('/', Path.DirectorySeparatorChar));
16+
17+
public EjectCompanionsTests() => Directory.CreateDirectory(_root);
18+
public void Dispose() { try { Directory.Delete(_root, recursive: true); } catch { } }
19+
20+
public static TheoryData<string> Ejectables
21+
{
22+
get
23+
{
24+
var data = new TheoryData<string>();
25+
foreach (var name in EjectCommand.EjectableNames()) data.Add(name);
26+
return data;
27+
}
28+
}
29+
30+
[Theory]
31+
[MemberData(nameof(Ejectables))]
32+
public void Ejecting_a_generator_alone_writes_its_source_and_its_emitters_and_nothing_else_of_the_packages(string name)
33+
{
34+
var entry = GeneratorRegistry.Get(name)!;
35+
var outcome = EjectCommand.Run([name], _root, force: false);
36+
Assert.Equal(0, outcome.ExitCode);
37+
38+
// Everything under codegen/ the command wrote: the files it names, the scaffold, and the helper
39+
// runtime for a generator whose output imports it.
40+
var written = Directory.GetFiles(Owned("codegen"), "*", SearchOption.AllDirectories)
41+
.Select(f => Path.GetRelativePath(_root, f).Replace(Path.DirectorySeparatorChar, '/'))
42+
.Where(f => f is not "codegen/Codegen.csproj" and not "codegen/Program.cs" && !f.StartsWith("codegen/runtime/", StringComparison.Ordinal))
43+
.Order(StringComparer.Ordinal)
44+
.ToList();
45+
var expected = EjectableGenerators.SourcePaths(entry)
46+
.Select(EjectableGenerators.OwnedRelativePath)
47+
.Order(StringComparer.Ordinal)
48+
.ToList();
49+
Assert.Equal(expected, written);
50+
51+
// Each is the embedded source, rewritten the one way eject rewrites it.
52+
foreach (var sourcePath in EjectableGenerators.SourcePaths(entry))
53+
Assert.Equal(
54+
EjectableGenerators.RewriteForEject(EjectableGenerators.ReadSourcePath(sourcePath)),
55+
File.ReadAllText(Owned(EjectableGenerators.OwnedRelativePath(sourcePath))));
56+
}
57+
58+
[Fact]
59+
public void An_emitter_two_generators_share_is_copied_once_and_the_report_names_it()
60+
{
61+
var outcome = EjectCommand.Run(["output-parser", "extractor"], _root, force: false);
62+
Assert.Equal(0, outcome.ExitCode);
63+
64+
var report = string.Join("\n", outcome.Out);
65+
Assert.Equal(1, System.Text.RegularExpressions.Regex.Matches(report, @"codegen/generators/ExtractDelegateEmitter\.cs \[created\]").Count);
66+
Assert.Contains("codegen/generators/Fr010FieldMapping.cs [created]", report);
67+
Assert.Contains("codegen/generators/Fr019SharedEnum.cs [created]", report);
68+
}
69+
70+
[Fact]
71+
public void A_later_eject_keeps_an_emitter_already_yours_and_force_replaces_it()
72+
{
73+
Assert.Equal(0, EjectCommand.Run(["output-parser"], _root, force: false).ExitCode);
74+
var emitter = Owned("codegen/generators/ExtractDelegateEmitter.cs");
75+
File.AppendAllText(emitter, "// my edit\n");
76+
var edited = File.ReadAllText(emitter);
77+
78+
// `extractor` shares the emitter: it is not a refusal, and the edit survives.
79+
var second = EjectCommand.Run(["extractor"], _root, force: false);
80+
Assert.Equal(0, second.ExitCode);
81+
Assert.Contains("codegen/generators/ExtractDelegateEmitter.cs [kept — already yours]", string.Join("\n", second.Out));
82+
Assert.Equal(edited, File.ReadAllText(emitter));
83+
84+
// --force replaces the generator copy and its emitters with the reference, as it always has.
85+
var forced = EjectCommand.Run(["extractor"], _root, force: true);
86+
Assert.Equal(0, forced.ExitCode);
87+
Assert.Contains("codegen/generators/ExtractDelegateEmitter.cs [replaced]", string.Join("\n", forced.Out));
88+
Assert.DoesNotContain("my edit", File.ReadAllText(emitter));
89+
}
90+
91+
[Fact]
92+
public void A_generator_with_no_emitters_reports_none()
93+
{
94+
var outcome = EjectCommand.Run(["filter-allowlist"], _root, force: false);
95+
Assert.DoesNotContain("emitters", string.Join("\n", outcome.Out));
96+
}
97+
98+
[Fact]
99+
public void Ejecting_api_docs_wires_it_into_the_scaffolded_program()
100+
{
101+
Assert.Equal(0, EjectCommand.Run(["api-docs"], _root, force: false).ExitCode);
102+
103+
var program = File.ReadAllText(Owned("codegen/Program.cs"));
104+
Assert.Contains("new ApiDocsGenerator(),", program);
105+
Assert.True(File.Exists(Owned("codegen/generators/CSharpApiModelBuilder.cs")));
106+
}
107+
108+
[Fact]
109+
public void Docs_hand_off_to_the_owned_project_only_when_the_project_owns_api_docs()
110+
{
111+
Assert.False(CodegenHandoff.HasOwnedDocs(_root));
112+
113+
// A project that ejected something else has an owned codegen project, and `gen` hands off to it; `docs`
114+
// does not, because that project's package may predate the `docs` command.
115+
Assert.Equal(0, EjectCommand.Run(["entity"], _root, force: false).ExitCode);
116+
Assert.True(CodegenHandoff.HasOwnedCodegen(_root));
117+
Assert.False(CodegenHandoff.HasOwnedDocs(_root));
118+
119+
Assert.Equal(0, EjectCommand.Run(["api-docs"], _root, force: false).ExitCode);
120+
Assert.True(CodegenHandoff.HasOwnedDocs(_root));
121+
}
122+
}

0 commit comments

Comments
 (0)