From 4255c2abf0381ea9742fbfadb95172aa3c3da8f9 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 11 Oct 2026 19:01:55 -0400 Subject: [PATCH 1/4] feat(csharp): eject hands over the emitters a generator writes through; api-docs is ejectable An ejected C# generator used to be one file over packaged emitters, so a defect in the enum, navigation, doc-comment or prompt-tier writers still waited for a release. Eject now copies those emitters with the generator, into codegen/generators/ in the same Codegen.Generators namespace: Fr019SharedEnum, XmlDocBuilder, M2MNavigation and ReverseFinders with entity, db-context and routes as each calls them; ExtractDelegateEmitter, Fr010FieldMapping and OutputFormatSpecEmitter with output-parser, extractor and output-prompt. The list is each registry entry's Companions. A file two generators share is copied once and never overwritten. Naming and type-mapping tables, the model walks and the generator base stay in the package as the engine. api-docs is registered for C# and ejectable. The api reference is now a generator (IDocsGenerator) that `dotnet meta docs` runs; `gen` and `verify --codegen` skip it and say so. `docs` hands off to an owned codegen project once it owns api-docs, and what the reference documents comes from the generators in that project's list (GeneratorApplicability.From), so it describes an owned copy. Moved to the engine so an owned copy needs no emitter: CSharpStringLiteral to CSharpNaming, ValueObjectNames.ResolveValueObject out of RenderHelperGenerator. EjectedGeneratorsAloneTests derives its list from the registry: each ejectable generator is ejected alone, compiled against the public API, checked against a written engine surface, and compared byte for byte with the packaged generator. Packaged output is unchanged. --- CHANGELOG.md | 48 +++ docs/features/own-your-codegen.md | 80 +++- docs/ports/csharp.md | 2 +- .../registry.json | 4 +- .../EjectCompanionsTests.cs | 122 ++++++ .../EjectEndToEndTests.cs | 54 +++ .../GenListAndSelectionTests.cs | 4 +- .../MetaObjects.Cli.Tests/OwnedCopyTests.cs | 23 +- .../csharp/MetaObjects.Cli/CodegenHandoff.cs | 14 +- server/csharp/MetaObjects.Cli/DocsCommand.cs | 64 +-- server/csharp/MetaObjects.Cli/EjectCommand.cs | 42 ++ server/csharp/MetaObjects.Cli/OwnedCopy.cs | 32 +- server/csharp/MetaObjects.Cli/Program.cs | 8 + .../ApiDocsRunTests.cs | 154 +++++++ .../EjectableGeneratorsTests.cs | 76 +++- .../EjectedGeneratorCompileTests.cs | 187 +-------- .../EjectedGeneratorsAloneTests.cs | 383 ++++++++++++++++++ .../ExtractorCodegenTests.cs | 2 +- .../GeneratorRegistryTests.cs | 2 + .../HelperRuntimeTests.cs | 2 +- .../PublishedSurfaceTests.cs | 32 ++ .../ApiDocs/CSharpApiModelBuilder.cs | 35 +- .../MetaObjects.Codegen/CSharpNaming.cs | 30 ++ .../csharp/MetaObjects.Codegen/CodegenCli.cs | 110 ++++- .../MetaObjects.Codegen/CodegenRunner.cs | 5 +- .../MetaObjects.Codegen/DocsGenerator.cs | 33 ++ .../EjectableGenerators.cs | 120 ++++-- .../csharp/MetaObjects.Codegen/Generator.cs | 10 + .../GeneratorApplicability.cs | 95 +++++ .../MetaObjects.Codegen/GeneratorRegistry.cs | 39 ++ .../Generators/ApiDocsGenerator.cs | 42 ++ .../Generators/ExtractDelegateEmitter.cs | 4 +- .../Generators/FindInbound.cs | 2 +- .../Generators/Fr010FieldMapping.cs | 34 +- .../Generators/NamesGenerator.cs | 2 +- .../Generators/OutputFormatSpecEmitter.cs | 2 +- .../Generators/RenderHelperGenerator.cs | 24 +- .../MetaObjects.Codegen.csproj | 18 + .../MetaObjects.Codegen/ValueObjectNames.cs | 18 + 39 files changed, 1588 insertions(+), 370 deletions(-) create mode 100644 server/csharp/MetaObjects.Cli.Tests/EjectCompanionsTests.cs create mode 100644 server/csharp/MetaObjects.Codegen.Tests/ApiDocsRunTests.cs create mode 100644 server/csharp/MetaObjects.Codegen.Tests/EjectedGeneratorsAloneTests.cs create mode 100644 server/csharp/MetaObjects.Codegen.Tests/PublishedSurfaceTests.cs create mode 100644 server/csharp/MetaObjects.Codegen/DocsGenerator.cs create mode 100644 server/csharp/MetaObjects.Codegen/GeneratorApplicability.cs create mode 100644 server/csharp/MetaObjects.Codegen/Generators/ApiDocsGenerator.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index adb99898f..2673a7615 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -138,6 +138,48 @@ 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 comes from the + generators in your `Program.cs` list: an owned `routes` whose `AppliesTo` you changed is + documented as it behaves, not as the packaged one does (`GeneratorApplicability.From`). + `dotnet meta gen` skips a docs generator and says so, and `verify --codegen` leaves it out. + 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, 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. + - **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. + 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 +786,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..afe391794 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -1014,7 +1014,9 @@ 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 @@ -1031,7 +1033,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 +1043,78 @@ 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 copy you ejected before the emitters were handed over** keeps compiling and running, with +the packaged emitters. To own them, 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. + +**It describes the generators you run.** The reference documents a route, an allowlist, a +render helper only if the generator that emits it would. The packaged generators answer that +through their static `AppliesTo`; when `docs` runs, the answers come from the generators in +your `Program.cs` list instead, so an owned `routes` whose `AppliesTo` you changed is +documented as it behaves. A generator you left in the package keeps answering as the package +does. The builder asks through `GenContext.Applicability` +(`GeneratorApplicability.From(list)` builds it), 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, and `gen` says so (`"api-docs" writes the api reference, so +gen skipped it. Run it with dotnet meta docs.`): it writes pages under the docs directory, +not code under `--out`. + ### 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..e03ca76de 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, and the reference then describes the generators you own. 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..3443f62e6 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 with a warning.", + "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/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..a7bd78c72 100644 --- a/server/csharp/MetaObjects.Cli.Tests/EjectEndToEndTests.cs +++ b/server/csharp/MetaObjects.Cli.Tests/EjectEndToEndTests.cs @@ -194,6 +194,60 @@ 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`, change what the owned routes serves, and run the owned project's `docs` command. The + /// reference is written by the OWNED api-docs copy and describes the OWNED routes, so the edit shows up + /// in the pages; and the same owned project's `gen` skips the docs generator and says so. + /// + [Fact] + public void Owned_api_docs_writes_the_reference_and_describes_the_owned_routes() + { + 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 decision: its routes serve no entity. + var routesPath = Path.Combine(_root, "codegen", "generators", "RoutesGenerator.cs"); + var routes = File.ReadAllText(routesPath); + const string signature = "public static bool AppliesTo(MetaObject entity, MetaRoot root) =>"; + Assert.Contains(signature, routes); + File.WriteAllText(routesPath, routes.Replace( + signature, + "public static bool AppliesTo(MetaObject entity, MetaRoot root) => false;\n" + + " private static bool PackagedAppliesTo(MetaObject entity, MetaRoot root) =>")); + + 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); // the owned api-docs described the model… + Assert.DoesNotContain("GET /api/", widget); // …and what the OWNED routes serve: nothing. + + // The same owned project's gen leaves the docs generator out and says why. + var (genExit, genOutput) = RunCodegenProject( + "gen", MetadataDir, "--out", OutDir, "--namespace", "Acme.Generated", "--generators", "api-docs"); + Assert.True(genExit == 0, $"gen failed (exit {genExit}):\n{genOutput}"); + Assert.Contains("writes the api reference, so `gen` skipped it", genOutput); + Assert.Empty(Directory.Exists(OutDir) ? 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..8b65a1893 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} —")); 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..a16842a14 100644 --- a/server/csharp/MetaObjects.Cli/CodegenHandoff.cs +++ b/server/csharp/MetaObjects.Cli/CodegenHandoff.cs @@ -18,6 +18,16 @@ 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, and the + /// owned project's package version is one that knows the docs command (a project + /// scaffolded before api-docs could be ejected has no such file, and its package does not). + 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 +36,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..0b161a732 100644 --- a/server/csharp/MetaObjects.Cli/EjectCommand.cs +++ b/server/csharp/MetaObjects.Cli/EjectCommand.cs @@ -9,6 +9,11 @@ // - 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); @@ -161,6 +166,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)); @@ -198,6 +204,42 @@ public static Outcome Run(IReadOnlyList names, string root, bool force) 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/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..2bac02aa2 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/ApiDocsRunTests.cs @@ -0,0 +1,154 @@ +// 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), the docs run takes a +// project's own copy in place of the packaged one, and what the reference documents is what the +// run's generators emit for — an owned copy of `routes` that serves nothing is documented as +// serving nothing. + +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; + } + + /// An owned copy that dropped AppliesTo: nothing to read, so the packaged answer stands. + private sealed class OwnedRoutesWithoutAppliesTo : IGenerator + { + public string Name => GeneratorRegistry.Get("routes")!.Factory(new GeneratorBuildContext()).Name; + public IEnumerable Generate(GenContext ctx) => []; + } + + 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 What_the_reference_documents_follows_the_generators_the_run_names() + { + var packaged = CodegenCli.RunDocs(Load(), Path.Combine(_out, "packaged"), "Acme.Generated", Facts, []); + var owned = CodegenCli.RunDocs(Load(), Path.Combine(_out, "owned"), "Acme.Generated", Facts, [new OwnedRoutesThatServeNothing()]); + + Assert.True(packaged.Ok && owned.Ok); + Assert.Equal(packaged.WrittenPaths.Order(), owned.WrittenPaths.Order()); + var rest = (string outDir) => Directory.GetFiles(outDir, "*.md", SearchOption.AllDirectories) + .Count(f => File.ReadAllText(f).Contains("GET /api/", StringComparison.Ordinal)); + Assert.True(rest(Path.Combine(_out, "packaged")) > 0, "the packaged routes serve something, or this proves nothing"); + Assert.Equal(0, rest(Path.Combine(_out, "owned"))); + } + + [Fact] + public void Applicability_answers_from_an_owned_copy_and_falls_back_to_the_packaged_generator() + { + var root = Load().Root; + var entity = root.Objects().First(o => GeneratorApplicability.Packaged.Routes(o, root)); + + Assert.True(GeneratorApplicability.From([]).Routes(entity, root)); + Assert.False(GeneratorApplicability.From([new OwnedRoutesThatServeNothing()]).Routes(entity, root)); + Assert.True(GeneratorApplicability.From([new OwnedRoutesWithoutAppliesTo()]).Routes(entity, root)); + // One owned copy answers one question; every other stays the packaged one. + Assert.Equal( + GeneratorApplicability.Packaged.DbContext(entity, root), + GeneratorApplicability.From([new OwnedRoutesThatServeNothing()]).DbContext(entity, root)); + } + + [Fact] + public void Every_question_the_docs_generator_asks_has_a_generator_that_answers_it() + { + // The wiring table and the record cannot drift: each property is answered by one registered generator. + var properties = typeof(GeneratorApplicability).GetProperties() + .Where(p => typeof(Delegate).IsAssignableFrom(p.PropertyType)) + .Select(p => p.Name) + .Order(StringComparer.Ordinal) + .ToList(); + Assert.Equal(properties, GeneratorApplicability.Sources.Keys.Order(StringComparer.Ordinal)); + Assert.All(GeneratorApplicability.Sources.Values, g => Assert.NotNull(GeneratorRegistry.Get(g))); + } + + [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_skips_a_docs_generator_and_says_so() + { + var outcome = CodegenCli.RunGen(Load(), _out, "Acme.Generated", false, [new ApiDocsGenerator(), new EntityGenerator()]); + + 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); + } + + [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..a8ae1e09a --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/EjectedGeneratorsAloneTests.cs @@ -0,0 +1,383 @@ +// 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 run", + ["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."); + } + } + + /// 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") + { + var nested = new MetaDataLoader().Load([new InMemoryStringSource(ExtractorCodegenTests.Model, id: "orders.json")]); + Assert.Empty(nested.Errors.Select(e => e.Code + ": " + e.Message)); + yield return ("nested-reply", nested.Root); + } + 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. The one + // exception is `callable`: it needs a stored-proc or table-function source, which neither corpus has. + if (name != "callable") + Assert.True(emitted > 0, $"\"{name}\" emitted nothing over either 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)}"); + } + + [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/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..fabcf300f 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 a project that owns a copy of a generator documents the copy. 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,26 @@ 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 — the answers the run's own generators give when a + /// project owns a copy (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 +115,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 +131,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 +144,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 +198,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 +214,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 +222,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 +247,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..7b766463e 100644 --- a/server/csharp/MetaObjects.Codegen/CSharpNaming.cs +++ b/server/csharp/MetaObjects.Codegen/CSharpNaming.cs @@ -1192,4 +1192,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..22d75e041 100644 --- a/server/csharp/MetaObjects.Codegen/CodegenCli.cs +++ b/server/csharp/MetaObjects.Codegen/CodegenCli.cs @@ -228,6 +228,10 @@ public static GenOutcome RunGen( IncludeNames = generators.Any(g => g.Name == "names"), }; + // A docs generator writes the api reference, not code: CodegenRunner leaves it out, and + // this run says so rather than writing pages into the generated-code tree. + var docsSkipped = generators.OfType().Select(g => g.Name).ToList(); + CodegenRunner.RunResult result; try { @@ -244,9 +248,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 it was handed and 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 that list emits 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.From(generators), + 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 +344,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 +406,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 +427,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]; @@ -436,6 +509,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..1cbea38c9 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen/DocsGenerator.cs @@ -0,0 +1,33 @@ +// 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 say they did. +// +// 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..2b9f9c0d5 100644 --- a/server/csharp/MetaObjects.Codegen/Generator.cs +++ b/server/csharp/MetaObjects.Codegen/Generator.cs @@ -268,6 +268,16 @@ 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 unless the run + /// supplies the ones its own generator list gives. + /// + 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..cbb738f1f --- /dev/null +++ b/server/csharp/MetaObjects.Codegen/GeneratorApplicability.cs @@ -0,0 +1,95 @@ +// 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. Asked of the packaged generators, the answer describes the +// packaged code. A project that owns a copy of `routes` and changed which entities it serves +// needs the answer to come from ITS copy, or the docs document a surface nobody generates. +// +// So the question is not hard-wired. The docs generator reads the answers from +// `GenContext.Applicability`, and whoever runs the docs fills it in: `dotnet meta docs` with the +// packaged generators, an owned `codegen/Program.cs` with the generator list the project runs +// (`From`). The docs generator itself — packaged or ejected — names no emitting generator, so an +// owned copy of it depends on nothing but this engine surface. + +using System.Reflection; +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. Defaults to 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, + }; + + /// The registry name of the generator that answers each question, keyed by the + /// property that carries the answer — what looks for in a run's list. + public static IReadOnlyDictionary Sources { get; } = new Dictionary(StringComparer.Ordinal) + { + [nameof(DbContext)] = "db-context", + [nameof(Routes)] = "routes", + [nameof(FilterAllowlist)] = "filter-allowlist", + [nameof(RenderHelper)] = "render-helper", + [nameof(OutputPrompt)] = "output-prompt", + [nameof(OutputParser)] = "output-parser", + }; + + /// + /// The answers for a run of : each question is answered by the + /// matching generator in the list when one is there — an owned copy, whose own + /// AppliesTo is read — and by the packaged generator when it is not. A generator is + /// matched the way matches it, by the + /// the packaged one carries. + /// + public static GeneratorApplicability From(IEnumerable generators) + { + var run = generators.ToList(); + Delegate? Owned(string property, Type delegateType) + { + var packaged = GeneratorRegistry.Get(Sources[property])!.Factory(new GeneratorBuildContext()); + var mine = run.FirstOrDefault(g => g.Name == packaged.Name); + // A copy that dropped or renamed AppliesTo is not an error here: the question then + // has no owned answer and the packaged one stands. + var method = mine?.GetType().GetMethod( + "AppliesTo", BindingFlags.Public | BindingFlags.Static, + delegateType.GetMethod("Invoke")!.GetParameters().Select(p => p.ParameterType).ToArray()); + return method is null ? null : Delegate.CreateDelegate(delegateType, method); + } + + return Packaged with + { + DbContext = (Func?)Owned(nameof(DbContext), typeof(Func)) ?? Packaged.DbContext, + Routes = (Func?)Owned(nameof(Routes), typeof(Func)) ?? Packaged.Routes, + FilterAllowlist = (Func?)Owned(nameof(FilterAllowlist), typeof(Func)) ?? Packaged.FilterAllowlist, + RenderHelper = (Func?)Owned(nameof(RenderHelper), typeof(Func)) ?? Packaged.RenderHelper, + OutputPrompt = (Func?)Owned(nameof(OutputPrompt), typeof(Func)) ?? Packaged.OutputPrompt, + OutputParser = (Func?)Owned(nameof(OutputParser), typeof(Func)) ?? Packaged.OutputParser, + }; + } +} diff --git a/server/csharp/MetaObjects.Codegen/GeneratorRegistry.cs b/server/csharp/MetaObjects.Codegen/GeneratorRegistry.cs index 23ac988ff..c68ea81ec 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`: a `gen` run that lists it skips it with a warning.", + 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..3aa608b0f --- /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 +// documents the generators the project actually runs. 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 @@ + + + + + + + + + + + + +