Skip to content

feat(csharp): hand emitters over on eject and make api-docs ejectable - #432

Merged
dmealing merged 4 commits into
mainfrom
fm/mo-1-1-0-eject-csharp
Oct 12, 2026
Merged

dmealing merged 4 commits into
mainfrom
fm/mo-1-1-0-eject-csharp

Conversation

@dmealing

@dmealing dmealing commented Oct 12, 2026 •

Copy link
Copy Markdown
Member

What Changed

  • dotnet meta eject now copies the emitters each generator writes through (Fr019SharedEnum, XmlDocBuilder, M2MNavigation, ReverseFinders, ExtractDelegateEmitter, Fr010FieldMapping, OutputFormatSpecEmitter) into codegen/generators/, so an owned generator no longer calls packaged writers. Naming and type-mapping tables and model walks stay packaged. template stays non-ejectable.
  • api-docs is a registered, ejectable docs generator (IDocsGenerator, DocsRunFacts, GeneratorApplicability). dotnet meta docs runs an owned copy when codegen/generators/ApiDocsGenerator.cs exists. gen and verify --codegen skip docs generators. Eject warns when an owned codegen/Codegen.csproj pins an older MetaObjects.Codegen than the tool.
  • Adds a registry-wide gate: each ejectable generator is ejected alone into an empty project, compiled, checked for references to packaged emitters outside the engine surface, and compared byte-for-byte with the packaged output. CSharpStringLiteral moved to CSharpNaming; the old Fr010FieldMapping member is [Obsolete].
  • Validation: 1 no-mistakes run for this PR (two review fix rounds inside it). Net hand-written line change against main: code +504, tests +812, docs and CHANGELOG +149.
  • After the run, a text-only correction of the api-docs registry and manifest note was pushed (aa0aff88e), checked locally with the C# suites, the other ports' registry-conformance tests and scripts/ci-local.sh --quick. The run count stays 1.
Validation

Risk Assessment

✅ Low: The round-1 and round-2 fixes hold when traced against the current code (engine no longer calls a handed-over emitter, the pin warning words non-inline pins safely, the gen skip warning fires only for a named selection on both paths), packaged output paths are unchanged, and the one remaining issue is a stale --list note.

Testing

Baseline ts-fast/ts-unit already green. Drove the C# eject surface live with the built 1.1.0 CLI in disposable temp projects: eject of api-docs alone and of all 12 ejectables, refusals (unknown, template, no-force overwrite), older/equal pin warnings across inline, multi-line and child-element forms, and owned vs packaged gen selection behavior. Owned api-docs output matched packaged docs byte-for-byte (50 pages). Full C# suites (Cli 254, Codegen 825 with 1 pre-existing skip, Render 332, Conformance 1388) passed in-process; they are not live product runs. One workaround: owned project used a ProjectReference to the local codegen build because MetaObjects.Codegen 1.1.0 is not in the offline NuGet cache. Overall pass; no findings.

  • Live validation: ✅ go - 12 of 14 scenarios driven live against the product
Scenario Result Live Evidence
Eject api-docs alone into an empty project writes the generator, its emitters and the codegen scaffold ✅ pass live dotnet MetaObjects.Cli.dll eject api-docs in empty temp dir: exit 0; wrote codegen/generators/ApiDocsGenerator.cs, 6 emitter companions, Codegen.csproj, Program.cs
Eject of all 12 ejectable generators in one call writes every copy and its companions, with no template copy ✅ pass live single eject with all ejectable names into fresh dir: exit 0; generator, emitter and codegen/runtime files written
Unknown name or template in eject is refused all-or-nothing ✅ pass live eject nope and eject template: exit 2, 'Nothing was ejected', template absent from ejectable list, no files written
Re-eject refuses to overwrite an owned copy unless --force ✅ pass live second eject api-docs: exit 1 'already owned'; with --force: exit 0 [replaced]
Older pinned MetaObjects.Codegen in an inline PackageReference prints a warning naming the line to change ✅ pass live pin 1.0.13 inline, eject api-docs: stdout warning names line 9 and says set to 1.1.0
Multi-line PackageReference with Version on its own line: warning names that line and never says to replace the whole element ✅ pass live Version="1.0.13" on line 10: warning says set the version on line 10 to 1.1.0, no full element
<Version> child element pin: warning names that line and never says to replace the whole element ✅ pass live <Version>1.0.13</Version> on line 10: warning names line 10, version-only instruction
Equal pin prints no older-pin warning ✅ pass live pin 1.1.0 equal to tool: eject callable exit 0, no warning on stdout
Packaged gen with --generators api-docs warns and skips the docs generator; gen with no selection is refused ✅ pass live gen meta --out o --generators api-docs: stderr 'api-docs writes the api reference, so gen skipped it. Run it with dotnet meta docs.' exit 0; no selection exit 2
Owned gen with api-docs only in Program.cs and no --generators selection stays silent ✅ pass live owned project wired to local MetaObjects.Codegen via ProjectReference (1.1.0 not in offline NuGet cache): gen exit 0, no api-docs warning
Owned gen with --generators api-docs warns that api-docs is skipped ✅ pass live same owned project with --generators api-docs: stderr warning 'api-docs writes the api reference, so gen skipped it'
Owned api-docs output equals packaged docs output byte-for-byte ✅ pass live owned docs meta --out (50 pages) vs packaged docs: diff -rq IDENTICAL
Eject-alone guard: each ejectable compiles alone, uses no packaged emit type outside the engine surface, every companion used, output byte-identical to packaged, callable corpus included ⏸️ untested no Only an in-process test run was done (dotnet test server/csharp/MetaObjects.Codegen.Tests, EjectedGeneratorsAloneTests: 825 passed, 1 skipped). No live product run drove this guard, so it is not a liv…
Full C# regression suites ⏸️ untested no Only test-suite runs were done (Cli.Tests 254 passed; Codegen.Tests 825 passed, 1 skipped; Render.Tests 332 passed; Conformance.Tests 1388 passed). These are in-process test runs, not live product sce…

Pipeline

Updates from git push no-mistakes

✅ **Intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • ⚠️ server/csharp/MetaObjects.Codegen/CSharpNaming.cs:1104 - The packaged engine still calls an emitter this change hands over. CSharpNaming.EnumTypeName (engine, stays packaged) calls Fr019SharedEnum.ResolveSharedEnumDecl, and Fr019SharedEnum.cs is now a companion of entity, db-context and extractor (GeneratorRegistry.cs:162, 172, 211). After eject there are two copies of the shared-enum predicate. The owned generators decide shared-versus-inline through the owned copy (EntityGenerator.cs:537, 869, 887; DbContextGenerator.cs:930; ExtractorGenerator.cs:228), then name the inline enum through CSharpNaming.EnumTypeName (EntityGenerator.cs:540, 872, 889; DbContextGenerator.cs:406, 932; ExtractorGenerator.cs:230; CSharpApiModelBuilder.cs:284, 310), which asks the packaged copy. Concrete sequence: an adopter ejects entity and edits the owned ResolveSharedEnumDecl or SharedEnumForField so that a declaration is treated as inline. The owned generator nests an enum, and EnumTypeName still returns the shared super's bare name. That is the name/declaration disagreement (CS0102) that the comment at CSharpNaming.cs:1092 says the single predicate exists to prevent. gen exits 0. EjectedGeneratorsAloneTests checks owned copy to package references only, so it does not see this direction. Fix: make EnumTypeName call the core authority SharedEnumDeclaration.ResolveSharedEnumDeclaration(field) directly, which is what Fr019SharedEnum.ResolveSharedEnumDecl already delegates to, so packaged output is unchanged. Also add a guard assertion that no packaged non-generator type references a type declared in a Companions file. This is the only such call site: a grep of the package for the seven emitters outside the generators and companions finds no other.
  • ⚠️ server/csharp/MetaObjects.Codegen.Tests/EjectedGeneratorsAloneTests.cs:319 - The byte-identity leg of the required structural guard asserts nothing for callable. Ejected_alone_it_emits_what_the_packaged_generator_emits_byte_for_byte exempts callable from the emitted &gt; 0 check because neither shared corpus has a storedProc or tableFunction source. For that generator the test compares two empty lists, so an ejected callable that emitted different text would pass. The change already solves the same problem for the prompt tier by adding the ExtractorCodegenTests.Model corpus in Corpora() (line 253). A callable model exists in Fr015CodegenTests.cs (storedProc and tableFunction entities with @parameterRef). Fix: yield that model from Corpora(&#34;callable&#34;) and remove the name != &#34;callable&#34; exception. Oracle: the packaged generator's output over the same model. Wrong behavior rejected: an ejected copy whose rewritten namespace or usings change the emitted FromSqlInterpolated wrapper.
  • ⚠️ server/csharp/MetaObjects.Cli/EjectCommand.cs:169 - Ejecting with the 1.1.0 tool into an owned project scaffolded by 1.0.x writes sources that do not compile against that project's pinned package, and nothing tells the user. Eject never edits codegen/Codegen.csproj, which pins MetaObjects.Codegen at the version of the tool that first scaffolded it. The sources eject now writes call API this change adds: CSharpNaming.CSharpStringLiteral (NamesGenerator.cs:108, ExtractDelegateEmitter.cs:241 and 299, OutputFormatSpecEmitter.cs:80, Fr010FieldMapping.cs:49 and 231), ValueObjectNames.ResolveValueObject (RenderHelperGenerator.cs:114 and 149), and IDocsGenerator, DocsRunFacts and GenContext.Applicability (ApiDocsGenerator.cs). Concrete sequence: a project owns entity from 1.0.13, upgrades the tool, and runs dotnet meta eject api-docs as the changelog advertises. The next dotnet meta gen hands off to dotnet run --project codegen and fails with CS0246 on IDocsGenerator, so a gen that worked before the eject is now broken until the pin is edited by hand. The eject output only says to add new ApiDocsGenerator() to Program.cs. Two statements in the change are wrong for this state. The HasOwnedDocs comment (CodegenHandoff.cs:19-23) says the presence of ApiDocsGenerator.cs means the pinned package knows docs. The upgrade recipe (docs/features/own-your-codegen.md:1080-1089, CHANGELOG.md:168-173) copies 1.1.0 emitter files in without saying the pin must be 1.1.0 or later first; for output-parser, extractor and output-prompt the copied Fr010FieldMapping.cs fails with CS0117. The class of hazard predates this change, but moving CSharpStringLiteral and ResolveValueObject extends it to generators whose source compiled against 1.0.x before. Smallest remedy: when the scaffold already exists and its MetaObjects.Codegen version differs from ToolVersion(), eject prints a line telling the user to bump it, and the two docs passages say the same. That adds user-visible output, so it needs the author's decision rather than a silent fix.
  • ⚠️ server/csharp/MetaObjects.Codegen/GeneratorApplicability.cs:70 - Simplification: GeneratorApplicability.From is not required by a stated intent requirement. The intent requires api-docs to be a registered, ejectable generator that references no packaged emit type outside the engine surface. GenContext.Applicability defaulting to GeneratorApplicability.Packaged satisfies that. From adds a further behavior: it finds a public static AppliesTo on each owned generator by reflection on the method name and parameter types, and substitutes it for the packaged predicate. The contract is implicit and fails silently. A copy that renames AppliesTo, adds a parameter, or moves it to a base class falls back to the packaged answer with no message (GetMethod without FlattenHierarchy, line 77-80), and a copy that changes the return type makes Delegate.CreateDelegate throw out of RunDocs uncaught. It is also asymmetric: it takes effect only when the project owns api-docs too, because docs hands off only then (CodegenHandoff.cs:40). A project that owns routes with a narrowed AppliesTo but not api-docs still gets the packaged surface documented, as before this change. Remedy per the intent's scope: remove From and Sources, and have CodegenCli.RunDocs (CodegenCli.cs:296) pass GeneratorApplicability.Packaged. If the owner wants docs to follow owned copies, that is a product decision to state, and an explicit contract (for example a marker interface the generators implement) would be the form to authorize.

🔧 Fix applied.
3 issues (1 warning, 2 infos) still open:

  • ⚠️ server/csharp/MetaObjects.Cli/EjectCommand.cs:169 - Ejecting with the 1.1.0 tool into an owned project scaffolded by 1.0.x writes sources that do not compile against that project's pinned package, and nothing tells the user. Eject never edits codegen/Codegen.csproj, which pins MetaObjects.Codegen at the version of the tool that first scaffolded it. The sources eject now writes call API this change adds: CSharpNaming.CSharpStringLiteral (NamesGenerator.cs:108, ExtractDelegateEmitter.cs:241 and 299, OutputFormatSpecEmitter.cs:80, Fr010FieldMapping.cs:49 and 231), ValueObjectNames.ResolveValueObject (RenderHelperGenerator.cs:114 and 149), and IDocsGenerator, DocsRunFacts and GenContext.Applicability (ApiDocsGenerator.cs). Concrete sequence: a project owns entity from 1.0.13, upgrades the tool, and runs dotnet meta eject api-docs as the changelog advertises. The next dotnet meta gen hands off to dotnet run --project codegen and fails with CS0246 on IDocsGenerator, so a gen that worked before the eject is now broken until the pin is edited by hand. The eject output only says to add new ApiDocsGenerator() to Program.cs. Two statements in the change are wrong for this state. The HasOwnedDocs comment (CodegenHandoff.cs:19-23) says the presence of ApiDocsGenerator.cs means the pinned package knows docs. The upgrade recipe (docs/features/own-your-codegen.md:1080-1089, CHANGELOG.md:168-173) copies 1.1.0 emitter files in without saying the pin must be 1.1.0 or later first; for output-parser, extractor and output-prompt the copied Fr010FieldMapping.cs fails with CS0117. The class of hazard predates this change, but moving CSharpStringLiteral and ResolveValueObject extends it to generators whose source compiled against 1.0.x before. Smallest remedy: when the scaffold already exists and its MetaObjects.Codegen version differs from ToolVersion(), eject prints a line telling the user to bump it, and the two docs passages say the same. That adds user-visible output, so it needs the author's decision rather than a silent fix.
  • ℹ️ server/csharp/MetaObjects.Codegen/CodegenCli.cs:251 - A project that owns api-docs gets a warning on every dotnet meta gen, and it cannot clear the warning without giving up the owned docs generator. Sequence: dotnet meta eject api-docs writes (or tells the user to add) new ApiDocsGenerator() in the one generators list in codegen/Program.cs. dotnet meta gen hands off; ComposeGenerators returns the owned list with no selection, and appends owned generators the selection did not name when there is one (CodegenCli.cs:404). RunGen then finds the IDocsGenerator (line 233) and adds &#34;api-docs&#34; writes the api reference, so gen skipped it. Run it with dotnet meta docs. on every run. Running dotnet meta docs does not make it stop. This is documented as intended (docs/features/own-your-codegen.md, the Program.cs lists new ApiDocsGenerator() paragraph), so it is the author's choice and not a defect to fix silently. The cross-port difference is that in TypeScript an owned docs generator lives in a separate docs.generators list, so the same warning only fires on a misplaced entry; in C# the supported configuration is the one that triggers it. A narrower form: warn only when a --generators selection names the docs generator, and stay silent when it is only present in the owned list. That changes user-visible output, so it needs the owner's decision.
  • ℹ️ server/csharp/MetaObjects.Cli/EjectCommand.cs:169 - Defect in code the round-1 fix introduced (the older-pin warning). PinnedCodegenVersion accepts three shapes: an inline Version attribute, a Version attribute on its own line of a multi-line element, and a &lt;Version&gt; child element (line 152). For the last two it returns the line of the attribute or child element. OlderPinWarning then always says change line N of codegen/Codegen.csproj to &lt;PackageReference Include=&#34;MetaObjects.Codegen&#34; Version=&#34;X&#34; /&gt;. Concrete input: &lt;PackageReference Include=&#34;MetaObjects.Codegen&#34;&gt; on line 9 and &lt;Version&gt;1.0.13&lt;/Version&gt; on line 10. The warning says to replace line 10 with a whole PackageReference element, which nests one PackageReference inside another and breaks the project file. The scaffold eject writes uses the inline form, so this only affects a project whose owner reformatted the file. Fix: word the instruction as setting the version on line N to the tool version, and print the full replacement element only when the version is an attribute on the same line as the element start. The existing tests cover the inline form only.

🔧 Fix applied.
1 info still open:

  • ℹ️ server/csharp/MetaObjects.Codegen/GeneratorRegistry.cs:265 - Sibling left behind by the round-2 fix (warn only when a --generators selection names the docs generator). The api-docs registry Note, which dotnet meta gen --list prints, still says "a gen run that lists it skips it with a warning." In an owned project the supported way to list it is new ApiDocsGenerator() in codegen/Program.cs, and that run is now silent by the maintainer's decision, so the note over-claims for exactly the configuration eject creates. Same statement, same staleness: fixtures/generator-registry-conformance/registry.json:93 ("On csharp ... dotnet meta gen skips it with a warning"). The CHANGELOG entry, docs/features/own-your-codegen.md and the DocsGenerator.cs header were already corrected in round 2. Fix: reword both to say the warning appears only when --generators names api-docs. No behavior change.
✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 12 of 14 scenarios driven live against the product
Scenario Result Live Evidence
Eject api-docs alone into an empty project writes the generator, its emitters and the codegen scaffold ✅ pass live dotnet MetaObjects.Cli.dll eject api-docs in empty temp dir: exit 0; wrote codegen/generators/ApiDocsGenerator.cs, 6 emitter companions, Codegen.csproj, Program.cs
Eject of all 12 ejectable generators in one call writes every copy and its companions, with no template copy ✅ pass live single eject with all ejectable names into fresh dir: exit 0; generator, emitter and codegen/runtime files written
Unknown name or template in eject is refused all-or-nothing ✅ pass live eject nope and eject template: exit 2, 'Nothing was ejected', template absent from ejectable list, no files written
Re-eject refuses to overwrite an owned copy unless --force ✅ pass live second eject api-docs: exit 1 'already owned'; with --force: exit 0 [replaced]
Older pinned MetaObjects.Codegen in an inline PackageReference prints a warning naming the line to change ✅ pass live pin 1.0.13 inline, eject api-docs: stdout warning names line 9 and says set to 1.1.0
Multi-line PackageReference with Version on its own line: warning names that line and never says to replace the whole element ✅ pass live Version="1.0.13" on line 10: warning says set the version on line 10 to 1.1.0, no full element
<Version> child element pin: warning names that line and never says to replace the whole element ✅ pass live <Version>1.0.13</Version> on line 10: warning names line 10, version-only instruction
Equal pin prints no older-pin warning ✅ pass live pin 1.1.0 equal to tool: eject callable exit 0, no warning on stdout
Packaged gen with --generators api-docs warns and skips the docs generator; gen with no selection is refused ✅ pass live gen meta --out o --generators api-docs: stderr 'api-docs writes the api reference, so gen skipped it. Run it with dotnet meta docs.' exit 0; no selection exit 2
Owned gen with api-docs only in Program.cs and no --generators selection stays silent ✅ pass live owned project wired to local MetaObjects.Codegen via ProjectReference (1.1.0 not in offline NuGet cache): gen exit 0, no api-docs warning
Owned gen with --generators api-docs warns that api-docs is skipped ✅ pass live same owned project with --generators api-docs: stderr warning 'api-docs writes the api reference, so gen skipped it'
Owned api-docs output equals packaged docs output byte-for-byte ✅ pass live owned docs meta --out (50 pages) vs packaged docs: diff -rq IDENTICAL
Eject-alone guard: each ejectable compiles alone, uses no packaged emit type outside the engine surface, every companion used, output byte-identical to packaged, callable corpus included ⏸️ untested no Only an in-process test run was done (dotnet test server/csharp/MetaObjects.Codegen.Tests, EjectedGeneratorsAloneTests: 825 passed, 1 skipped). No live product run drove this guard, so it is not a liv…
Full C# regression suites ⏸️ untested no Only test-suite runs were done (Cli.Tests 254 passed; Codegen.Tests 825 passed, 1 skipped; Render.Tests 332 passed; Conformance.Tests 1388 passed). These are in-process test runs, not live product sce…
  • scripts/ci-local.sh --only ts-fast --only ts-unit --strict-toolchains
  • Built CLI from worktree to /tmp (disposable): dotnet build server/csharp/MetaObjects.Cli; dotnet MetaObjects.Cli.dll --version = 1.1.0
  • Live: eject api-docs in empty temp project (exit 0; codegen/Codegen.csproj, Program.cs, generator, 6 emitters)
  • Live: eject of all 12 ejectable names in one call (exit 0; companions and codegen/runtime copied)
  • Live: eject nope and eject template (exit 2, 'Nothing was ejected', template absent from ejectable list)
  • Live: re-eject without --force (exit 1, 'already owned'); with --force (exit 0, [replaced])
  • Live: older pin 1.0.13 inline -> warning names line 9 and 1.1.0; multi-line Version attr -> line 10, version-only instruction; <Version> child -> line 10; equal pin 1.1.0 -> no warning
  • Live: packaged gen --generators api-docs -> stderr skip warning, exit 0; gen without selection -> exit 2
  • Live (workaround: owned codegen/Codegen.csproj ProjectReference to worktree MetaObjects.Codegen, since 1.1.0 is not in offline NuGet cache): owned gen with no selection silent; with --generators api-docs warns
  • Live: owned docs meta --out (50 pages) vs packaged docs: diff -rq IDENTICAL
  • dotnet test server/csharp/MetaObjects.Cli.Tests -> 254 passed, 0 failed (in-process, not live)
  • dotnet test server/csharp/MetaObjects.Codegen.Tests -> 825 passed, 1 skipped (pre-existing mutation harness), 0 failed; includes EjectedGeneratorsAloneTests (in-process, not live)
  • dotnet test server/csharp/MetaObjects.Render.Tests -> 332 passed; dotnet test server/csharp/MetaObjects.Conformance.Tests -> 1388 passed (in-process, not live)
  • Cleanup: removed /tmp/mo-eval temp projects; worktree git status clean
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

…h; api-docs is ejectable

An ejected C# generator used to be one file over packaged emitters, so a defect in
the enum, navigation, doc-comment or prompt-tier writers still waited for a release.
Eject now copies those emitters with the generator, into codegen/generators/ in the
same Codegen.Generators namespace: Fr019SharedEnum, XmlDocBuilder, M2MNavigation and
ReverseFinders with entity, db-context and routes as each calls them;
ExtractDelegateEmitter, Fr010FieldMapping and OutputFormatSpecEmitter with
output-parser, extractor and output-prompt. The list is each registry entry's
Companions. A file two generators share is copied once and never overwritten.
Naming and type-mapping tables, the model walks and the generator base stay in the
package as the engine.

api-docs is registered for C# and ejectable. The api reference is now a generator
(IDocsGenerator) that `dotnet meta docs` runs; `gen` and `verify --codegen` skip it
and say so. `docs` hands off to an owned codegen project once it owns api-docs, and
what the reference documents comes from the generators in that project's list
(GeneratorApplicability.From), so it describes an owned copy.

Moved to the engine so an owned copy needs no emitter: CSharpStringLiteral to
CSharpNaming, ValueObjectNames.ResolveValueObject out of RenderHelperGenerator.

EjectedGeneratorsAloneTests derives its list from the registry: each ejectable
generator is ejected alone, compiled against the public API, checked against a
written engine surface, and compared byte for byte with the packaged generator.
Packaged output is unchanged.
…es it

The registry Note shown by gen --list and the matching manifest note still said a
gen run that lists api-docs warns. After the gen change, an owned project that
lists it in codegen/Program.cs is silent; only a --generators selection naming it
warns. Text only.
@dmealing
dmealing merged commit 49dbb4b into main Oct 12, 2026
1 check passed
@dmealing
dmealing deleted the fm/mo-1-1-0-eject-csharp branch October 12, 2026 00:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant