diff --git a/CHANGELOG.md b/CHANGELOG.md index adb99898f..0fea01d41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -138,6 +138,62 @@ generator catalog and the Atlas evaluation are 1.2 work. `GeneratorRegistry.GeneratorInfo.ejectWith()`, the classes eject copies with a generator. +- **C#: an ejected generator owns the emitters it writes through, and `api-docs` can be + ejected.** `dotnet meta eject` used to copy one file per generator, so an owned `entity` + still called four packaged writers, and an owned `output-parser`, `extractor` or + `output-prompt` still called the prompt tier's field mapping and format-spec writer. A + defect in any of them waited for a release. Eject now hands + over the writers 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`. A file two generators share is copied once + and never overwritten (`--force` replaces it, as it replaces the generator). What stays in + the package is the engine: the generator base, `CSharpNaming`, `ValueObjectNames` and + `PackageBindingResolver` (the naming and type-mapping tables), and the questions asked of + the model (`FindInbound`, `TphPlan`, `InstanceArtifacts`, `ReportRows`). `template` is not + ejectable: it has no emit logic of its own. Packaged output is unchanged and gated to be. + - **`api-docs` is a generator you can own.** `dotnet meta eject api-docs` copies the page + generator with its page model, builder, renderer and path math. `dotnet meta docs` hands + off to `dotnet run --project codegen -- docs` once `codegen/generators/ApiDocsGenerator.cs` + exists, so your copy writes the reference. What the reference documents is still what the + packaged generators emit for (`GeneratorApplicability.Packaged`): an owned `routes` whose + `AppliesTo` you changed is documented as the packaged one behaves, unless you edit the + owned builder. + `dotnet meta gen` and `verify --codegen` leave a docs generator out. `gen` says so only + when a `--generators` selection names `api-docs`; an owned `Program.cs` that lists it runs + without a warning. + New public API in `MetaObjects.Codegen`: `IDocsGenerator`, `DocsRunFacts`, + `GeneratorApplicability`, `GenContext.Applicability` and `GenContext.Docs`, + `CodegenCli.RunDocs`, and `ValueObjectNames.ResolveValueObject` (moved from + `RenderHelperGenerator`, where it was `internal`). `CSharpStringLiteral` moved from + `Fr010FieldMapping` to `CSharpNaming`, so `names` needs no emitter; the old member stays as an + `[Obsolete]` forwarder (see **Deprecated**). + - **A copy you ejected before this release** keeps compiling and running: it finds the + packaged emitters as it always did. `dotnet meta gen --list` now marks it as missing the + ones it was not handed (`Fr019SharedEnum.cs missing`). To own them, first raise the + `MetaObjects.Codegen` version in `codegen/Codegen.csproj` to 1.1.0 or later (the 1.1.0 + emitter files are written against the 1.1.0 package, and some call API 1.0.x does not + have), then eject the same name + into a scratch directory (`dotnet meta eject entity --root `) and copy the emitter + files from its `codegen/generators/` into yours; your copy and your edits are untouched, + and it resolves the new files by namespace. + - **Eject warns about an older package pin.** Eject writes the sources of the tool you run + and never edits `codegen/Codegen.csproj`. When that file pins a `MetaObjects.Codegen` older + than the tool, `dotnet meta eject` now prints a warning with the pinned version, the + tool's version and the line to change; the owned project does not compile until the pin + is raised. A 1.0.x project needs this before `dotnet meta eject api-docs`, and before + re-ejecting `names`, `render-helper` or a prompt-tier generator. + - **A gate for the rule.** For every ejectable generator the registry lists, a test ejects it + alone, compiles it against the public API, fails on any reference to a packaged emitter, + generator or docs writer outside a written engine surface, fails on a handed-over file + nothing calls, and compares its output with the packaged generator's over the shared + corpora. A registered generator with no eject source and no written exemption fails it, as + does a generator class the package ships that no registry entry builds. A second test + compiles the package without the handed-over emitters, so nothing that stays packaged + calls one. + The cross-port manifest lists `api-docs` for `csharp`. + - **The stock `entity`, `queries`, `routes` and `barrel` generators are importable again (TypeScript).** `entityFile`, `queriesFile`, `routesFile` and `barrel` are exported from `@metaobjectsdev/codegen-ts/generators` once more, beside `namesFile`, `routesFileHono` @@ -744,6 +800,12 @@ until you regenerate. One rule is relaxed: an authored `validator.length @min: 0` on a `@required` string now admits the empty string, as the generated schema already did. +### Deprecated + +- **C#: `Fr010FieldMapping.CSharpStringLiteral(string)` is `[Obsolete]`.** It forwards to + `CSharpNaming.CSharpStringLiteral(string)`, which every generator now calls (so an ejected + `names` needs no emitter). Same signature and result; switch the call when you next touch it. + ### Removed - **The standalone `mermaid-er` generator (TypeScript).** `mermaidErDiagram()`, its diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md index 845e7b5fa..73d932282 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -1014,12 +1014,18 @@ ejectable generator's `.cs` file ships embedded in `MetaObjects.Codegen`, and a byte-identical to the file the package compiles. Eject writes it to `codegen/generators/Generator.cs`, renaming its namespace to `Codegen.Generators` and adding the `using` lines that rename needs; nothing else changes, except in `routes` (see -[below](#ejecting-routes-hands-over-the-helper-runtime-too)). +[below](#ejecting-routes-hands-over-the-helper-runtime-too)). The emitters the generator +writes its output through come with it +([below](#ejecting-a-generator-hands-over-the-emitters-it-writes-through)). On first use eject also writes `codegen/Codegen.csproj`, a console project referencing `MetaObjects.Codegen` at the tool's version, and `codegen/Program.cs`, which lists your generators with `new` and calls the public runner. After that, both files are yours: eject -never touches them again, even with `--force`, and prints what to add instead. Once +never touches them again, even with `--force`, and prints what to add instead. That includes +the package version: eject writes the sources of the tool you run, so when +`codegen/Codegen.csproj` pins an older `MetaObjects.Codegen` than the tool, eject prints a +warning with the pinned version, the tool's version and the line to change. The owned +project does not compile until the pin is the tool's version or later. Once `codegen/Codegen.csproj` exists, `dotnet meta gen` and `dotnet meta verify --codegen` hand off to `dotnet run --project codegen` with the same arguments, so your owned generators run everywhere the tool did. @@ -1031,7 +1037,9 @@ them. So ejecting `names` from a nine-generator selection changes one generator' not the other eight. The owned project finds metadata exactly as the tool does, including `.metaobjects/config.json`'s `sources` and `libraries`. Eject never overwrites a copy without `--force`, and `dotnet meta gen --list` marks owned copies `identical` or `DIFFERS: N behind, M of your -own`. The `template` primitive is not ejectable; it has no emit logic of its own. +own`, and names an emitter that was edited or removed after eject +(`Fr019SharedEnum.cs DIFFERS: 0 behind, 1 of your own`). The `template` primitive is not +ejectable; it has no emit logic of its own. `dotnet meta eject requirement-tests` copies `RequirementTestsGenerator.cs`, which holds the generator and its default rendering. Its options are public properties you set in @@ -1039,6 +1047,85 @@ generator and its default rendering. Its options are public properties you set i types (`IRequirementTestRenderer`, `IRequirementTestFilter`), the identity function and the digest stay in the package. +### Ejecting a generator hands over the emitters it writes through + +A generator that calls an emitter writes part of its output through it. If eject left the +emitter in the package, the copy would own the orchestration and the package would still own +the text, and a defect in that text would wait for a release. So eject copies the emitters +too, next to the generator in `codegen/generators/`, in the same `Codegen.Generators` +namespace: + +| Eject | Also writes | +|---|---| +| `entity` | `Fr019SharedEnum.cs` (shared and provided enums), `M2MNavigation.cs` (the many-to-many navigation), `ReverseFinders.cs` (the reverse FK finders), `XmlDocBuilder.cs` (doc comments) | +| `db-context` | `Fr019SharedEnum.cs`, `M2MNavigation.cs`, `XmlDocBuilder.cs` | +| `routes` | `M2MNavigation.cs` | +| `output-parser`, `extractor` | `ExtractDelegateEmitter.cs`, `Fr010FieldMapping.cs`; `extractor` also `Fr019SharedEnum.cs` | +| `output-prompt` | `OutputFormatSpecEmitter.cs`, `Fr010FieldMapping.cs` | +| `api-docs` | the page model, builder, renderer and path math (below), `Fr010FieldMapping.cs`, `M2MNavigation.cs` | + +The copies are the packaged source with the same namespace edit as the generator and no +other change. A file two generators share is written once: a later eject finds it, says +`[kept — already yours]` and leaves your edits alone, and `--force` replaces it with the +reference as it replaces the generator. The copies see one another as members of their own +namespace, and a member of the namespace you are in wins over a name a `using` brings in, so +an owned generator reaches its owned emitters and never the packaged ones of the same name. + +**What stays in the package is the engine**, public and unchanged: the generator contract +(`IGenerator`, `GenContext`, `GenConfig`, `EmittedFile`), the naming and type-mapping tables +(`CSharpNaming`, `ValueObjectNames`, `PackageBindingResolver`), and the questions a generator +asks of the model (`FindInbound`, `TphPlan`, `InstanceArtifacts`, `ReportRows`). Anything that +returns emitted text is handed over; a table or a model question is not. The line is written +down and checked: a test ejects each generator the registry lists into an empty project, +compiles it against the public API and fails on any reference to a packaged emitter, generator +or docs writer that is not on that list. A second test compiles the package without the +handed-over emitters, so nothing that stays packaged calls one. + +**A copy you ejected before the emitters were handed over** keeps compiling and running, with +the packaged emitters. To own them, first raise the `MetaObjects.Codegen` version in +`codegen/Codegen.csproj` to 1.1.0 or later: the 1.1.0 emitter files are written against the +1.1.0 package, and some call API that 1.0.x does not have. Then eject the same name +into a scratch directory and copy the emitter files into your project; your copy and your +edits are untouched: + +```bash +scratch=$(mktemp -d) +dotnet meta eject entity --root "$scratch" +cp "$scratch"/codegen/generators/{Fr019SharedEnum,M2MNavigation,ReverseFinders,XmlDocBuilder}.cs codegen/generators/ +dotnet meta gen --list # no longer reports an emitter missing +``` + +### `api-docs`: the api reference as an owned generator + +`dotnet meta docs` writes the C# api reference. `dotnet meta eject api-docs` copies the +generator that writes it, with the page model (`CSharpApiModel.cs`), the builder that decides +what to document (`CSharpApiModelBuilder.cs`), the renderer (`CSharpApiDocsRenderer.cs`) and +the page-path math (`DocsPaths.cs`). Change which nodes get a page, how a page reads, or +where it lands, by editing them. + +Once `codegen/generators/ApiDocsGenerator.cs` exists, `dotnet meta docs` hands off to +`dotnet run --project codegen -- docs` with the same arguments, and the reference is written +by your copy. A project that owns `codegen/` but not `api-docs` keeps the packaged reference: +the hand-off for `docs` is keyed to the generator, not to the project, because an owned +project pinned to an older `MetaObjects.Codegen` does not know the `docs` command. The owned +copy needs `MetaObjects.Codegen` 1.1.0 or later for the same reason; eject warns when +`codegen/Codegen.csproj` pins less. + +**It describes what the packaged generators emit.** The reference documents a route, an +allowlist, a render helper only if the packaged generator that emits it would, which each +answers through its static `AppliesTo`. That holds in a project that owns one of those +generators too: an owned `routes` whose `AppliesTo` you changed is still documented as the +packaged one behaves. To document your own surface, edit the owned builder +(`CSharpApiModelBuilder.cs`), which is where the question is asked. The builder asks through +`GenContext.Applicability` (`GeneratorApplicability.Packaged`), never through a generator +class, which is why the copy depends on nothing but the engine. + +`Program.cs` lists `new ApiDocsGenerator()` beside the code generators. `dotnet meta gen` and +`verify --codegen` skip it: it writes pages under the docs directory, not code under `--out`. +The skip is silent while the generator is only in that list. When a `--generators` selection +names `api-docs`, `gen` says so (`"api-docs" writes the api reference, so gen skipped it. +Run it with dotnet meta docs.`), with or without an owned project. + ### Ejecting routes hands over the helper runtime too Generated routes call a handful of helpers: the filter parser, the EF Core filter diff --git a/docs/ports/csharp.md b/docs/ports/csharp.md index ae5ed8d54..ed99726e1 100644 --- a/docs/ports/csharp.md +++ b/docs/ports/csharp.md @@ -81,7 +81,7 @@ for a worked example. ## Generate -**Own a generator.** `dotnet meta eject ...` 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). +**Own a generator.** `dotnet meta eject ...` 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. Ejecting into a project whose `codegen/Codegen.csproj` pins an older `MetaObjects.Codegen` than the tool prints a warning naming the line to change. See [Own your codegen → C#](../features/own-your-codegen.md#c-dotnet-meta-eject). ```bash # Generate EF Core entities + AppDbContext + CRUD minimal-API routes diff --git a/fixtures/generator-registry-conformance/registry.json b/fixtures/generator-registry-conformance/registry.json index e283f70f2..cf9109d46 100644 --- a/fixtures/generator-registry-conformance/registry.json +++ b/fixtures/generator-registry-conformance/registry.json @@ -90,8 +90,8 @@ "concept": "Per-entity/template SDK API reference (generated-code API; human + agent forms).", "tier": "native", "layer": "docs", - "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.", - "ports": ["typescript"] + "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, warning only when `--generators` names it.", + "ports": ["typescript", "csharp"] }, "trace-helper": { "concept": "Per-entity typed record/call trace helpers (extract + buildLlmCallRow + persist; LlmCallBase-derived entities only).", diff --git a/server/csharp/MetaObjects.Cli.Tests/EjectCommandTests.cs b/server/csharp/MetaObjects.Cli.Tests/EjectCommandTests.cs index b20529fd5..f0ef53d8a 100644 --- a/server/csharp/MetaObjects.Cli.Tests/EjectCommandTests.cs +++ b/server/csharp/MetaObjects.Cli.Tests/EjectCommandTests.cs @@ -129,6 +129,117 @@ public void A_second_eject_of_a_different_generator_does_not_touch_the_existing_ Assert.Contains("adopter's own edit", File.ReadAllText(Path.Combine(CodegenDir, "Program.cs"))); } + private string CsprojPath => Path.Combine(CodegenDir, "Codegen.csproj"); + + /// Scaffold with this tool, then re-pin the owned project the way an older tool left it. + private string ScaffoldPinnedAt(string? version) + { + Assert.Equal(0, EjectCommand.Run(["entity"], _root, force: false).ExitCode); + var scaffolded = File.ReadAllText(CsprojPath); + var pin = $"Version=\"{EjectCommand.ToolVersion()}\""; + Assert.Contains(pin, scaffolded); + if (version is not null) File.WriteAllText(CsprojPath, scaffolded.Replace(pin, $"Version=\"{version}\"")); + return File.ReadAllText(CsprojPath); + } + + // Eject writes this tool's sources and never edits the owned project's package pin. A project + // scaffolded by an older tool therefore gets sources its pinned package cannot compile, and the + // only place that can say so before `dotnet run --project codegen` fails is eject's own report. + [Fact] + public void Ejecting_into_a_project_pinned_to_an_older_package_warns_and_names_the_line_to_change() + { + var csproj = ScaffoldPinnedAt("0.9.0"); + var pinLine = Array.FindIndex(csproj.Split('\n'), l => l.Contains("PackageReference", StringComparison.Ordinal)) + 1; + + var result = EjectCommand.Run(["api-docs"], _root, force: false); + + Assert.Equal(0, result.ExitCode); + var warning = Assert.Single(result.Out, l => l.StartsWith("warning:", StringComparison.Ordinal)); + Assert.Contains("MetaObjects.Codegen 0.9.0", warning); + Assert.Contains(EjectCommand.ToolVersion(), warning); + Assert.Contains("will not compile", warning); + Assert.Contains($"set the version on line {pinLine} of codegen/Codegen.csproj to {EjectCommand.ToolVersion()}", warning); + Assert.Contains( + $"", + result.Out[result.Out.ToList().IndexOf(warning) + 1]); + Assert.Equal(csproj, File.ReadAllText(CsprojPath)); + } + + // The line the warning names carries only the version in these shapes, so pasting a whole + // PackageReference element over it would nest one inside another, or drop what else the + // reference declares. The warning names the line and the version and prints no element. + [Theory] + [InlineData("")] + [InlineData("\n 0.9.0\n ")] + [InlineData("")] + public void An_older_pin_not_in_the_scaffolded_form_is_named_by_line_and_version_only(string reference) + { + ScaffoldPinnedAt(null); + var csproj = + "\n \n " + reference + "\n \n\n"; + File.WriteAllText(CsprojPath, csproj); + var versionLine = Array.FindIndex(csproj.Split('\n'), l => l.Contains("0.9.0", StringComparison.Ordinal)) + 1; + + var result = EjectCommand.Run(["api-docs"], _root, force: false); + + Assert.Equal(0, result.ExitCode); + var warning = Assert.Single(result.Out, l => l.StartsWith("warning:", StringComparison.Ordinal)); + Assert.Contains($"set the version on line {versionLine} of codegen/Codegen.csproj to {EjectCommand.ToolVersion()}.", warning); + Assert.DoesNotContain(result.Out, l => l.Contains(" l.Contains("warning", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Ejecting_into_a_project_with_no_package_pin_says_nothing_about_it() + { + ScaffoldPinnedAt(null); + File.WriteAllText(CsprojPath, ""); + + var result = EjectCommand.Run(["api-docs"], _root, force: false); + + Assert.Equal(0, result.ExitCode); + Assert.DoesNotContain(result.Out, l => l.Contains("warning", StringComparison.OrdinalIgnoreCase)); + } + + // The order is semver.org's (§11), whose own worked chain is + // 1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-alpha.beta < 1.0.0-beta.2 < 1.0.0-beta.11 < 1.0.0-rc.1 < 1.0.0. + // A string or System.Version comparison gets the release candidate and the two-digit parts wrong. + [Theory] + [InlineData("1.0.13", "1.1.0", true)] + [InlineData("1.1.0-rc.1", "1.1.0", true)] + [InlineData("1.1.0", "1.1.0-rc.1", false)] + [InlineData("1.1.0", "1.1.0", false)] + [InlineData("1.1.0+8ef72c3", "1.1.0", false)] + [InlineData("1.9.0", "1.10.0", true)] + [InlineData("1.10.0", "1.9.0", false)] + [InlineData("1.0.0-alpha", "1.0.0-alpha.1", true)] + [InlineData("1.0.0-alpha.1", "1.0.0-alpha.beta", true)] + [InlineData("1.0.0-alpha.beta", "1.0.0-beta.2", true)] + [InlineData("1.0.0-beta.2", "1.0.0-beta.11", true)] + [InlineData("1.0.0-beta.11", "1.0.0-rc.1", true)] + [InlineData("1.0.0-rc.1", "1.0.0-beta.11", false)] + [InlineData("1.*", "1.1.0", false)] + [InlineData("$(MetaObjectsVersion)", "1.1.0", false)] + public void A_pin_is_older_by_semantic_version_precedence(string pinned, string required, bool older) + { + Assert.Equal(older, EjectCommand.IsOlderVersion(pinned, required)); + } + [Fact] public void Ejected_copy_is_content_identical_to_the_rewritten_reference() { diff --git a/server/csharp/MetaObjects.Cli.Tests/EjectCompanionsTests.cs b/server/csharp/MetaObjects.Cli.Tests/EjectCompanionsTests.cs new file mode 100644 index 000000000..90050edfc --- /dev/null +++ b/server/csharp/MetaObjects.Cli.Tests/EjectCompanionsTests.cs @@ -0,0 +1,122 @@ +// ADR-0034 Amendment 3 — eject hands over the emitters a generator writes its output through +// (GeneratorRegistryEntry.Companions), not just the generator. The compile-and-engine-surface +// proof is MetaObjects.Codegen.Tests' EjectedGeneratorsAloneTests; this is the command's half: +// what `dotnet meta eject ` actually writes, derived from the registry for every name. + +using MetaObjects.Cli; +using MetaObjects.Codegen; +using Xunit; + +namespace MetaObjects.Cli.Tests; + +public sealed class EjectCompanionsTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "meta-eject-companions-" + Guid.NewGuid().ToString("N")); + private string Owned(string relative) => Path.Combine(_root, relative.Replace('/', Path.DirectorySeparatorChar)); + + public EjectCompanionsTests() => Directory.CreateDirectory(_root); + public void Dispose() { try { Directory.Delete(_root, recursive: true); } catch { } } + + public static TheoryData Ejectables + { + get + { + var data = new TheoryData(); + foreach (var name in EjectCommand.EjectableNames()) data.Add(name); + return data; + } + } + + [Theory] + [MemberData(nameof(Ejectables))] + public void Ejecting_a_generator_alone_writes_its_source_and_its_emitters_and_nothing_else_of_the_packages(string name) + { + var entry = GeneratorRegistry.Get(name)!; + var outcome = EjectCommand.Run([name], _root, force: false); + Assert.Equal(0, outcome.ExitCode); + + // Everything under codegen/ the command wrote: the files it names, the scaffold, and the helper + // runtime for a generator whose output imports it. + var written = Directory.GetFiles(Owned("codegen"), "*", SearchOption.AllDirectories) + .Select(f => Path.GetRelativePath(_root, f).Replace(Path.DirectorySeparatorChar, '/')) + .Where(f => f is not "codegen/Codegen.csproj" and not "codegen/Program.cs" && !f.StartsWith("codegen/runtime/", StringComparison.Ordinal)) + .Order(StringComparer.Ordinal) + .ToList(); + var expected = EjectableGenerators.SourcePaths(entry) + .Select(EjectableGenerators.OwnedRelativePath) + .Order(StringComparer.Ordinal) + .ToList(); + Assert.Equal(expected, written); + + // Each is the embedded source, rewritten the one way eject rewrites it. + foreach (var sourcePath in EjectableGenerators.SourcePaths(entry)) + Assert.Equal( + EjectableGenerators.RewriteForEject(EjectableGenerators.ReadSourcePath(sourcePath)), + File.ReadAllText(Owned(EjectableGenerators.OwnedRelativePath(sourcePath)))); + } + + [Fact] + public void An_emitter_two_generators_share_is_copied_once_and_the_report_names_it() + { + var outcome = EjectCommand.Run(["output-parser", "extractor"], _root, force: false); + Assert.Equal(0, outcome.ExitCode); + + var report = string.Join("\n", outcome.Out); + Assert.Equal(1, System.Text.RegularExpressions.Regex.Matches(report, @"codegen/generators/ExtractDelegateEmitter\.cs \[created\]").Count); + Assert.Contains("codegen/generators/Fr010FieldMapping.cs [created]", report); + Assert.Contains("codegen/generators/Fr019SharedEnum.cs [created]", report); + } + + [Fact] + public void A_later_eject_keeps_an_emitter_already_yours_and_force_replaces_it() + { + Assert.Equal(0, EjectCommand.Run(["output-parser"], _root, force: false).ExitCode); + var emitter = Owned("codegen/generators/ExtractDelegateEmitter.cs"); + File.AppendAllText(emitter, "// my edit\n"); + var edited = File.ReadAllText(emitter); + + // `extractor` shares the emitter: it is not a refusal, and the edit survives. + var second = EjectCommand.Run(["extractor"], _root, force: false); + Assert.Equal(0, second.ExitCode); + Assert.Contains("codegen/generators/ExtractDelegateEmitter.cs [kept — already yours]", string.Join("\n", second.Out)); + Assert.Equal(edited, File.ReadAllText(emitter)); + + // --force replaces the generator copy and its emitters with the reference, as it always has. + var forced = EjectCommand.Run(["extractor"], _root, force: true); + Assert.Equal(0, forced.ExitCode); + Assert.Contains("codegen/generators/ExtractDelegateEmitter.cs [replaced]", string.Join("\n", forced.Out)); + Assert.DoesNotContain("my edit", File.ReadAllText(emitter)); + } + + [Fact] + public void A_generator_with_no_emitters_reports_none() + { + var outcome = EjectCommand.Run(["filter-allowlist"], _root, force: false); + Assert.DoesNotContain("emitters", string.Join("\n", outcome.Out)); + } + + [Fact] + public void Ejecting_api_docs_wires_it_into_the_scaffolded_program() + { + Assert.Equal(0, EjectCommand.Run(["api-docs"], _root, force: false).ExitCode); + + var program = File.ReadAllText(Owned("codegen/Program.cs")); + Assert.Contains("new ApiDocsGenerator(),", program); + Assert.True(File.Exists(Owned("codegen/generators/CSharpApiModelBuilder.cs"))); + } + + [Fact] + public void Docs_hand_off_to_the_owned_project_only_when_the_project_owns_api_docs() + { + Assert.False(CodegenHandoff.HasOwnedDocs(_root)); + + // A project that ejected something else has an owned codegen project, and `gen` hands off to it; `docs` + // does not, because that project's package may predate the `docs` command. + Assert.Equal(0, EjectCommand.Run(["entity"], _root, force: false).ExitCode); + Assert.True(CodegenHandoff.HasOwnedCodegen(_root)); + Assert.False(CodegenHandoff.HasOwnedDocs(_root)); + + Assert.Equal(0, EjectCommand.Run(["api-docs"], _root, force: false).ExitCode); + Assert.True(CodegenHandoff.HasOwnedDocs(_root)); + } +} diff --git a/server/csharp/MetaObjects.Cli.Tests/EjectEndToEndTests.cs b/server/csharp/MetaObjects.Cli.Tests/EjectEndToEndTests.cs index f270b9daa..18dba3569 100644 --- a/server/csharp/MetaObjects.Cli.Tests/EjectEndToEndTests.cs +++ b/server/csharp/MetaObjects.Cli.Tests/EjectEndToEndTests.cs @@ -194,6 +194,69 @@ private void PointScaffoldAtThisCheckout(string repoRoot) File.WriteAllText(CodegenCsproj, rewritten); } + /// + /// An owned api-docs generator, run for real (ADR-0025 / ADR-0034 Amendment 3): eject `routes` and + /// `api-docs`, add a page to the owned api-docs copy, and run the owned project's `docs` command. The + /// reference is written by the OWNED api-docs copy, so the added page is on disk beside the model's; + /// and the same owned project's `gen` skips the docs generator, saying so only when asked for it by name. + /// + [Fact] + public void Owned_api_docs_writes_the_reference() + { + var repoRoot = RepoRoot(); + Directory.CreateDirectory(MetadataDir); + File.WriteAllText(Path.Combine(MetadataDir, "meta.acme.json"), """ + { "metadata.root": { "package": "acme::widgets", "children": [ + { "object.entity": { "name": "Widget", "children": [ + { "source.rdb": { "@table": "widgets" } }, + { "field.uuid": { "name": "id", "@required": true } }, + { "field.string": { "name": "label", "@required": true, "@maxLength": 80 } }, + { "identity.primary": { "name": "pk", "@fields": "id", "@generation": "uuid" } } + ]}} + ]}} + """); + + var eject = EjectCommand.Run(["routes", "api-docs"], _root, force: false); + Assert.Equal(0, eject.ExitCode); + Assert.True(CodegenHandoff.HasOwnedDocs(_root)); + PointScaffoldAtThisCheckout(repoRoot); + + // The project's own edit: its reference carries a page the packaged generator does not write. + var apiDocsPath = Path.Combine(_root, "codegen", "generators", "ApiDocsGenerator.cs"); + var apiDocs = File.ReadAllText(apiDocsPath); + const string lastPage = "yield return Page(facts, \"AGENT-API.md\", renderer.RenderAgentApi(model));"; + Assert.Contains(lastPage, apiDocs); + File.WriteAllText(apiDocsPath, apiDocs.Replace( + lastPage, + lastPage + "\n yield return Page(facts, \"OWNED.md\", \"written by the owned api-docs\");")); + + var docsOut = Path.Combine(_root, "docs"); + var (docsExit, docsOutput) = RunCodegenProject("docs", MetadataDir, "--out", docsOut, "--namespace", "Acme.Generated"); + Assert.True(docsExit == 0, $"docs failed (exit {docsExit}):\n{docsOutput}"); + Assert.Contains("api page(s) written", docsOutput); + + var widget = File.ReadAllText(Directory.GetFiles(docsOut, "Widget.md", SearchOption.AllDirectories).Single()); + Assert.Contains("DbSet", widget); + Assert.Equal( + "written by the owned api-docs", + File.ReadAllText(Directory.GetFiles(docsOut, "OWNED.md", SearchOption.AllDirectories).Single())); + + // The same owned project's gen leaves the docs generator out. With the generator only in the + // owned list, that is the everyday run and it has nothing to say about it. + var (genExit, genOutput) = RunCodegenProject("gen", MetadataDir, "--out", OutDir, "--namespace", "Acme.Generated"); + Assert.True(genExit == 0, $"gen failed (exit {genExit}):\n{genOutput}"); + Assert.DoesNotContain("writes the api reference", genOutput); + Assert.NotEmpty(Directory.GetFiles(OutDir, "*.cs", SearchOption.AllDirectories)); + Assert.Empty(Directory.GetFiles(OutDir, "*.md", SearchOption.AllDirectories)); + + // Asked for by name, it is still left out, and gen says why. + var (namedExit, namedOutput) = RunCodegenProject( + "gen", MetadataDir, "--out", OutDir, "--namespace", "Acme.Generated", "--generators", "api-docs"); + Assert.True(namedExit == 0, $"gen failed (exit {namedExit}):\n{namedOutput}"); + Assert.Contains("writes the api reference, so `gen` skipped it", namedOutput); + Assert.Empty(Directory.GetFiles(OutDir, "*.md", SearchOption.AllDirectories)); + } + /// /// The helper runtime an ejected routes generator brings with it (ADR-0034 Amendment 3): /// eject routes, generate through the owned project, and build + RUN a real ASP.NET Core diff --git a/server/csharp/MetaObjects.Cli.Tests/GenListAndSelectionTests.cs b/server/csharp/MetaObjects.Cli.Tests/GenListAndSelectionTests.cs index 15542349b..fb8882d5b 100644 --- a/server/csharp/MetaObjects.Cli.Tests/GenListAndSelectionTests.cs +++ b/server/csharp/MetaObjects.Cli.Tests/GenListAndSelectionTests.cs @@ -47,7 +47,7 @@ public void ListHeader_says_generators_are_owned_via_eject() public void ListLines_prints_all_generators_with_stable_names_and_descriptions() { var lines = GenCommand.ListLines(_tmp); - Assert.Equal(12, lines.Count); + Assert.Equal(13, lines.Count); foreach (var name in new[] { "entity", "db-context", "routes", "output-parser", "extractor", @@ -58,6 +58,8 @@ public void ListLines_prints_all_generators_with_stable_names_and_descriptions() "names", // ADR-0057 — per-requirement xUnit tests calling a project-owned witness. "requirement-tests", + // ADR-0025 — the api reference `dotnet meta docs` writes. + "api-docs", }) { Assert.Contains(lines, l => l.Contains($" {name} —")); @@ -97,6 +99,21 @@ public void Previously_omitted_output_prompt_is_now_selectable_and_emits() Assert.True(File.Exists(Path.Combine(OutDir, "Alpha.responseFormat.cs"))); } + [Fact] + public void Selecting_the_docs_generator_writes_no_pages_and_says_which_command_does() + { + // api-docs is in the catalog, so a selection can name it; its pages belong to `dotnet meta docs`. + var outcome = GenCommand.Run( + MetaDir, OutDir, "Acme.Generated", + emitAbstractShapes: false, generatorNames: ["entity", "api-docs"], templateRoot: null); + + Assert.True(outcome.Ok, string.Join("; ", outcome.LoadErrors)); + var warning = Assert.Single(outcome.Result!.Warnings, w => w.Contains("\"api-docs\"", StringComparison.Ordinal)); + Assert.Contains("`dotnet meta docs`", warning); + Assert.NotEmpty(Directory.GetFiles(OutDir, "*.cs", SearchOption.AllDirectories)); + Assert.Empty(Directory.GetFiles(OutDir, "*.md", SearchOption.AllDirectories)); + } + [Fact] public void Unknown_generator_name_surfaces_as_an_error_not_a_throw() { diff --git a/server/csharp/MetaObjects.Cli.Tests/OwnedCopyTests.cs b/server/csharp/MetaObjects.Cli.Tests/OwnedCopyTests.cs index 82e4d9f6b..91c965c1b 100644 --- a/server/csharp/MetaObjects.Cli.Tests/OwnedCopyTests.cs +++ b/server/csharp/MetaObjects.Cli.Tests/OwnedCopyTests.cs @@ -56,15 +56,30 @@ public void Status_is_null_when_nothing_is_owned() [Fact] public void Status_is_identical_for_a_freshly_ejected_unmodified_copy() { + // Ejected the way the command does it: the generator AND the emitters it was handed with. var entry = GeneratorRegistry.Get("entity")!; - var dir = Path.Combine(_tmp, "codegen", "generators"); - Directory.CreateDirectory(dir); - var rewritten = EjectableGenerators.RewriteForEject(EjectableGenerators.ReadSource("entity")!); - File.WriteAllText(Path.Combine(dir, entry.SourceFileName!), rewritten); + Assert.Equal(0, EjectCommand.Run(["entity"], _tmp, force: false).ExitCode); Assert.Equal("identical", OwnedCopy.Status(_tmp, entry)); } + [Fact] + public void Status_names_an_emitter_that_was_edited_or_removed_after_eject() + { + var entry = GeneratorRegistry.Get("entity")!; + Assert.Equal(0, EjectCommand.Run(["entity"], _tmp, force: false).ExitCode); + var generators = Path.Combine(_tmp, "codegen", "generators"); + + File.AppendAllText(Path.Combine(generators, "Fr019SharedEnum.cs"), "// my edit\n"); + File.Delete(Path.Combine(generators, "XmlDocBuilder.cs")); + + var status = OwnedCopy.Status(_tmp, entry); + Assert.StartsWith("Fr019SharedEnum.cs DIFFERS: 0 behind, 1 of your own", status); + Assert.Contains("; XmlDocBuilder.cs missing", status); + // The generator copy itself is untouched, so its own clause is absent. + Assert.DoesNotContain("EntityGenerator", status); + } + [Fact] public void Status_reports_differs_after_a_local_edit() { diff --git a/server/csharp/MetaObjects.Cli/CodegenHandoff.cs b/server/csharp/MetaObjects.Cli/CodegenHandoff.cs index 5267f979e..ba92483ba 100644 --- a/server/csharp/MetaObjects.Cli/CodegenHandoff.cs +++ b/server/csharp/MetaObjects.Cli/CodegenHandoff.cs @@ -18,6 +18,19 @@ public static class CodegenHandoff public static bool HasOwnedCodegen(string projectRoot) => File.Exists(Path.Combine(projectRoot, "codegen", "Codegen.csproj")); + /// True iff owns the api-docs generator: the ejected + /// codegen project exists AND carries codegen/generators/ApiDocsGenerator.cs. That file is + /// what `dotnet meta docs` hands off for — the project's own copy writes the reference. Its + /// presence says a tool that can eject api-docs wrote it, NOT that the package + /// codegen/Codegen.csproj pins knows the docs command: eject never edits that pin, so + /// a project scaffolded by an older tool keeps the older one until its owner raises it, and + /// warns when it writes into such a project. A project with no such + /// file never ejected api-docs, and docs stays with the packaged generator whatever it pins. + public static bool HasOwnedDocs(string projectRoot) => + HasOwnedCodegen(projectRoot) && + MetaObjects.Codegen.GeneratorRegistry.Get("api-docs")?.SourceFileName is { } file && + File.Exists(Path.Combine(projectRoot, "codegen", "generators", file)); + /// /// When has an ejected codegen project, run `dotnet /// run --project codegen -- <args>` (inheriting this process's console) and set @@ -26,7 +39,9 @@ public static bool HasOwnedCodegen(string projectRoot) => /// public static bool TryRun(string projectRoot, string[] args, out int exitCode) { - if (!HasOwnedCodegen(projectRoot)) + // `docs` hands off only for a project that owns the api-docs generator. + var handsOff = args is ["docs", ..] ? HasOwnedDocs(projectRoot) : HasOwnedCodegen(projectRoot); + if (!handsOff) { exitCode = 0; return false; diff --git a/server/csharp/MetaObjects.Cli/DocsCommand.cs b/server/csharp/MetaObjects.Cli/DocsCommand.cs index c7a2688b0..b748b39ea 100644 --- a/server/csharp/MetaObjects.Cli/DocsCommand.cs +++ b/server/csharp/MetaObjects.Cli/DocsCommand.cs @@ -1,13 +1,12 @@ // `dotnet meta docs` — emit the generated C# SDK api surface (the `api/csharp` // surface of the cross-port SDK-docs contract) under the --out directory. // -// Wiring only: every symbol name comes from the CSharpApiModelBuilder (which keys off -// the CSharpNaming seam + each generator's AppliesTo predicate) and every path comes -// from DocsPaths — nothing is re-derived here. Output paths are collision-checked -// (duplicate page path → failure) before anything is written, mirroring the Java Mojo. +// Wiring only: the pages come from the `api-docs` generator, which builds them from the +// CSharpApiModelBuilder (the CSharpNaming seam + each generator's AppliesTo predicate) and +// DocsPaths — nothing is re-derived here. CodegenCli.RunDocs collision-checks the output +// paths (duplicate page path → failure) before anything is written, mirroring the Java Mojo. using MetaObjects.Codegen; -using MetaObjects.Codegen.ApiDocs; using MetaObjects.Loader; namespace MetaObjects.Cli; @@ -26,7 +25,7 @@ public sealed record Outcome( } /// The default api-surface subdir (the cross-port contract's api/csharp). - public const string DefaultApiSubDir = "api/csharp"; + public const string DefaultApiSubDir = DocsRunFacts.DefaultApiSubDir; /// /// Build the C# api-docs surface for and write it under @@ -55,54 +54,9 @@ public static Outcome Run( LoadResult load, string outDir, string project, string ns, string apiSubDir = DefaultApiSubDir, string? modelBaseUrl = null) { - var loadErrors = load.Errors.Select(e => e.Code.ToString()).ToList(); - if (loadErrors.Count > 0) - return new Outcome(loadErrors, []); - - // The display namespace seam needs a GenConfig; only Namespace/binding are read. - var config = new GenConfig { OutDir = outDir, Namespace = ns }; - var model = new CSharpApiModelBuilder(config).Build(load.Root, project); - var renderer = new CSharpApiDocsRenderer(); - const DocsPaths.Layout layout = DocsPaths.Layout.Package; - - // Collect path → content first so a duplicate page path fails BEFORE any write. - // A collision is a clean, reportable diagnostic (not an uncaught throw out of the CLI). - var emitted = new Dictionary(StringComparer.Ordinal); - try - { - foreach (var unit in model.Units) - { - var pagePath = DocsPaths.DocPageOutputPath(layout, unit.Package, unit.Node); - var apiPagePathFromDocsRoot = apiSubDir + "/" + pagePath; - var modelHref = DocsPaths.ModelCrossHref(apiPagePathFromDocsRoot, pagePath, modelBaseUrl); - Put(emitted, pagePath, renderer.RenderUnitPage(unit, modelHref)); - } - Put(emitted, "README.md", renderer.RenderIndex(model, layout)); - Put(emitted, "AGENT-API.md", renderer.RenderAgentApi(model)); - } - catch (DuplicatePagePathException ex) - { - return new Outcome(loadErrors, [], ex.Message); - } - - var apiRoot = Path.Combine(outDir, apiSubDir.Replace('/', Path.DirectorySeparatorChar)); - var written = new List(); - foreach (var (rel, content) in emitted) - { - var dest = Path.Combine(apiRoot, rel.Replace('/', Path.DirectorySeparatorChar)); - Directory.CreateDirectory(Path.GetDirectoryName(dest)!); - File.WriteAllText(dest, content); - written.Add(apiSubDir + "/" + rel); - } - return new Outcome(loadErrors, written); + // The pipeline is the one an owned codegen/Program.cs runs too (CodegenCli.RunDocs), with the + // packaged api-docs generator: no owned generator list on this path. + var outcome = CodegenCli.RunDocs(load, outDir, ns, new DocsRunFacts(project, modelBaseUrl, apiSubDir), []); + return new Outcome(outcome.LoadErrors, outcome.WrittenPaths, outcome.CollisionError); } - - private static void Put(Dictionary emitted, string path, string content) - { - if (!emitted.TryAdd(path, content)) - throw new DuplicatePagePathException($"dotnet meta docs — duplicate api page output path: {path}"); - } - - /// Raised when two units resolve to the same api doc page path (a collision). - private sealed class DuplicatePagePathException(string message) : Exception(message); } diff --git a/server/csharp/MetaObjects.Cli/EjectCommand.cs b/server/csharp/MetaObjects.Cli/EjectCommand.cs index 134e9f39b..768c1a1e4 100644 --- a/server/csharp/MetaObjects.Cli/EjectCommand.cs +++ b/server/csharp/MetaObjects.Cli/EjectCommand.cs @@ -9,15 +9,27 @@ // - a generator whose OUTPUT imports the helper runtime (routes) brings that runtime's // source with it, into codegen/runtime/ (see MetaObjects.Codegen.HelperRuntime), so // the adopter owns every line their generated routes call; +// - a generator that writes part of its output through an emitter (the registry entry's +// Companions: the prompt tier's field mapping and format-spec writer, the entity tier's +// enum, navigation and doc-comment writers, the api reference's page builder) brings +// that emitter's source with it, beside it under codegen/, so the copy is the generator +// and not a shell around the package's; // - an existing copy is NEVER overwritten without --force; // - every name is validated BEFORE anything is written (a partial eject — some names // copied, one refused — is worse than refusing the whole call); // - the scaffold (Codegen.csproj / Program.cs) is written ONLY when missing, and is // NEVER touched again by a later eject, --force included: once it exists it is the // adopter's owned config, exactly like metaobjects.config.ts on the TS side, and -// eject REPORTS what to wire rather than editing it. +// eject REPORTS what to wire rather than editing it; +// - the sources eject writes are the TOOL's version's, and an existing Codegen.csproj may pin +// an older MetaObjects.Codegen they do not compile against, so eject WARNS when the pin is +// older than the tool and names the line to change (it still never edits the file). +using System.Numerics; using System.Reflection; +using System.Text.RegularExpressions; +using System.Xml; +using System.Xml.Linq; using MetaObjects.Codegen; namespace MetaObjects.Cli; @@ -79,6 +91,88 @@ public static string PackageVersion(string? informational, Version? assembly) return assembly?.ToString(3) ?? "1.0.0"; } + private const string PinnedPackage = "MetaObjects.Codegen"; + + // semver.org's grammar for the versions NuGet publishes this package under: three numeric + // parts, an optional pre-release, optional build metadata (which never orders). + private static readonly Regex SemanticVersion = new( + @"^(?[0-9]+)\.(?[0-9]+)\.(?[0-9]+)(?:-(?
[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z.-]+)?$",
+        RegexOptions.Compiled);
+
+    /// 
+    /// True when  precedes  as semantic
+    /// versions (semver.org §11, so a pre-release precedes its release: 1.1.0-rc.1 is
+    /// older than 1.1.0). False when either is not a plain semantic version — an MSBuild
+    /// property, a range, a floating version — because nothing can be said about such a pin.
+    /// 
+    public static bool IsOlderVersion(string pinned, string required)
+    {
+        var a = SemanticVersion.Match(pinned);
+        var b = SemanticVersion.Match(required);
+        if (!a.Success || !b.Success) return false;
+
+        foreach (var part in (string[])["major", "minor", "patch"])
+        {
+            var order = BigInteger.Parse(a.Groups[part].Value).CompareTo(BigInteger.Parse(b.Groups[part].Value));
+            if (order != 0) return order < 0;
+        }
+        var (preA, preB) = (a.Groups["pre"], b.Groups["pre"]);
+        if (!preA.Success || !preB.Success) return preA.Success && !preB.Success;
+
+        var (idsA, idsB) = (preA.Value.Split('.'), preB.Value.Split('.'));
+        for (var i = 0; i < Math.Min(idsA.Length, idsB.Length); i++)
+        {
+            var numericA = idsA[i].All(char.IsAsciiDigit);
+            var numericB = idsB[i].All(char.IsAsciiDigit);
+            var order = numericA && numericB
+                ? BigInteger.Parse(idsA[i]).CompareTo(BigInteger.Parse(idsB[i]))
+                : numericA != numericB ? (numericA ? -1 : 1) : string.CompareOrdinal(idsA[i], idsB[i]);
+            if (order != 0) return order < 0;
+        }
+        return idsA.Length < idsB.Length;
+    }
+
+    /// The version  pins MetaObjects.Codegen to and the
+    /// 1-based line that carries it, or null when the file is not readable XML or has no such
+    /// PackageReference with a version. Scaffolded is true when that line is the whole
+    /// reference in the form eject scaffolds: one element, Include and Version, no children.
+    private static (string Version, int Line, bool Scaffolded)? PinnedCodegenVersion(string csprojPath)
+    {
+        XDocument doc;
+        try { doc = XDocument.Load(csprojPath, LoadOptions.SetLineInfo); }
+        catch (Exception e) when (e is XmlException or IOException or UnauthorizedAccessException) { return null; }
+
+        var reference = doc.Descendants().FirstOrDefault(e =>
+            e.Name.LocalName == "PackageReference" &&
+            string.Equals((string?)e.Attribute("Include"), PinnedPackage, StringComparison.OrdinalIgnoreCase));
+        XObject? version = reference?.Attribute("Version") as XObject
+            ?? reference?.Elements().FirstOrDefault(e => e.Name.LocalName == "Version");
+        return version switch
+        {
+            XAttribute a => (a.Value.Trim(), ((IXmlLineInfo)a).LineNumber,
+                ((IXmlLineInfo)a).LineNumber == ((IXmlLineInfo)reference!).LineNumber
+                && reference.Attributes().Count() == 2 && !reference.Nodes().Any()),
+            XElement e => (e.Value.Trim(), ((IXmlLineInfo)e).LineNumber, false),
+            _ => null,
+        };
+    }
+
+    /// What eject says when the owned project pins a package older than the sources it was
+    /// just handed: empty when the pin is this tool's version or newer, or cannot be read.
+    private static IReadOnlyList OlderPinWarning(string csprojPath)
+    {
+        var tool = ToolVersion();
+        if (PinnedCodegenVersion(csprojPath) is not { } pin || !IsOlderVersion(pin.Version, tool)) return [];
+        var warning =
+            $"warning: codegen/Codegen.csproj pins {PinnedPackage} {pin.Version}, older than the {tool} this " +
+            $"eject wrote its sources from. Those sources are written against the {tool} package API, so the " +
+            $"owned project will not compile until the pin is {tool} or later. Eject never edits that file: " +
+            $"set the version on line {pin.Line} of codegen/Codegen.csproj to {tool}";
+        return pin.Scaffolded
+            ? ["", warning + ", so the line reads", $"  "]
+            : ["", warning + "."];
+    }
+
     private static string ProgramCsContent(IEnumerable classNames)
     {
         var news = string.Join("\n", classNames.Select(c => $"    new {c}(),"));
@@ -161,6 +255,7 @@ public static Outcome Run(IReadOnlyList names, string root, bool force)
             File.WriteAllText(dest, rewritten);
             outLines.Add($"ejected \"{name}\" -> codegen/generators/{file} [{status}]");
         }
+        outLines.AddRange(EjectCompanions(root, names.Select(n => known[n]).ToList(), force));
         var runtimeUsers = names.Where(n => HelperRuntime.UsedBy(EjectableGenerators.ReadSource(n)!)).ToList();
         if (runtimeUsers.Count > 0)
             outLines.AddRange(EjectRuntime(root, runtimeUsers, force));
@@ -193,11 +288,48 @@ public static Outcome Run(IReadOnlyList names, string root, bool force)
                 outLines.Add($"  new {ClassNameFor(known[name])}(),");
             outLines.Add("to the `generators` list there (and remove the packaged import if this " +
                           "replaces a generator you ran unejected before).");
+            if (File.Exists(csprojPath)) outLines.AddRange(OlderPinWarning(csprojPath));
         }
 
         return new Outcome(0, outLines, []);
     }
 
+    /// 
+    /// Copy the emitters the ejected  write their output through
+    /// () next to the generators, each once however
+    /// many of them call it. A file that already exists is the adopter's and is kept — possibly
+    /// edited, possibly shared with a generator ejected earlier — unless ,
+    /// which replaces it with the reference exactly as it replaces the generator copy.
+    /// 
+    private static List EjectCompanions(string root, IReadOnlyList entries, bool force)
+    {
+        var companions = entries.SelectMany(e => e.Companions).Distinct(StringComparer.Ordinal).ToList();
+        var lines = new List();
+        if (companions.Count == 0) return lines;
+
+        lines.Add("");
+        lines.Add(
+            $"{string.Join(", ", entries.Where(e => e.Companions.Count > 0).Select(e => $"\"{e.Name}\""))} " +
+            "write their output through emitters; copied their source so you own that too:");
+        foreach (var sourcePath in companions)
+        {
+            var relative = EjectableGenerators.OwnedRelativePath(sourcePath);
+            var dest = Path.Combine(root, relative.Replace('/', Path.DirectorySeparatorChar));
+            string status;
+            if (!File.Exists(dest)) status = "created";
+            else if (force) status = "replaced";
+            else
+            {
+                lines.Add($"  {relative} [kept — already yours]");
+                continue;
+            }
+            Directory.CreateDirectory(Path.GetDirectoryName(dest)!);
+            File.WriteAllText(dest, EjectableGenerators.RewriteForEject(EjectableGenerators.ReadSourcePath(sourcePath)));
+            lines.Add($"  {relative} [{status}]");
+        }
+        return lines;
+    }
+
     /// 
     /// Copy the helper runtime () into codegen/runtime/ for the
     /// ejected generator(s)  whose output imports it. A file that
diff --git a/server/csharp/MetaObjects.Cli/GenCommand.cs b/server/csharp/MetaObjects.Cli/GenCommand.cs
index 8c06e4455..b59a6935a 100644
--- a/server/csharp/MetaObjects.Cli/GenCommand.cs
+++ b/server/csharp/MetaObjects.Cli/GenCommand.cs
@@ -169,7 +169,7 @@ public static Outcome Run(
         // `codegen/Program.cs` calls too, with its owned generators composed into the same
         // selection (CodegenCli.ComposeGenerators) — one implementation, not two.
         var apiOutcome = MetaObjects.Codegen.CodegenCli.RunGen(
-            load, outDir, ns, emitAbstractShapes, generators, projectRoot, columnNaming, baseline);
+            load, outDir, ns, emitAbstractShapes, generators, projectRoot, columnNaming, baseline, names);
         return new Outcome(apiOutcome.LoadErrors, apiOutcome.Result);
     }
 
diff --git a/server/csharp/MetaObjects.Cli/OwnedCopy.cs b/server/csharp/MetaObjects.Cli/OwnedCopy.cs
index d7539bbd6..50a9203e5 100644
--- a/server/csharp/MetaObjects.Cli/OwnedCopy.cs
+++ b/server/csharp/MetaObjects.Cli/OwnedCopy.cs
@@ -57,6 +57,14 @@ public static Comparison Compare(string owned, string reference)
     /// "identical" / "DIFFERS: N behind, M of your own", or null when
     ///  is not ejectable or nothing is owned at  yet. What dotnet meta gen --list appends to each entry.
+    /// 
+    /// The verdict covers the emitters the generator was handed with
+    /// () too: they are owned code
+    /// the generator's output depends on. The generator's own counts come first; each companion that
+    /// differs adds a "; <file> DIFFERS: N behind, M of your own" clause and one that is
+    /// gone a "; <file> missing" one. A companion shared with another owned generator is
+    /// reported under each.
+    /// 
     /// 
     public static string? Status(string root, MetaObjects.Codegen.GeneratorRegistryEntry entry)
     {
@@ -68,9 +76,27 @@ public static Comparison Compare(string owned, string reference)
         var reference = MetaObjects.Codegen.EjectableGenerators.RewriteForEject(
             MetaObjects.Codegen.EjectableGenerators.ReadSourceFile(file));
         var cmp = Compare(mine, reference);
-        return cmp.Verdict == Verdict.Identical
-            ? "identical"
-            : $"DIFFERS: {cmp.Behind} behind, {cmp.OfYourOwn} of your own";
+        var clauses = new List();
+        if (cmp.Verdict == Verdict.Differs)
+            clauses.Add($"DIFFERS: {cmp.Behind} behind, {cmp.OfYourOwn} of your own");
+
+        foreach (var sourcePath in entry.Companions)
+        {
+            var name = sourcePath[(sourcePath.LastIndexOf('/') + 1)..];
+            var companion = Path.Combine(root, MetaObjects.Codegen.EjectableGenerators.OwnedRelativePath(sourcePath)
+                .Replace('/', Path.DirectorySeparatorChar));
+            if (!File.Exists(companion))
+            {
+                clauses.Add($"{name} missing");
+                continue;
+            }
+            var c = Compare(File.ReadAllText(companion),
+                MetaObjects.Codegen.EjectableGenerators.RewriteForEject(
+                    MetaObjects.Codegen.EjectableGenerators.ReadSourcePath(sourcePath)));
+            if (c.Verdict == Verdict.Differs)
+                clauses.Add($"{name} DIFFERS: {c.Behind} behind, {c.OfYourOwn} of your own");
+        }
+        return clauses.Count == 0 ? "identical" : string.Join("; ", clauses);
     }
 
     /// 
diff --git a/server/csharp/MetaObjects.Cli/Program.cs b/server/csharp/MetaObjects.Cli/Program.cs
index ba30e609c..ce369355c 100644
--- a/server/csharp/MetaObjects.Cli/Program.cs
+++ b/server/csharp/MetaObjects.Cli/Program.cs
@@ -274,6 +274,14 @@ static int RunDocs(string[] rest)
     // falls back to the port-neutral .metaobjects/config.json ladder.
     var resolvedMeta = ResolveMetadataDirOrExit(metadataDir);
 
+    // ADR-0034 Amendment 3 hand-off, for a project that owns the api-docs generator: ITS copy
+    // writes the reference, from the generator list in its own codegen/Program.cs.
+    var docsProjectRoot = metadataDir is not null
+        ? GenCommand.ProjectRootFor(metadataDir)
+        : Directory.GetCurrentDirectory();
+    if (CodegenHandoff.TryRun(docsProjectRoot, ["docs", .. rest], out var docsHandoffExit))
+        return docsHandoffExit;
+
     // Default the project label to the input directory's leaf name (cosmetic — surfaces
     // in the AGENT-API header). Trailing-separator-safe.
     project ??= new DirectoryInfo(Path.TrimEndingDirectorySeparator(Path.GetFullPath(resolvedMeta.Directory))).Name;
diff --git a/server/csharp/MetaObjects.Codegen.Tests/ApiDocsRunTests.cs b/server/csharp/MetaObjects.Codegen.Tests/ApiDocsRunTests.cs
new file mode 100644
index 000000000..42de69a96
--- /dev/null
+++ b/server/csharp/MetaObjects.Codegen.Tests/ApiDocsRunTests.cs
@@ -0,0 +1,139 @@
+// The docs run: `api-docs` as a generator (ADR-0025), what runs it, and what it documents.
+//
+// A project can own the api reference like any other generator. These cover the engine around it:
+// a docs generator is not a code generator (`gen` skips it, and says so when asked for it by name), the docs run takes a
+// project's own copy in place of the packaged one, and what the reference documents is what the
+// PACKAGED generators emit for, whichever generators the run names.
+
+using MetaObjects.Codegen.Generators;
+using MetaObjects.Loader;
+using MetaObjects.Meta;
+using Xunit;
+
+namespace MetaObjects.Codegen.Tests;
+
+public sealed class ApiDocsRunTests : IDisposable
+{
+    private readonly string _out = Path.Combine(Path.GetTempPath(), "meta-docs-run-" + Guid.NewGuid().ToString("N"));
+    public ApiDocsRunTests() => Directory.CreateDirectory(_out);
+    public void Dispose() { try { Directory.Delete(_out, recursive: true); } catch { } }
+
+    private static LoadResult Load()
+    {
+        var result = new MetaDataLoader().Load([new FileSource(CorpusPaths.FitnessMetadata)]);
+        Assert.Empty(result.Errors.Select(e => e.Code + ": " + e.Message));
+        return result;
+    }
+
+    private static readonly DocsRunFacts Facts = new("fitness");
+
+    /// Stands in for a project's owned routes: it answers to the packaged generator's name
+    /// and carries its own AppliesTo, the way an ejected copy does.
+    private sealed class OwnedRoutesThatServeNothing : IGenerator
+    {
+        public string Name => GeneratorRegistry.Get("routes")!.Factory(new GeneratorBuildContext()).Name;
+        public IEnumerable Generate(GenContext ctx) => [];
+        public static bool AppliesTo(MetaObject entity, MetaRoot root) => false;
+    }
+
+    private sealed class OwnedDocs(string path, string content) : IDocsGenerator
+    {
+        public string Name => "api-docs";
+        public IEnumerable Generate(GenContext ctx) => [new EmittedFile(path, content)];
+    }
+
+    private static string Page(string outDir, string name) =>
+        File.ReadAllText(Directory.GetFiles(outDir, name, SearchOption.AllDirectories).Single());
+
+    [Fact]
+    public void The_packaged_docs_run_writes_the_reference_under_the_api_subdirectory()
+    {
+        var outcome = CodegenCli.RunDocs(Load(), _out, "Acme.Generated", Facts, []);
+
+        Assert.True(outcome.Ok);
+        Assert.Contains("api/csharp/README.md", outcome.WrittenPaths);
+        Assert.Contains("api/csharp/AGENT-API.md", outcome.WrittenPaths);
+        Assert.All(outcome.WrittenPaths, p => Assert.True(File.Exists(Path.Combine(_out, p.Replace('/', Path.DirectorySeparatorChar)))));
+    }
+
+    [Fact]
+    public void The_reference_documents_the_packaged_generators_whichever_generators_the_run_names()
+    {
+        var packagedOut = Path.Combine(_out, "packaged");
+        var ownedOut = Path.Combine(_out, "owned");
+        var packaged = CodegenCli.RunDocs(Load(), packagedOut, "Acme.Generated", Facts, []);
+        var owned = CodegenCli.RunDocs(Load(), ownedOut, "Acme.Generated", Facts, [new OwnedRoutesThatServeNothing()]);
+
+        Assert.True(packaged.Ok && owned.Ok);
+        Assert.Equal(packaged.WrittenPaths, owned.WrittenPaths);
+        string Read(string outDir, string page) => File.ReadAllText(Path.Combine(outDir, page.Replace('/', Path.DirectorySeparatorChar)));
+        Assert.Contains(packaged.WrittenPaths, p => Read(packagedOut, p).Contains("GET /api/", StringComparison.Ordinal));
+        Assert.All(packaged.WrittenPaths, p => Assert.Equal(Read(packagedOut, p), Read(ownedOut, p)));
+    }
+
+    [Fact]
+    public void A_projects_own_docs_generator_takes_the_place_of_the_packaged_one()
+    {
+        var outcome = CodegenCli.RunDocs(Load(), _out, "Acme.Generated", Facts, [new OwnedDocs("api/csharp/OWNED.md", "mine")]);
+
+        Assert.True(outcome.Ok);
+        Assert.Equal(["api/csharp/OWNED.md"], outcome.WrittenPaths);
+        Assert.Equal("mine", Page(_out, "OWNED.md"));
+        Assert.False(File.Exists(Path.Combine(_out, "api", "csharp", "README.md")));
+    }
+
+    [Fact]
+    public void Two_pages_at_one_path_fail_the_run_before_anything_is_written()
+    {
+        var outcome = CodegenCli.RunDocs(Load(), _out, "Acme.Generated", Facts,
+            [new OwnedDocs("api/csharp/X.md", "a"), new OwnedDocs("api/csharp/X.md", "b")]);
+
+        Assert.False(outcome.Ok);
+        Assert.Contains("duplicate api page output path: api/csharp/X.md", outcome.CollisionError);
+        Assert.False(Directory.Exists(Path.Combine(_out, "api")));
+    }
+
+    [Fact]
+    public void A_code_run_whose_selection_names_a_docs_generator_skips_it_and_says_so()
+    {
+        var outcome = CodegenCli.RunGen(
+            Load(), _out, "Acme.Generated", false, [new ApiDocsGenerator(), new EntityGenerator()],
+            selection: ["api-docs", "entity"]);
+
+        Assert.True(outcome.Ok);
+        Assert.Contains(outcome.Result!.Warnings, w => w == CodegenCli.DocsGeneratorSkipped("api-docs"));
+        Assert.Contains("`dotnet meta docs`", CodegenCli.DocsGeneratorSkipped("api-docs"));
+        Assert.DoesNotContain(outcome.Result.Files, f => f.Path.Contains("AGENT-API", StringComparison.Ordinal));
+        Assert.NotEmpty(outcome.Result.Files);
+    }
+
+    // An owned project lists its docs generator beside its code generators, because that one list is
+    // where `docs` finds it. A code run that did not ask for it by name skips it and has nothing to say:
+    // a warning here would fire on every `gen` and could only be cleared by giving the generator up.
+    [Theory]
+    [InlineData(null)]
+    [InlineData("entity")]
+    public void A_code_run_that_only_finds_a_docs_generator_in_its_list_skips_it_silently(string? selected)
+    {
+        var outcome = CodegenCli.RunGen(
+            Load(), _out, "Acme.Generated", false, [new ApiDocsGenerator(), new EntityGenerator()],
+            selection: selected is null ? null : [selected]);
+
+        Assert.True(outcome.Ok);
+        Assert.DoesNotContain(outcome.Result!.Warnings, w => w.Contains("api reference", StringComparison.Ordinal));
+        Assert.DoesNotContain(outcome.Result.Files, f => f.Path.Contains("AGENT-API", StringComparison.Ordinal));
+        Assert.NotEmpty(outcome.Result.Files);
+    }
+
+    [Fact]
+    public void A_drift_check_leaves_a_docs_generator_out_too()
+    {
+        var load = Load();
+        var config = new GenConfig { OutDir = _out, Namespace = "Acme.Generated" };
+        CodegenRunner.Run(config, load.Root, [new EntityGenerator()]);
+
+        var drift = CodegenDrift.Compute(config, load.Root, [new EntityGenerator(), new ApiDocsGenerator()]);
+
+        Assert.True(drift.Clean, string.Join("\n", drift.Lines));
+    }
+}
diff --git a/server/csharp/MetaObjects.Codegen.Tests/EjectableGeneratorsTests.cs b/server/csharp/MetaObjects.Codegen.Tests/EjectableGeneratorsTests.cs
index e3d846bfe..7ae20ebbb 100644
--- a/server/csharp/MetaObjects.Codegen.Tests/EjectableGeneratorsTests.cs
+++ b/server/csharp/MetaObjects.Codegen.Tests/EjectableGeneratorsTests.cs
@@ -38,32 +38,54 @@ public void Template_primitive_is_not_ejectable()
     [Fact]
     public void Embedded_copies_are_byte_identical_to_their_source_files()
     {
-        var dir = GeneratorsDir();
-        foreach (var entry in EjectableGenerators.Entries())
+        // The generators AND the emitters handed over with them: every path any entry hands over.
+        var packageDir = Path.Combine(CorpusPaths.RepoRoot(), "server", "csharp", "MetaObjects.Codegen");
+        foreach (var sourcePath in EjectableGenerators.AllSourcePaths())
         {
-            var embedded = EjectableGenerators.ReadSourceFile(entry.SourceFileName!);
-            var onDisk = File.ReadAllText(Path.Combine(dir, entry.SourceFileName!));
+            var embedded = EjectableGenerators.ReadSourcePath(sourcePath);
+            var onDisk = File.ReadAllText(Path.Combine(packageDir, sourcePath));
             Assert.True(
                 Normalize(embedded) == Normalize(onDisk),
-                $"embedded Generators/{entry.SourceFileName} diverges from its source file — " +
+                $"embedded {sourcePath} diverges from its source file — " +
                 "rebuild MetaObjects.Codegen after editing the generator.");
         }
     }
 
     [Fact]
-    public void Embedded_resource_set_matches_the_registry_ejectable_set_exactly()
+    public void Embedded_resource_set_matches_what_the_registry_hands_over_exactly()
     {
+        // Every source file eject hands over — each entry's SourceFileName and its Companions — is
+        // an embedded resource, and nothing else under Generators/, Docs/ or ApiDocs/ is.
         var asm = typeof(EjectableGenerators).Assembly;
-        var embeddedNames = asm.GetManifestResourceNames()
-            .Where(n => n.StartsWith(EjectableGenerators.ResourcePrefix, StringComparison.Ordinal))
-            .Select(n => n[EjectableGenerators.ResourcePrefix.Length..])
+        var embedded = asm.GetManifestResourceNames()
+            .Where(n => EjectableGenerators.SourceDirectories.Any(d =>
+                n.StartsWith($"MetaObjects.Codegen.{d}.", StringComparison.Ordinal)))
             .OrderBy(n => n, StringComparer.Ordinal)
             .ToList();
-        var registryNames = EjectableGenerators.Entries()
-            .Select(e => e.SourceFileName!)
+        var handedOver = EjectableGenerators.AllSourcePaths()
+            .Select(EjectableGenerators.ResourceName)
             .OrderBy(n => n, StringComparer.Ordinal)
             .ToList();
-        Assert.Equal(registryNames, embeddedNames);
+        Assert.Equal(handedOver, embedded);
+    }
+
+    [Fact]
+    public void Every_companion_is_a_file_the_package_ships_and_no_two_files_share_a_name()
+    {
+        // Owned copies are written flat, into codegen/generators/, so a name two source directories share
+        // would make eject overwrite one with the other.
+        var packageDir = Path.Combine(CorpusPaths.RepoRoot(), "server", "csharp", "MetaObjects.Codegen");
+        var all = EjectableGenerators.AllSourcePaths();
+        Assert.All(all, p => Assert.True(File.Exists(Path.Combine(packageDir, p)), $"{p} does not exist"));
+        Assert.Equal(all.Count, all.Select(p => p[(p.LastIndexOf('/') + 1)..]).Distinct(StringComparer.Ordinal).Count());
+    }
+
+    [Fact]
+    public void Ownership_is_one_flat_directory_whichever_package_directory_a_file_came_from()
+    {
+        Assert.Equal("codegen/generators/EntityGenerator.cs", EjectableGenerators.OwnedRelativePath("Generators/EntityGenerator.cs"));
+        Assert.Equal("codegen/generators/XmlDocBuilder.cs", EjectableGenerators.OwnedRelativePath("Docs/XmlDocBuilder.cs"));
+        Assert.Equal("codegen/generators/DocsPaths.cs", EjectableGenerators.OwnedRelativePath("ApiDocs/DocsPaths.cs"));
     }
 
     [Fact]
@@ -121,15 +143,35 @@ public void RewriteForEject_renames_the_namespace_and_adds_the_two_usings_it_rep
     public void RewriteForEject_runs_clean_on_every_real_ejectable_source()
     {
         // Every real embedded source actually contains the line being rewritten — this
-        // would fail loudly (not silently) if a generator file's namespace declaration
-        // ever changed shape.
-        foreach (var entry in EjectableGenerators.Entries())
+        // would fail loudly (not silently) if a file's namespace declaration ever changed
+        // shape — and every one lands in the owned namespace, whichever package directory it came from.
+        foreach (var sourcePath in EjectableGenerators.AllSourcePaths())
         {
-            var source = EjectableGenerators.ReadSourceFile(entry.SourceFileName!);
-            var rewritten = EjectableGenerators.RewriteForEject(source);
+            var rewritten = EjectableGenerators.RewriteForEject(EjectableGenerators.ReadSourcePath(sourcePath));
             Assert.Contains("namespace Codegen.Generators;", rewritten);
+            Assert.DoesNotContain("namespace MetaObjects.Codegen.", rewritten);
+            Assert.DoesNotContain("using MetaObjects.Codegen.Docs;", rewritten);
+            Assert.DoesNotContain("using MetaObjects.Codegen.ApiDocs;", rewritten);
         }
     }
 
+    [Fact]
+    public void RewriteForEject_adds_a_using_only_when_the_source_does_not_already_say_it()
+    {
+        const string src =
+            "using MetaObjects.Codegen.Generators;\n" +
+            "using MetaObjects.Codegen.ApiDocs;\n" +
+            "\n" +
+            "namespace MetaObjects.Codegen.ApiDocs;\n" +
+            "\n" +
+            "public sealed class Builder\n{\n}\n";
+
+        var rewritten = EjectableGenerators.RewriteForEject(src);
+
+        Assert.Equal(1, System.Text.RegularExpressions.Regex.Matches(rewritten, @"using MetaObjects\.Codegen\.Generators;").Count);
+        Assert.DoesNotContain("using MetaObjects.Codegen.ApiDocs;", rewritten);
+        Assert.Contains("namespace Codegen.Generators;", rewritten);
+    }
+
     private static string Normalize(string s) => s.Replace("\r\n", "\n");
 }
diff --git a/server/csharp/MetaObjects.Codegen.Tests/EjectedGeneratorCompileTests.cs b/server/csharp/MetaObjects.Codegen.Tests/EjectedGeneratorCompileTests.cs
index 53d4cc901..df7c05691 100644
--- a/server/csharp/MetaObjects.Codegen.Tests/EjectedGeneratorCompileTests.cs
+++ b/server/csharp/MetaObjects.Codegen.Tests/EjectedGeneratorCompileTests.cs
@@ -1,21 +1,12 @@
-// ADR-0034 Amendment 3 — eject, proof (a): "eject + unchanged copy -> generated output
-// byte-identical to the packaged generator for a real fixture."
+// ADR-0034 Amendment 3 — eject, proof (a) for the generators with options: "eject + unchanged
+// copy -> generated output byte-identical to the packaged generator for a real fixture."
 //
-// The tool ships compiled, so an ejected copy is just TEXT until something actually
-// compiles it. This test does that for real — via Roslyn, in-memory, the same mechanism
-// CodegenCompileConformanceTests already uses to prove generated OUTPUT compiles — but
-// here the SOURCE UNDER TEST is the ejected GENERATOR itself: rewrite it exactly as
-// `dotnet meta eject` would (EjectableGenerators.RewriteForEject), compile it against
-// the PUBLIC MetaObjects.Codegen API only (never a ProjectReference to internals), load
-// the resulting assembly, instantiate the generator by reflection, and run it against the
-// shared fitness corpus. Its output must equal the packaged generator's, file for file,
-// byte for byte. This is also the real verification of "make public whatever an ejected
-// copy needs to compile" (task item 2): if a helper is still internal, compilation fails
-// here with a concrete CS0122, not a hypothesis.
+// EjectedGeneratorsAloneTests proves it for EVERY registered generator, with its handed-over
+// emitters and from the registry; this file keeps the two cases that need more than a default run:
+// requirement-tests, which emits nothing for the shared fitness corpus and takes options and
+// project hooks. The compile is the same one, so a copy here is the real thing: rewritten
+// exactly as `dotnet meta eject` writes it and compiled against the PUBLIC MetaObjects.Codegen API.
 
-using System.Reflection;
-using Microsoft.CodeAnalysis;
-using Microsoft.CodeAnalysis.CSharp;
 using MetaObjects.Codegen.Generators;
 using MetaObjects.Core.Requirement;
 using MetaObjects.Loader;
@@ -36,166 +27,10 @@ internal static MetaRoot LoadCorpus()
         return result.Root;
     }
 
-    /// 
-    /// BCL/EF references (shared with every other Roslyn test in this project) plus the
-    /// MetaObjects assemblies an ejected generator's PUBLIC API surface depends on.
-    /// Deliberately does NOT reference sibling Generators/*.cs source files — an ejected
-    /// copy only ever sees the PACKAGED assembly's public surface, never another
-    /// generator's source, so this list is exactly what an adopter's own
-    /// codegen/Codegen.csproj would resolve via its MetaObjects.Codegen PackageReference.
-    /// 
-    private static List References()
-    {
-        var refs = DbContextCompileTests.BuildReferences();
-        refs.Add(MetadataReference.CreateFromFile(typeof(IGenerator).Assembly.Location));
-        refs.Add(MetadataReference.CreateFromFile(typeof(MetaObject).Assembly.Location));
-        refs.Add(MetadataReference.CreateFromFile(typeof(MetaObjects.Render.FilesystemProvider).Assembly.Location));
-        return refs;
-    }
-
-    /// 
-    /// Rewrite (as eject would), compile in-memory, and instantiate  from the Codegen.Generators namespace the rewrite
-    /// targets. Fails the test (not silently) on any compile error or missing type —
-    /// this IS the "verify the actual list by compiling" check.
-    /// 
-    // The real codegen/Codegen.csproj the eject design doc has `dotnet meta eject` write
-    // sets enable (matching every other project in this
-    // repo), which is what lets Generators/*.cs reference List<>/StringComparer/etc. with
-    // no explicit `using System.Collections.Generic;` line — the SDK generates exactly
-    // this global-usings file into the build. A standalone Roslyn compile has no SDK
-    // step, so it is reproduced here; without it, EVERY ejectable generator fails with
-    // dozens of CS0246s that have nothing to do with eject's own rewrite.
-    private const string ImplicitUsings =
-        "global using global::System;\n" +
-        "global using global::System.Collections.Generic;\n" +
-        "global using global::System.IO;\n" +
-        "global using global::System.Linq;\n" +
-        "global using global::System.Threading;\n" +
-        "global using global::System.Threading.Tasks;\n";
-
-    internal static IGenerator CompileAndInstantiate(string stableName, string className, object?[] ctorArgs)
-    {
-        var source = EjectableGenerators.RewriteForEject(EjectableGenerators.ReadSource(stableName)!);
-        var tree = CSharpSyntaxTree.ParseText(source, new CSharpParseOptions(LanguageVersion.CSharp12), path: className + ".cs");
-        var implicitUsingsTree = CSharpSyntaxTree.ParseText(ImplicitUsings, new CSharpParseOptions(LanguageVersion.CSharp12), path: "GlobalUsings.g.cs");
-        var comp = CSharpCompilation.Create(
-            "ejected_" + stableName.Replace('-', '_') + "_" + Guid.NewGuid().ToString("N"),
-            [tree, implicitUsingsTree], References(), new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
-
-        using var ms = new MemoryStream();
-        var emit = comp.Emit(ms);
-        var errors = emit.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error).Select(d => d.ToString()).ToList();
-        Assert.True(emit.Success,
-            $"ejected \"{stableName}\" (rewritten as `dotnet meta eject` would write it) failed to " +
-            $"compile against the PUBLIC MetaObjects.Codegen API ({errors.Count} error(s)):\n" +
-            string.Join("\n", errors));
-
-        ms.Position = 0;
-        var asm = Assembly.Load(ms.ToArray());
-        var typeName = "Codegen.Generators." + className;
-        var type = asm.GetType(typeName)
-            ?? throw new InvalidOperationException($"compiled ejected assembly has no type {typeName}");
-        return (IGenerator)(Activator.CreateInstance(type, ctorArgs)
-            ?? throw new InvalidOperationException($"could not construct {typeName}"));
-    }
-
-    /// Every ejectable generator with a parameterless constructor, paired with
-    /// the C# class name its file declares (render-helper takes a ctor arg — its own
-    /// test below).
-    public static TheoryData ParameterlessEjectables => new()
-    {
-        { "entity", "EntityGenerator" },
-        { "db-context", "DbContextGenerator" },
-        { "routes", "RoutesGenerator" },
-        { "output-parser", "OutputParserGenerator" },
-        { "extractor", "ExtractorGenerator" },
-        { "output-prompt", "OutputPromptGenerator" },
-        { "filter-allowlist", "FilterAllowlistGenerator" },
-        { "names", "NamesGenerator" },
-        { "callable", "CallableGenerator" },
-    };
-
-    [Theory]
-    [MemberData(nameof(ParameterlessEjectables))]
-    public void Ejected_unmodified_copy_compiles_and_matches_the_packaged_generator_byte_for_byte(
-        string stableName, string className)
-    {
-        var root = LoadCorpus();
-        var ctx = new GenContext
-        {
-            Entities = root.Objects(),
-            Root = root,
-            Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Generated", IncludeNames = true },
-        };
-
-        var ejected = CompileAndInstantiate(stableName, className, []);
-        var ejectedFiles = ejected.Generate(ctx).OrderBy(f => f.Path, StringComparer.Ordinal).ToList();
-
-        var packaged = GeneratorRegistry.Get(stableName)!.Factory(new GeneratorBuildContext());
-        var packagedFiles = packaged.Generate(ctx).OrderBy(f => f.Path, StringComparer.Ordinal).ToList();
-
-        // Not every generator emits against this fixture (e.g. "callable" needs a
-        // storedProc/tableFunction source, which meta.fitness.json has none of) — the
-        // claim under test is PARITY with the packaged generator, not fixture coverage
-        // (CodegenCompileConformanceTests already covers "does this corpus exercise the
-        // generator at all"). Equal empty lists still proves the ejected copy behaves
-        // identically to the packaged one for this input.
-        //
-        // The ONE documented difference: a generator whose output imports the helper runtime
-        // (routes) imports the adopter's owned copy once ejected — `using Codegen.Runtime;`
-        // where the packaged output says `using MetaObjects.Codegen.Runtime;`. Nothing else.
-        var usesRuntime = HelperRuntime.UsedBy(EjectableGenerators.ReadSource(stableName)!);
-        string Expected(string packagedContent) => usesRuntime
-            ? packagedContent.Replace(
-                $"using {HelperRuntime.PackagedNamespace};", $"using {HelperRuntime.OwnedNamespace};", StringComparison.Ordinal)
-            : packagedContent;
-
-        Assert.Equal(packagedFiles.Select(f => f.Path), ejectedFiles.Select(f => f.Path));
-        for (int i = 0; i < packagedFiles.Count; i++)
-            Assert.Equal(Expected(packagedFiles[i].Content), ejectedFiles[i].Content);
-
-        // And an ejected copy's output reaches into the package's runtime namespace ONLY for
-        // core: ExtractObject, the reply parser the prompt tier delegates to. Every helper
-        // type it calls is the adopter's own copy.
-        var packageRuntimeLines = ejectedFiles
-            .SelectMany(f => f.Content.Split('\n').Select(l => (f.Path, Line: l)))
-            .Where(x => x.Line.Contains(HelperRuntime.PackagedNamespace, StringComparison.Ordinal)
-                        && !x.Line.Contains("ExtractObject", StringComparison.Ordinal))
-            .Select(x => $"{x.Path}: {x.Line.Trim()}")
-            .ToList();
-        Assert.True(packageRuntimeLines.Count == 0,
-            $"ejected \"{stableName}\" output still references a helper in {HelperRuntime.PackagedNamespace}:\n" +
-            string.Join("\n", packageRuntimeLines));
-    }
-
-    [Fact]
-    public void Ejected_render_helper_compiles_and_matches_the_packaged_generator()
-    {
-        var root = LoadCorpus();
-        var ctx = new GenContext
-        {
-            Entities = root.Objects(),
-            Root = root,
-            Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Generated", IncludeNames = true },
-        };
-
-        var ejected = CompileAndInstantiate("render-helper", "RenderHelperGenerator", [CorpusPaths.FitnessTemplateRoot]);
-        var ejectedFiles = ejected.Generate(ctx).OrderBy(f => f.Path, StringComparer.Ordinal).ToList();
-
-        var packaged = GeneratorRegistry.Get("render-helper")!.Factory(new GeneratorBuildContext(CorpusPaths.FitnessTemplateRoot));
-        var packagedFiles = packaged.Generate(ctx).OrderBy(f => f.Path, StringComparer.Ordinal).ToList();
-
-        Assert.True(ejectedFiles.Count > 0, "render-helper emitted no files against the fixture — nothing was proven");
-        Assert.Equal(packagedFiles.Select(f => f.Path), ejectedFiles.Select(f => f.Path));
-        for (int i = 0; i < packagedFiles.Count; i++)
-            Assert.Equal(packagedFiles[i].Content, ejectedFiles[i].Content);
-    }
-
     /// 
     /// requirement-tests, over the identity corpus's worked-example: meta.fitness.json has
-    /// no requirement, so the parameterless theory above compares two empty lists for this generator and
-    /// proves nothing. Both generators are set to NON-default options so a copy that ignored a property, or
+    /// no requirement, so the generic parity test compares two empty lists for this generator over it and
+    /// proves little. Both generators are set to NON-default options so a copy that ignored a property, or
     /// that fell back to its own defaults, could not agree with the packaged one by accident.
     /// 
     [Fact]
@@ -207,7 +42,7 @@ public void Ejected_requirement_tests_compiles_against_the_public_API_and_matche
 
         const string testNamespace = "Acme.Owned.Req";
         const string witnessClass = "Acme.Owned.Witnesses";
-        var ejected = CompileAndInstantiate("requirement-tests", "RequirementTestsGenerator", []);
+        var ejected = EjectedGeneratorsAloneTests.EjectAlone("requirement-tests").Generator;
         Assert.Equal("Codegen.Generators", ejected.GetType().Namespace);
         foreach (var (property, value) in new (string, object)[]
                  {
@@ -282,7 +117,7 @@ public void Ejected_requirement_tests_accepts_a_project_filter_and_renderer_and_
         var load = MetaDataLoader.FromDirectory(input, strict: true);
         Assert.Empty(load.Errors.Select(e => e.Code + ": " + e.Message));
 
-        var ejected = CompileAndInstantiate("requirement-tests", "RequirementTestsGenerator", []);
+        var ejected = EjectedGeneratorsAloneTests.EjectAlone("requirement-tests").Generator;
         var ejectedType = ejected.GetType();
         ejectedType.GetProperty("Filter")!.SetValue(ejected, new ProjectKeepEverythingFilter());
         ejectedType.GetProperty("Renderer")!.SetValue(ejected, new ProjectRenderer());
diff --git a/server/csharp/MetaObjects.Codegen.Tests/EjectedGeneratorsAloneTests.cs b/server/csharp/MetaObjects.Codegen.Tests/EjectedGeneratorsAloneTests.cs
new file mode 100644
index 000000000..edb4d7fc5
--- /dev/null
+++ b/server/csharp/MetaObjects.Codegen.Tests/EjectedGeneratorsAloneTests.cs
@@ -0,0 +1,434 @@
+// ADR-0034 Amendment 3 — eject, the depth gate: every ejectable generator, ejected ALONE.
+//
+// The eject tests that came before this one proved that a copy compiles and matches the
+// packaged generator. They could not say what the copy still LEANS ON: a copy that calls the
+// package's emitters compiles, matches, and is a shell — a defect in the code it writes would
+// still wait for a MetaObjects release. So this test asks the question directly, for every
+// generator the registry says is ejectable, never from a list typed by hand:
+//
+//   1. COMPILES ALONE — the generator and the files eject hands over with it (its registry
+//      entry's SourceFileName + Companions), rewritten exactly as `dotnet meta eject` writes
+//      them, compile against the package's PUBLIC API and nothing else: no sibling generator's
+//      source, no other generator's companions.
+//   2. LEANS ONLY ON THE ENGINE — every type of the package the compiled copy references is on
+//      the engine surface below. A packaged emitter, generator or docs writer is not.
+//   3. OWNS EVERY COMPANION — each companion file is used, so the list is the closure and not a
+//      habit.
+//   4. SAME BYTES — run over the shared corpora, the copy emits exactly what the packaged
+//      generator emits.
+//
+// THE ENGINE SURFACE is what an owned copy is allowed to call in the package (ruled in the
+// eject audit, question 2): the generator base and its config and context, the naming and
+// type-mapping tables, the model questions a generator asks (which entities are persisted,
+// which template is inbound, how a package binds to a namespace), the hook types a project
+// implements, and the runtime the generated code is compiled with. Everything that RETURNS
+// EMITTED TEXT is handed over instead. Each entry carries the reason it is engine; the test
+// fails on a referenced type that is not listed, and on a listed type nothing references.
+
+using System.Reflection;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using MetaObjects.Codegen.Generators;
+using MetaObjects.Loader;
+using MetaObjects.Meta;
+using Xunit;
+
+namespace MetaObjects.Codegen.Tests;
+
+public class EjectedGeneratorsAloneTests
+{
+    private const string PackageAssembly = "MetaObjects.Codegen";
+
+    /// The package types an owned copy may call, each with why it is engine rather than emit.
+    internal static readonly IReadOnlyDictionary EngineSurface = new Dictionary(StringComparer.Ordinal)
+    {
+        // The generator model.
+        ["MetaObjects.Codegen.IGenerator"] = "the generator contract",
+        ["MetaObjects.Codegen.IDocsGenerator"] = "the docs-surface marker the runner partitions on",
+        ["MetaObjects.Codegen.EmittedFile"] = "the generator's output record",
+        ["MetaObjects.Codegen.GenContext"] = "the per-run state handed to every generator",
+        ["MetaObjects.Codegen.GenConfig"] = "the resolved generation config",
+        ["MetaObjects.Codegen.DocsRunFacts"] = "the facts of a docs run, carried on GenContext",
+        ["MetaObjects.Codegen.GeneratorApplicability"] = "what the docs generator asks of the generators it documents, answered by the packaged ones",
+        ["MetaObjects.Codegen.PerEntityGenerator"] = "the one-file-per-entity base class",
+        ["MetaObjects.Codegen.ColumnNamingStrategy"] = "config enum read by naming",
+        // Naming, type mapping and binding: tables, ruled engine.
+        ["MetaObjects.Codegen.CSharpNaming"] = "the naming and type-mapping tables",
+        ["MetaObjects.Codegen.ValueObjectNames"] = "the one answer to what a value object's type is called and where it lives",
+        ["MetaObjects.Codegen.PackageBindingResolver"] = "package -> namespace binding",
+        // Questions about the model.
+        ["MetaObjects.Codegen.InstanceArtifacts"] = "the predicate: does this object produce instance artifacts",
+        ["MetaObjects.Codegen.ReportRows"] = "the row model a view-backed report is generated from: a model derivation",
+        ["MetaObjects.Codegen.Generators.FindInbound"] = "the model walk: which templates are inbound and what shape they return",
+        ["MetaObjects.Codegen.Generators.InboundShape"] = "what FindInbound returns: the response value object and its format",
+        ["MetaObjects.Codegen.Generators.TphPlan"] = "the model plan: which entities share a table and how they are told apart",
+        ["MetaObjects.Codegen.Generators.TphPlanBuilder"] = "builds the TPH plan from the model",
+        ["MetaObjects.Codegen.Generators.TphSubtypePlan"] = "one subtype's place in the TPH plan",
+        ["MetaObjects.Codegen.ObjectNames"] = "the resolved physical names of an object, what CSharpNaming returns",
+        ["MetaObjects.Codegen.FieldNames"] = "the resolved physical names of a field, what CSharpNaming returns",
+        ["MetaObjects.Codegen.KeyNames"] = "the resolved physical names of a key, what CSharpNaming returns",
+        ["MetaObjects.Codegen.SourceNames"] = "the resolved physical names of a source, what CSharpNaming returns",
+        ["MetaObjects.Codegen.SuperNames"] = "the resolved physical names of a super, what CSharpNaming returns",
+        // Hooks a project implements; the generator that calls them is a copy, the types stay shared.
+        ["MetaObjects.Codegen.IRequirementTestRenderer"] = "hook type a project implements",
+        ["MetaObjects.Codegen.RequirementTestArgs"] = "hook argument type",
+        ["MetaObjects.Codegen.RequirementTestClaim"] = "hook argument type",
+        ["MetaObjects.Codegen.RenderedTest"] = "hook result type",
+    };
+
+    /// What a registered generator may be without an eject source, and why.
+    internal static readonly IReadOnlyDictionary NotEjectable = new Dictionary(StringComparer.Ordinal)
+    {
+        ["template"] = "the Mustache primitive has no emit logic of its own to own (ADR-0034 Amendment 3, ruled again for 1.1.0)",
+    };
+
+    // The SDK generates these for every project that sets enable, which the
+    // codegen/Codegen.csproj eject writes does; a standalone Roslyn compile has no SDK step.
+    private const string ImplicitUsings =
+        "global using global::System;\n" +
+        "global using global::System.Collections.Generic;\n" +
+        "global using global::System.IO;\n" +
+        "global using global::System.Linq;\n" +
+        "global using global::System.Threading;\n" +
+        "global using global::System.Threading.Tasks;\n";
+
+    /// Every registered generator that has an eject source — from the registry, never typed here.
+    public static TheoryData Ejectables
+    {
+        get
+        {
+            var data = new TheoryData();
+            foreach (var entry in EjectableGenerators.Entries()) data.Add(entry.Name);
+            return data;
+        }
+    }
+
+    internal sealed record Alone(
+        GeneratorRegistryEntry Entry, CSharpCompilation Compilation, IReadOnlyList<(string Path, SyntaxTree Tree)> Trees, IGenerator Generator);
+
+    /// 
+    /// Eject  alone: its source and its companions, rewritten as eject writes
+    /// them, compiled against the package's public API. Fails the test on a compile error.
+    ///  edits the generator's own file to the shape an older eject wrote.
+    /// 
+    internal static Alone EjectAlone(string name, Func? asEjectedBefore = null)
+    {
+        var entry = GeneratorRegistry.Get(name)!;
+        var parse = new CSharpParseOptions(LanguageVersion.CSharp12);
+        var trees = EjectableGenerators.SourcePaths(entry)
+            .Select(path => (Path: path, Tree: CSharpSyntaxTree.ParseText(
+                (path == "Generators/" + entry.SourceFileName ? asEjectedBefore ?? (x => x) : x => x)(
+                    EjectableGenerators.RewriteForEject(EjectableGenerators.ReadSourcePath(path))),
+                parse, path: path)))
+            .ToList();
+        var comp = CSharpCompilation.Create(
+            "ejected_" + name.Replace('-', '_') + "_" + Guid.NewGuid().ToString("N"),
+            [.. trees.Select(t => t.Tree), CSharpSyntaxTree.ParseText(ImplicitUsings, parse, path: "GlobalUsings.g.cs")],
+            References(), new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
+
+        using var ms = new MemoryStream();
+        var emit = comp.Emit(ms);
+        var errors = emit.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error).Select(d => d.ToString()).ToList();
+        Assert.True(emit.Success,
+            $"\"{name}\" ejected alone — {string.Join(", ", trees.Select(t => t.Path))}, rewritten as `dotnet meta eject` " +
+            $"writes them — failed to compile against the PUBLIC MetaObjects.Codegen API ({errors.Count} error(s)). " +
+            "A missing type is a companion the registry entry does not list; an inaccessible one is an emitter " +
+            "that must be made public or handed over:\n" + string.Join("\n", errors));
+
+        ms.Position = 0;
+        var asm = Assembly.Load(ms.ToArray());
+        var className = Path.GetFileNameWithoutExtension(entry.SourceFileName!);
+        var type = asm.GetType("Codegen.Generators." + className)
+            ?? throw new InvalidOperationException($"compiled ejected assembly has no type Codegen.Generators.{className}");
+        return new Alone(entry, comp, trees, Construct(type));
+    }
+
+    private static IGenerator Construct(Type type)
+    {
+        // A generator takes either nothing or the template root (render-helper); anything else is a new
+        // shape this test must be taught, which is the point of failing here.
+        var ctor = type.GetConstructors().Single();
+        var args = ctor.GetParameters().Select(p => p.ParameterType == typeof(string) && p.Name == "templateRoot"
+            ? (object)CorpusPaths.FitnessTemplateRoot
+            : throw new InvalidOperationException($"{type.Name}'s constructor takes {p.ParameterType.Name} {p.Name}; teach EjectedGeneratorsAloneTests how to supply it")).ToArray();
+        return (IGenerator)ctor.Invoke(args);
+    }
+
+    private static List References()
+    {
+        var refs = DbContextCompileTests.BuildReferences();
+        refs.Add(MetadataReference.CreateFromFile(typeof(IGenerator).Assembly.Location));
+        refs.Add(MetadataReference.CreateFromFile(typeof(MetaObject).Assembly.Location));
+        refs.Add(MetadataReference.CreateFromFile(typeof(MetaObjects.Render.FilesystemProvider).Assembly.Location));
+        return refs;
+    }
+
+    /// The top-level types of the package that 's compiled files reference.
+    internal static SortedSet PackagedTypesReferenced(Alone alone)
+    {
+        var found = new SortedSet(StringComparer.Ordinal);
+        void Note(ISymbol? symbol)
+        {
+            var type = symbol as INamedTypeSymbol ?? symbol?.ContainingType;
+            while (type?.ContainingType is { } outer) type = outer;
+            if (type is { ContainingAssembly.Name: PackageAssembly })
+                found.Add(type.OriginalDefinition.ToDisplayString());
+        }
+        foreach (var (_, tree) in alone.Trees)
+        {
+            var model = alone.Compilation.GetSemanticModel(tree);
+            foreach (var node in tree.GetRoot().DescendantNodes())
+            {
+                switch (node)
+                {
+                    case SimpleNameSyntax name:
+                        Note(model.GetSymbolInfo(name).Symbol);
+                        break;
+                    case ObjectCreationExpressionSyntax or ImplicitObjectCreationExpressionSyntax:
+                        Note(model.GetTypeInfo(node).Type);
+                        break;
+                }
+            }
+        }
+        // The types the copy's own files declare are not the package's, whatever their namespace says.
+        return found;
+    }
+
+    [Theory]
+    [MemberData(nameof(Ejectables))]
+    public void Ejected_alone_it_compiles_against_the_public_API(string name)
+    {
+        var alone = EjectAlone(name);
+        Assert.NotEmpty(alone.Trees);
+    }
+
+    [Theory]
+    [MemberData(nameof(Ejectables))]
+    public void Ejected_alone_it_calls_the_package_only_through_the_engine_surface(string name)
+    {
+        var alone = EjectAlone(name);
+        var emitters = PackagedTypesReferenced(alone).Where(t => !EngineSurface.ContainsKey(t)).ToList();
+        Assert.True(emitters.Count == 0,
+            $"\"{name}\" ejected alone still calls the package's {string.Join(", ", emitters)}. A type that returns " +
+            "emitted text is handed over with the generator (list it in the registry entry's Companions, and make " +
+            "any inaccessible member it needs public); a type that is a table or a model question is engine and " +
+            "goes on EjectedGeneratorsAloneTests.EngineSurface with the reason.");
+    }
+
+    [Theory]
+    [MemberData(nameof(Ejectables))]
+    public void Ejected_alone_every_companion_is_used(string name)
+    {
+        var alone = EjectAlone(name);
+        foreach (var companion in alone.Entry.Companions)
+        {
+            var tree = alone.Trees.Single(t => t.Path == companion).Tree;
+            var declared = tree.GetRoot().DescendantNodes().OfType()
+                .Where(d => d.Parent is not BaseTypeDeclarationSyntax)
+                .Select(d => alone.Compilation.GetSemanticModel(tree).GetDeclaredSymbol(d)!)
+                .ToList();
+            var used = alone.Trees.Where(t => t.Path != companion).Any(other =>
+            {
+                var model = alone.Compilation.GetSemanticModel(other.Tree);
+                return other.Tree.GetRoot().DescendantNodes().OfType().Any(n =>
+                {
+                    var symbol = model.GetSymbolInfo(n).Symbol;
+                    var type = symbol as INamedTypeSymbol ?? symbol?.ContainingType;
+                    return type is not null && declared.Any(d => SymbolEqualityComparer.Default.Equals(d, type.OriginalDefinition));
+                });
+            });
+            Assert.True(used,
+                $"\"{name}\" hands over {companion}, but neither the generator nor another companion calls it. " +
+                "Remove it from the registry entry's Companions: an owned copy should not carry code it does not run.");
+        }
+    }
+
+    private static MetaRoot LoadInline(string model, string id)
+    {
+        var loaded = new MetaDataLoader().Load([new InMemoryStringSource(model, id: id)]);
+        Assert.Empty(loaded.Errors.Select(e => e.Code + ": " + e.Message));
+        return loaded.Root;
+    }
+
+    /// The corpora every generator is run over: the shared fitness model, and the worked example (the
+    /// one with requirements, which the fitness model has none of).
+    private static IEnumerable<(string Label, MetaRoot Root)> Corpora(string generator)
+    {
+        // The prompt tier emits only for a responding prompt, and the extractor only for one whose response
+        // nests an object: neither shared corpus has that, so those generators also run over the nested-reply
+        // model the extractor's own codegen tests use. (render-helper is left off it: its template files live
+        // under the fitness template root, and the model names others.)
+        if (generator is "output-parser" or "extractor" or "output-prompt")
+            yield return ("nested-reply", LoadInline(ExtractorCodegenTests.Model, "orders.json"));
+        // `callable` emits only for a stored-proc or table-function source, which neither shared corpus has:
+        // it also runs over the model its own codegen tests use.
+        if (generator is "callable")
+            yield return ("callable", LoadInline(Fr015CodegenTests.Model, "fr015.json"));
+        yield return ("fitness", EjectedGeneratorCompileTests.LoadCorpus());
+        var worked = MetaDataLoader.FromDirectory(
+            Path.Combine(CorpusPaths.RepoRoot(), "fixtures", "requirement-test-identity-conformance", "worked-example", "input"), strict: true);
+        Assert.Empty(worked.Errors.Select(e => e.Code + ": " + e.Message));
+        yield return ("worked-example", worked.Root);
+    }
+
+    private static List Run(IGenerator generator, MetaRoot root)
+    {
+        var ctx = new GenContext
+        {
+            Entities = root.Objects(),
+            Root = root,
+            Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Generated", IncludeNames = true },
+            Docs = new DocsRunFacts("fitness"),
+        };
+        return generator.Generate(ctx).OrderBy(f => f.Path, StringComparer.Ordinal).ToList();
+    }
+
+    [Theory]
+    [MemberData(nameof(Ejectables))]
+    public void Ejected_alone_it_emits_what_the_packaged_generator_emits_byte_for_byte(string name)
+    {
+        var alone = EjectAlone(name);
+        var packaged = GeneratorRegistry.Get(name)!.Factory(new GeneratorBuildContext(CorpusPaths.FitnessTemplateRoot));
+        // The ONE documented difference: a generator whose output imports the helper runtime imports the
+        // adopter's owned copy once ejected — `using Codegen.Runtime;` where the packaged output says
+        // `using MetaObjects.Codegen.Runtime;`. Nothing else.
+        var usesRuntime = HelperRuntime.UsedBy(EjectableGenerators.ReadSource(name)!);
+        string Expected(string content) => usesRuntime
+            ? content.Replace($"using {HelperRuntime.PackagedNamespace};", $"using {HelperRuntime.OwnedNamespace};", StringComparison.Ordinal)
+            : content;
+
+        var emitted = 0;
+        foreach (var (label, root) in Corpora(name))
+        {
+            var want = Run(packaged, root);
+            var got = Run(alone.Generator, root);
+            Assert.True(want.Select(f => f.Path).SequenceEqual(got.Select(f => f.Path)),
+                $"\"{name}\" over {label}: the copy emits different files than the packaged generator.");
+            for (var i = 0; i < want.Count; i++)
+                Assert.True(Expected(want[i].Content) == got[i].Content,
+                    $"\"{name}\" over {label}: {want[i].Path} differs between the packaged generator and the ejected copy.");
+            // An ejected copy's output reaches into the package's runtime namespace ONLY for core: ExtractObject,
+            // the reply parser the prompt tier delegates to. Every helper type it calls is the adopter's own copy.
+            var packageRuntimeLines = got
+                .SelectMany(f => f.Content.Split('\n').Select(l => (f.Path, Line: l)))
+                .Where(x => x.Line.Contains(HelperRuntime.PackagedNamespace, StringComparison.Ordinal)
+                            && !x.Line.Contains("ExtractObject", StringComparison.Ordinal))
+                .Select(x => $"{x.Path}: {x.Line.Trim()}")
+                .ToList();
+            Assert.True(packageRuntimeLines.Count == 0,
+                $"ejected \"{name}\" output over {label} still references a helper in {HelperRuntime.PackagedNamespace}:\n" +
+                string.Join("\n", packageRuntimeLines));
+            emitted += want.Count;
+        }
+        // Parity of two empty lists proves nothing, so a generator must emit over at least one corpus.
+        Assert.True(emitted > 0, $"\"{name}\" emitted nothing over any corpus, so nothing was proven about it.");
+    }
+
+    [Fact]
+    public void A_copy_ejected_before_the_emitters_were_handed_over_binds_to_the_ones_copied_in_later()
+    {
+        // The upgrade path for an existing owned copy: its file is as an older eject wrote it, with the
+        // package's emitter namespaces still imported, and the emitters are copied in beside it. Names in the
+        // copy's own namespace win over names a using brings in, so the copy reaches the owned emitters.
+        var alone = EjectAlone("entity", source => "using MetaObjects.Codegen.Docs;\n" + source);
+
+        var emitters = PackagedTypesReferenced(alone).Where(t => !EngineSurface.ContainsKey(t)).ToList();
+        Assert.True(emitters.Count == 0, $"an older copy still binds to the package's {string.Join(", ", emitters)}");
+    }
+
+    /// 
+    /// The other direction of the engine line: what stays packaged must not call what is handed over. An
+    /// engine type that called a handed-over emitter would keep asking the PACKAGED copy after an eject,
+    /// while the owned generators ask the owned one, and the two answers part the moment the copy is edited.
+    /// The oracle is the compiler: the package's own sources, built without the companion files, may fail
+    /// only inside the ejectable generators, which are handed over with them.
+    /// 
+    [Fact]
+    public void The_packaged_engine_compiles_without_the_emitters_eject_hands_over()
+    {
+        var packageDir = Path.Combine(CorpusPaths.RepoRoot(), "server", "csharp", "MetaObjects.Codegen");
+        var handedOver = EjectableGenerators.AllSourcePaths().ToHashSet(StringComparer.Ordinal);
+        var companions = EjectableGenerators.Entries().SelectMany(e => e.Companions).ToHashSet(StringComparer.Ordinal);
+        var sources = Directory.GetFiles(packageDir, "*.cs", SearchOption.AllDirectories)
+            .Select(f => Path.GetRelativePath(packageDir, f).Replace(Path.DirectorySeparatorChar, '/'))
+            .Where(p => !p.StartsWith("bin/", StringComparison.Ordinal) && !p.StartsWith("obj/", StringComparison.Ordinal))
+            .ToList();
+        Assert.Subset(sources.ToHashSet(StringComparer.Ordinal), handedOver);
+
+        var parse = new CSharpParseOptions(LanguageVersion.CSharp12);
+        // The package's own assembly is left off the references: its sources are what is being compiled.
+        var references = References().OfType()
+            .Where(r => Path.GetFileNameWithoutExtension(r.FilePath) != PackageAssembly)
+            .ToList();
+        List<(string Path, string Error)> Errors(IEnumerable paths) => CSharpCompilation.Create(
+                "package_" + Guid.NewGuid().ToString("N"),
+                [.. paths.Select(p => CSharpSyntaxTree.ParseText(File.ReadAllText(Path.Combine(packageDir, p)), parse, path: p)),
+                 CSharpSyntaxTree.ParseText(ImplicitUsings, parse, path: "GlobalUsings.g.cs")],
+                references, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary))
+            .GetDiagnostics()
+            .Where(d => d.Severity == DiagnosticSeverity.Error)
+            .Select(d => (d.Location.SourceTree?.FilePath ?? "", d.ToString()))
+            .ToList();
+
+        // Whole, the package compiles here, so an error below is the removal's doing and not this setup's.
+        Assert.Empty(Errors(sources).Select(e => e.Error));
+
+        var without = Errors(sources.Where(p => !companions.Contains(p)));
+        Assert.Contains(without, e => handedOver.Contains(e.Path));
+        var engine = without.Where(e => !handedOver.Contains(e.Path)).Select(e => e.Error).ToList();
+        Assert.True(engine.Count == 0,
+            "a packaged type outside the ejectable generators calls an emitter eject hands over, so after an eject " +
+            "the package and the owned copy each answer for themselves. Move what the engine needs into the engine " +
+            "(or call the core authority the emitter delegates to):\n" + string.Join("\n", engine));
+    }
+
+    [Fact]
+    public void Every_registered_generator_is_ejectable_or_exempt_with_a_reason()
+    {
+        foreach (var entry in GeneratorRegistry.List())
+        {
+            if (entry.SourceFileName is not null)
+            {
+                Assert.DoesNotContain(entry.Name, NotEjectable.Keys);
+                continue;
+            }
+            Assert.True(NotEjectable.TryGetValue(entry.Name, out var reason) && reason.Length > 0,
+                $"\"{entry.Name}\" is registered with no eject source and no written exemption. Make it ejectable, " +
+                "or add it to EjectedGeneratorsAloneTests.NotEjectable with the ruling that exempts it.");
+        }
+        foreach (var exempt in NotEjectable.Keys)
+            Assert.NotNull(GeneratorRegistry.Get(exempt));
+    }
+
+    [Fact]
+    public void Every_generator_class_the_package_ships_is_registered()
+    {
+        var registered = GeneratorRegistry.List()
+            .Select(e => e.Factory(new GeneratorBuildContext()).GetType())
+            .ToHashSet();
+        var shipped = typeof(IGenerator).Assembly.GetExportedTypes()
+            .Where(t => t is { IsClass: true, IsAbstract: false } && typeof(IGenerator).IsAssignableFrom(t))
+            .Where(t => !registered.Contains(t))
+            .Select(t => t.FullName)
+            .ToList();
+        Assert.True(shipped.Count == 0,
+            "the package ships a generator no registry entry builds, so no catalog, eject command or test sees it: " +
+            string.Join(", ", shipped));
+    }
+
+    [Fact]
+    public void The_engine_surface_lists_only_types_some_ejected_copy_references_and_that_exist()
+    {
+        var referencedBySome = EjectableGenerators.Entries()
+            .SelectMany(e => PackagedTypesReferenced(EjectAlone(e.Name)))
+            .ToHashSet(StringComparer.Ordinal);
+        var packageTypes = typeof(IGenerator).Assembly.GetExportedTypes().Select(t => t.FullName!).ToHashSet(StringComparer.Ordinal);
+
+        var missing = EngineSurface.Keys.Where(k => !packageTypes.Contains(k)).ToList();
+        Assert.True(missing.Count == 0, $"engine surface names types the package does not export: {string.Join(", ", missing)}");
+        var stale = EngineSurface.Keys.Where(k => !referencedBySome.Contains(k)).ToList();
+        Assert.True(stale.Count == 0,
+            $"engine surface lists types no ejected copy references (remove them): {string.Join(", ", stale)}");
+    }
+}
diff --git a/server/csharp/MetaObjects.Codegen.Tests/ExtractorCodegenTests.cs b/server/csharp/MetaObjects.Codegen.Tests/ExtractorCodegenTests.cs
index 0370fa9ce..931a97b28 100644
--- a/server/csharp/MetaObjects.Codegen.Tests/ExtractorCodegenTests.cs
+++ b/server/csharp/MetaObjects.Codegen.Tests/ExtractorCodegenTests.cs
@@ -38,7 +38,7 @@ public sealed class ExtractorCodegenTests
     //   • labels  : REQUIRED enum ARRAY -> IReadOnlyList (per-element Enum.Parse)
     //   • note    : scalar (not @required)
     //   • shipTo  : single nested Customer (not @required)
-    private const string Model = """
+    internal const string Model = """
     { "metadata.root": { "package": "acme::orders", "children": [
       { "object.value": { "name": "Customer", "children": [
         { "field.string": { "name": "name", "@required": true } }
diff --git a/server/csharp/MetaObjects.Codegen.Tests/Fr015CodegenTests.cs b/server/csharp/MetaObjects.Codegen.Tests/Fr015CodegenTests.cs
index 41887d5d9..902eac288 100644
--- a/server/csharp/MetaObjects.Codegen.Tests/Fr015CodegenTests.cs
+++ b/server/csharp/MetaObjects.Codegen.Tests/Fr015CodegenTests.cs
@@ -37,7 +37,7 @@ public class Fr015CodegenTests
     // One value-object (the proc args, two fields in declaration order), a storedProc
     // entity referencing it via @parameterRef, a tableFunction entity, and a zero-arg
     // storedProc entity (no @parameterRef).
-    private const string Model = """
+    internal const string Model = """
     { "metadata.root": { "package": "acme::analytics", "children": [
       { "object.value": { "name": "PhaseSummaryArgs", "children": [
         { "field.int":       { "name": "caseId", "@required": true } },
diff --git a/server/csharp/MetaObjects.Codegen.Tests/GeneratorRegistryTests.cs b/server/csharp/MetaObjects.Codegen.Tests/GeneratorRegistryTests.cs
index 19eabc758..00cba8009 100644
--- a/server/csharp/MetaObjects.Codegen.Tests/GeneratorRegistryTests.cs
+++ b/server/csharp/MetaObjects.Codegen.Tests/GeneratorRegistryTests.cs
@@ -24,6 +24,8 @@ public sealed class GeneratorRegistryTests
         "names",
         // ADR-0057 — per-requirement xUnit tests calling a project-owned witness.
         "requirement-tests",
+        // ADR-0025 — the api reference `dotnet meta docs` writes, as a generator a project can own.
+        "api-docs",
     ];
 
     [Fact]
diff --git a/server/csharp/MetaObjects.Codegen.Tests/HelperRuntimeTests.cs b/server/csharp/MetaObjects.Codegen.Tests/HelperRuntimeTests.cs
index ecf77513c..a3eb6146d 100644
--- a/server/csharp/MetaObjects.Codegen.Tests/HelperRuntimeTests.cs
+++ b/server/csharp/MetaObjects.Codegen.Tests/HelperRuntimeTests.cs
@@ -156,7 +156,7 @@ private static List GenerateWithEjectedRoutes()
             Root = root,
             Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Generated", IncludeNames = true },
         };
-        var ownedRoutes = EjectedGeneratorCompileTests.CompileAndInstantiate("routes", "RoutesGenerator", []);
+        var ownedRoutes = EjectedGeneratorsAloneTests.EjectAlone("routes").Generator;
         var generators = new List
         {
             new EntityGenerator(), new DbContextGenerator(), new FilterAllowlistGenerator(), new NamesGenerator(), ownedRoutes,
diff --git a/server/csharp/MetaObjects.Codegen.Tests/PublishedSurfaceTests.cs b/server/csharp/MetaObjects.Codegen.Tests/PublishedSurfaceTests.cs
new file mode 100644
index 000000000..5bbcdb1cb
--- /dev/null
+++ b/server/csharp/MetaObjects.Codegen.Tests/PublishedSurfaceTests.cs
@@ -0,0 +1,32 @@
+// A MINOR release removes no published public member (docs/compatibility-policy.md): a member that
+// moved stays as a forwarder, and a constructor that gained a parameter keeps its old signature.
+
+using MetaObjects.Codegen.ApiDocs;
+using MetaObjects.Codegen.Generators;
+using Xunit;
+
+namespace MetaObjects.Codegen.Tests;
+
+public class PublishedSurfaceTests
+{
+    [Fact]
+    public void The_1_0_member_Fr010FieldMapping_CSharpStringLiteral_still_answers_as_it_did()
+    {
+#pragma warning disable CS0618 // the point is to call the obsolete member
+        Assert.Equal("a\\\"b\\n", Fr010FieldMapping.CSharpStringLiteral("a\"b\n"));
+        Assert.Equal("plain", Fr010FieldMapping.CSharpStringLiteral("plain"));
+#pragma warning restore CS0618
+        Assert.NotNull(typeof(Fr010FieldMapping).GetMethod(nameof(Fr010FieldMapping.CSharpStringLiteral))!
+            .GetCustomAttributes(typeof(ObsoleteAttribute), false).SingleOrDefault());
+    }
+
+    [Fact]
+    public void The_1_0_CSharpApiModelBuilder_constructor_keeps_its_signature()
+    {
+        var one = typeof(CSharpApiModelBuilder).GetConstructor([typeof(GenConfig)]);
+        Assert.NotNull(one);
+        var model = new CSharpApiModelBuilder(new GenConfig { OutDir = "/tmp", Namespace = "Acme" })
+            .Build(EjectedGeneratorCompileTests.LoadCorpus(), "fitness");
+        Assert.NotEmpty(model.Units);
+    }
+}
diff --git a/server/csharp/MetaObjects.Codegen/ApiDocs/CSharpApiModelBuilder.cs b/server/csharp/MetaObjects.Codegen/ApiDocs/CSharpApiModelBuilder.cs
index d60da3fc0..75e671018 100644
--- a/server/csharp/MetaObjects.Codegen/ApiDocs/CSharpApiModelBuilder.cs
+++ b/server/csharp/MetaObjects.Codegen/ApiDocs/CSharpApiModelBuilder.cs
@@ -3,7 +3,8 @@
 // This is the ACCURATE-BY-CONSTRUCTION half of the api-docs pipeline: every
 // documented symbol name comes from the CSharpNaming seam (never re-concatenated
 // here), and every inclusion decision comes from the corresponding generator's
-// static AppliesTo(...) predicate (never re-implemented here). The result is that
+// AppliesTo(...) predicate (never re-implemented here), asked through
+// GeneratorApplicability so this builder names no generator class. The result is that
 // what this builder documents == what the generators emit, by SHARING the single
 // source of truth rather than mirroring it.
 //
@@ -34,12 +35,25 @@ namespace MetaObjects.Codegen.ApiDocs;
 public sealed class CSharpApiModelBuilder
 {
     private readonly GenConfig _config;
+    private readonly GeneratorApplicability _applies;
 
     /// 
     /// The codegen config (namespace + binding) used ONLY to resolve a symbol's display
     /// namespace via the same  the generators use.
     /// 
-    public CSharpApiModelBuilder(GenConfig config) => _config = config;
+    public CSharpApiModelBuilder(GenConfig config) : this(config, GeneratorApplicability.Packaged)
+    {
+    }
+
+    /// As above.
+    /// 
+    /// What each documented generator emits for (see ).
+    /// 
+    public CSharpApiModelBuilder(GenConfig config, GeneratorApplicability applicability)
+    {
+        _config = config;
+        _applies = applicability;
+    }
 
     /// Build the SDK-surface model for  from .
     public CSharpApiModel Build(MetaRoot root, string project)
@@ -100,7 +114,7 @@ public CSharpApiModel Build(MetaRoot root, string project)
         // and skip it. A value object passes none of the AppliesTo predicates → MODEL only.
 
         // DATA_ACCESS — the DbSet on the generated AppDbContext (entity OR projection).
-        if (DbContextGenerator.AppliesTo(obj, root))
+        if (_applies.DbContext(obj, root))
         {
             var dbSet = CSharpNaming.DbSetName(obj);
             symbols.Add(new ApiSymbol(
@@ -116,7 +130,7 @@ public CSharpApiModel Build(MetaRoot root, string project)
         // writable source) gets a read-only DbSet but no write shape, so documenting
         // "validation on the create/update shape" for it would over-claim. This matches
         // the Python builder's writable-table gate (read-only projections excluded).
-        if (DbContextGenerator.AppliesTo(obj, root) && !obj.IsReadOnlyProjection())
+        if (_applies.DbContext(obj, root) && !obj.IsReadOnlyProjection())
         {
             var cls = CSharpNaming.ModelClassName(obj);
             symbols.Add(new ApiSymbol(
@@ -129,11 +143,11 @@ public CSharpApiModel Build(MetaRoot root, string project)
         // REST — one symbol per verb+path the routes generator registers. AddRestSymbols
         // itself emits only the read verbs (GET list / GET by id) for a read-only
         // projection (its `writable` gate), so the documented routes match generation.
-        if (RoutesGenerator.AppliesTo(obj, root))
+        if (_applies.Routes(obj, root))
             AddRestSymbols(symbols, obj, ns, root);
 
         // FILTER — the per-entity filter allowlist (every routed read surface).
-        if (FilterAllowlistGenerator.AppliesTo(obj))
+        if (_applies.FilterAllowlist(obj))
         {
             var filter = CSharpNaming.FilterAllowlistName(obj);
             symbols.Add(new ApiSymbol(
@@ -183,7 +197,7 @@ private ApiUnit BuildTemplateUnit(MetaData tmpl, MetaRoot root)
         var name = tmpl.Name;
         var symbols = new List();
 
-        if (RenderHelperGenerator.AppliesTo(tmpl, root))
+        if (_applies.RenderHelper(tmpl, root))
         {
             var render = CSharpNaming.RenderHelperName(name);
             var returns = IsEmailKind(tmpl) ? "EmailDocument" : "string";
@@ -199,7 +213,7 @@ private ApiUnit BuildTemplateUnit(MetaData tmpl, MetaRoot root)
         if (PayloadShape(tmpl, root) is { } reqVo)
             symbols.Add(PayloadSymbol(reqVo, root, "the typed payload value object rendered INTO the template"));
         // ADR-0052: the inbound symbols belong to the RESPONDING PROMPT, not to an output.
-        if (OutputPromptGenerator.AppliesTo(tmpl, root))
+        if (_applies.OutputPrompt(tmpl, root))
         {
             var prompt = CSharpNaming.ResponseFormatClassName(name);
             symbols.Add(new ApiSymbol(
@@ -207,7 +221,7 @@ private ApiUnit BuildTemplateUnit(MetaData tmpl, MetaRoot root)
                 $"static class {prompt}",
                 "builds the response-format prompt fragment"));
         }
-        if (OutputParserGenerator.AppliesTo(tmpl, root))
+        if (_applies.OutputParser(tmpl, root))
         {
             var parser = CSharpNaming.ParserClassName(name);
             symbols.Add(new ApiSymbol(
@@ -232,7 +246,7 @@ private ApiUnit BuildTemplateUnit(MetaData tmpl, MetaRoot root)
     /// The value object a template's @payloadRef resolves to (ADR-0042 package-local), or null.
     private static MetaData? PayloadShape(MetaData tmpl, MetaRoot root) =>
         tmpl.Attr(TEMPLATE_ATTR_PAYLOAD_REF) is string payloadRef
-            ? RenderHelperGenerator.ResolveValueObject(root, payloadRef, global::MetaObjects.NamingRefs.EffectivePackage(tmpl))
+            ? ValueObjectNames.ResolveValueObject(root, payloadRef, global::MetaObjects.NamingRefs.EffectivePackage(tmpl))
             : null;
 
     /// A PAYLOAD symbol for a value object's POCO: its emitted name, in its own namespace.
diff --git a/server/csharp/MetaObjects.Codegen/CSharpNaming.cs b/server/csharp/MetaObjects.Codegen/CSharpNaming.cs
index 4ac07c381..8c6cd03dd 100644
--- a/server/csharp/MetaObjects.Codegen/CSharpNaming.cs
+++ b/server/csharp/MetaObjects.Codegen/CSharpNaming.cs
@@ -5,6 +5,7 @@
 // unchanged. Scalar subtypes map to the natural .NET types; date/time/timestamp
 // use the modern DateOnly/TimeOnly/DateTime trio.
 
+using MetaObjects.Core.Field;
 using MetaObjects.Meta;
 using MetaObjects.Codegen.Generators;
 using MetaObjects.Persistence.Db;
@@ -1089,7 +1090,7 @@ public static bool IsRequired(MetaObject entity, MetaField field)
     ///   Otherwise use <EntityPascal><FieldPascal>.
     /// 
     /// 
-    /// The predicate is  — the SAME one
+    /// The predicate is  — the SAME one
     /// that decides whether a type is materialized — because a name and a declaration have
     /// to agree. Keying on "has a super" instead let a field that extends another ENTITY's
     /// concrete field (the ordinary way a projection restates a column it reads, e.g.
@@ -1101,7 +1102,7 @@ public static bool IsRequired(MetaObject entity, MetaField field)
     /// 
     public static string EnumTypeName(MetaObject entity, MetaField field)
     {
-        if (Fr019SharedEnum.ResolveSharedEnumDecl(field) is { } shared)
+        if (SharedEnumDeclaration.ResolveSharedEnumDeclaration(field) is { } shared)
             return Pascal(shared.Name);
         return Pascal(entity.Name) + Pascal(field.Name);
     }
@@ -1192,4 +1193,34 @@ public static void AssertNoCollectionNameCollisions(IReadOnlyList en
             }
         }
     }
+
+    private static readonly char[] CSharpEscapeChars = { '\\', '"', '\t', '\n', '\r' };
+
+    /// 
+    /// Escape a value for embedding inside a C# double-quoted string literal.
+    /// Scans before it builds: the names generator calls this two-to-four times per
+    /// member of every entity, and virtually every value — "object", "entity", a field
+    /// name, a snake_case column — contains none of these. IndexOfAny is vectorized
+    /// and allocates nothing, so the common case costs no allocation at all.
+    /// 
+    public static string CSharpStringLiteral(string value) =>
+        value.IndexOfAny(CSharpEscapeChars) < 0 ? value : CSharpStringLiteralSlow(value);
+
+    private static string CSharpStringLiteralSlow(string value)
+    {
+        var sb = new System.Text.StringBuilder(value.Length + 4);
+        foreach (char c in value)
+        {
+            switch (c)
+            {
+                case '\\': sb.Append("\\\\"); break;
+                case '"': sb.Append("\\\""); break;
+                case '\t': sb.Append("\\t"); break;
+                case '\n': sb.Append("\\n"); break;
+                case '\r': sb.Append("\\r"); break;
+                default: sb.Append(c); break;
+            }
+        }
+        return sb.ToString();
+    }
 }
diff --git a/server/csharp/MetaObjects.Codegen/CodegenCli.cs b/server/csharp/MetaObjects.Codegen/CodegenCli.cs
index ebdb79926..c12f9de60 100644
--- a/server/csharp/MetaObjects.Codegen/CodegenCli.cs
+++ b/server/csharp/MetaObjects.Codegen/CodegenCli.cs
@@ -200,10 +200,17 @@ public static GenOutcome RunGen(
     /// to the current directory for a caller that names no project (mirrors
     /// GenConfig.ProjectRoot's own default).
     /// 
+    /// 
+    /// The --generators names the run was asked for, when it was asked by name. A docs
+    /// generator is never run here; one this selection names is reported as skipped
+    /// (), and one that is only in 
+    /// — an owned list, where the docs run looks for it — is left out without a word.
+    /// 
     public static GenOutcome RunGen(
         LoadResult load, string outDir, string ns, bool emitAbstractShapes,
         IReadOnlyList generators, string? projectRoot = null,
-        ColumnNamingStrategy columnNaming = ColumnNamingStrategy.Literal, string baseline = "default")
+        ColumnNamingStrategy columnNaming = ColumnNamingStrategy.Literal, string baseline = "default",
+        IReadOnlyList? selection = null)
     {
         var loadErrors = load.Errors.Select(e => e.Code.ToString()).ToList();
         if (loadErrors.Count > 0) return new GenOutcome(loadErrors, null);
@@ -228,6 +235,11 @@ public static GenOutcome RunGen(
             IncludeNames = generators.Any(g => g.Name == "names"),
         };
 
+        // A docs generator writes the api reference, not code: CodegenRunner leaves it out. A run
+        // asked for one by name says so; an owned list carries one for the docs run, not for this.
+        var docsSkipped = generators.OfType().Select(g => g.Name)
+            .Where(name => selection?.Contains(name) == true).ToList();
+
         CodegenRunner.RunResult result;
         try
         {
@@ -244,9 +256,74 @@ public static GenOutcome RunGen(
             // during the walk — a clean error, not a stack trace.
             return new GenOutcome([$"{GeneratorFailedPrefix}{ex.Message}"], null);
         }
+        if (docsSkipped.Count > 0)
+            result = result with { Warnings = [.. result.Warnings, .. docsSkipped.Select(DocsGeneratorSkipped)] };
         return new GenOutcome(loadErrors, result);
     }
 
+    /// What a code run says about a docs generator its selection named and it did not run.
+    public static string DocsGeneratorSkipped(string name) =>
+        $"\"{name}\" writes the api reference, so `gen` skipped it. Run it with `dotnet meta docs`.";
+
+    // ------------------------------------------------------------------------
+    // The docs run. `dotnet meta docs` and an owned `codegen/Program.cs`'s `docs` command
+    // are the same pipeline, as `gen` is: this one place builds the context, runs the docs
+    // generators and writes the pages.
+    // ------------------------------------------------------------------------
+
+    /// The outcome of a docs run: pure data, no console I/O.
+    public sealed record DocsOutcome(
+        IReadOnlyList LoadErrors, IReadOnlyList WrittenPaths, string? CollisionError = null)
+    {
+        public bool Ok => LoadErrors.Count == 0 && CollisionError is null;
+    }
+
+    /// 
+    /// Write the api reference for 's model under .
+    /// The docs generator is the one in  that implements
+    ///  — a project's own copy — else the packaged
+    /// api-docs. What it documents is what the packaged generators emit for
+    /// (). A page path two units resolve to fails the run
+    /// before anything is written.
+    /// 
+    public static DocsOutcome RunDocs(
+        LoadResult load, string outDir, string ns, DocsRunFacts facts, IReadOnlyList generators)
+    {
+        var loadErrors = load.Errors.Select(e => e.Code.ToString()).ToList();
+        if (loadErrors.Count > 0) return new DocsOutcome(loadErrors, []);
+
+        var docs = generators.OfType().ToList();
+        if (docs.Count == 0) docs.Add(GeneratorRegistry.Get("api-docs")!.Factory(new GeneratorBuildContext()) as IDocsGenerator
+            ?? throw new InvalidOperationException("the packaged api-docs generator is not an IDocsGenerator"));
+
+        var ctx = new GenContext
+        {
+            Entities = load.Root.Objects().Where(o => !o.IsReport()).ToList(),
+            Root = load.Root,
+            Config = new GenConfig { OutDir = outDir, Namespace = ns },
+            Applicability = GeneratorApplicability.Packaged,
+            Docs = facts,
+        };
+
+        // Collect path -> content first, so a duplicate page path fails BEFORE any write: a
+        // clean, reportable diagnostic, not an uncaught throw out of the CLI.
+        var emitted = new Dictionary(StringComparer.Ordinal);
+        foreach (var generator in docs)
+            foreach (var file in generator.Generate(ctx))
+                if (!emitted.TryAdd(file.Path, file.Content))
+                    return new DocsOutcome(loadErrors, [], $"dotnet meta docs — duplicate api page output path: {file.Path}");
+
+        var written = new List();
+        foreach (var (path, content) in emitted)
+        {
+            var dest = Path.Combine(outDir, path.Replace('/', Path.DirectorySeparatorChar));
+            Directory.CreateDirectory(Path.GetDirectoryName(dest)!);
+            File.WriteAllText(dest, content);
+            written.Add(path);
+        }
+        return new DocsOutcome(loadErrors, written);
+    }
+
     // ------------------------------------------------------------------------
     // The ejected codegen/Program.cs entry point (ADR-0034 Amendment 3 / the eject
     // design doc's C# section, "Hand-off"): `dotnet meta gen` and `dotnet meta verify
@@ -275,20 +352,22 @@ public static int Run(string[] args, IReadOnlyList generators)
                 "           [--emit-abstract-shapes] [--column-naming literal|snake_case|kebab-case]\n" +
                 "           [--baseline default|adopt] [--template-root ] [--template-spec ]\n" +
                 "     | verify --codegen [] --out  [--namespace ] [--generators ]\n" +
-                "           [--column-naming literal|snake_case|kebab-case] [--template-root ] [--lax]");
+                "           [--column-naming literal|snake_case|kebab-case] [--template-root ] [--lax]\n" +
+                "     | docs [] --out  [--namespace ] [--project ] [--model-base-url ]");
             return 2;
         }
         return args[0] switch
         {
             "gen" => RunGenArgs(args[1..], generators),
             "verify" => RunVerifyArgs(args[1..], generators),
+            "docs" => RunDocsArgs(args[1..], generators),
             _ => Unknown(args[0]),
         };
     }
 
     private static int Unknown(string cmd)
     {
-        Console.Error.WriteLine($"codegen (owned): unknown command \"{cmd}\" — expected \"gen\" or \"verify\".");
+        Console.Error.WriteLine($"codegen (owned): unknown command \"{cmd}\" — expected \"gen\", \"verify\" or \"docs\".");
         return 2;
     }
 
@@ -335,7 +414,7 @@ public static IReadOnlyList ComposeGenerators(
     /// The flags both owned commands share, parsed once.
     private sealed class OwnedArgs
     {
-        public string? MetadataDir, OutDir, TemplateRoot, TemplateSpecPath, ColumnNamingRaw, GeneratorsCsv;
+        public string? MetadataDir, OutDir, TemplateRoot, TemplateSpecPath, ColumnNamingRaw, GeneratorsCsv, Project, ModelBaseUrl;
         public string Namespace = "Generated";
         public string Baseline = "default";
         public bool NamespaceExplicit, EmitAbstractShapes, Codegen, Lax;
@@ -356,6 +435,8 @@ public static OwnedArgs Parse(string[] rest)
                 else if (rest[i] == "--template-root" && i + 1 < rest.Length) a.TemplateRoot = rest[++i];
                 else if (rest[i] == "--template-spec" && i + 1 < rest.Length) a.TemplateSpecPath = rest[++i];
                 else if (rest[i] == "--generators" && i + 1 < rest.Length) a.GeneratorsCsv = rest[++i];
+                else if (rest[i] == "--project" && i + 1 < rest.Length) a.Project = rest[++i];
+                else if (rest[i] == "--model-base-url" && i + 1 < rest.Length) a.ModelBaseUrl = rest[++i];
                 else if (rest[i] == "--codegen") a.Codegen = true;
                 else if (rest[i] == "--lax") a.Lax = true;
                 else if (!rest[i].StartsWith('-')) a.MetadataDir ??= rest[i];
@@ -415,7 +496,7 @@ private static int RunGenArgs(string[] rest, IReadOnlyList owned)
         }
 
         WarnUnknownAttrs(meta);
-        var outcome = RunGen(meta.Load(), a.OutDir, a.Namespace, a.EmitAbstractShapes, suite, projectRoot, columnNaming, a.Baseline);
+        var outcome = RunGen(meta.Load(), a.OutDir, a.Namespace, a.EmitAbstractShapes, suite, projectRoot, columnNaming, a.Baseline, a.Selection);
         if (!outcome.Ok)
         {
             ReportFailure(outcome, "codegen (owned) gen");
@@ -436,6 +517,37 @@ private static int RunGenArgs(string[] rest, IReadOnlyList owned)
         return 0;
     }
 
+    private static int RunDocsArgs(string[] rest, IReadOnlyList owned)
+    {
+        var a = OwnedArgs.Parse(rest);
+        if (a.OutDir is null)
+        {
+            Console.Error.WriteLine("usage: docs [] --out  [--namespace ] [--project ] [--model-base-url ]");
+            return 2;
+        }
+        if (ResolveOrReport(a.MetadataDir) is not { } meta) return 2;
+
+        // The label defaults to the metadata directory's leaf name, as `dotnet meta docs` does.
+        var project = a.Project
+            ?? new DirectoryInfo(Path.TrimEndingDirectorySeparator(Path.GetFullPath(meta.Directory))).Name;
+        var outcome = RunDocs(meta.Load(), a.OutDir, a.Namespace, new DocsRunFacts(project, a.ModelBaseUrl), owned);
+        if (!outcome.Ok)
+        {
+            foreach (var e in outcome.LoadErrors) Console.Error.WriteLine($"  load error: {e}");
+            if (outcome.CollisionError is not null)
+            {
+                Console.Error.WriteLine($"  {outcome.CollisionError}");
+                Console.Error.WriteLine("codegen (owned) docs: FAILED (duplicate api page output path)");
+                return 1;
+            }
+            Console.Error.WriteLine("codegen (owned) docs: FAILED (metadata did not load cleanly)");
+            return 1;
+        }
+        foreach (var p in outcome.WrittenPaths) Console.WriteLine($"  written: {p}");
+        Console.WriteLine($"codegen (owned) docs: {outcome.WrittenPaths.Count} api page(s) written");
+        return 0;
+    }
+
     private static int RunVerifyArgs(string[] rest, IReadOnlyList owned)
     {
         var a = OwnedArgs.Parse(rest);
diff --git a/server/csharp/MetaObjects.Codegen/CodegenRunner.cs b/server/csharp/MetaObjects.Codegen/CodegenRunner.cs
index cac81d2c7..6f6cb30d7 100644
--- a/server/csharp/MetaObjects.Codegen/CodegenRunner.cs
+++ b/server/csharp/MetaObjects.Codegen/CodegenRunner.cs
@@ -64,7 +64,10 @@ public static RunResult Run(GenConfig config, MetaRoot root, IReadOnlyList g is not IDocsGenerator))
         {
             foreach (var file in generator.Generate(ctx))
             {
diff --git a/server/csharp/MetaObjects.Codegen/DocsGenerator.cs b/server/csharp/MetaObjects.Codegen/DocsGenerator.cs
new file mode 100644
index 000000000..c6077afc5
--- /dev/null
+++ b/server/csharp/MetaObjects.Codegen/DocsGenerator.cs
@@ -0,0 +1,34 @@
+// The docs surface of the generator model.
+//
+// `dotnet meta gen` writes code; `dotnet meta docs` writes the api reference. Both are run
+// from generators, but a docs generator must never be run by `gen`: it writes pages under the
+// docs directory, not code under the generated-code directory, and `gen`'s hash manifest and
+// overwrite policy are about code. A generator that takes part in the docs run says so by
+// implementing IDocsGenerator; `gen` and `verify --codegen` skip it, and `gen` says so when its
+// `--generators` selection named it.
+//
+// One list serves both commands. An owned `codegen/Program.cs` lists a docs generator beside the
+// code generators, and each command runs the half that is its own.
+
+namespace MetaObjects.Codegen;
+
+/// A generator run by dotnet meta docs, not by dotnet meta gen. The paths
+/// it emits are relative to the docs output directory.
+public interface IDocsGenerator : IGenerator
+{
+}
+
+/// 
+/// The facts of a docs run that the docs generator needs and a code run has no use for. They
+/// ride on  rather than on the generator's constructor so that an
+/// owned copy of a docs generator, built by its owner with new, is handed them the same
+/// way the packaged one is.
+/// 
+/// The label the agent page carries in its header.
+/// When set, the base URL the model pages' back-links federate to.
+/// Where under the docs output directory the api reference lands.
+public sealed record DocsRunFacts(string Project, string? ModelBaseUrl = null, string ApiSubDir = DocsRunFacts.DefaultApiSubDir)
+{
+    /// The api-surface subdirectory of the cross-port docs contract.
+    public const string DefaultApiSubDir = "api/csharp";
+}
diff --git a/server/csharp/MetaObjects.Codegen/EjectableGenerators.cs b/server/csharp/MetaObjects.Codegen/EjectableGenerators.cs
index e9880d48b..9ab5ded26 100644
--- a/server/csharp/MetaObjects.Codegen/EjectableGenerators.cs
+++ b/server/csharp/MetaObjects.Codegen/EjectableGenerators.cs
@@ -2,12 +2,13 @@
 // (C# section) — the embedded-resource surface `dotnet meta eject` reads from.
 //
 // The tool ships COMPILED: there is no source tree on disk beside `dotnet-meta` for an
-// eject command to copy from. So every ejectable generator's own .cs file is embedded
-// as an assembly resource (MetaObjects.Codegen.csproj's EmbeddedResource item group —
-// the same AOT-safe pattern MetaObjects/MetaObjects.csproj already uses for
-// spec/metamodel/*.json), and this class is the one place that reads them. A byte-
-// identity test (MetaObjects.Codegen.Tests/EjectableGeneratorsTests) asserts every
-// embedded copy matches its Generators/ source, so they cannot drift.
+// eject command to copy from. So every ejectable generator's own .cs file, and the emitter
+// files its registry entry hands over with it (Companions), are embedded as assembly
+// resources (MetaObjects.Codegen.csproj's EmbeddedResource item group — the same AOT-safe
+// pattern MetaObjects/MetaObjects.csproj already uses for spec/metamodel/*.json), and this
+// class is the one place that reads them. A byte-identity test
+// (MetaObjects.Codegen.Tests/EjectableGeneratorsTests) asserts every embedded copy matches
+// its source file, so they cannot drift.
 
 using System.Reflection;
 
@@ -55,26 +56,71 @@ public static string ReadSourceFile(string fileName)
         return reader.ReadToEnd();
     }
 
-    // The exact line every ejectable generator source declares (verified by
-    // RewriteForEject_runs_clean_on_every_real_ejectable_source).
-    private const string PackagedNamespaceDecl = "namespace MetaObjects.Codegen.Generators;";
+    /// The package directories whose sources eject hands over, each a namespace
+    /// MetaObjects.Codegen.<Dir> in the package. Every handed-over file lands in the one
+    /// owned namespace Codegen.Generators, beside the generators ():
+    /// an owned generator and the emitters it was handed then see each other as members of the
+    /// namespace they are in, which wins over the package's types of the same name that the
+    /// using directives bring in.
+    public static readonly IReadOnlyList SourceDirectories = ["Generators", "Docs", "ApiDocs"];
+
+    /// The package-relative paths eject writes for : its own
+    /// source first, then its . Empty for a
+    /// generator that is not ejectable.
+    public static IReadOnlyList SourcePaths(GeneratorRegistryEntry entry) =>
+        entry.SourceFileName is { } file ? ["Generators/" + file, .. entry.Companions] : [];
+
+    /// Every package-relative source path any ejectable entry hands over, once each.
+    public static IReadOnlyList AllSourcePaths() =>
+        Entries().SelectMany(SourcePaths).Distinct(StringComparer.Ordinal).ToList();
+
+    /// The embedded copy's logical resource name — MSBuild's default for
+    /// <dir>/<file> under RootNamespace MetaObjects.Codegen.
+    public static string ResourceName(string sourcePath) =>
+        "MetaObjects.Codegen." + sourcePath.Replace('/', '.');
+
+    /// Where an owned copy of  lands, relative to the
+    /// project root: codegen/generators/<file>, whichever package directory it came from.
+    public static string OwnedRelativePath(string sourcePath) =>
+        "codegen/generators/" + sourcePath[(sourcePath.LastIndexOf('/') + 1)..];
+
+    /// The embedded packaged source at . Throws if
+    /// the resource is missing — a packaging defect, never a user error: every caller takes
+    /// the path from the registry.
+    public static string ReadSourcePath(string sourcePath)
+    {
+        var resource = ResourceName(sourcePath);
+        using var stream = typeof(EjectableGenerators).Assembly.GetManifestResourceStream(resource)
+            ?? throw new InvalidOperationException(
+                $"embedded resource missing: {resource} — add  to MetaObjects.Codegen.csproj.");
+        using var reader = new StreamReader(stream);
+        return reader.ReadToEnd();
+    }
+
+    // The namespace line every ejectable source declares: `namespace MetaObjects.Codegen.;`
+    // (verified by RewriteForEject_runs_clean_on_every_real_ejectable_source).
+    private static readonly System.Text.RegularExpressions.Regex PackagedNamespaceDecl = new(
+        @"^namespace MetaObjects\.Codegen\.(?Generators|Docs|ApiDocs);",
+        System.Text.RegularExpressions.RegexOptions.Multiline | System.Text.RegularExpressions.RegexOptions.Compiled);
 
     /// 
-    /// The edits dotnet meta eject makes to a generator's embedded source
-    /// before writing it into an adopter's repo: rename its namespace from the packaged
-    /// MetaObjects.Codegen.Generators to Codegen.Generators (the adopter's
-    /// own codegen/ project — matching its directory, codegen/generators/),
-    /// adding the THREE using directives that namespace's enclosing-namespace
-    /// lookup used to supply for free: code in MetaObjects.Codegen.Generators
-    /// resolves an unqualified GenConfig / IGenerator / ValueObjectNames
-    /// / Fr010FieldMapping / NamingRefs / … via C#'s enclosing-namespace
-    /// fallback (a name unresolved in A.B.C is looked up in A.B, then
-    /// A, then globally — so MetaObjects.Codegen.Generators reaches
+    /// The edits dotnet meta eject makes to an embedded source before writing it into an
+    /// adopter's repo: rename its namespace from the packaged MetaObjects.Codegen.<Dir>
+    /// to Codegen.Generators (the adopter's own codegen/ project — matching its
+    /// directory, codegen/generators/), adding the THREE using directives that
+    /// namespace's enclosing-namespace lookup used to supply for free: code in
+    /// MetaObjects.Codegen.Generators resolves an unqualified GenConfig /
+    /// IGenerator / ValueObjectNames / FindInbound / NamingRefs / …
+    /// via C#'s enclosing-namespace fallback (a name unresolved in A.B.C is looked up in
+    /// A.B, then A, then globally — so MetaObjects.Codegen.Generators reaches
     /// MetaObjects.Codegen AND top-level MetaObjects unqualified);
-    /// Codegen.Generators is an unrelated tree and gets none of that fallback, so
-    /// the copy needs all three directives spelled out.
+    /// Codegen.Generators is an unrelated tree and gets none of that fallback, so the copy
+    /// needs all three directives spelled out (each only when the source does not already say it).
+    /// A using MetaObjects.Codegen.Docs; or using MetaObjects.Codegen.ApiDocs; is
+    /// dropped: what it named is in the copy's own namespace now.
     /// 
-    /// The SECOND and last edit applies only to a generator whose output imports the helper
+    /// The SECOND edit applies only to a generator whose output imports the helper
     /// runtime ( — today, routes): its
     /// HelperRuntimeNamespace constant moves from MetaObjects.Codegen.Runtime
     /// to Codegen.Runtime, the owned copy eject writes beside it
@@ -94,20 +140,22 @@ public static string ReadSourceFile(string fileName)
     /// 
     public static string RewriteForEject(string source)
     {
-        var idx = source.IndexOf(PackagedNamespaceDecl, StringComparison.Ordinal);
-        if (idx < 0)
+        var match = PackagedNamespaceDecl.Match(source);
+        if (!match.Success)
             throw new InvalidOperationException(
-                "ejectable generator source has no \"" + PackagedNamespaceDecl +
-                "\" line to rewrite — cannot eject it safely. This is a packaging defect, " +
-                "not a user error: every embedded generator source must declare that exact " +
-                "namespace line.");
-        const string replacement =
-            "using MetaObjects;\n" +
-            "using MetaObjects.Codegen;\n" +
-            "using MetaObjects.Codegen.Generators;\n" +
-            "\n" +
-            "namespace Codegen.Generators;";
-        return HelperRuntime.RewriteGeneratorForEject(
-            source[..idx] + replacement + source[(idx + PackagedNamespaceDecl.Length)..]);
+                "ejectable source has no \"namespace MetaObjects.Codegen.;\" " +
+                "line to rewrite — cannot eject it safely. This is a packaging defect, " +
+                "not a user error: every embedded source must declare one of those namespace lines.");
+        var usings = new[] { "MetaObjects", "MetaObjects.Codegen", "MetaObjects.Codegen.Generators" }
+            .Where(ns => !source.Contains($"using {ns};", StringComparison.Ordinal))
+            .Select(ns => $"using {ns};\n");
+        var replacement = string.Concat(usings) + "\nnamespace Codegen.Generators;";
+        var rewritten = source[..match.Index] + replacement + source[(match.Index + match.Length)..];
+        rewritten = DroppedUsings.Replace(rewritten, "");
+        return HelperRuntime.RewriteGeneratorForEject(rewritten);
     }
+
+    private static readonly System.Text.RegularExpressions.Regex DroppedUsings = new(
+        @"^using MetaObjects\.Codegen\.(Docs|ApiDocs);\r?\n",
+        System.Text.RegularExpressions.RegexOptions.Multiline | System.Text.RegularExpressions.RegexOptions.Compiled);
 }
diff --git a/server/csharp/MetaObjects.Codegen/Generator.cs b/server/csharp/MetaObjects.Codegen/Generator.cs
index f50d2a64d..14411d5f9 100644
--- a/server/csharp/MetaObjects.Codegen/Generator.cs
+++ b/server/csharp/MetaObjects.Codegen/Generator.cs
@@ -268,6 +268,15 @@ public sealed class GenContext
     public required MetaRoot Root { get; init; }
     public required GenConfig Config { get; init; }
     public Action Warn { get; init; } = _ => { };
+
+    /// 
+    /// What the api-docs generator asks of the generators it documents (see
+    /// ): the packaged generators' answers.
+    /// 
+    public GeneratorApplicability Applicability { get; init; } = GeneratorApplicability.Packaged;
+
+    /// The facts of a docs run, or null in a code run.
+    public DocsRunFacts? Docs { get; init; }
 }
 
 /// A codegen unit. Kebab-case  surfaces in diagnostics.
diff --git a/server/csharp/MetaObjects.Codegen/GeneratorApplicability.cs b/server/csharp/MetaObjects.Codegen/GeneratorApplicability.cs
new file mode 100644
index 000000000..f73d84834
--- /dev/null
+++ b/server/csharp/MetaObjects.Codegen/GeneratorApplicability.cs
@@ -0,0 +1,45 @@
+// GeneratorApplicability — what the api-docs generator asks of the generators it documents.
+//
+// The api reference documents the code the OTHER generators emit, and it decides what to
+// document by asking each of them "would you emit for this?" — the static `AppliesTo` every
+// one of those generators exposes. The docs generator does not name those generators: it reads
+// the answers from `GenContext.Applicability`, which carries the packaged generators' own
+// predicates (`Packaged`). So the docs generator — packaged or ejected — depends on nothing but
+// this engine surface, and the reference describes what the packaged generators emit for,
+// including in a project that owns a copy of one of them.
+
+using MetaObjects.Codegen.Generators;
+using MetaObjects.Meta;
+
+namespace MetaObjects.Codegen;
+
+/// 
+/// One answer per generator the api reference documents: does it emit for this object or
+/// template. The answers are the packaged generators' own predicates ().
+/// 
+public sealed record GeneratorApplicability
+{
+    /// db-context — a DbSet on the generated context.
+    public required Func DbContext { get; init; }
+    /// routes — the object's CRUD route handlers.
+    public required Func Routes { get; init; }
+    /// filter-allowlist — the object's filter allowlist.
+    public required Func FilterAllowlist { get; init; }
+    /// render-helper — the template's render helper.
+    public required Func RenderHelper { get; init; }
+    /// output-prompt — the template's response-format fragment.
+    public required Func OutputPrompt { get; init; }
+    /// output-parser — the template's reply parser.
+    public required Func OutputParser { get; init; }
+
+    /// The packaged generators' own predicates.
+    public static GeneratorApplicability Packaged { get; } = new()
+    {
+        DbContext = DbContextGenerator.AppliesTo,
+        Routes = RoutesGenerator.AppliesTo,
+        FilterAllowlist = FilterAllowlistGenerator.AppliesTo,
+        RenderHelper = RenderHelperGenerator.AppliesTo,
+        OutputPrompt = OutputPromptGenerator.AppliesTo,
+        OutputParser = OutputParserGenerator.AppliesTo,
+    };
+}
diff --git a/server/csharp/MetaObjects.Codegen/GeneratorRegistry.cs b/server/csharp/MetaObjects.Codegen/GeneratorRegistry.cs
index 23ac988ff..5f5f797fd 100644
--- a/server/csharp/MetaObjects.Codegen/GeneratorRegistry.cs
+++ b/server/csharp/MetaObjects.Codegen/GeneratorRegistry.cs
@@ -101,6 +101,20 @@ public sealed record GeneratorRegistryEntry
     /// 
public string? SourceFileName { get; init; } /// + /// The emitter source files dotnet meta eject hands over WITH this generator, as + /// package-relative paths (Generators/Fr019SharedEnum.cs, Docs/XmlDocBuilder.cs) — + /// each also an embedded resource of the same name (see MetaObjects.Codegen.csproj). + /// A generator that calls an emitter writes part of the output through it, so an owned copy + /// that left the emitter in the package would own a shell: a defect in that part of the output + /// would still wait for a MetaObjects release. The list is the CLOSURE — what the generator + /// calls, and what those call in turn — never anything the copy could not compile without, + /// and never the engine (naming and type-mapping tables, the model walks, the generator + /// base), which stays packaged and public. EjectedGeneratorsAloneTests ejects each generator + /// alone into an empty project and fails on a missing entry, and on one nothing calls. + /// Files shared by several generators are copied once and never overwritten. + /// + public IReadOnlyList Companions { get; init; } = []; + /// /// Stable names of the generators whose output this one's output references. A run /// that selects this entry without them gets a warning from gen (see /// ) and --list shows them. @@ -145,6 +159,7 @@ private static IGenerator RenderHelper(GeneratorBuildContext ctx) => Layer = GeneratorLayer.Model, Factory = _ => new EntityGenerator(), SourceFileName = "EntityGenerator.cs", + Companions = ["Generators/Fr019SharedEnum.cs", "Generators/M2MNavigation.cs", "Generators/ReverseFinders.cs", "Docs/XmlDocBuilder.cs"], }, ["db-context"] = new() { @@ -154,6 +169,7 @@ private static IGenerator RenderHelper(GeneratorBuildContext ctx) => Layer = GeneratorLayer.Persistence, Factory = _ => new DbContextGenerator(), SourceFileName = "DbContextGenerator.cs", + Companions = ["Generators/Fr019SharedEnum.cs", "Generators/M2MNavigation.cs", "Docs/XmlDocBuilder.cs"], // DbSet for every entity class EntityGenerator emits. Requires = ["entity"], }, @@ -165,6 +181,7 @@ private static IGenerator RenderHelper(GeneratorBuildContext ctx) => Layer = GeneratorLayer.Api, Factory = _ => new RoutesGenerator(), SourceFileName = "RoutesGenerator.cs", + Companions = ["Generators/M2MNavigation.cs"], // The handlers take AppDbContext, read FilterAllowlist and bind the // entity classes; without any of the three the output does not compile. Requires = ["entity", "db-context", "filter-allowlist"], @@ -178,6 +195,7 @@ private static IGenerator RenderHelper(GeneratorBuildContext ctx) => Factory = _ => new OutputParserGenerator(), Note = "Needs `entity` in the same run: it references each value object's own POCO (ADR-0056).", SourceFileName = "OutputParserGenerator.cs", + Companions = ["Generators/ExtractDelegateEmitter.cs", "Generators/Fr010FieldMapping.cs"], // ADR-0056: the value object's own POCO, which EntityGenerator emits. Requires = ["entity"], }, @@ -190,6 +208,7 @@ private static IGenerator RenderHelper(GeneratorBuildContext ctx) => Factory = _ => new ExtractorGenerator(), Note = "Needs `entity` in the same run: it references each value object's own POCO (ADR-0056).", SourceFileName = "ExtractorGenerator.cs", + Companions = ["Generators/ExtractDelegateEmitter.cs", "Generators/Fr010FieldMapping.cs", "Generators/Fr019SharedEnum.cs"], // ADR-0056: the value object's own POCO, which EntityGenerator emits. Requires = ["entity"], }, @@ -201,6 +220,7 @@ private static IGenerator RenderHelper(GeneratorBuildContext ctx) => Layer = GeneratorLayer.Capability, Factory = _ => new OutputPromptGenerator(), SourceFileName = "OutputPromptGenerator.cs", + Companions = ["Generators/OutputFormatSpecEmitter.cs", "Generators/Fr010FieldMapping.cs"], }, ["render-helper"] = new() { @@ -233,6 +253,25 @@ private static IGenerator RenderHelper(GeneratorBuildContext ctx) => Factory = _ => new NamesGenerator(), SourceFileName = "NamesGenerator.cs", }, + // The api reference `dotnet meta docs` writes (ADR-0025), as a generator so a project can own + // it. It is not part of `gen`: it implements IDocsGenerator, which `gen` skips. + ["api-docs"] = new() + { + Name = "api-docs", + Description = "Per-entity/template SDK API reference (generated-code API; human + agent forms).", + Tier = GeneratorTier.Native, + Layer = GeneratorLayer.Docs, + Factory = _ => new ApiDocsGenerator(), + Note = "Run by `dotnet meta docs` as its api surface (ADR-0025), not by `dotnet meta gen`: `gen` skips it, and warns when `--generators` names it.", + SourceFileName = "ApiDocsGenerator.cs", + // The page IR, its builder, the renderer and the path math; and the field walk and the + // M:N navigation the builder documents with. + Companions = [ + "ApiDocs/CSharpApiModel.cs", "ApiDocs/CSharpApiModelBuilder.cs", + "ApiDocs/CSharpApiDocsRenderer.cs", "ApiDocs/DocsPaths.cs", + "Generators/Fr010FieldMapping.cs", "Generators/M2MNavigation.cs", + ], + }, ["template"] = new() { Name = "template", diff --git a/server/csharp/MetaObjects.Codegen/Generators/ApiDocsGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/ApiDocsGenerator.cs new file mode 100644 index 000000000..0b54c2d42 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen/Generators/ApiDocsGenerator.cs @@ -0,0 +1,42 @@ +// api-docs — the C# SDK api reference (the `api/csharp` surface of the cross-port SDK-docs +// contract): one page per entity, value object, projection, served report and template, an +// index, and the agent-facing AGENT-API page. +// +// Run by `dotnet meta docs`, not by `dotnet meta gen` (it implements IDocsGenerator): its files +// land under the docs output directory, relative to it. The project label, the model back-link +// base and the api subdirectory come from the docs run (`ctx.Docs`), and what each documented +// generator emits for comes from `ctx.Applicability`, so an owned copy of this generator +// names no emitting generator. The page IR, the renderer and the page paths live beside it +// (ApiDocs/), and eject hands them over with it. + +using MetaObjects.Codegen.ApiDocs; + +namespace MetaObjects.Codegen.Generators; + +public sealed class ApiDocsGenerator : IDocsGenerator +{ + public string Name => "api-docs"; + + public IEnumerable Generate(GenContext ctx) + { + // A docs run supplies the facts. A caller that runs this generator directly, as a registry-wide + // probe does, gets the packaged defaults and a neutral project label. + var facts = ctx.Docs ?? new DocsRunFacts("project"); + + var model = new CSharpApiModelBuilder(ctx.Config, ctx.Applicability).Build(ctx.Root, facts.Project); + var renderer = new CSharpApiDocsRenderer(); + const DocsPaths.Layout layout = DocsPaths.Layout.Package; + + foreach (var unit in model.Units) + { + var pagePath = DocsPaths.DocPageOutputPath(layout, unit.Package, unit.Node); + var modelHref = DocsPaths.ModelCrossHref(facts.ApiSubDir + "/" + pagePath, pagePath, facts.ModelBaseUrl); + yield return Page(facts, pagePath, renderer.RenderUnitPage(unit, modelHref)); + } + yield return Page(facts, "README.md", renderer.RenderIndex(model, layout)); + yield return Page(facts, "AGENT-API.md", renderer.RenderAgentApi(model)); + } + + private static EmittedFile Page(DocsRunFacts facts, string pagePath, string content) => + new(facts.ApiSubDir + "/" + pagePath, content); +} diff --git a/server/csharp/MetaObjects.Codegen/Generators/ExtractDelegateEmitter.cs b/server/csharp/MetaObjects.Codegen/Generators/ExtractDelegateEmitter.cs index 65d04eccf..763f2df89 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/ExtractDelegateEmitter.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/ExtractDelegateEmitter.cs @@ -238,7 +238,7 @@ public static string DelegatingMembers(MetaData vo, MetaData root, GenConfig con ? " ADR-0042 FQN (this payload's bare name collides with a same-short-name object elsewhere in the run)." : ""; sb.AppendLine($" /// The payload's metadata name — resolve it against a loaded MetaRoot to obtain the runtime MetaObject.{ambiguousNote}"); - sb.AppendLine($" public const string PAYLOAD_FQN = \"{Fr010FieldMapping.CSharpStringLiteral(bakedPayloadFqn)}\";"); + sb.AppendLine($" public const string PAYLOAD_FQN = \"{CSharpNaming.CSharpStringLiteral(bakedPayloadFqn)}\";"); sb.AppendLine(); sb.AppendLine($" /// Tolerant best-effort extraction delegating to the runtime; fully populates nested-object and"); sb.AppendLine($" /// array-of-object components by reading the live metadata. Never throws."); @@ -296,7 +296,7 @@ private static void EmitMapper(StringBuilder sb, MetaData vo, MetaData root, Gen /// The mirror-field initializer expression that reads from the assembled object. private static string MapperArg(MetaData field, MetaData root) { - string key = $"\"{Fr010FieldMapping.CSharpStringLiteral(field.Name)}\""; + string key = $"\"{CSharpNaming.CSharpStringLiteral(field.Name)}\""; // Object BEFORE array (object-before-isArray): array-of-objects maps to nested mirrors. if (IsObjectField(field)) diff --git a/server/csharp/MetaObjects.Codegen/Generators/FindInbound.cs b/server/csharp/MetaObjects.Codegen/Generators/FindInbound.cs index 3f215dbc3..cf814b7f7 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/FindInbound.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/FindInbound.cs @@ -94,7 +94,7 @@ public static List InboundTemplates(MetaData root) => // Java is immune by construction — its FindInbound.responseShape resolves value-only // too. This makes C# fail closed the same way: an unresolvable-or-wrong-typed ref yields // no parser rather than an uncompilable one. - var vo = RenderHelperGenerator.ResolveValueObject(root, reference, referrerPkg); + var vo = ValueObjectNames.ResolveValueObject(root, reference, referrerPkg); if (vo is null) return null; return new InboundShape(vo, reference, ResponseFormatOf(tmpl)); } diff --git a/server/csharp/MetaObjects.Codegen/Generators/Fr010FieldMapping.cs b/server/csharp/MetaObjects.Codegen/Generators/Fr010FieldMapping.cs index 2436e4541..19855cd26 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/Fr010FieldMapping.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/Fr010FieldMapping.cs @@ -46,7 +46,7 @@ public static string PropertiesMapLiteral(object? attr) if (attr is not IReadOnlyDictionary d) return "null"; var entries = d.Where(kv => kv.Value is not null) .OrderBy(kv => kv.Key, System.StringComparer.Ordinal) - .Select(kv => $"[\"{CSharpStringLiteral(kv.Key)}\"] = \"{CSharpStringLiteral(kv.Value!.ToString()!)}\"") + .Select(kv => $"[\"{CSharpNaming.CSharpStringLiteral(kv.Key)}\"] = \"{CSharpNaming.CSharpStringLiteral(kv.Value!.ToString()!)}\"") .ToList(); if (entries.Count == 0) return "null"; return "new Dictionary { " + string.Join(", ", entries) + " }"; @@ -226,33 +226,7 @@ public static bool HasXmlText(MetaData field) => (field.Attr(MetaObjects.Template.TemplateConstants.FIELD_ATTR_XML_TEXT) is string s && s.Equals("true", StringComparison.OrdinalIgnoreCase)); - private static readonly char[] CSharpEscapeChars = { '\\', '"', '\t', '\n', '\r' }; - - /// - /// Escape a value for embedding inside a C# double-quoted string literal. - /// Scans before it builds: the names generator calls this two-to-four times per - /// member of every entity, and virtually every value — "object", "entity", a field - /// name, a snake_case column — contains none of these. IndexOfAny is vectorized - /// and allocates nothing, so the common case costs no allocation at all. - /// - public static string CSharpStringLiteral(string value) => - value.IndexOfAny(CSharpEscapeChars) < 0 ? value : CSharpStringLiteralSlow(value); - - private static string CSharpStringLiteralSlow(string value) - { - var sb = new System.Text.StringBuilder(value.Length + 4); - foreach (char c in value) - { - switch (c) - { - case '\\': sb.Append("\\\\"); break; - case '"': sb.Append("\\\""); break; - case '\t': sb.Append("\\t"); break; - case '\n': sb.Append("\\n"); break; - case '\r': sb.Append("\\r"); break; - default: sb.Append(c); break; - } - } - return sb.ToString(); - } + /// Escape a value for embedding inside a C# double-quoted string literal. + [Obsolete("Moved to CSharpNaming.CSharpStringLiteral, which every generator now calls; this forwarder keeps the 1.0.x member.")] + public static string CSharpStringLiteral(string value) => CSharpNaming.CSharpStringLiteral(value); } diff --git a/server/csharp/MetaObjects.Codegen/Generators/NamesGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/NamesGenerator.cs index 2cd250ff3..7001a2069 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/NamesGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/NamesGenerator.cs @@ -105,7 +105,7 @@ protected override EmittedFile GenerateOne(MetaObject entity, GenContext ctx) => /// identifier and may hold either character. /// private static Member Str(string name, string value, string nodePath) => - new(name, $"const string {name} = \"{Fr010FieldMapping.CSharpStringLiteral(value)}\";", nodePath); + new(name, $"const string {name} = \"{CSharpNaming.CSharpStringLiteral(value)}\";", nodePath); /// /// Every constant an artifact declares, in emission order, grouped so the emitter can diff --git a/server/csharp/MetaObjects.Codegen/Generators/OutputFormatSpecEmitter.cs b/server/csharp/MetaObjects.Codegen/Generators/OutputFormatSpecEmitter.cs index cc86891f5..acad6bc88 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/OutputFormatSpecEmitter.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/OutputFormatSpecEmitter.cs @@ -77,6 +77,6 @@ private static string PromptFieldLiteral(MetaData field) // ADR-0039: resolving — @example/@instruction may be inherited via extends. private static string OptStringAttr(MetaData field, string attrName) => field.Attr(attrName) is string v - ? $"\"{Fr010FieldMapping.CSharpStringLiteral(v)}\"" + ? $"\"{CSharpNaming.CSharpStringLiteral(v)}\"" : "null"; } diff --git a/server/csharp/MetaObjects.Codegen/Generators/RenderHelperGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/RenderHelperGenerator.cs index 07843efb1..753e7dc9e 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/RenderHelperGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/RenderHelperGenerator.cs @@ -111,7 +111,7 @@ public virtual IEnumerable Generate(GenContext ctx) // object.projection (#210; same contract as the parser/prompt // generators). Its POCO, emitted by EntityGenerator, is the payload type. // ADR-0042: a bare @payloadRef resolves in the template's package. - var vo = ResolveValueObject(ctx.Root, payloadRef, global::MetaObjects.NamingRefs.EffectivePackage(tmpl)); + var vo = ValueObjectNames.ResolveValueObject(ctx.Root, payloadRef, global::MetaObjects.NamingRefs.EffectivePackage(tmpl)); if (vo is null) continue; // EmitHelper runs the build-time drift gate first and THROWS (fails @@ -121,24 +121,6 @@ public virtual IEnumerable Generate(GenContext ctx) return files; } - /// The payload shape a @payloadRef names, or null. ADR-0042: - /// package-local — an FQN binds the exact package; a bare ref binds - /// (the template's package) FIRST, else a root-level object. - /// #210 — a template-level payload target is an object.value OR a SOURCELESS - /// object.projection ("sourceless" per the #248 persistability contract: no - /// declared/inherited source.* child; nested @objectRef targets stay - /// value-only — ). - internal static MetaData? ResolveValueObject(MetaRoot root, string payloadRef, string referrerPkg = "") - { - var obj = global::MetaObjects.NamingRefs.ResolveObjectRef(root, payloadRef, referrerPkg); - if (obj is null) return null; - if (obj.SubType == OBJECT_SUBTYPE_VALUE) return obj; - if (obj.SubType != OBJECT_SUBTYPE_PROJECTION) return null; - // ADR-0039: resolving — a source anywhere in the extends chain binds the - // projection to a backing store, which disqualifies it as a payload shape. - return obj.Children().Any(c => c.Type == TYPE_SOURCE) ? null : obj; - } - /// /// True iff is a template this generator renders. Both template /// subtypes render (ADR-0052 — the subtype axis is DIRECTION); only what comes back @@ -154,7 +136,7 @@ private static bool IsRenderable(MetaData node) => /// True iff this generator emits a render helper for : a /// renderable template whose @payloadRef resolves to a root-level /// object.value or sourceless object.projection (#210 — delegates to - /// the widened ). Single source of truth shared by + /// the widened ). Single source of truth shared by /// the generator loop AND the api-docs builder (so docs never claim a suppressed /// symbol). /// @@ -164,7 +146,7 @@ public static bool AppliesTo(MetaData tmpl, MetaRoot root) // ADR-0039: resolving — @payloadRef may be inherited via an abstract template base. if (tmpl.Attr(TEMPLATE_ATTR_PAYLOAD_REF) is not string payloadRef) return false; // ADR-0042: a bare @payloadRef resolves in the template's package. - return ResolveValueObject(root, payloadRef, global::MetaObjects.NamingRefs.EffectivePackage(tmpl)) is not null; + return ValueObjectNames.ResolveValueObject(root, payloadRef, global::MetaObjects.NamingRefs.EffectivePackage(tmpl)) is not null; } protected virtual EmittedFile EmitHelper(MetaData tmpl, MetaData vo, string payloadRef, GenContext ctx) diff --git a/server/csharp/MetaObjects.Codegen/MetaObjects.Codegen.csproj b/server/csharp/MetaObjects.Codegen/MetaObjects.Codegen.csproj index 5cb170274..a3aa9a618 100644 --- a/server/csharp/MetaObjects.Codegen/MetaObjects.Codegen.csproj +++ b/server/csharp/MetaObjects.Codegen/MetaObjects.Codegen.csproj @@ -38,6 +38,11 @@ entries: every OTHER Native entry sets SourceFileName; `template` does not). ADD A NEW EJECTABLE GENERATOR: add its Include line here AND its SourceFileName in GeneratorRegistry.cs — the embed-set-equality test fails until both are done. + + The EMITTERS an ejectable generator calls are embedded the same way, in the same group, + and listed on its registry entry as `Companions`: eject hands them over with the + generator, so an owned copy owns the code that writes its output and not a shell around + it. The embedded set is exactly the registry's SourceFileName and Companions files. --> @@ -51,6 +56,19 @@ + + + + + + + + + + + + +