From 5ead4b4b43be5148afba0a04510cb273c60aecbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Labont=C3=A9?= Date: Wed, 28 Oct 2026 08:37:32 -0400 Subject: [PATCH 1/4] docs: C# component guide, interactive map and starter generated-code contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A C# implementation guide (docs/csharp-component-guide.md) for setting up or auditing a component repo that receives generated code: layout, build wiring, the seams generated code calls by name, extension patterns, the contract, tests, agent instructions, the regen flow, and a 25-check audit written to be handed to Claude. docs/csharp-component.html is the same material as an interactive map with a tickable checklist and a copyable report; scripts/check-csharp-page.py keeps its data in step with the guide and the contract. templates/component-contract/csharp/ is the starter mechanical contract the guide's §6 seeds: 16 rules for the specfuse.loop.gen_contract engine, a builtins module, a mutation test, the expectations skeleton with the five stable section names, the verification.yml gate set and a CI workflow. Green on a fresh seed and every rule bites (scripts/test-csharp-contract-template.sh, run in CI). Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 15 + README.md | 3 +- docs/component-repos.md | 7 +- docs/csharp-component-guide.md | 520 ++++++++++++++ docs/csharp-component.html | 655 ++++++++++++++++++ scripts/check-csharp-page.py | 122 ++++ scripts/test-csharp-contract-template.sh | 143 ++++ .../csharp/.github/workflows/gen-contract.yml | 38 + templates/component-contract/csharp/README.md | 59 ++ .../docs/generated-code-expectations.md | 111 +++ .../csharp/gen-contract.yml | 337 +++++++++ .../csharp/scripts/check-gen-contract.sh | 41 ++ .../csharp/scripts/gen_contract_checks.py | 95 +++ .../csharp/scripts/test-gen-contract.sh | 152 ++++ .../csharp/verification.snippet.yml | 32 + 15 files changed, 2327 insertions(+), 3 deletions(-) create mode 100644 docs/csharp-component-guide.md create mode 100644 docs/csharp-component.html create mode 100644 scripts/check-csharp-page.py create mode 100755 scripts/test-csharp-contract-template.sh create mode 100644 templates/component-contract/csharp/.github/workflows/gen-contract.yml create mode 100644 templates/component-contract/csharp/README.md create mode 100644 templates/component-contract/csharp/docs/generated-code-expectations.md create mode 100644 templates/component-contract/csharp/gen-contract.yml create mode 100755 templates/component-contract/csharp/scripts/check-gen-contract.sh create mode 100644 templates/component-contract/csharp/scripts/gen_contract_checks.py create mode 100755 templates/component-contract/csharp/scripts/test-gen-contract.sh create mode 100644 templates/component-contract/csharp/verification.snippet.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 74ca9ee..600cdad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,21 @@ jobs: - name: Flows page matches the kit run: python scripts/check-flows-page.py + # docs/csharp-component.html is the interactive form of the C# component + # guide; its embedded data must list the guide's checks and the starter + # contract's rules, not a stale copy of either. + - name: C# component page matches the guide and the starter contract + run: python scripts/check-csharp-page.py + + # The starter contract must be green on a fresh seed and every rule must + # bite. The engine is specfuse-loop's, a test-time dependency here, in the + # same direction as the core package below: the kit depends on the loop, + # never the reverse. + - name: C# starter contract is green and every rule bites + run: | + python -m pip install --quiet 'specfuse-loop>=0.30' + ./scripts/test-csharp-contract-template.sh + - name: Clean-install and bootstrap from the built wheel run: | python -m venv /tmp/ci diff --git a/README.md b/README.md index c67c09d..a051fe6 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ See [`examples/hello-orders/`](examples/hello-orders/) for a complete worked exa | **Handbooks** ([`handbooks/`](handbooks/)) | 6 authoritative documents: REST API, AsyncAPI, Arazzo, vendor extensions, AI access policy framework, and the generator's project file. Together these define the full spec-authoring contract. | | **Samples** ([`samples/`](samples/)) | 4 canonical YAML templates — endpoints, async messages, scenarios, recipes — that every authored file should pattern-match against. | | **Schemas** ([`schemas/`](schemas/)) | Working Spectral rulesets and custom functions for validation under `schemas/spectral/`. CI lints the bundled example against them. [`schemas/README.md`](schemas/README.md) also covers how to run Spectral so a crash cannot pass as a clean spec, and how to turn the ruleset on against specs that predate it. | -| **Templates** ([`templates/`](templates/)) | The `project-init/` skeleton, plus the AI Access Policy template. | +| **Templates** ([`templates/`](templates/)) | The `project-init/` skeleton, the AI Access Policy template, and the starter generated-code contract for C# component repos (`component-contract/csharp/`: 16 rules, a mutation test, the expectations skeleton, the CI workflow). | | **Claude assets** | 5 Claude Code sub-agents and 20 authoring skills (`/specfuse-authoring:design-scenario`, `/specfuse-authoring:design-async`, `/specfuse-authoring:design-recipe`, etc.) that automate spec design. Distributed as the `specfuse-authoring` plugin in the `specfuse/specfuse` marketplace; `init` wires the plugin and scaffolds the contract the skills read into `.specfuse/authoring/`. | | **Bundled example** ([`examples/hello-orders/`](examples/hello-orders/)) | A 61-file complete Specfuse project — 2 domains, 3 entities, 1 state-transition event, 1 cross-domain scenario, 2 setup recipes, filled AI access policy, CI workflow. Serves as the kit's regression net. | @@ -71,6 +71,7 @@ See [`examples/hello-orders/`](examples/hello-orders/) for a complete worked exa | Look up a `x-*` extension | [`handbooks/Vendor_Extensions.md`](handbooks/Vendor_Extensions.md) | — | | Implement field encryption at runtime | [`handbooks/Field_Encryption_Runtime.md`](handbooks/Field_Encryption_Runtime.md) | — | | Configure the generator project file | [`handbooks/Project_File.md`](handbooks/Project_File.md) | — | +| Set up or audit a C# backend repo that receives generated code | [`docs/csharp-component-guide.md`](docs/csharp-component-guide.md), with [`docs/csharp-component.html`](docs/csharp-component.html) as the interactive map | hand the guide to Claude (§"How to use this guide with Claude"); seed [`templates/component-contract/csharp/`](templates/component-contract/csharp/) | | See how it all fits together | [`docs/flows.html`](docs/flows.html), an interactive map of the lifecycle, project layout, skills, validation layers and upgrades. Every project gets a copy at `.specfuse/authoring/docs/flows.html`. | Open it in a browser | | See a complete worked example | [`examples/hello-orders/README.md`](examples/hello-orders/README.md) | — | diff --git a/docs/component-repos.md b/docs/component-repos.md index 24663e1..80adcc7 100644 --- a/docs/component-repos.md +++ b/docs/component-repos.md @@ -121,12 +121,15 @@ The generator keeps one file per group under `stateDir` (default `.specfuse/stat - `baseline` rewrites every group's state file, so restore the files for groups whose component was not on a clean, merged `main` (`git checkout -- .specfuse/state/.json`). Commit the rest in a spec-repo PR on its own (no spec changes), citing the component PR and commit. - `baseline` rewrites only `.json`. If `.paths.json` has drifted too, it takes a real regen whose output matches the component's `main` byte for byte. -## 7. Contract checks in the component (optional) +## 7. Contract checks in the component -A component can also guard its generated tree mechanically, with a CI check that fails when generated files are hand-edited or don't meet the shape the component depends on, plus a short doc describing that expected shape. Tooling for this is not shipped by the kit yet ([#171](https://github.com/specfuse/authoring/issues/171)). Until it is, keep any such check small and specific to the component. +A component can guard its generated tree mechanically: a contract file (`gen-contract.yml`) whose rules fail when generated files are hand-edited or do not meet the shape the component depends on, run as a `code` gate in `.specfuse/verification.yml`, plus a short prose doc (`docs/generated-code-expectations.md`) saying what "acceptable" means beyond the rules. The ownership is settled ([#171](https://github.com/specfuse/authoring/issues/171)): the rules and the prose are the component's, the engine is `specfuse.loop.gen_contract` in specfuse-loop 0.30+, and what the generator's own QA harness reads from those two files is published as a stable interface ([`CONSUMER-CONTRACT.md`](https://github.com/clabonte/generator/blob/main/docs/CONSUMER-CONTRACT.md)). + +For C#, the kit ships a starter: [`templates/component-contract/csharp/`](../templates/component-contract/csharp/), 16 rules green on a fresh seed, each with a mutation case, and the full walkthrough is [`csharp-component-guide.md`](csharp-component-guide.md) §6. `add-component` will seed it once [#181](https://github.com/specfuse/authoring/issues/181) lands; until then its README has the copy-and-sed steps. Other languages keep any such check small and specific to the component. ## Where to go next +- [`csharp-component-guide.md`](csharp-component-guide.md): the C# component in full, with an audit checklist for Claude - [`Project_File.md`](../handbooks/Project_File.md): `destination`, `cleanGenerated`, `cleanScope`, `stateDir` - [`Vendor_Extensions.md` §4.5](../handbooks/Vendor_Extensions.md): `x-manual`, the operations that stay hand-written - [`generator-access.md`](generator-access.md): generator access for whoever runs regens diff --git a/docs/csharp-component-guide.md b/docs/csharp-component-guide.md new file mode 100644 index 0000000..a273ebb --- /dev/null +++ b/docs/csharp-component-guide.md @@ -0,0 +1,520 @@ + + +# Setting up a C# component repo + +[Setting up component repos](component-repos.md) says what every component repo, in any language, has to get right. This guide is the C# version in full: the layout the generator writes into, the build wiring and host files the generator never writes, the seams generated code calls by name, how hand-written code extends generated code without ever editing it, the mechanical contract that keeps all of that true, and the regen flow. It ends in an **audit checklist** (§10) that a person or a coding agent can run against an existing repo and report on, check by check. + +It is written to be handed to Claude. Every rule has a reason, a way to verify it from a shell, and a pointer to the section that explains the fix. The interactive companion, [`csharp-component.html`](csharp-component.html), is the same material as a map you click through. + +Project names in this guide come from the kit's bundled example, [`hello-orders`](../examples/hello-orders/): the solution is `HelloOrders`, the layered projects are `HelloOrders.Domain`, `HelloOrders.Infrastructure`, `HelloOrders.Api`, and so on. Substitute your own root namespace throughout. + +> **New repo?** `specfuse authoring add-component csharp ` ([`add-component.md`](add-component.md)) seeds the host files in §2 and §4 once, with the package versions the kit's pinned generator compiles against. This guide is what the seed implements, and what to check when a repo was set up some other way. + +## How to use this guide with Claude + +Open a Claude Code session in the component repo and paste: + +``` +Read docs/csharp-component-guide.md from the Specfuse authoring kit (or the copy at ). +Audit this repository against every check in §10, in order. For each check, run the +verification command, then report one line: the check ID, PASS or FAIL, and the +evidence (a path, a line, a command output). Do not fix anything yet. When the audit +is complete, list the FAILs grouped by the section that explains the fix, and +propose the smallest change for each. Wait for my approval before editing. +``` + +For a new repo, replace "Audit" with "Set up this repository so that every check in §10 passes, starting from §1", and keep the approval step: the host files in §2 and §4 are yours to own, so the first version deserves a look. + +## 0. The model in one paragraph + +Generation runs **from the spec repo**. The project file there names, for each C# group, a `destination` (`../Backend/`) and a `basePackage` (`HelloOrders.Domain`); the generator writes that group's files into `//gen-src/` and, with `cleanGenerated: true`, wipes that folder first on every full-group regen. It writes **only** `gen-src/`, plus a `CHANGELOG.md` and a bookkeeping manifest beside it. Everything that makes the generated code compile and run (the solution, the project files, `Directory.Build.props`, `Program.cs`, the handful of classes generated code calls by name) is yours, written once and never regenerated. Everything that extends the generated code lives in `src/` beside `gen-src/`, through `partial` classes, generated `*Base` classes you derive from, and `partial` hook methods. A small mechanical contract (`gen-contract.yml`) keeps that split true on every pull request. + +## 1. Layout + +``` +~/work/hello-orders/ +├── hello-orders-specs/ the spec repo: specs, hello-orders-project.json, .specfuse/state/ +└── Backend/ this repo + ├── HelloOrders.slnx + ├── Directory.Build.props compiles src/ and gen-src/ in every project (§2) + ├── global.json + ├── gen-contract.yml the mechanical contract (§6) + ├── HelloOrders.Domain/ + │ ├── HelloOrders.Domain.csproj + │ ├── CHANGELOG.md written by the generator: the regen-over-regen surface diff + │ ├── gen-src/ generated; wiped and rewritten on every full-group regen + │ │ ├── Entities/ ValueObjects/ Enums/ Models/ Events/ Snapshots/ + │ │ ├── Services/ Repositories/ Mappers/ Exceptions/ Validators/ + │ │ └── ConfigureServices.g.cs AddServices() + │ └── src/ yours: partial classes, custom services, mapper hooks + ├── HelloOrders.Infrastructure/ + │ ├── gen-src/ DbContext, Configurations/, Repositories/, Converters/, Events/, Interceptors/ + │ └── src/ yours: EF migrations, external services, the host-side contracts (§4) + ├── HelloOrders.Application/ only with an Application group: handlers and jobs + │ ├── gen-src//Handlers/ I{X}Handler + {X}HandlerBase + │ ├── gen-src//Jobs/ I{X}JobHandler + {X}JobHandlerBase + │ ├── gen-src//Registration/ Add{Domain}Workers() + │ └── src//Handlers/ yours: the concrete handlers (§5) + ├── HelloOrders.Api/ + │ ├── gen-src/ Controllers/, Models/, Mappers/, Authorization/, Application/Interfaces/ + │ └── src/ Program.cs, Utils/UserHelper.cs, Filters/ExceptionFilter.cs, your application services + └── HelloOrders.Test/ only with a Test group + ├── gen-src/ Fakes/ (A.Order()), Fixtures/, Functional/Api/, Generated/Unit/ + └── src/ yours: ApiTestFixture, ApiTestBase, InMemoryDbTestHelper, hand-written tests +``` + +The rules behind the picture: + +- **Sibling checkouts.** The `../Backend/` destinations in the project file resolve only when the spec repo and the component sit next to each other. Write the layout into this repo's README. +- **One project folder per group `basePackage`.** `HelloOrders.Domain` is both the folder, the `.csproj` name and the namespace root; generated namespaces are `HelloOrders.Domain.Entities`, `HelloOrders.Domain.Services`, and so on. Several groups share one `destination`, each writing into its own `basePackage` subfolder ([`Project_File.md` §7.1](../handbooks/Project_File.md#71-groups)). +- **`gen-src/` is generated, `src/` is yours.** Every generated C# file is a `.g.cs`, opens with `#nullable enable`, and carries the `` header block with the line `Generated by the Specfuse Generator`. Nothing else is ever under `gen-src/`, and no `.g.cs` is ever outside it. +- **Two files land beside `gen-src/`, not in it.** `CHANGELOG.md` is the generator's own surface diff for the group and is committed. `.generated-paths.json` is a timestamped manifest of what the last run wrote; ignore it in git (§2). + +## 2. Build wiring + +The generator emits no `.sln`, no `.csproj`, no `Directory.Build.props` and no `Program.cs` ([specfuse/authoring#177](https://github.com/specfuse/authoring/issues/177)). These are the host, seeded once and owned here. + +### 2.1 `Directory.Build.props` at the solution root + +```xml + + + net10.0 + enable + enable + false + + $(NoWarn);CS1591;CS8618 + + + + + + +``` + +Default globbing is **off** on purpose: with it on, a `.cs` file anywhere in a project folder joins the build, so a stale file parked beside `gen-src/` compiles and shadows the generated one. With it off, the two `Compile` globs are the whole story, and a file is either generated or yours by where it sits. The same file is where `TreatWarningsAsErrors` goes once the build is clean. + +### 2.2 One `.csproj` per layer, with the layering in the references + +| Project | SDK | References | Packages the generated code needs (generator 0.19) | +|---|---|---|---| +| `HelloOrders.Domain` | `Microsoft.NET.Sdk` | none | `Riok.Mapperly`, `Microsoft.Extensions.Logging`, `Microsoft.AspNetCore.Http`, `Microsoft.Data.SqlClient`, `Newtonsoft.Json` | +| `HelloOrders.Infrastructure` | `Microsoft.NET.Sdk` | Domain | `Microsoft.EntityFrameworkCore` (+ `.Relational`, `.SqlServer`, `.InMemory`), `Azure.Messaging.ServiceBus`, `Azure.Storage.Blobs`, `Microsoft.Extensions.Http`, `Microsoft.Extensions.Configuration.EnvironmentVariables` | +| `HelloOrders.Application` | `Microsoft.NET.Sdk` | Domain | `Microsoft.Extensions.Logging` | +| `HelloOrders.Api` | `Microsoft.NET.Sdk.Web` | Domain, Application, Infrastructure | `Riok.Mapperly`, `Newtonsoft.Json` | +| `HelloOrders.Test` | `Microsoft.NET.Sdk` (`IsTestProject`) | all of the above | `xunit`, `xunit.runner.visualstudio`, `Microsoft.NET.Test.Sdk`, `Moq`, `AwesomeAssertions`, `Microsoft.AspNetCore.Mvc.Testing`, `Microsoft.EntityFrameworkCore.InMemory`, `coverlet.collector` | + +The references are the Clean Architecture layering made compile-time: Domain references nothing in the solution; Infrastructure implements Domain's interfaces; Api talks to Domain and references Infrastructure **only** to wire it up in `Program.cs`. Infrastructure referencing Application is allowed when Application owns ports (interfaces its use cases need and Infrastructure implements), never for Application's behaviour. The exact versions are those the kit's pinned generator compiles against; `add-component` seeds them, [`compatibility.md`](../compatibility.md) says when a generator release needs a new one, and `dotnet build` names the missing package when it does. + +Two libraries are deliberately **not** on the list: AutoMapper and FluentAssertions. Both became commercially licensed; generated code maps through Mapperly and asserts through AwesomeAssertions (the Apache-2.0 fork with the same `.Should()` API). Referencing FluentAssertions beside AwesomeAssertions makes every assertion ambiguous. + +### 2.3 Solution, SDK pin and git + +- `HelloOrders.slnx` (or `.sln`) lists every project; `global.json` pins the SDK (`"version": "10.0.100", "rollForward": "feature"`). +- `.gitignore` carries `bin/`, `obj/`, `TestResults/` and **`*.generated-paths.json`**. Generated code itself is **committed**: every regen is then a reviewable diff, CI and teammates build without Java or generator access, and a spec change and its effect are visible side by side. +- `.gitattributes` normalises `*.cs`, `*.csproj`, `*.json` and `*.md` to LF so a regen on another OS does not rewrite every line. + +## 3. Project-file groups for C# + +The spec repo's project file declares one group per project folder. This is the shape that compiles together ([`Project_File.md` §8](../handbooks/Project_File.md#8-group-fields), artifact catalogue in [§11.1](../handbooks/Project_File.md#111-c--csharp-artifacts)): + +```json +{ "language": "C#", "name": "Domain", "basePackage": "HelloOrders.Domain", "destination": "../Backend/", "cleanGenerated": true, + "artifacts": ["domainProject", "domainValidation", "domainExceptions", "entity", "valueObject", "enum", "domainModel", + "serviceInterface", "repositoryInterface", "service", "event", "snapshot", "snapshotContext", + "tenancyMarker", "eventRuntime"] }, +{ "language": "C#", "name": "Infrastructure", "basePackage": "HelloOrders.Infrastructure", "destination": "../Backend/", "cleanGenerated": true, + "artifacts": ["infrastructureProject", "entityTypeConfiguration", "valueObjectConverter", "dbContext", "repository", "eventRuntimeImpl"] }, +{ "language": "C#", "name": "Api", "basePackage": "HelloOrders.Api", "destination": "../Backend/", "cleanGenerated": true, + "artifacts": ["apiProject", "apiModel", "apiMapper", "apiController", "applicationServiceInterface", "authorizationRuntime"] } +``` + +Add as the project grows: + +| Group | `basePackage` | Artifacts | Notes | +|---|---|---|---| +| `Application` | `HelloOrders.Application` | `asyncEventHandler`, `asyncJobInterface`, `asyncConsumerRegistration` | One `I{X}Handler` + `{X}HandlerBase` per consumed event, per-domain `Add{Domain}Workers()` registrations. | +| `AzureFunctions` / `Workers` | `HelloOrders.AzureFunctions` / `.Workers` | `azureFunctionTopicTrigger`, `azureFunctionTimerTrigger`, `eventRuntimeFunctions` / `hostedTopicConsumer` | Set `configs.applicationPackage` to the Application group's `basePackage`. | +| `Test` | `HelloOrders.Test` | `entityBuilder`, `newDtoBuilder`, `updateDtoBuilder`, `valueObjectFake`, `apiModelFake`, `testSeed`, `serviceUnitTest`, `mapperTest`, `valueObjectTest`, `efConfigTest`, `authMatrixTest`, `authorizationConformanceTest`, `apiFunctionalTest`, `eventBuilder`, `eventContractTest`, `recipeFixture`, `scenarioFunctionalTest` | Needs the hand-written fixtures in §4.4. | + +Three things the project file must get right for C#: + +- **`tenancy` is required** at the current generator whenever `service` is registered: generated services always use the event runtime, and the event runtime requires a tenancy block ([`Project_File.md` §4.1](../handbooks/Project_File.md#41-tenancy-c-only-today), [clabonte/generator#2883](https://github.com/clabonte/generator/issues/2883)). `dbContextClassname` names the EF Core context (`HelloOrdersDbContext`); unset, it derives from `name`. +- **`cleanGenerated: true` on every C# group.** The wipe is gated on a full-group regen; a run filtered with `--artifact` skips it, so partial output never deletes a sibling artifact's files ([§8.9](../handbooks/Project_File.md#89-cleangenerated)). `cleanScope` is not needed for C#: the default wipe target is `gen-src/` under the group's folder. +- **`functionalTests`** decides which generated API functional tests run in parallel (`collectionNamePattern`, `collectionGroups`); unset, every generated test shares one xUnit collection and the suite is serial. + +## 4. The composition root and the seams generated code calls by name + +The generator emits registrations and contracts; the host calls the registrations and implements the contracts. Nothing generated calls `Program.cs`, and several generated types reference a hand-written class **by name** and compile only once you provide it. + +### 4.1 `Program.cs` + +```csharp +using HelloOrders.Api.Authorization; +using HelloOrders.Api.Filters; +using HelloOrders.Domain; +using HelloOrders.Infrastructure; +using HelloOrders.Infrastructure.Concurrency; +using HelloOrders.Infrastructure.Events; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddControllers(options => options.Filters.Add()); + +// Generated registrations (gen-src/**/ConfigureServices.g.cs and friends). +builder.Services.AddServices(); // Domain: I{Entity}Service -> Default{Entity}Service +builder.Services.AddRepositories(); // Infrastructure: I{Entity}Repository -> {Entity}Repository +builder.Services.AddSpecfuseAuthorization(); // Api: [RoleRequired] runtime, claims reader, placeholders +builder.Services.AddConcurrencyTokenSink(); // Infrastructure: ETag / If-Match plumbing + +// Events are buffered per request and dispatched on commit. AddNullEventDispatcher drops +// them (local runs without a broker); production calls AddEventRuntime() for Azure Service Bus. +builder.Services.AddEventRuntimeCore().AddNullEventDispatcher(); + +var connectionString = builder.Configuration.GetConnectionString("Default"); +if (string.IsNullOrEmpty(connectionString)) + builder.Services.AddInMemoryDatabase("HelloOrders"); +else + builder.Services.AddSqlServerDatabase(connectionString); + +var app = builder.Build(); +app.MapControllers(); +app.Run(); +``` + +With an Application group, add `builder.Services.Add{Domain}Workers()` per domain (or the aggregate `AddAllWorkers()`), which registers every concrete handler and job found by assembly scan. With field encryption declared in the spec, add `builder.Services.AddFieldEncryption()`; nothing generated calls it for you. + +**Register your own implementations after the generated calls, or rely on `TryAdd`.** Every generated registration uses `TryAdd*`, so a registration made before or after it wins. A custom `I{Entity}Service` is registered right after `AddServices()`; a decorator wraps after both. + +### 4.2 Two classes the API's generated code names + +| Type | Who calls it | What it must do | +|---|---|---| +| `HelloOrders.Api.Utils.UserHelper` | every generated controller: `new CallerContext(UserHelper.GetUser(User))` | A static `GetUser(ClaimsPrincipal)` returning the generated `AuthenticatedUser`: user id, roles, tenant memberships from your identity provider's claims. From generator 0.20.0, `"generateUserHelper": true` in the project file emits a default one instead; keep exactly one. | +| `HelloOrders.Api.Filters.ExceptionFilter` | nothing generated; you add it to `AddControllers` | Maps the generated domain exceptions (`NotFoundException`, `ConflictException`, `PreconditionFailedException`, …) to their `StatusCode` and `Errors` list. Without it a `NotFoundException` is a 500. | + +### 4.3 Contracts registered as throwing placeholders + +Generated registrations install a placeholder for every contract that has no generated implementation. The placeholder keeps the container building and **fails by name on first use**, so a forgotten one is never silent. Implement the ones your spec needs, in `src/` of the layer that owns the knowledge, and register them anywhere (TryAdd means order does not matter): + +| Contract | Needed when | Placeholder behaviour | +|---|---|---| +| `ITenantResourceCheck` | the project declares `tenancy` | throws on the first role-gated request that reaches the tenant check; single-tenant projects get a generated `RoleSufficientTenantResourceCheck` instead | +| `IRecordVisibilityResolver` | `tenancy` with the generated `Tenant` query filter | `AddSqlServerDatabase` registers a resolver that throws on the first tenant-filtered query; `AddInMemoryDatabase` defaults to seeing everything | +| `IPrincipalKeyResolver` | optimistic concurrency with principal-keyed tokens | `AddPrincipalKeyResolver()` installs a factory that throws `PrincipalKeyResolverNotConfiguredException` | +| `ICallerFieldResolver` | `x-populate-from` caller fields | `AddCallerFieldResolver()` placeholder throws, naming the method | +| `IDataKeyProvider`, `IKeyWrapper`, the decryption-failure policy | field encryption declared in the spec | `AddFieldEncryption()` placeholders throw `FieldEncryptionNotConfiguredException` naming the contract | +| the event dispatcher | always, when `eventRuntime` is registered | `AddNullEventDispatcher()` drops events; `AddEventRuntime()` dispatches to Azure Service Bus through the outbox | + +### 4.4 What the generated tests expect from `HelloOrders.Test/src` + +With a Test group, the generated tests derive from and call three hand-written types, by name, in these namespaces: + +| Type | Namespace | Used by | Must provide | +|---|---|---|---| +| `ApiTestFixture` | `HelloOrders.Test.Fixtures` | every generated functional test (`[Collection]` fixture) | a `WebApplicationFactory` over the test database, with fake authentication so `CreateHttpClient(role)` can mint a caller | +| `ApiTestBase` | `HelloOrders.Test.Functional.Api` | `class OrdersApiTests(ApiTestFixture fixture) : ApiTestBase(fixture)` | `CreateHttpClient(role)` and `EnsureFakeData()`; the generated `AsAllowedRole` / `AsAnonymous` theories call both | +| `InMemoryDbTestHelper` | `HelloOrders.Test.Helpers` (or as `using`s allow) | generated EF configuration tests: `new InMemoryDbTestHelper($"config-test-{Guid.NewGuid():N}")` | an isolated in-memory `DbContext` per test | + +The generated `TestSeed` (stable GUIDs and a pre-wired entity graph) and the `A.{Entity}()` builders point at each other; `EnsureFakeData()` is where you seed that graph into the test database. A bare `A.Order()` is not a self-contained row: it carries foreign keys to `TestSeed` ancestors, and a read through the generated query filters joins them. + +## 5. Extending generated code + +Never edit a file under `gen-src/`. A wrong generated file is fixed in the spec or in the generator, then regenerated; an edit disappears on the next run. Four patterns cover everything else, all in `src/`, all in the generated type's own namespace. + +### 5.1 `partial` classes + +Every generated entity, DTO, mapper and hook class is `partial`. A computed property, a domain method, an extra attribute: a `partial class` in `src/` with the same namespace. + +```csharp +// HelloOrders.Domain/src/Entities/Order.cs +namespace HelloOrders.Domain.Entities; + +public partial class Order +{ + public bool IsDraft => Status == OrderStatus.Draft; +} +``` + +### 5.2 Derive from the generated `*Base` + +For every event handler, scheduled job and gated application service the generator emits an interface plus an abstract base. The base holds the operational contract: logging scope, the spec-declared timeout, inbox deduplication and the shared transaction for handlers, per-item scopes for jobs, the `If-Match` precondition for application services. **The concrete class is yours and must derive from the base.** + +```csharp +// HelloOrders.Application/src/Orders/Handlers/OrderPlacedHandler.cs +public sealed class OrderPlacedHandler( + ILogger logger, + IEventInboxStore inbox, + ITransactionScopeFactory scopeFactory, + IOrderRepository orders) : OrderPlacedHandlerBase(logger, inbox, scopeFactory) +{ + protected override Task HandleOrderPlacedAsync(EventEnvelope envelope, OrderPlacedEvent body, CancellationToken ct) + => /* the business logic, nothing else */; +} +``` + +The trap, and the reason §6 has two rules for it: `class OrderPlacedHandler : IOrderPlacedHandler` **also compiles**, is picked up by the generated assembly scan, and is invoked by the trigger exactly as if it had adopted the base, with none of the contract in effect. Nothing reports it. The same shape applies to `OrdersApplicationService : IOrdersApplicationService` without `OrdersApplicationServiceBase`: the precondition gate is a non-virtual method on the base, so the service silently drops `If-Match` and every concurrent write becomes a blind overwrite. Watch for it when a regen introduces a base for a class that already existed. + +### 5.3 Mapper hooks + +Values a request does not carry (a status, a timestamp, a default) are set in a generated `partial` hook the generator declares and you implement: + +```csharp +// HelloOrders.Domain/src/Mappers/OrderDomainMapperHooks.cs +namespace HelloOrders.Domain.Mappers; + +public static partial class OrderDomainMapperHooks +{ + static partial void OnAfterToOrder(NewOrder source, Order target) + { + target.Status = OrderStatus.Draft; + } +} +``` + +Mapperly makes every mapping a compile-time method: a missing or mistyped mapping is a build error, not a runtime one. + +### 5.4 Replace a generated default + +`AddServices()` registers `IOrderService -> DefaultOrderService` with `TryAdd`. A custom service derives from the default (or implements the interface) in `src/` and is registered after `AddServices()` with a plain `AddScoped`, which replaces the default. Custom business logic that does not fit a generated operation is a hand-written class beside it, never an edit to the default. + +What stays hand-written by design: operations marked `x-manual` in the spec ([`Vendor_Extensions.md` §4.5](../handbooks/Vendor_Extensions.md)), EF Core migrations (derived from the generated configurations, owned here), and everything the generator does not know your domain needs. + +## 6. The generated-code contract + +The split in §1 and the patterns in §5 are only as true as something checks them. A component repo guards them mechanically with a **contract file** and keeps the judgement calls in a **prose bar** the generator's own QA harness reads. The ownership is settled ([specfuse/authoring#171](https://github.com/specfuse/authoring/issues/171)): the rules are the component's, the engine is the loop's, and what the generator reads is published as a stable interface ([`CONSUMER-CONTRACT.md`](https://github.com/clabonte/generator/blob/main/docs/CONSUMER-CONTRACT.md)). + +### 6.1 Seed the starter contract + +The kit ships a starter for C# in [`templates/component-contract/csharp/`](../templates/component-contract/csharp/): 16 rules, green on a fresh seed, each with a mutation case proving it bites. Seed it by hand (its README has the exact commands) or wait for `add-component` to do it ([#181](https://github.com/specfuse/authoring/issues/181)): + +| Block | Rules | What breaks without them | +|---|---|---| +| Markers and layout | `GEN-001` to `GEN-005` | a hand-written file hides under `gen-src/` and is wiped; a stale `.g.cs` outside it shadows the current output; a file without the header is linted and "fixed" by hand | +| Layering | `GEN-010` to `GEN-014` | Domain grows a dependency on persistence; Api reaches the DbContext directly; a project reference inverts a layer | +| Host wiring | `GEN-020`, `GEN-021` | half the solution stops compiling; the generator's manifests dirty every regen diff | +| Hand-written code | `GEN-030` | a generated file copied into `src/` and edited | +| Extension points | `GEN-040` to `GEN-042` | a declared handler nobody wrote dead-letters every delivery; a service drops `If-Match`; a handler bypasses inbox dedup | + +The engine is `specfuse.loop.gen_contract` (specfuse-loop 0.30+, part of `pipx install specfuse`). The seeded `scripts/check-gen-contract.sh` runs it; `scripts/test-gen-contract.sh` is the mutation test. + +```bash +./scripts/check-gen-contract.sh # every rule; 0 green, 1 violations, 2 cannot run +./scripts/check-gen-contract.sh --list # what is enforced +./scripts/check-gen-contract.sh --explain GEN-040 +./scripts/test-gen-contract.sh # every rule bites +``` + +### 6.2 Grow it from review findings + +A review finding that recurs becomes a rule, not a longer checklist. The bar for a new rule: green on the default branch the day it lands (or shipped with a `waived_until:` block that freezes today's count and names the event that ends it); a mutation case in `scripts/test-gen-contract.sh`; a `why:` that says what breaks in production. Project-specific shapes the generic checks cannot express (a tenancy marker on every entity, a value object's `Empty`, an event label's two segments) go in `scripts/gen_contract_checks.py` as `builtin` functions. + +### 6.3 The prose bar + +`docs/generated-code-expectations.md` (the starter ships a skeleton) is read by the generator's consumer-QA reviewer and by anyone reviewing a regen. Its five section names are the stable interface: **Standing expectations**, **Integration contract**, **Known failure modes**, **Accepted quirks**, **Do not report**. The last two are suppression lists; keep the do-not-report table pointing at the rule IDs, so the reviewer never re-judges what the gate decides. + +### 6.4 Wire it as a gate + +`.specfuse/verification.yml` (`specfuse init ` creates it) carries a `code` gate set. That key is what the Specfuse loop runs as its exit oracle and what the generator's harness runs against every regen. The starter's `verification.snippet.yml` is the shape: `gen-contract` first (seconds, no toolchain), `build` with `needs: [gen-contract]`, `tests` with `needs: [build]`, so a structural breach never pays for the .NET chain. CI runs the same order: the starter's `.github/workflows/gen-contract.yml` runs the contract and its mutation test on every PR with no SDK; the `dotnet build` / `dotnet test` job sits beside it. + +## 7. Tests and coverage + +- **Generated tests live in `gen-src/`, hand-written tests in `src/`.** The Test group emits builders, fakes, the seed, and whole suites (service unit tests, mapper tests, EF configuration tests, the authorization matrix, API functional tests, event contract tests). They are regenerated like everything else. +- **Coverage measures `src/` only.** `coverlet.runsettings` excludes `**/gen-src/**` (and `**/Migrations/**`); generated tests exercising generated code count for nothing, so the number is about the code you wrote. A floor in `.coverage-floor` and a `coverage` gate with `needs: [tests]` ratchets it. +- **Unit tests mock everything** through the generated interfaces; a hand-built `DbContext` goes through `InMemoryDbTestHelper`. Functional tests go through `ApiTestFixture`. Mark integration tests with `[Trait("Category", "Integration")]` so `--filter "Category!=Integration"` is the daily run. +- **AwesomeAssertions, never FluentAssertions** (§2.2). + +## 8. Agent instructions + +Put the rules an agent will need in the first thirty lines of `CLAUDE.md` (or `AGENTS.md`), before anything else, because it reads them before it reads the error. The seed from `add-component` carries them; an older repo writes them in: + +1. `*/gen-src/**` and every `*.g.cs` are generated from `../`; the next regen overwrites them. **Never edit them.** If a task seems to need it, stop: the fix belongs in the spec or the generator. Say so and note that a regen is required. +2. Before writing a model, DTO, repository, mapper, service or controller by hand, check `gen-src/`. It probably exists. +3. Hand-written code goes in `src/`: `partial` classes in the generated namespace, concrete classes deriving from the generated `*Base`, `partial` mapper hooks. Never implement a generated `I{X}Handler` or `I{Tag}ApplicationService` directly. +4. The layering (§2.2) and where the seams live (§4). +5. The regen flow (§9), and that `./scripts/check-gen-contract.sh` is the first thing to run after any regen. + +## 9. The regen flow + +1. **The spec change merges** in the spec repo. +2. **Regenerate on a branch of this repo** with no uncommitted work. From the spec repo root (`cleanGenerated` deletes before it writes, so the tree must be disposable): + ```bash + cd ../hello-orders-specs + ./scripts/specfuse/generate.sh Domain Infrastructure Api # one jar run per group, bundled once + ``` + `generate.sh` refuses a destination repo with uncommitted changes. +3. **Run the contract, then build.** `./scripts/check-gen-contract.sh` first: a red rule is a generator defect or a hand-written file in the wrong place, and either is cheaper to see before the build. Then `dotnet build` and the unit tests. Fix the hand-written `src/` that the regen broke (a renamed member, a new abstract method on a base, a new placeholder contract); never the generated side. +4. **Review by file name, not by path.** A regen rewrites the whole tree, so diff size is not a signal. Diff changed models by file name (a moved file reads as delete plus add), diff each enum's **values**, diff operation parameter lists, trace every hand-written type that mirrors a generated one, and read `CHANGELOG.md` in each project folder: it is the generator's own account of what changed in the surface. Removals are the highest-risk content of a regen and nothing mechanical catches them. +5. **One PR per component**, containing the regen and only its required follow-ups. CI runs the contract and the mutation test, then the build. +6. **Merge, then advance the baseline** in the spec repo from the merged default branch, never from a local regen: + ```bash + cd ../hello-orders-specs + ./scripts/specfuse/advance-state.sh --repo Backend --dry-run + ./scripts/specfuse/advance-state.sh --repo Backend + ``` + and open a spec-repo PR with only the changed `.specfuse/state/.json` files ([`component-repos.md` §6](component-repos.md#6-advance-the-statedir-baseline-only-from-merged-component-code)). + +When generated output is wrong, file it against the generator with the generated C# that is expected, never a proposed template change; the generator agent locates the template from the output shape. When the spec is wrong, the component files a `specfuse:spec-issue` in the spec repo. + +## 10. The audit checklist + +Run from the component repo root. Every check names the section with the explanation and the fix. The IDs are stable; the interactive page uses the same ones. + +### Layout (§1) + +**CS-01 · The spec repo is a sibling checkout and every C# group's `destination` resolves to this repo.** +```bash +python3 -c 'import json,glob,os; p=glob.glob("../*/*-project.json")[0]; d=json.load(open(p)); print(p); [print(g["name"], os.path.realpath(os.path.join(os.path.dirname(p), g["destination"]))) for g in d["groups"] if g["language"] in ("C#","csharp")]' +``` +Every printed path is this repo. + +**CS-02 · One project folder per C# group `basePackage`, each with `gen-src/` and `src/`.** +```bash +for d in */gen-src; do p=${d%/gen-src}; [ -f "$p/$p.csproj" ] && [ -d "$p/src" ] && echo "ok $p" || echo "FAIL $p"; done +``` + +**CS-03 · Nothing hand-written under `gen-src/`, no `.g.cs` outside it, every generated file marked.** Covered by `GEN-001` to `GEN-005`. +```bash +./scripts/check-gen-contract.sh --only GEN-001 GEN-002 GEN-003 GEN-004 GEN-005 +``` + +### Build wiring (§2) + +**CS-04 · `Directory.Build.props` turns default globbing off and compiles exactly `src\**\*.cs` and `gen-src\**\*.cs`.** `GEN-020`. +```bash +./scripts/check-gen-contract.sh --only GEN-020 +``` + +**CS-05 · Project references express the layering.** Domain references nothing; Infrastructure never references Api; Api references Infrastructure only as the composition root. `GEN-013`, `GEN-014`, plus a look at the Api project: +```bash +./scripts/check-gen-contract.sh --only GEN-013 GEN-014 && grep -n ProjectReference *.Api/*.csproj +``` + +**CS-06 · A fresh regen builds with zero edits and zero warnings.** +```bash +dotnet build --nologo -warnaserror +``` + +**CS-07 · `global.json` pins the SDK; the solution lists every project.** +```bash +cat global.json && dotnet sln list 2>/dev/null || grep -c '/dev/null | head +``` + +**CS-25 · The README documents the sibling layout, the regen commands and the baseline step.** +```bash +grep -n 'generate.sh\|advance-state.sh\|\.\./' README.md | head +``` + +## Where to go next + +- [`component-repos.md`](component-repos.md): the language-neutral rules, the commit policy and the baseline step in full +- [`add-component.md`](add-component.md): the seeded host for a new C# repo +- [`Project_File.md`](../handbooks/Project_File.md): `destination`, `basePackage`, `cleanGenerated`, `tenancy`, the C# artifact catalogue +- [`templates/component-contract/csharp/`](../templates/component-contract/csharp/): the starter contract, its mutation test and the expectations skeleton +- [`csharp-component.html`](csharp-component.html): this guide as an interactive map, with the checklist you can tick off and copy diff --git a/docs/csharp-component.html b/docs/csharp-component.html new file mode 100644 index 0000000..e1b480a --- /dev/null +++ b/docs/csharp-component.html @@ -0,0 +1,655 @@ + + + + + + +C# Component Map + + + + + + + +
+
+
+

C# Component Map

+

How a C# repo receives Specfuse-generated code and stays sound across regens. The written guide is csharp-component-guide.md; this page is the same material as a map. Click anything.

+
+
+ generated, rewritten on every regen + yours, in src/ + seeded once, then yours + what breaks +
+
+ +
+ +
+
+ +
Project names follow the kit's bundled example, hello-orders. Substitute your own root namespace. Checklist ticks stay in this browser only.
+
+ + + + + + diff --git a/scripts/check-csharp-page.py b/scripts/check-csharp-page.py new file mode 100644 index 0000000..445d96c --- /dev/null +++ b/scripts/check-csharp-page.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +# +# Copyright 2026 Specfuse Contributors +# Licensed under the Apache License, Version 2.0. See LICENSE. +# +"""Fail when docs/csharp-component.html drifts from what it describes. + +The C# component map keeps its facts in one JSON block (#guide-data). The +Markdown guide and the starter contract are the sources of truth, so this +check compares the block with them and fails CI instead of shipping a page +that lists a check the guide no longer has, or a rule the contract no longer +ships: + + - checklist IDs the page's checks == the `**CS-nn ·` checks in + docs/csharp-component-guide.md, same order + - contract rule IDs the page's rules == the `id:` entries of + templates/component-contract/csharp/gen-contract.yml, + and the guide's §6 table names every block + - guide anchors every `#anchor` the page links into the guide is a + heading the guide has (GitHub slug rules) + - relative links every relative href on the page resolves from docs/ + - views the nav data and the renderers agree + - seams and patterns every `check` they cite is a checklist ID + +Usage: python3 scripts/check-csharp-page.py (exit 1 on drift) +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PAGE = ROOT / "docs" / "csharp-component.html" +GUIDE = ROOT / "docs" / "csharp-component-guide.md" +CONTRACT = ROOT / "templates" / "component-contract" / "csharp" / "gen-contract.yml" + +errors: list[str] = [] + + +def check(cond: bool, msg: str) -> None: + if not cond: + errors.append(msg) + + +def slug(heading: str) -> str: + """GitHub's heading-to-anchor rule: lowercase, drop punctuation, spaces to dashes.""" + text = re.sub(r"`([^`]*)`", r"\1", heading).strip() + text = text.lower() + text = re.sub(r"[^\w\- ]", "", text) + return text.replace(" ", "-") + + +html = PAGE.read_text(encoding="utf-8") +m = re.search(r'', html, re.S) +if not m: + sys.exit("check-csharp-page: no #guide-data block in docs/csharp-component.html") +data = json.loads(m.group(1)) + +guide = GUIDE.read_text(encoding="utf-8") +contract = CONTRACT.read_text(encoding="utf-8") + +# Checklist IDs, in order. +guide_checks = re.findall(r"^\*\*(CS-\d{2}) ·", guide, re.M) +page_checks = [c["id"] for c in data["checks"]] +check(guide_checks == page_checks, + f"checklist: page has {page_checks}, guide has {guide_checks}") +check(len(set(page_checks)) == len(page_checks), "checklist: duplicate IDs on the page") + +# Contract rule IDs. +contract_rules = re.findall(r"^\s*- id: (GEN-\d{3})", contract, re.M) +page_rules = [r["id"] for r in data["rules"]] +check(contract_rules == page_rules, + f"rules: page has {page_rules}, contract has {contract_rules}") +ranges = [(int(a), int(b)) for a, b in re.findall(r"`GEN-(\d{3})` to `GEN-(\d{3})`", guide)] +for rid in contract_rules: + n = int(rid[4:]) + named = f"`{rid}`" in guide or any(a <= n <= b for a, b in ranges) + check(named, f"rules: {rid} is in the contract but the guide never names it (singly or in a range)") + +# Guide anchors the page links into. +headings = re.findall(r"^#{1,6} (.+)$", guide, re.M) +anchors = {slug(h) for h in headings} +anchor_refs = {c["guide"] for c in data["checks"]} | {t["guide"] for t in data["tree"]} +anchor_refs |= set(re.findall(r'guideLink\("(#[^"]+)"', html)) +for a in sorted(anchor_refs): + check(a.lstrip("#") in anchors, f"anchor: the page links {a} but the guide has no such heading") + +# Relative links resolve from docs/. +for href in set(re.findall(r'href="([^"#][^"]*)"', html)): + if re.match(r"https?:", href) or "${" in href: # a JS template, not a link + continue + target = (PAGE.parent / href.split("#")[0]).resolve() + check(target.exists(), f"link: {href} does not resolve from docs/") + +# Views: nav data and the render table agree. +view_ids = [v["id"] for v in data["views"]] +rendered = re.findall(r"const v = \{ (.*?) \}\[ui\.view\]", html) +check(bool(rendered), "views: cannot find the render table") +if rendered: + keys = re.findall(r"(\w+): view", rendered[0]) + check(set(keys) == set(view_ids), f"views: nav has {view_ids}, renderers have {keys}") + +# Seams and patterns cite real checks. +for s in data["seams"]: + check(s["check"] in page_checks, f"seam {s['id']}: cites unknown check {s['check']}") +for p in data["patterns"]: + check(p["check"] in page_checks, f"pattern {p['id']}: cites unknown check {p['check']}") + +# The prompt on the page is the prompt in the guide. +prompt_in_guide = re.search(r"```\n(Read docs/csharp-component-guide\.md.*?)```", guide, re.S) +check(prompt_in_guide is not None and prompt_in_guide.group(1).strip() == data["prompt"].strip(), + "prompt: the page's Claude prompt differs from the guide's") + +if errors: + print("check-csharp-page: drift between docs/csharp-component.html and its sources:") + for e in errors: + print(f" - {e}") + sys.exit(1) +print(f"check-csharp-page: OK ({len(page_checks)} checks, {len(page_rules)} rules, {len(anchor_refs)} anchors)") diff --git a/scripts/test-csharp-contract-template.sh b/scripts/test-csharp-contract-template.sh new file mode 100755 index 0000000..aef7f81 --- /dev/null +++ b/scripts/test-csharp-contract-template.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# +# Copyright 2026 Specfuse Contributors +# Licensed under the Apache License, Version 2.0. See LICENSE. +# +# Prove the C# starter contract (templates/component-contract/csharp/) in both +# directions: seeded into a minimal component tree shaped like the one +# docs/csharp-component-guide.md describes, every rule is green; and the +# template's own mutation test makes every rule fail on cue. +# +# The synthetic tree carries the layout the guide prescribes and nothing else: +# Directory.Build.props, three layered .csproj files, one generated file per +# layer with the generator's markers, a generated handler base with its +# hand-written subclass, and a generated application-service base with its +# subclass. No .NET SDK is needed; the contract reads files, not builds. +# +# Needs the engine: specfuse-loop 0.30+ importable by python3 (or by the +# interpreter named in SPECFUSE_PYTHON). +# +# ./scripts/test-csharp-contract-template.sh +# +# Exit 0 when the seed is green and every mutation case passes. + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +KIT="$ROOT/templates/component-contract/csharp" +PY="${SPECFUSE_PYTHON:-python3}" +PREFIX=HelloOrders + +if ! "$PY" -c "import specfuse.loop.gen_contract" >/dev/null 2>&1; then + echo "test-csharp-contract-template: specfuse.loop.gen_contract is not importable by $PY" >&2 + echo " python3 -m pip install 'specfuse-loop>=0.30' (or set SPECFUSE_PYTHON)" >&2 + exit 2 +fi + +WORK="$(mktemp -d "${TMPDIR:-/tmp}/csharp-contract.XXXXXX")" +trap 'rm -rf "$WORK"' EXIT +cd "$WORK" +git init -q -b main . +git config user.email ci@example.invalid +git config user.name ci + +# ── Seed the contract exactly as the template README says ────────────────── +cp "$KIT/gen-contract.yml" . +mkdir -p scripts docs .github/workflows +cp "$KIT/scripts/"* scripts/ +cp "$KIT/docs/generated-code-expectations.md" docs/ +cp "$KIT/.github/workflows/gen-contract.yml" .github/workflows/ +sed -i.bak "s/__Prefix__/$PREFIX/g" gen-contract.yml docs/generated-code-expectations.md +rm -f gen-contract.yml.bak docs/generated-code-expectations.md.bak +chmod +x scripts/check-gen-contract.sh scripts/test-gen-contract.sh + +# ── A minimal component tree ─────────────────────────────────────────────── +cat > Directory.Build.props <<'EOF' + + + false + + + + + + +EOF + +cat > .gitignore <<'EOF' +bin/ +obj/ +*.generated-paths.json +EOF + +header() { + cat <<'EOF' +#nullable enable + +// +// Generated = true +// Generator = specfuse-generator +// +/* + * Generated by the Specfuse Generator (https://github.com/specfuse/generator) + */ +EOF +} + +mkdir -p "$PREFIX.Domain/gen-src/Entities" "$PREFIX.Domain/src/Entities" \ + "$PREFIX.Infrastructure/gen-src/Repositories" "$PREFIX.Infrastructure/src" \ + "$PREFIX.Api/gen-src/Controllers" "$PREFIX.Api/gen-src/Application/Interfaces" "$PREFIX.Api/src/Application" \ + "$PREFIX.Application/gen-src/Orders/Handlers" "$PREFIX.Application/src/Orders/Handlers" + +cat > "$PREFIX.Domain/$PREFIX.Domain.csproj" <<'EOF' + + +EOF +cat > "$PREFIX.Infrastructure/$PREFIX.Infrastructure.csproj" < + + + +
+EOF +cat > "$PREFIX.Api/$PREFIX.Api.csproj" < + + + + + +EOF + +{ header; printf 'namespace %s.Domain.Entities;\n\npublic partial class Order { public Guid Id { get; set; } }\n' "$PREFIX"; } \ + > "$PREFIX.Domain/gen-src/Entities/Order.g.cs" +{ header; printf 'using %s.Domain.Entities;\n\nnamespace %s.Infrastructure.Repositories;\n\npublic class OrderRepository { }\n' "$PREFIX" "$PREFIX"; } \ + > "$PREFIX.Infrastructure/gen-src/Repositories/OrderRepository.g.cs" +{ header; printf 'using %s.Domain.Entities;\n\nnamespace %s.Api.Controllers;\n\npublic class OrdersApi { }\n' "$PREFIX" "$PREFIX"; } \ + > "$PREFIX.Api/gen-src/Controllers/OrdersApi.g.cs" +{ header; printf 'namespace %s.Api.Application.Interfaces;\n\npublic abstract class OrdersApplicationServiceBase { }\n' "$PREFIX"; } \ + > "$PREFIX.Api/gen-src/Application/Interfaces/OrdersApplicationServiceBase.g.cs" +{ header; printf 'namespace %s.Application.Orders.Handlers;\n\npublic interface IOrderPlacedHandler { }\npublic abstract class OrderPlacedHandlerBase : IOrderPlacedHandler { }\n' "$PREFIX"; } \ + > "$PREFIX.Application/gen-src/Orders/Handlers/OrderPlacedHandlerBase.g.cs" + +printf 'namespace %s.Domain.Entities;\n\npublic partial class Order { public bool IsDraft => true; }\n' "$PREFIX" \ + > "$PREFIX.Domain/src/Entities/Order.cs" +printf 'namespace %s.Api.Application;\n\npublic sealed class OrdersApplicationService : OrdersApplicationServiceBase { }\n' "$PREFIX" \ + > "$PREFIX.Api/src/Application/OrdersApplicationService.cs" +printf 'namespace %s.Application.Orders.Handlers;\n\npublic sealed class OrderPlacedHandler(int x) : OrderPlacedHandlerBase { }\n' "$PREFIX" \ + > "$PREFIX.Application/src/Orders/Handlers/OrderPlacedHandler.cs" + +git add -A +git commit -q -m "seed" + +echo "── contract on the seeded tree" +./scripts/check-gen-contract.sh --quiet +echo "── every rule has a mutation case that bites" +status=0 +out="$(./scripts/test-gen-contract.sh)" || status=$? +printf '%s\n' "$out" +if [ "$status" -ne 0 ] || ! grep -q ' 0 missed, 0 skipped' <<<"$out"; then + echo "FAIL: a rule did not bite, or a case was skipped on the synthetic tree." >&2 + exit 1 +fi +echo "OK: the C# starter contract is green on a fresh seed and every rule bites." diff --git a/templates/component-contract/csharp/.github/workflows/gen-contract.yml b/templates/component-contract/csharp/.github/workflows/gen-contract.yml new file mode 100644 index 0000000..95982d7 --- /dev/null +++ b/templates/component-contract/csharp/.github/workflows/gen-contract.yml @@ -0,0 +1,38 @@ +# Generated-code contract for a C# Specfuse component repo. +# +# Runs the structural contract (gen-contract.yml) on every pull request, before +# any .NET toolchain is set up, so a marker, layering or extension-point breach +# fails in under a minute instead of after the build. The mutation test runs +# too: a rule that cannot fail is not a gate. +# +# Seeded from the Specfuse authoring kit and owned by this repo. Put the +# `dotnet build` / `dotnet test` job beside it or in its own workflow; this one +# deliberately needs no SDK. + +name: gen-contract + +on: + pull_request: + push: + branches: [main] + +jobs: + gen-contract: + name: gen-contract + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + + # The engine ships in specfuse-loop (specfuse.loop.gen_contract). + - name: Install the engine + run: python -m pip install --quiet 'specfuse-loop>=0.30' + + - name: Generated-code contract + run: ./scripts/check-gen-contract.sh + + - name: Prove the rules bite + run: ./scripts/test-gen-contract.sh diff --git a/templates/component-contract/csharp/README.md b/templates/component-contract/csharp/README.md new file mode 100644 index 0000000..0b9dec1 --- /dev/null +++ b/templates/component-contract/csharp/README.md @@ -0,0 +1,59 @@ +# Starter generated-code contract for a C# component repo + +The files here are the mechanical half of a C# component's generated-code bar, +ready to seed into a repo that receives Specfuse-generated C#. They are seeded +**once** and owned by the component from then on; the kit never rewrites them +(the decision record is [specfuse/authoring#171](https://github.com/specfuse/authoring/issues/171), +and `add-component` will seed them automatically once +[#181](https://github.com/specfuse/authoring/issues/181) lands). + +| File | Lands at | What it is | +|---|---|---| +| `gen-contract.yml` | repo root | 16 starter rules: markers, layering, host wiring, extension-point adoption. Every rule green on a fresh seed. | +| `scripts/gen_contract_checks.py` | `scripts/` | The one project-specific check (`generated_base_adopted`) the generic engine cannot express. | +| `scripts/check-gen-contract.sh` | `scripts/` | Runs the contract through the shared engine (`specfuse.loop.gen_contract`, specfuse-loop 0.30+). | +| `scripts/test-gen-contract.sh` | `scripts/` | Mutation test: breaks the tree one rule at a time and asserts the rule reports it. | +| `docs/generated-code-expectations.md` | `docs/` | The prose half, with the five section names the generator's QA reviewer reads. | +| `verification.snippet.yml` | merge into `.specfuse/verification.yml` | The `code` gate set: contract, build, tests. | +| `.github/workflows/gen-contract.yml` | `.github/workflows/` | CI: contract plus mutation test on every PR, no .NET SDK needed. | + +## Seeding by hand + +From the component repo root, with `` the solution's root namespace +(`HelloOrders` for `HelloOrders.Domain` / `.Infrastructure` / `.Api`): + +```bash +KIT=/path/to/authoring/templates/component-contract/csharp +cp "$KIT/gen-contract.yml" . +mkdir -p scripts docs .github/workflows +cp "$KIT/scripts/"* scripts/ +cp "$KIT/docs/generated-code-expectations.md" docs/ +cp "$KIT/.github/workflows/gen-contract.yml" .github/workflows/ +sed -i.bak 's/__Prefix__/HelloOrders/g' gen-contract.yml docs/generated-code-expectations.md && rm -f *.bak docs/*.bak +chmod +x scripts/check-gen-contract.sh scripts/test-gen-contract.sh +python3 -m pip install 'specfuse-loop>=0.30' # or: pipx install specfuse +./scripts/check-gen-contract.sh # must be green +./scripts/test-gen-contract.sh # every rule must bite +``` + +Then paste the `code:` block of `verification.snippet.yml` into +`.specfuse/verification.yml`. The full walkthrough, with what each rule +protects and how to extend the contract, is +[`docs/csharp-component-guide.md`](../../../docs/csharp-component-guide.md) +§6. + +## Extending the contract + +Add a rule when a review finding recurs. The bar for a new rule: + +1. Green on the default branch the day it lands, or shipped with a + `waived_until:` block that freezes today's count and names the event that + ends it. +2. A case in `scripts/test-gen-contract.sh` proving it bites. +3. A `why:` that says what breaks in production, not what a style guide says. + The `why` is printed with every violation; it is what makes a finding + actionable without a conversation. + +The engine's own reference (check kinds, `waived_until`, `allow_empty`, the +`builtins:` module) is the module docstring: +`python3 -c "import specfuse.loop.gen_contract as m; print(m.__doc__)"`. diff --git a/templates/component-contract/csharp/docs/generated-code-expectations.md b/templates/component-contract/csharp/docs/generated-code-expectations.md new file mode 100644 index 0000000..11cdc7e --- /dev/null +++ b/templates/component-contract/csharp/docs/generated-code-expectations.md @@ -0,0 +1,111 @@ +# Generated-code expectations — __Prefix__ (C#) + +The semantic bar this repository holds Specfuse-generated code to. Two +readers: a person reviewing a regen PR, and the generator's consumer-QA +reviewer, which reads this file from inside a clone of this repo and treats +the five section names below as a stable interface +([`CONSUMER-CONTRACT.md`](https://github.com/clabonte/generator/blob/main/docs/CONSUMER-CONTRACT.md)). +Keep the headings; add what you like under them. + +`gen-contract.yml` is the mechanical bar: rule IDs, `file:line`, identical on +every run. This file is what "acceptable" means beyond it. When a review +finding here turns out to be mechanically checkable, the durable fix is a new +rule in `gen-contract.yml`, not a longer list in this file. + +## Markers + +Every generated file is a `.g.cs` under a `gen-src/` folder, opens with +`#nullable enable`, and carries the `` header block with the +`Generated by the Specfuse Generator` line. That header is what excludes the +file from analyzers and formatters; the folder and the extension are what the +regen's clean step keys on. Enforced by `GEN-001` to `GEN-005`. + +## Layering + +`__Prefix__.Domain` depends on nothing in the solution. `__Prefix__.Infrastructure` +depends on Domain. `__Prefix__.Api` depends on Domain and references +Infrastructure only to wire it up in `Program.cs`. Enforced over generated +`using` lines and over the project references by `GEN-010` to `GEN-014`. + +## Build wiring + +`Directory.Build.props` turns default globbing off and compiles exactly +`src/**/*.cs` and `gen-src/**/*.cs` in every project. The generator's +`*.generated-paths.json` manifests are git-ignored. Enforced by `GEN-020` +and `GEN-021`. + +## Extension points + +Hand-written code extends generated code three ways, all in `src/`: +`partial` classes in the generated type's namespace, concrete classes derived +from a generated `*Base`, and the `partial` mapper-hook methods. A generated +file is never copied into `src/` and edited (`GEN-030`). A generated base is +always adopted: a handler or job interface is never implemented directly, and +a gated application service always derives from its +`{Tag}ApplicationServiceBase` (`GEN-040` to `GEN-042`). + +## Integration contract + +Hand-written code depends on these generated surfaces. A rename or removal +here breaks the app even when the generated tree itself compiles: + +- Concrete handlers, jobs and application services derive from generated + `*Base` classes and interfaces; DI registration resolves them by the + generated interface, never by a concrete type the generator does not emit. +- Hand-owned EF migrations are derived from the generated + `EntityTypeConfiguration` classes: a column rename that arrives without + `x-legacy-names` in the spec silently orphans persisted data. +- Hand-written tests consume the generated fakes and builders (`A.Order()`, + `TestSeed.OrderId`); a builder surface change goes through the generator's + one-cycle deprecation shim, never disappears outright. +- Generated controllers call `UserHelper.GetUser(User)`; generated functional + tests derive from `ApiTestBase` and call `CreateHttpClient(role)` and + `EnsureFakeData()`. Those types are hand-written here and must keep those + members. + +## Standing expectations + +- A fresh `dotnet build` succeeds on regenerated output with zero edits. +- Entity and EF configuration agree: every property a configuration + references exists on the entity, and every mapped column has a property. +- Navigations are configured on both sides with matching shapes, never twice + with conflicting ones. +- Builder defaults survive a real insert: no required field defaulted to + null, no string default longer than its declared max length. +- The generated authorization tests reflect the spec's `x-roles` exactly. + +## Known failure modes — re-check each regen + + + +- (none yet) + +## Accepted quirks — do not re-report + +- Per-package `CHANGELOG.md` files inflate the regen diff; that is their job. +- `_Deprecated` builder shims linger exactly one regen cycle by design. + +## Do not report — the gate already decides these + +Run `./scripts/check-gen-contract.sh --list` first. These are settled by rule +ID and `file:line`, identically on every run; a review finding that duplicates +one is noise. + +### Do-not-report table + +| Do not review | Rule | +|---|---| +| `.g.cs` extension, gen-src placement, foreign files under gen-src | `GEN-001`, `GEN-004`, `GEN-005` | +| `#nullable enable` present | `GEN-002` | +| auto-generated header block | `GEN-003` | +| Clean Architecture layering in generated code and project references | `GEN-010` to `GEN-014` | +| `Directory.Build.props` compile roots, ignored manifests | `GEN-020`, `GEN-021` | +| generated header in a hand-written file | `GEN-030` | +| unadopted `*Base` classes, direct handler-interface implementations | `GEN-040` to `GEN-042` | + +What remains is the judgement: does the regen do what was asked, and does it +break something no rule states yet. Removals are the highest-risk content of +a regen and nothing mechanical catches them: a generated file, type or member +that disappeared needs its hand-written call sites listed, or an empty list +after actually looking. diff --git a/templates/component-contract/csharp/gen-contract.yml b/templates/component-contract/csharp/gen-contract.yml new file mode 100644 index 0000000..d80cad5 --- /dev/null +++ b/templates/component-contract/csharp/gen-contract.yml @@ -0,0 +1,337 @@ +# gen-contract.yml — the machine-checkable half of this repo's generated-code bar. +# +# Seeded from the Specfuse authoring kit (templates/component-contract/csharp/) +# and owned by this repo from then on: the rules are yours, the engine is +# `specfuse.loop.gen_contract` (specfuse-loop 0.30+). Every rule below is an +# assertion with a rule ID, a `file:line` verdict and a `why` that says what +# breaks when it is violated, so an agent can act on a finding without a +# conversation and two runs over the same tree always agree. +# +# ./scripts/check-gen-contract.sh # every rule +# ./scripts/check-gen-contract.sh --list # what is enforced +# ./scripts/check-gen-contract.sh --explain GEN-004 +# ./scripts/check-gen-contract.sh --json out.json +# ./scripts/test-gen-contract.sh # mutation test: prove the rules bite +# +# Rule ID blocks: +# GEN-00x markers and file layout +# GEN-01x layer dependencies (generated code, and the project references) +# GEN-02x host wiring (the files the generator never writes) +# GEN-03x hand-written code holds up its end +# GEN-04x generated extension points are adopted +# +# Adding a rule: it must be green on the default branch the day it lands. If +# the tree already violates it, ship it with a `waived_until:` block (dated, +# with the event that ends it) or as a `ratchet`, never red. Give it a +# mutation case in scripts/test-gen-contract.sh in the same change. +# +# `__Prefix__` is the solution's root namespace (the `basePackage` of the +# C# groups minus the layer suffix): HelloOrders for HelloOrders.Domain, +# HelloOrders.Infrastructure, HelloOrders.Api. Replace it once when seeding. +# +# The engine reads this file with the loop's own strict YAML subset: block +# style only, no anchors, no flow mappings. Prose goes in folded scalars (>), +# regexes in single quotes. + +version: 1 +builtins: scripts/gen_contract_checks.py + +filesets: + gen_src: + include: + - "*/gen-src/**/*.cs" + # Everything under gen-src/ that is NOT C#. Disjoint from `gen_src`, so + # GEN-001 and GEN-005 never both fire on the same file. A group that emits a + # non-C# file into gen-src/ by design (a worker manifest, for instance) + # names it in `exclude` here with a comment saying which artifact writes it. + gen_src_non_cs: + include: + - "*/gen-src/**" + exclude: + - "*/gen-src/**/*.cs" + all_cs: + include: + - "**/*.cs" + # Every hand-written C# file in the solution. + src: + include: + - "*/src/**/*.cs" + # Hand-written production code: every project's src/ except the test + # projects. A test double standing in for a generated contract is not a + # defect, so the extension-point rules read this set. + production_src: + include: + - "*/src/**/*.cs" + exclude: + - "*.Test/**" + - "*.Tests/**" + domain_gen: + include: + - "__Prefix__.Domain/gen-src/**/*.cs" + infrastructure_gen: + include: + - "__Prefix__.Infrastructure/gen-src/**/*.cs" + api_gen: + include: + - "__Prefix__.Api/gen-src/**/*.cs" + domain_csproj: + include: + - "__Prefix__.Domain/*.csproj" + infrastructure_csproj: + include: + - "__Prefix__.Infrastructure/*.csproj" + build_props: + include: + - "Directory.Build.props" + gitignore: + include: + - ".gitignore" + +rules: + + # ── Markers and file layout ──────────────────────────────────────────── + # The generator stamps every C# file three ways: the `.g.cs` extension, + # `#nullable enable` on line 1, and the `` header block. + # The header is what opts the file out of analyzers and formatters; the + # extension and the folder are what the next regen's clean step keys on. + + - id: GEN-001 + title: Every file under gen-src/ is a .g.cs file + severity: error + doc: docs/generated-code-expectations.md#markers + why: > + A generated file without the .g.cs extension is indistinguishable from + hand-written code, so the next regen either overwrites developer work or + leaves a stale artifact behind that still compiles. + check: filename_matches + scope: gen_src + pattern: '\.g\.cs$' + + - id: GEN-002 + title: Every generated file opens with `#nullable enable` + severity: error + doc: docs/generated-code-expectations.md#markers + why: > + Generated code takes part in the solution's nullable contract. A file + missing the pragma silently downgrades every reference type in it to + oblivious, and the CS86xx warnings that would have caught a bad + annotation never fire. + check: first_line_equals + scope: gen_src + value: "#nullable enable" + + - id: GEN-003 + title: Every generated file carries the auto-generated header block + severity: error + doc: docs/generated-code-expectations.md#markers + why: > + `// ` is what excludes the file from analyzers and + formatters. Without it a regen produces thousands of style findings that + bury the real ones, and a formatter "fixes" code the next regen rewrites. + check: require_pattern + scope: gen_src + patterns: + - '// + A .g.cs outside gen-src/ is a stale artifact from an earlier generator + layout or a file someone moved. It still compiles, so it shadows or + duplicates the current output instead of failing loudly, and no regen + will ever clean it up. + check: forbid_paths + scope: all_cs + pattern: '\.g\.cs$' + unless_path: '/gen-src/' + + - id: GEN-005 + title: Nothing but generated C# lives under gen-src/ + severity: error + doc: docs/generated-code-expectations.md#markers + why: > + gen-src/ is wiped and re-emitted on every full-group regen, so anything + parked there is lost without warning: a README, a stray .editorconfig + (which would re-enable analyzers on the generated tree), leftover .json + or .bak files. Notes belong in docs/, extensions in src/. + check: forbid_paths + scope: gen_src_non_cs + pattern: '.' + + # ── Layer dependencies ───────────────────────────────────────────────── + # Domain depends on nothing in the solution. Infrastructure implements + # Domain's interfaces. Api talks to Domain and references Infrastructure + # only to wire it up in Program.cs. Two views of the same rule: the + # `using` lines the generator emits, and the ProjectReference edges you own. + + - id: GEN-010 + title: Domain gen-src depends on no other layer + severity: error + doc: docs/generated-code-expectations.md#layering + why: > + Domain is the dependency sink. An inbound using from Infrastructure or + Api inverts the architecture and makes the domain untestable without a + database. In generated code it is a generator defect: report it rather + than work around it. + check: forbid_pattern + scope: domain_gen + pattern: '^using __Prefix__\.(Infrastructure|Application|Api)\b' + + - id: GEN-011 + title: Infrastructure gen-src depends on Domain only + severity: error + doc: docs/generated-code-expectations.md#layering + why: > + Infrastructure implements Domain interfaces. A using of Api makes the + persistence layer depend on its own callers. + check: forbid_pattern + scope: infrastructure_gen + pattern: '^using __Prefix__\.Api\b' + + - id: GEN-012 + title: Api gen-src never references Infrastructure directly + severity: error + doc: docs/generated-code-expectations.md#layering + why: > + Controllers reach persistence through Domain interfaces resolved by DI. + A direct Infrastructure using in generated code would make every + controller untestable without a real DbContext. + check: forbid_pattern + scope: api_gen + pattern: '^using __Prefix__\.Infrastructure\b' + + - id: GEN-013 + title: The Domain project references no other project + severity: error + doc: docs/generated-code-expectations.md#layering + why: > + A ProjectReference is the compile-time form of GEN-010. With one in + place the using-line rule can be satisfied while the dependency exists + anyway, through a fully-qualified name. + check: forbid_pattern + scope: domain_csproj + pattern: ' + Api is the composition root and already references Infrastructure. The + reverse edge is a cycle MSBuild refuses, or, split across assemblies, a + persistence layer that cannot be built or tested without the web host. + check: forbid_pattern + scope: infrastructure_csproj + pattern: ' + With default globbing on, a .cs file anywhere in a project folder joins + the build, so a file parked beside gen-src/ compiles and shadows the + generated one. With one of the two Compile globs missing, half the + solution silently stops compiling and the build goes red on every type + it defined. + check: require_pattern + scope: build_props + patterns: + - 'false' + - 'Compile Include="src\\\*\*\\\*\.cs"' + - 'Compile Include="gen-src\\\*\*\\\*\.cs"' + + - id: GEN-021 + title: The generator's bookkeeping manifests are git-ignored + severity: error + doc: docs/generated-code-expectations.md#build-wiring + why: > + The generator writes a `.generated-paths.json` beside each + gen-src/ on every run, stamped with a timestamp. Tracked, it dirties + the tree on every regen and turns every component PR into a diff of + noise that hides the generated change. + check: require_pattern + scope: gitignore + patterns: + - '^\*\.generated-paths\.json$' + + # ── Hand-written code holds up its end ───────────────────────────────── + + - id: GEN-030 + title: No hand-written file carries the Specfuse generator header + severity: error + doc: docs/generated-code-expectations.md#extension-points + why: > + A generated file copied into src/ and edited is the hand edit the + gen-src/ rule exists to forbid, moved one folder over. It compiles + beside the real one until the next regen renames something, and then + the copy is the stale one that still wins. The match is the generator's + own header line, not the bare `` marker: EF Core + migrations and designer files carry that marker legitimately. + check: forbid_pattern + scope: src + pattern: 'Generated by the Specfuse Generator' + + # ── Generated extension points are adopted ───────────────────────────── + # The generator emits an abstract `*Base` plus an interface for every event + # handler, scheduled job and gated application service; the concrete class + # is yours, in src/. Implementing the interface directly compiles, resolves + # through DI and runs, with none of the base's contract in effect (inbox + # dedup, transaction scope, timeout, the If-Match precondition), and nothing + # reports it. These rules make that silent drop a red gate. + + - id: GEN-040 + title: Every generated handler and job base has a concrete class deriving from it + severity: error + doc: docs/generated-code-expectations.md#extension-points + why: > + A `{X}HandlerBase` with no subclass is a handler the spec declared and + nobody wrote. The generated trigger still subscribes to the label, so + every delivery dead-letters until someone notices the queue. + check: builtin + name: generated_base_adopted + base_glob: "*/gen-src/**/*HandlerBase.g.cs" + consumer_glob: "*/src/**/*.cs" + allow_empty: true + + - id: GEN-041 + title: Every generated application-service base has a concrete class deriving from it + severity: error + doc: docs/generated-code-expectations.md#extension-points + why: > + The If-Match precondition lives in a non-virtual method on + `{Tag}ApplicationServiceBase` that delegates to a `...Core` override. A + concrete service that implements the interface without the base compiles + clean and silently drops the precondition: every concurrent write + becomes a blind overwrite. + check: builtin + name: generated_base_adopted + base_glob: "*/gen-src/**/*ApplicationServiceBase.g.cs" + consumer_glob: "*/src/**/*.cs" + allow_empty: true + + - id: GEN-042 + title: No production class implements a generated handler interface without its base + severity: error + doc: docs/generated-code-expectations.md#extension-points + why: > + `class X : IOrderPlacedHandler` compiles, is picked up by the generated + assembly scan and is invoked by the trigger exactly as if it derived + from `OrderPlacedHandlerBase`, with none of the base's scaffolding. The + supported shape is `class X(...) : OrderPlacedHandlerBase(...)`, which + already implements the interface. Only interfaces the generator emitted + count, and only in production code: a test double implementing one is + what a test is for. + check: builtin + name: generated_handler_interface_implemented_directly + scope: production_src + interface_glob: "*/gen-src/**/*.g.cs" + allow_empty: true diff --git a/templates/component-contract/csharp/scripts/check-gen-contract.sh b/templates/component-contract/csharp/scripts/check-gen-contract.sh new file mode 100755 index 0000000..ec265b1 --- /dev/null +++ b/templates/component-contract/csharp/scripts/check-gen-contract.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Run this repo's generated-code contract (gen-contract.yml) through the shared +# engine, the same way from a shell, from .specfuse/verification.yml and from CI. +# +# ./scripts/check-gen-contract.sh # every rule +# ./scripts/check-gen-contract.sh --list # what is enforced +# ./scripts/check-gen-contract.sh --explain GEN-004 +# ./scripts/check-gen-contract.sh --only GEN-001 GEN-004 +# ./scripts/check-gen-contract.sh --json findings.json +# +# Exit 0 clean, 1 violations, 2 the checker could not run. +# +# The engine is `specfuse.loop.gen_contract`, shipped in specfuse-loop 0.30+ +# (`pipx install specfuse` brings it). Until the umbrella CLI dispatches +# `specfuse gen-contract`, the module is run directly. Set SPECFUSE_PYTHON to +# the interpreter that has specfuse-loop installed when it is not `python3`. + +set -uo pipefail +cd "$(git rev-parse --show-toplevel)" + +PY="${SPECFUSE_PYTHON:-python3}" + +if command -v specfuse >/dev/null 2>&1 && specfuse gen-contract --help >/dev/null 2>&1; then + exec specfuse gen-contract "$@" +fi + +if ! command -v "$PY" >/dev/null 2>&1; then + echo "check-gen-contract: $PY not found" >&2 + exit 2 +fi + +if ! "$PY" -c "import specfuse.loop.gen_contract" >/dev/null 2>&1; then + cat >&2 <=0.30' + Or point SPECFUSE_PYTHON at an interpreter that has it (pipx: ~/.local/pipx/venvs/specfuse/bin/python). +EOF + exit 2 +fi + +exec "$PY" -m specfuse.loop.gen_contract "$@" diff --git a/templates/component-contract/csharp/scripts/gen_contract_checks.py b/templates/component-contract/csharp/scripts/gen_contract_checks.py new file mode 100644 index 0000000..1eb203d --- /dev/null +++ b/templates/component-contract/csharp/scripts/gen_contract_checks.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Project-specific checks for gen-contract.yml (`builtins:`). + +Seeded from the Specfuse authoring kit and owned by this repo. The shared +engine (`specfuse.loop.gen_contract`) runs the generic checks; a rule with +`check: builtin` and `name: ` resolves here. Every function has the +signature + + fn(root, rule, scope, filesets, files) -> list[Finding] + +and may use `Finding`, `Scope` and `read` from the engine. Keep each check +small and deterministic: the same tree must always produce the same findings. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from specfuse.loop.gen_contract import Finding, Scope, read + + +def generated_base_adopted(root, rule, _scope, filesets, files): + """Every generated `*Base.g.cs` matched by `base_glob` is derived from by + some file matched by `consumer_glob`. + + The generator emits the extension point (an abstract base plus an + interface); the concrete class is the consumer's. A base nobody derives + from is a declared handler, job or gated service that does not exist at + runtime, and the build cannot see it. The rule needs two globs: + + base_glob: "*/gen-src/**/*HandlerBase.g.cs" + consumer_glob: "*/src/**/*.cs" + + A base is adopted when a consumer file names it in a base list, as + `: XBase` or `, XBase`, with or without a primary-constructor argument + list after it. + """ + bases = Scope({"include": [rule["base_glob"]]}, filesets, files).paths + consumers = Scope({"include": [rule["consumer_glob"]]}, filesets, files).paths + texts = [read(root, p) for p in consumers] + out: list[Finding] = [] + for base in bases: + name = Path(base).name.removesuffix(".g.cs") + derived = re.compile(rf"[:,]\s*{re.escape(name)}\b") + if not any(derived.search(t) for t in texts): + out.append(Finding(rule["id"], rule["severity"], base, 0, + f"no type under {rule['consumer_glob']} derives from {name}; " + f"the extension point the generator emitted is unused")) + return out + + +_HANDLER_INTERFACE = re.compile(r"\binterface\s+(I\w+Handler)\b") +_CLASS_DECL = re.compile( + r"^\s*(?:(?:public|internal|private|protected|sealed|abstract|partial|static)\s+)*" + r"class\s+(?P\w+)\s*(?:<[^>]*>)?\s*(?:\([^)]*\))?\s*:\s*(?P[^{\n]+)", + re.M) + + +def generated_handler_interface_implemented_directly(root, rule, scope, filesets, files): + """A class in scope lists a generated `I*Handler` interface in its base + list and no `*HandlerBase` beside it. + + The generator declares the interface and an abstract `{X}HandlerBase` + that implements it; the concrete class derives from the base. A class + that names the interface directly compiles, is found by the generated + assembly scan and runs with none of the base's scaffolding. Interfaces + are collected from the generated files matched by `interface_glob`, so a + hand-written `IFooHandler` is never flagged; `scope` decides which + hand-written files are judged (production code, not test doubles). + """ + gen = Scope({"include": [rule["interface_glob"]]}, filesets, files).paths + interfaces: set[str] = set() + for p in gen: + interfaces.update(_HANDLER_INTERFACE.findall(read(root, p))) + out: list[Finding] = [] + if not interfaces: + return out + for p in scope.paths: + text = read(root, p) + for m in _CLASS_DECL.finditer(text): + bases = [b.strip().split("(")[0].strip() for b in m.group("bases").split(",")] + hit = [b for b in bases if b in interfaces] + if hit and not any(b.endswith("HandlerBase") for b in bases): + out.append(Finding(rule["id"], rule["severity"], p, + text.count("\n", 0, m.start()) + 1, + f"{m.group('name')} implements {hit[0]} directly; derive from " + f"{hit[0][1:]}Base instead so the generated scaffolding applies")) + return out + + +BUILTINS = { + "generated_base_adopted": generated_base_adopted, + "generated_handler_interface_implemented_directly": generated_handler_interface_implemented_directly, +} diff --git a/templates/component-contract/csharp/scripts/test-gen-contract.sh b/templates/component-contract/csharp/scripts/test-gen-contract.sh new file mode 100755 index 0000000..e4fea2a --- /dev/null +++ b/templates/component-contract/csharp/scripts/test-gen-contract.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +# Mutation test for gen-contract.yml. +# +# A gate nobody can make fail is not a gate. This breaks the tree one rule at a +# time, asserts that the matching rule reports it, and restores the file. Every +# rule in the contract has a case here; a rule without one is only known to be +# green, not known to work. Add a case when you add a rule. +# +# ./scripts/test-gen-contract.sh +# +# Mutations are applied to tracked files and reverted with `git checkout --`. +# A case refuses to touch a file that already has uncommitted changes, so a +# revert can never eat work in progress. The rest of the worktree may be dirty. +# +# The cases locate a victim file by glob, so the script works on any solution +# the contract describes. A case whose victim does not exist yet (no handler +# base, say) is reported SKIP, not failed. + +set -uo pipefail +cd "$(git rev-parse --show-toplevel)" + +CHECK="./scripts/check-gen-contract.sh --quiet --only" +pass=0; fail=0; skip=0 +TOUCHED=() +CREATED=() + +cleanup() { + [ ${#TOUCHED[@]} -gt 0 ] && git checkout -- "${TOUCHED[@]}" 2>/dev/null + for f in ${CREATED[@]+"${CREATED[@]}"}; do + git rm -q --cached "$f" 2>/dev/null; rm -f "$f" + done + return 0 +} +trap cleanup EXIT + +# verdict — run the one rule and grade: exit 1 is the catch we +# want; exit 0 is a miss; exit 2 means the checker could not run (an unknown +# rule id, a broken contract) and is a failure too, never a silent pass. +verdict() { + local rule="$1" what="$2" rc=0 + $CHECK "$rule" >/dev/null 2>&1 || rc=$? + case "$rc" in + 1) echo " ok $rule"; pass=$((pass+1)) ;; + 0) echo " MISS $rule did not catch $what"; fail=$((fail+1)) ;; + *) echo " ERR $rule: the checker could not run (exit $rc) — is the rule in gen-contract.yml?"; fail=$((fail+1)) ;; + esac +} + +# first_tracked — the first tracked path matching the glob, or empty. +first_tracked() { git ls-files -- "$1" | head -1; } + +# expect_fail — mutate, assert the rule fails, revert. +expect_fail() { + local rule="$1" file="$2" prog="$3" + if [ -z "$file" ]; then + echo " SKIP $rule — no file to mutate"; skip=$((skip+1)); return + fi + if ! git diff --quiet -- "$file" || ! git diff --cached --quiet -- "$file"; then + echo " SKIP $rule — $file has uncommitted changes"; skip=$((skip+1)); return + fi + TOUCHED+=("$file") + sed -i.bak "$prog" "$file" && rm -f "$file.bak" + verdict "$rule" "its mutation in $file" + git checkout -- "$file" +} + +# expect_fail_newfile — add a file, assert the rule fails, remove it. +expect_fail_newfile() { + local rule="$1" path="$2" content="$3" + if [ -e "$path" ]; then + echo " SKIP $rule — $path already exists"; skip=$((skip+1)); return + fi + mkdir -p "$(dirname "$path")"; printf '%s\n' "$content" > "$path" + git add -N "$path" >/dev/null + CREATED+=("$path") + verdict "$rule" "new file $path" + git rm -q --cached "$path" 2>/dev/null; rm -f "$path" +} + +# expect_fail_delete — remove a file from the index, assert the rule fails, restore. +expect_fail_delete() { + local rule="$1" file="$2" + if [ -z "$file" ]; then + echo " SKIP $rule — no file to remove"; skip=$((skip+1)); return + fi + if ! git diff --quiet -- "$file" || ! git diff --cached --quiet -- "$file"; then + echo " SKIP $rule — $file has uncommitted changes"; skip=$((skip+1)); return + fi + TOUCHED+=("$file") + rm -f "$file" + verdict "$rule" "the removal of $file" + git checkout -- "$file" +} + +echo "gen-contract mutation test" +echo " sanity: the contract is green before any mutation" +if ! ./scripts/check-gen-contract.sh --quiet >/dev/null 2>&1; then + echo " the contract is already red; fix that first (./scripts/check-gen-contract.sh)"; exit 1 +fi + +GEN_CS="$(first_tracked '*/gen-src/*.g.cs')" +DOMAIN_GEN="$(first_tracked '*.Domain/gen-src/*.g.cs')" +INFRA_GEN="$(first_tracked '*.Infrastructure/gen-src/*.g.cs')" +API_GEN="$(first_tracked '*.Api/gen-src/*.g.cs')" +DOMAIN_CSPROJ="$(first_tracked '*.Domain/*.csproj')" +INFRA_CSPROJ="$(first_tracked '*.Infrastructure/*.csproj')" +SRC_CS="$(first_tracked '*/src/*.cs')" +HANDLER_BASE="$(first_tracked '*/gen-src/*HandlerBase.g.cs')" +APPSVC_BASE="$(first_tracked '*/gen-src/*ApplicationServiceBase.g.cs')" +PREFIX="${DOMAIN_CSPROJ%%.Domain/*}" + +# Markers and layout +expect_fail_newfile GEN-001 "${GEN_CS%/*}/NotGenerated.cs" "#nullable enable" +expect_fail GEN-002 "$GEN_CS" '1s/^#nullable enable$/#nullable disable/' +expect_fail GEN-003 "$GEN_CS" 's#// ##" +expect_fail GEN-014 "$INFRA_CSPROJ" "s###" + +# Host wiring +expect_fail GEN-020 Directory.Build.props 's#gen-src\\\*\*\\\*\.cs#generated\\**\\*.cs#' +expect_fail GEN-021 .gitignore '/^\*\.generated-paths\.json$/d' + +# Hand-written code +expect_fail GEN-030 "$SRC_CS" '1i\ +// Generated by the Specfuse Generator (https://github.com/specfuse/generator)' + +# Extension points +if [ -n "$HANDLER_BASE" ]; then + expect_fail_newfile GEN-040 "${HANDLER_BASE%/*}/UnadoptedHandlerBase.g.cs" "#nullable enable" +else + echo " SKIP GEN-040 — no *HandlerBase.g.cs in the tree yet"; skip=$((skip+1)) +fi +if [ -n "$APPSVC_BASE" ]; then + expect_fail_newfile GEN-041 "${APPSVC_BASE%/*}/UnadoptedApplicationServiceBase.g.cs" "#nullable enable" +else + echo " SKIP GEN-041 — no *ApplicationServiceBase.g.cs in the tree yet"; skip=$((skip+1)) +fi +expect_fail_newfile GEN-042 "${SRC_CS%/*}/DirectHandler.cs" "public sealed class DirectHandler : IOrderPlacedHandler { }" + +echo +echo " $pass ok, $fail missed, $skip skipped" +[ "$fail" -eq 0 ] diff --git a/templates/component-contract/csharp/verification.snippet.yml b/templates/component-contract/csharp/verification.snippet.yml new file mode 100644 index 0000000..36d55db --- /dev/null +++ b/templates/component-contract/csharp/verification.snippet.yml @@ -0,0 +1,32 @@ +# The `code` gate set for .specfuse/verification.yml in a C# component repo. +# +# Paste the `code:` block into .specfuse/verification.yml (created by +# `specfuse init `), or keep it as the whole file if the repo has no +# other gate sets yet. The generator's consumer harness reads exactly this +# key, and the Specfuse loop runs it as the exit oracle of every work unit. +# +# Cheap, self-contained checks first; `build` hangs off `gen-contract` so a +# structural breach never pays for the .NET chain; `tests` hangs off `build` +# so `--no-build` is only ever reached after a clean build in the same run. + +code: + - name: gen-contract + # Structural contract on generated and hand-written code: rule IDs, + # file:line, no judgement calls. Seconds, no toolchain. + command: "./scripts/check-gen-contract.sh" + + - name: build + needs: [gen-contract] + command: "dotnet build --nologo -warnaserror" + + - name: tests + needs: [build] + command: "dotnet test --no-build --nologo --filter \"Category!=Integration\" --settings coverlet.runsettings --collect:\"XPlat Code Coverage\" --results-directory ./CoverageResults" + + # Optional: a coverage floor over hand-written src/ only. coverlet.runsettings + # excludes **/gen-src/** so generated tests and generated code count for + # nothing. See the guide, check CS-22. + # - name: coverage + # needs: [tests] + # tier: broad + # command: "./scripts/check-coverage-floor.sh CoverageResults" From 36a90d207d95a36d03d6e0ad92ca06107431d26d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Labont=C3=A9?= Date: Wed, 28 Oct 2026 09:52:41 -0400 Subject: [PATCH 2/4] docs(csharp): correct the C# guide and starter contract against the generator Review fixes for the C# component guide, its interactive page and the starter contract, each checked against the generator source and the 0.19.0 pin. - Path manifests are committed, not ignored. *.generated-paths.json takes its capturedAt from the spec repo's baseline, so an unchanged regen leaves it byte-identical (clabonte/generator#2055). GEN-021 is inverted to forbid ignoring it; the same error is corrected in component-repos.md. - AddServices() and AddRepositories() register with plain AddScoped, not TryAdd: only a registration made after them wins. The TryAdd claim now covers the placeholder contracts only. - The starter tolerates more valid shapes: GEN-020 accepts forward-slash Compile globs, and any project ending in Tests (UnitTests, IntegrationTests) counts as test code. The kit test proves both, plus a repo with no .gitignore. - verification.snippet.yml is green on a fresh seed: no -warnaserror, and no coverlet.runsettings dependency until the repo writes one. - The guide states its pin: tenancy is required at 0.19.0 (fixed in 0.20.0), generateUserHelper is past the pin. It says what add-component seeds and what it leaves to the repo, admits gen-src/workers.json, scopes CHANGELOG.md to what the C# extractor tracks, documents the layout the filesets assume, and drops the PyYAML dependency from CS-20. - Template README counts two builtins and says where the folder lives in an installed kit. Co-Authored-By: Claude Opus 5.5 --- docs/component-repos.md | 2 +- docs/csharp-component-guide.md | 41 ++++++++++--------- docs/csharp-component.html | 16 ++++---- scripts/test-csharp-contract-template.sh | 38 ++++++++++++++++- templates/component-contract/csharp/README.md | 7 +++- .../docs/generated-code-expectations.md | 9 ++-- .../csharp/gen-contract.yml | 29 ++++++++----- .../csharp/scripts/test-gen-contract.sh | 3 +- .../csharp/verification.snippet.yml | 14 ++++--- 9 files changed, 107 insertions(+), 52 deletions(-) diff --git a/docs/component-repos.md b/docs/component-repos.md index 80adcc7..e74d5da 100644 --- a/docs/component-repos.md +++ b/docs/component-repos.md @@ -81,7 +81,7 @@ Run these from the spec repo's generate wrapper as soon as the group finishes, s Commit generated code in the component repo. Every regen then shows up as a reviewable diff in a component PR. CI and teammates build without Java or generator access. And a spec change and its effect on each component are visible side by side. -Ignore the generator's volatile bookkeeping files, such as the per-group `*.generated-paths.json` manifests. The Dart package may need its own `.gitignore` exception if the app ignores `*.g.dart` at the root. +Commit the generator's per-group `*.generated-paths.json` path manifests too: they record every emitted path and every file a regen moved, and their `capturedAt` comes from the spec repo's `/.paths.json` baseline rather than the clock, so a regen that moved nothing leaves them byte-identical. The Dart package may need its own `.gitignore` exception if the app ignores `*.g.dart` at the root. **What a green build does not prove.** A regen can change a field's name or an enum's wire value while every hand-written type that mirrors it still compiles. When you review a regen PR: diff --git a/docs/csharp-component-guide.md b/docs/csharp-component-guide.md index a273ebb..7a3ec32 100644 --- a/docs/csharp-component-guide.md +++ b/docs/csharp-component-guide.md @@ -9,9 +9,9 @@ Licensed under the Apache License, Version 2.0. See LICENSE. It is written to be handed to Claude. Every rule has a reason, a way to verify it from a shell, and a pointer to the section that explains the fix. The interactive companion, [`csharp-component.html`](csharp-component.html), is the same material as a map you click through. -Project names in this guide come from the kit's bundled example, [`hello-orders`](../examples/hello-orders/): the solution is `HelloOrders`, the layered projects are `HelloOrders.Domain`, `HelloOrders.Infrastructure`, `HelloOrders.Api`, and so on. Substitute your own root namespace throughout. +Project names in this guide use `HelloOrders`, the root namespace of the kit's bundled example [`hello-orders`](../examples/hello-orders/) (which itself generates only a Domain group): the solution is `HelloOrders`, the layered projects are `HelloOrders.Domain`, `HelloOrders.Infrastructure`, `HelloOrders.Api`, and so on. Substitute your own root namespace throughout. -> **New repo?** `specfuse authoring add-component csharp ` ([`add-component.md`](add-component.md)) seeds the host files in §2 and §4 once, with the package versions the kit's pinned generator compiles against. This guide is what the seed implements, and what to check when a repo was set up some other way. +> **New repo?** `specfuse authoring add-component csharp ` ([`add-component.md`](add-component.md)) seeds most of the host files in §2 and §4 once, with the package versions the kit's pinned generator compiles against: the solution, `Directory.Build.props`, the Domain, Infrastructure and Api projects, `Program.cs`, `UserHelper`, `ExceptionFilter`, `.gitignore` and a `CLAUDE.md`. `global.json`, `.gitattributes`, the Application and Test projects and the §6 contract are yours to add. This guide is what the seed implements, and what to check when a repo was set up some other way. ## How to use this guide with Claude @@ -70,8 +70,8 @@ The rules behind the picture: - **Sibling checkouts.** The `../Backend/` destinations in the project file resolve only when the spec repo and the component sit next to each other. Write the layout into this repo's README. - **One project folder per group `basePackage`.** `HelloOrders.Domain` is both the folder, the `.csproj` name and the namespace root; generated namespaces are `HelloOrders.Domain.Entities`, `HelloOrders.Domain.Services`, and so on. Several groups share one `destination`, each writing into its own `basePackage` subfolder ([`Project_File.md` §7.1](../handbooks/Project_File.md#71-groups)). -- **`gen-src/` is generated, `src/` is yours.** Every generated C# file is a `.g.cs`, opens with `#nullable enable`, and carries the `` header block with the line `Generated by the Specfuse Generator`. Nothing else is ever under `gen-src/`, and no `.g.cs` is ever outside it. -- **Two files land beside `gen-src/`, not in it.** `CHANGELOG.md` is the generator's own surface diff for the group and is committed. `.generated-paths.json` is a timestamped manifest of what the last run wrote; ignore it in git (§2). +- **`gen-src/` is generated, `src/` is yours.** Every generated C# file is a `.g.cs`, opens with `#nullable enable`, and carries the `` header block with the line `Generated by the Specfuse Generator`. No `.g.cs` is ever outside `gen-src/`. The one non-C# file a C# group can write there is `gen-src/workers.json`, from `hostedTopicConsumer`; the starter contract's `gen_src_non_cs` fileset is where you admit it (§6.1). +- **Two files land beside `gen-src/`, not in it.** `CHANGELOG.md` is the generator's own surface diff for the group and is committed. `.generated-paths.json` is the generator's manifest of every path the group emitted and every file that moved since the last regen; **commit it** too. Its `capturedAt` comes from the spec repo's `/.paths.json` baseline, not the clock, so a regen that moved nothing leaves it byte-identical; ignoring it throws away the old-to-new path mapping a layout-changing release hands you. ## 2. Build wiring @@ -116,7 +116,7 @@ Two libraries are deliberately **not** on the list: AutoMapper and FluentAsserti ### 2.3 Solution, SDK pin and git - `HelloOrders.slnx` (or `.sln`) lists every project; `global.json` pins the SDK (`"version": "10.0.100", "rollForward": "feature"`). -- `.gitignore` carries `bin/`, `obj/`, `TestResults/` and **`*.generated-paths.json`**. Generated code itself is **committed**: every regen is then a reviewable diff, CI and teammates build without Java or generator access, and a spec change and its effect are visible side by side. +- `.gitignore` carries `bin/`, `obj/` and `TestResults/`, and **not** `*.generated-paths.json` (§1). Generated code itself is **committed**: every regen is then a reviewable diff, CI and teammates build without Java or generator access, and a spec change and its effect are visible side by side. - `.gitattributes` normalises `*.cs`, `*.csproj`, `*.json` and `*.md` to LF so a regen on another OS does not rewrite every line. ## 3. Project-file groups for C# @@ -144,7 +144,7 @@ Add as the project grows: Three things the project file must get right for C#: -- **`tenancy` is required** at the current generator whenever `service` is registered: generated services always use the event runtime, and the event runtime requires a tenancy block ([`Project_File.md` §4.1](../handbooks/Project_File.md#41-tenancy-c-only-today), [clabonte/generator#2883](https://github.com/clabonte/generator/issues/2883)). `dbContextClassname` names the EF Core context (`HelloOrdersDbContext`); unset, it derives from `name`. +- **`tenancy` is required** at the kit's pinned generator (0.19.0) whenever `service` is registered: generated services always use the event runtime, and the event runtime requires a tenancy block ([`Project_File.md` §4.1](../handbooks/Project_File.md#41-tenancy-c-only-today), [clabonte/generator#2883](https://github.com/clabonte/generator/issues/2883), fixed in generator 0.20.0; the requirement goes away at the pin bump that reaches it). `dbContextClassname` names the EF Core context (`HelloOrdersDbContext`); unset, it derives from `name`. - **`cleanGenerated: true` on every C# group.** The wipe is gated on a full-group regen; a run filtered with `--artifact` skips it, so partial output never deletes a sibling artifact's files ([§8.9](../handbooks/Project_File.md#89-cleangenerated)). `cleanScope` is not needed for C#: the default wipe target is `gen-src/` under the group's folder. - **`functionalTests`** decides which generated API functional tests run in parallel (`collectionNamePattern`, `collectionGroups`); unset, every generated test shares one xUnit collection and the suite is serial. @@ -188,13 +188,13 @@ app.Run(); With an Application group, add `builder.Services.Add{Domain}Workers()` per domain (or the aggregate `AddAllWorkers()`), which registers every concrete handler and job found by assembly scan. With field encryption declared in the spec, add `builder.Services.AddFieldEncryption()`; nothing generated calls it for you. -**Register your own implementations after the generated calls, or rely on `TryAdd`.** Every generated registration uses `TryAdd*`, so a registration made before or after it wins. A custom `I{Entity}Service` is registered right after `AddServices()`; a decorator wraps after both. +**Register your own implementations after the generated calls.** `AddServices()` and `AddRepositories()` register with a plain `AddScoped`, so the last registration wins: a custom `I{Entity}Service` goes right after `AddServices()`, a decorator after both. The placeholder contracts in §4.3 are the exception: they are registered with `TryAdd`, so for them order does not matter. ### 4.2 Two classes the API's generated code names | Type | Who calls it | What it must do | |---|---|---| -| `HelloOrders.Api.Utils.UserHelper` | every generated controller: `new CallerContext(UserHelper.GetUser(User))` | A static `GetUser(ClaimsPrincipal)` returning the generated `AuthenticatedUser`: user id, roles, tenant memberships from your identity provider's claims. From generator 0.20.0, `"generateUserHelper": true` in the project file emits a default one instead; keep exactly one. | +| `HelloOrders.Api.Utils.UserHelper` | every generated controller: `new CallerContext(UserHelper.GetUser(User))` | A static `GetUser(ClaimsPrincipal)` returning the generated `AuthenticatedUser`: user id, roles, tenant memberships from your identity provider's claims. From generator 0.20.0 (newer than the kit's 0.19.0 pin), `"generateUserHelper": true` in the project file emits a default one instead; keep exactly one. | | `HelloOrders.Api.Filters.ExceptionFilter` | nothing generated; you add it to `AddControllers` | Maps the generated domain exceptions (`NotFoundException`, `ConflictException`, `PreconditionFailedException`, …) to their `StatusCode` and `Errors` list. Without it a `NotFoundException` is a 500. | ### 4.3 Contracts registered as throwing placeholders @@ -280,7 +280,7 @@ Mapperly makes every mapping a compile-time method: a missing or mistyped mappin ### 5.4 Replace a generated default -`AddServices()` registers `IOrderService -> DefaultOrderService` with `TryAdd`. A custom service derives from the default (or implements the interface) in `src/` and is registered after `AddServices()` with a plain `AddScoped`, which replaces the default. Custom business logic that does not fit a generated operation is a hand-written class beside it, never an edit to the default. +`AddServices()` registers `IOrderService -> DefaultOrderService` with a plain `AddScoped`. A custom service derives from the default (or implements the interface) in `src/` and is registered after `AddServices()`; the later registration is the one DI resolves. Custom business logic that does not fit a generated operation is a hand-written class beside it, never an edit to the default. What stays hand-written by design: operations marked `x-manual` in the spec ([`Vendor_Extensions.md` §4.5](../handbooks/Vendor_Extensions.md)), EF Core migrations (derived from the generated configurations, owned here), and everything the generator does not know your domain needs. @@ -296,10 +296,12 @@ The kit ships a starter for C# in [`templates/component-contract/csharp/`](../te |---|---|---| | Markers and layout | `GEN-001` to `GEN-005` | a hand-written file hides under `gen-src/` and is wiped; a stale `.g.cs` outside it shadows the current output; a file without the header is linted and "fixed" by hand | | Layering | `GEN-010` to `GEN-014` | Domain grows a dependency on persistence; Api reaches the DbContext directly; a project reference inverts a layer | -| Host wiring | `GEN-020`, `GEN-021` | half the solution stops compiling; the generator's manifests dirty every regen diff | +| Host wiring | `GEN-020`, `GEN-021` | half the solution stops compiling; an ignored path manifest loses the move record a layout change hands over | | Hand-written code | `GEN-030` | a generated file copied into `src/` and edited | | Extension points | `GEN-040` to `GEN-042` | a declared handler nobody wrote dead-letters every delivery; a service drops `If-Match`; a handler bypasses inbox dedup | +**The filesets assume the layout of §1:** project folders at the repo root, named `.Domain`, `.Infrastructure` and `.Api`, test projects ending in `.Test` or `Tests`. A repo laid out differently (projects under `src/`, a layer named otherwise) edits the `filesets:` block once when seeding; a scope that matches nothing reports an error rather than passing, so a wrong glob is never silent. + The engine is `specfuse.loop.gen_contract` (specfuse-loop 0.30+, part of `pipx install specfuse`). The seeded `scripts/check-gen-contract.sh` runs it; `scripts/test-gen-contract.sh` is the mutation test. ```bash @@ -324,13 +326,13 @@ A review finding that recurs becomes a rule, not a longer checklist. The bar for ## 7. Tests and coverage - **Generated tests live in `gen-src/`, hand-written tests in `src/`.** The Test group emits builders, fakes, the seed, and whole suites (service unit tests, mapper tests, EF configuration tests, the authorization matrix, API functional tests, event contract tests). They are regenerated like everything else. -- **Coverage measures `src/` only.** `coverlet.runsettings` excludes `**/gen-src/**` (and `**/Migrations/**`); generated tests exercising generated code count for nothing, so the number is about the code you wrote. A floor in `.coverage-floor` and a `coverage` gate with `needs: [tests]` ratchets it. +- **Coverage measures `src/` only.** A `coverlet.runsettings` you write (the starter does not ship one) excludes `**/gen-src/**` (and `**/Migrations/**`); generated tests exercising generated code count for nothing, so the number is about the code you wrote. A floor in `.coverage-floor` and a `coverage` gate with `needs: [tests]` ratchets it. - **Unit tests mock everything** through the generated interfaces; a hand-built `DbContext` goes through `InMemoryDbTestHelper`. Functional tests go through `ApiTestFixture`. Mark integration tests with `[Trait("Category", "Integration")]` so `--filter "Category!=Integration"` is the daily run. - **AwesomeAssertions, never FluentAssertions** (§2.2). ## 8. Agent instructions -Put the rules an agent will need in the first thirty lines of `CLAUDE.md` (or `AGENTS.md`), before anything else, because it reads them before it reads the error. The seed from `add-component` carries them; an older repo writes them in: +Put the rules an agent will need in the first thirty lines of `CLAUDE.md` (or `AGENTS.md`), before anything else, because it reads them before it reads the error. The seed from `add-component` carries rules 1, 2 and 4; add 3 and 5 when you seed the contract (§6). An older repo writes all five in: 1. `*/gen-src/**` and every `*.g.cs` are generated from `../`; the next regen overwrites them. **Never edit them.** If a task seems to need it, stop: the fix belongs in the spec or the generator. Say so and note that a regen is required. 2. Before writing a model, DTO, repository, mapper, service or controller by hand, check `gen-src/`. It probably exists. @@ -348,7 +350,7 @@ Put the rules an agent will need in the first thirty lines of `CLAUDE.md` (or `A ``` `generate.sh` refuses a destination repo with uncommitted changes. 3. **Run the contract, then build.** `./scripts/check-gen-contract.sh` first: a red rule is a generator defect or a hand-written file in the wrong place, and either is cheaper to see before the build. Then `dotnet build` and the unit tests. Fix the hand-written `src/` that the regen broke (a renamed member, a new abstract method on a base, a new placeholder contract); never the generated side. -4. **Review by file name, not by path.** A regen rewrites the whole tree, so diff size is not a signal. Diff changed models by file name (a moved file reads as delete plus add), diff each enum's **values**, diff operation parameter lists, trace every hand-written type that mirrors a generated one, and read `CHANGELOG.md` in each project folder: it is the generator's own account of what changed in the surface. Removals are the highest-risk content of a regen and nothing mechanical catches them. +4. **Review by file name, not by path.** A regen rewrites the whole tree, so diff size is not a signal. Diff changed models by file name (a moved file reads as delete plus add), diff each enum's **values**, diff operation parameter lists, trace every hand-written type that mirrors a generated one, and read `CHANGELOG.md` where the generator writes one. For C# it tracks Domain entities, value objects and services and the Test group's event builders, not controllers, DTOs or EF configuration, so it complements the file-by-file review rather than replacing it. Removals are the highest-risk content of a regen and nothing mechanical catches them. 5. **One PR per component**, containing the regen and only its required follow-ups. CI runs the contract and the mutation test, then the build. 6. **Merge, then advance the baseline** in the spec repo from the merged default branch, never from a local regen: ```bash @@ -394,21 +396,22 @@ for d in */gen-src; do p=${d%/gen-src}; [ -f "$p/$p.csproj" ] && [ -d "$p/src" ] ./scripts/check-gen-contract.sh --only GEN-013 GEN-014 && grep -n ProjectReference *.Api/*.csproj ``` -**CS-06 · A fresh regen builds with zero edits and zero warnings.** +**CS-06 · A fresh regen builds with zero edits.** ```bash -dotnet build --nologo -warnaserror +dotnet build --nologo ``` +Adding `-warnaserror` (or `TreatWarningsAsErrors`, §2.1) once the build is clean is a team choice, not a generator requirement. **CS-07 · `global.json` pins the SDK; the solution lists every project.** ```bash cat global.json && dotnet sln list 2>/dev/null || grep -c 'C# Component Map "guide": "#5-extending-generated-code"}, {"id": "changelog", "name": "CHANGELOG.md", "kind": "gen", "depth": 2, "title": "The generator's own surface diff", "body": "Written by the generator beside gen-src/, committed, and preserved by the clean step. It is diffed against the spec repo's .specfuse/state baseline, which is why that baseline must describe what shipped on this repo's default branch.", - "facts": [["Read it", "during regen review: it names what changed in the public surface"], ["Ignore", "*.generated-paths.json, the timestamped manifest beside it (GEN-021)"]], + "facts": [["Read it", "during regen review: it names what changed in the public surface"], ["Commit", "*.generated-paths.json beside it too: the path manifest, byte-identical when nothing moved (GEN-021)"]], "guide": "#9-the-regen-flow"}, {"id": "infra", "name": "HelloOrders.Infrastructure/", "kind": "auto", "depth": 1, "title": "The Infrastructure project", "body": "The EF Core DbContext, entity configurations, value-object converters, repositories, the event runtime (outbox interceptor, dispatcher), concurrency and encryption primitives. References Domain, and Application only for its ports.", @@ -297,7 +297,7 @@

C# Component Map

"body": "Nothing generated calls Program.cs. It calls the generated registrations: AddServices(), AddRepositories(), AddSpecfuseAuthorization(), AddConcurrencyTokenSink(), AddEventRuntimeCore().AddNullEventDispatcher() (or AddEventRuntime() for Azure Service Bus), AddSqlServerDatabase() / AddInMemoryDatabase(), plus Add{Domain}Workers() with an Application group and AddFieldEncryption() with encryption in the spec.", "fails": "A registration left out fails at runtime by name: the first controller that needs an unregistered I{Entity}Service throws from the container.", "cmd": "builder.Services.AddServices();\nbuilder.Services.AddRepositories();\nbuilder.Services.AddSpecfuseAuthorization();\nbuilder.Services.AddConcurrencyTokenSink();\nbuilder.Services.AddEventRuntimeCore().AddNullEventDispatcher();\nbuilder.Services.AddSqlServerDatabase(connectionString);", "check": "CS-09"}, - {"id": "userhelper", "name": "Utils/UserHelper.GetUser(ClaimsPrincipal)", "layer": "Api/src", "kind": "yours", "when": "always (or generateUserHelper: true from generator 0.20)", + {"id": "userhelper", "name": "Utils/UserHelper.GetUser(ClaimsPrincipal)", "layer": "Api/src", "kind": "yours", "when": "always (or generateUserHelper: true from generator 0.20, past the kit's 0.19 pin)", "body": "Every generated controller builds its CallerContext from UserHelper.GetUser(User). The generator references it by name; you map your identity provider's claims to the generated AuthenticatedUser: user id, roles, tenant memberships. From generator 0.20.0 the project file's generateUserHelper: true emits a default one; keep exactly one or the build fails with CS0101.", "fails": "Without it the Api project does not compile: every controller names a type that does not exist.", "cmd": "public static class UserHelper\n{\n public static AuthenticatedUser GetUser(ClaimsPrincipal user) =>\n new(user.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? \"anonymous\");\n}", "check": "CS-10"}, @@ -318,7 +318,7 @@

C# Component Map

"fails": "FieldEncryptionNotConfiguredException naming the contract, on the first encrypted write or read.", "cmd": "builder.Services.AddFieldEncryption();\nservices.AddSingleton();", "check": "CS-12"}, {"id": "concurrency", "name": "IPrincipalKeyResolver · ICallerFieldResolver", "layer": "Infrastructure/src", "kind": "yours", "when": "principal-keyed concurrency tokens · x-populate-from caller fields", - "body": "Two more placeholders of the same shape: AddPrincipalKeyResolver() and AddCallerFieldResolver() install factories that throw PrincipalKeyResolverNotConfiguredException / CallerFieldResolverNotConfiguredException naming the method. Register yours anywhere; TryAdd means a registration before or after the generated call wins.", + "body": "Two more placeholders of the same shape: AddPrincipalKeyResolver() and AddCallerFieldResolver() install factories that throw PrincipalKeyResolverNotConfiguredException / CallerFieldResolverNotConfiguredException naming the method. Register yours anywhere: placeholders are registered with TryAdd, so a registration before or after the generated call wins.", "fails": "Fails by name on first use, never at startup.", "cmd": "services.AddScoped();", "check": "CS-12"}, {"id": "dispatcher", "name": "the event dispatcher", "layer": "Api/src (Program.cs)", "kind": "yours", "when": "eventRuntime registered (always, with service)", @@ -345,7 +345,7 @@

C# Component Map

"code": "// HelloOrders.Domain/src/Mappers/OrderDomainMapperHooks.cs\nnamespace HelloOrders.Domain.Mappers;\n\npublic static partial class OrderDomainMapperHooks\n{\n static partial void OnAfterToOrder(NewOrder source, Order target)\n {\n target.Status = OrderStatus.Draft;\n }\n}", "trap": "Setting the value in the controller or the service instead works until the next entry point (a recipe fixture, a bulk import, a handler) maps the same request and ships the entity without it.", "check": "CS-16"}, {"id": "replace", "name": "replace a generated default", "who": "yours", - "summary": "AddServices() registers IOrderService -> DefaultOrderService with TryAdd. A custom service derives from the default (or implements the interface) in src/ and is registered after AddServices() with a plain AddScoped, which replaces it. A decorator wraps after both.", + "summary": "AddServices() registers IOrderService -> DefaultOrderService with a plain AddScoped. A custom service derives from the default (or implements the interface) in src/ and is registered after AddServices(); the last registration wins. A decorator wraps after both.", "code": "// Program.cs\nbuilder.Services.AddServices();\nbuilder.Services.AddScoped(); // replaces DefaultOrderService\n\n// HelloOrders.Domain/src/Services/OrderService.cs\npublic class OrderService(...) : DefaultOrderService(...)\n{\n public override Task PlaceAsync(Guid id, CallerContext caller, CancellationToken ct) { … }\n}", "trap": "Editing DefaultOrderService in gen-src/ instead is the hand edit the whole layout exists to forbid: it compiles, passes review once, and is gone on the next regen.", "check": "CS-17"} ], @@ -371,7 +371,7 @@

C# Component Map

{"id": "GEN-013", "block": "Layering", "title": "The Domain project references no other project", "why": "A ProjectReference is the compile-time form of GEN-010. With one in place the using-line rule can be satisfied while the dependency exists anyway through a fully-qualified name."}, {"id": "GEN-014", "block": "Layering", "title": "The Infrastructure project never references the Api project", "why": "Api is the composition root and already references Infrastructure. The reverse edge is a cycle, or a persistence layer that cannot be built or tested without the web host."}, {"id": "GEN-020", "block": "Host wiring", "title": "Directory.Build.props compiles src/ and gen-src/ and nothing else", "why": "With default globbing on, a .cs file anywhere in a project folder joins the build and a file parked beside gen-src/ shadows the generated one. With one of the two globs missing, half the solution silently stops compiling."}, - {"id": "GEN-021", "block": "Host wiring", "title": "The generator's bookkeeping manifests are git-ignored", "why": ".generated-paths.json is rewritten with a timestamp on every run. Tracked, it dirties the tree on every regen and turns every component PR into a diff of noise."}, + {"id": "GEN-021", "block": "Host wiring", "title": "The generator's path manifests are not git-ignored", "why": ".generated-paths.json records every emitted path and every file a regen moved. Its timestamp comes from the spec repo's baseline, so an unchanged regen leaves it byte-identical; ignored, the old-to-new mapping a layout change hands over is lost."}, {"id": "GEN-030", "block": "Hand-written code", "title": "No hand-written file carries the Specfuse generator header", "why": "A generated file copied into src/ and edited is the hand edit the gen-src/ rule exists to forbid, moved one folder over. It compiles beside the real one until the next regen renames something, and then the copy is the stale one that wins. EF Core's own marker on migrations is not matched."}, {"id": "GEN-040", "block": "Extension points", "title": "Every generated handler and job base has a concrete class deriving from it", "why": "A {X}HandlerBase with no subclass is a handler the spec declared and nobody wrote. The generated trigger still subscribes to the label, so every delivery dead-letters until someone notices the queue."}, {"id": "GEN-041", "block": "Extension points", "title": "Every generated application-service base has a concrete class deriving from it", "why": "The If-Match precondition lives in a non-virtual method on {Tag}ApplicationServiceBase. A service implementing the interface without the base compiles clean and silently drops the precondition: every concurrent write becomes a blind overwrite."}, @@ -384,9 +384,9 @@

C# Component Map

{"id": "CS-03", "section": "Layout", "guide": "#1-layout", "title": "Nothing hand-written under gen-src/, no .g.cs outside it, every generated file marked (GEN-001 to GEN-005).", "verify": "./scripts/check-gen-contract.sh --only GEN-001 GEN-002 GEN-003 GEN-004 GEN-005"}, {"id": "CS-04", "section": "Build wiring", "guide": "#21-directorybuildprops-at-the-solution-root", "title": "Directory.Build.props turns default globbing off and compiles exactly src\\**\\*.cs and gen-src\\**\\*.cs (GEN-020).", "verify": "./scripts/check-gen-contract.sh --only GEN-020"}, {"id": "CS-05", "section": "Build wiring", "guide": "#22-one-csproj-per-layer-with-the-layering-in-the-references", "title": "Project references express the layering: Domain references nothing, Infrastructure never references Api, Api references Infrastructure only as the composition root.", "verify": "./scripts/check-gen-contract.sh --only GEN-013 GEN-014 && grep -n ProjectReference *.Api/*.csproj"}, - {"id": "CS-06", "section": "Build wiring", "guide": "#22-one-csproj-per-layer-with-the-layering-in-the-references", "title": "A fresh regen builds with zero edits and zero warnings.", "verify": "dotnet build --nologo -warnaserror"}, + {"id": "CS-06", "section": "Build wiring", "guide": "#22-one-csproj-per-layer-with-the-layering-in-the-references", "title": "A fresh regen builds with zero edits (-warnaserror once clean is a team choice).", "verify": "dotnet build --nologo"}, {"id": "CS-07", "section": "Build wiring", "guide": "#23-solution-sdk-pin-and-git", "title": "global.json pins the SDK; the solution lists every project.", "verify": "cat global.json && dotnet sln list 2>/dev/null || grep -c 'C# Component Map {"id": "CS-17", "section": "Extension discipline", "guide": "#54-replace-a-generated-default", "title": "Custom services and decorators are registered after the generated AddServices() / AddRepositories() calls.", "verify": "grep -n 'AddServices\\|AddRepositories\\|AddScoped .gitignore <<'EOF' bin/ obj/ -*.generated-paths.json EOF header() { @@ -140,4 +139,41 @@ if [ "$status" -ne 0 ] || ! grep -q ' 0 missed, 0 skipped' <<<"$out"; then echo "FAIL: a rule did not bite, or a case was skipped on the synthetic tree." >&2 exit 1 fi + +# ── Shapes the starter must accept, each committed on top of the seed and +# rolled back: the engine reads tracked files only. +echo "── variants that must stay green" +variant_green() { + git add -A + git commit -q -m "variant: $1" + if ./scripts/check-gen-contract.sh --quiet; then + echo " ok $1" + else + echo "FAIL: the contract rejects a valid shape: $1" >&2 + exit 1 + fi + git reset -q --hard HEAD~1 +} + +cat > Directory.Build.props <<'EOF' + + + false + + + + + + +EOF +variant_green "forward-slash Compile globs" + +mkdir -p "$PREFIX.UnitTests/src" +printf 'namespace %s.UnitTests;\n\npublic sealed class FakeOrderPlacedHandler : IOrderPlacedHandler { }\n' "$PREFIX" \ + > "$PREFIX.UnitTests/src/FakeOrderPlacedHandler.cs" +variant_green "a test double in a *UnitTests project" + +git rm -q .gitignore +variant_green "no .gitignore at all" + echo "OK: the C# starter contract is green on a fresh seed and every rule bites." diff --git a/templates/component-contract/csharp/README.md b/templates/component-contract/csharp/README.md index 0b9dec1..ccf5fbb 100644 --- a/templates/component-contract/csharp/README.md +++ b/templates/component-contract/csharp/README.md @@ -10,7 +10,7 @@ and `add-component` will seed them automatically once | File | Lands at | What it is | |---|---|---| | `gen-contract.yml` | repo root | 16 starter rules: markers, layering, host wiring, extension-point adoption. Every rule green on a fresh seed. | -| `scripts/gen_contract_checks.py` | `scripts/` | The one project-specific check (`generated_base_adopted`) the generic engine cannot express. | +| `scripts/gen_contract_checks.py` | `scripts/` | The two project-specific checks the generic engine cannot express: `generated_base_adopted` and `generated_handler_interface_implemented_directly`. | | `scripts/check-gen-contract.sh` | `scripts/` | Runs the contract through the shared engine (`specfuse.loop.gen_contract`, specfuse-loop 0.30+). | | `scripts/test-gen-contract.sh` | `scripts/` | Mutation test: breaks the tree one rule at a time and asserts the rule reports it. | | `docs/generated-code-expectations.md` | `docs/` | The prose half, with the five section names the generator's QA reviewer reads. | @@ -23,7 +23,10 @@ From the component repo root, with `` the solution's root namespace (`HelloOrders` for `HelloOrders.Domain` / `.Infrastructure` / `.Api`): ```bash -KIT=/path/to/authoring/templates/component-contract/csharp +# This folder: templates/component-contract/csharp in an authoring checkout, or +# specfuse/authoring/_kit/templates/component-contract/csharp inside the +# installed package (with pipx, under ~/.local/pipx/venvs/specfuse/lib/python3.*/site-packages/). +KIT=/path/to/component-contract/csharp cp "$KIT/gen-contract.yml" . mkdir -p scripts docs .github/workflows cp "$KIT/scripts/"* scripts/ diff --git a/templates/component-contract/csharp/docs/generated-code-expectations.md b/templates/component-contract/csharp/docs/generated-code-expectations.md index 11cdc7e..902ff3d 100644 --- a/templates/component-contract/csharp/docs/generated-code-expectations.md +++ b/templates/component-contract/csharp/docs/generated-code-expectations.md @@ -31,14 +31,15 @@ Infrastructure only to wire it up in `Program.cs`. Enforced over generated `Directory.Build.props` turns default globbing off and compiles exactly `src/**/*.cs` and `gen-src/**/*.cs` in every project. The generator's -`*.generated-paths.json` manifests are git-ignored. Enforced by `GEN-020` -and `GEN-021`. +`*.generated-paths.json` path manifests are committed, never git-ignored. +Enforced by `GEN-020` and `GEN-021`. ## Extension points -Hand-written code extends generated code three ways, all in `src/`: +Hand-written code extends generated code four ways, all in `src/`: `partial` classes in the generated type's namespace, concrete classes derived -from a generated `*Base`, and the `partial` mapper-hook methods. A generated +from a generated `*Base`, the `partial` mapper-hook methods, and a custom +service registered after `AddServices()` to replace a generated default. A generated file is never copied into `src/` and edited (`GEN-030`). A generated base is always adopted: a handler or job interface is never implemented directly, and a gated application service always derives from its diff --git a/templates/component-contract/csharp/gen-contract.yml b/templates/component-contract/csharp/gen-contract.yml index d80cad5..efdf47f 100644 --- a/templates/component-contract/csharp/gen-contract.yml +++ b/templates/component-contract/csharp/gen-contract.yml @@ -32,6 +32,11 @@ # The engine reads this file with the loop's own strict YAML subset: block # style only, no anchors, no flow mappings. Prose goes in folded scalars (>), # regexes in single quotes. +# +# The filesets below assume project folders at the repo root named +# __Prefix__.Domain, __Prefix__.Infrastructure and __Prefix__.Api, and test +# projects ending in `.Test` or `Tests`. A different layout edits `filesets:` +# once when seeding; a scope that matches nothing is an error, not a pass. version: 1 builtins: scripts/gen_contract_checks.py @@ -64,7 +69,7 @@ filesets: - "*/src/**/*.cs" exclude: - "*.Test/**" - - "*.Tests/**" + - "*Tests/**" domain_gen: include: - "__Prefix__.Domain/gen-src/**/*.cs" @@ -246,22 +251,24 @@ rules: scope: build_props patterns: - 'false' - - 'Compile Include="src\\\*\*\\\*\.cs"' - - 'Compile Include="gen-src\\\*\*\\\*\.cs"' + - 'Compile Include="src[\\/]\*\*[\\/]\*\.cs"' + - 'Compile Include="gen-src[\\/]\*\*[\\/]\*\.cs"' - id: GEN-021 - title: The generator's bookkeeping manifests are git-ignored + title: The generator's path manifests are not git-ignored severity: error doc: docs/generated-code-expectations.md#build-wiring why: > - The generator writes a `.generated-paths.json` beside each - gen-src/ on every run, stamped with a timestamp. Tracked, it dirties - the tree on every regen and turns every component PR into a diff of - noise that hides the generated change. - check: require_pattern + `.generated-paths.json` beside each gen-src/ records every path + the group emitted and every file a regen moved: the old-to-new mapping + a layout-changing generator release hands over. Its capturedAt is read + from the spec repo's baseline, not the clock, so a regen that moved + nothing leaves it byte-identical. Ignored, the mapping never reaches + the repo and a layout move reads as an unexplained delete plus add. + check: forbid_pattern scope: gitignore - patterns: - - '^\*\.generated-paths\.json$' + pattern: 'generated-paths' + allow_empty: true # ── Hand-written code holds up its end ───────────────────────────────── diff --git a/templates/component-contract/csharp/scripts/test-gen-contract.sh b/templates/component-contract/csharp/scripts/test-gen-contract.sh index e4fea2a..0a63d1d 100755 --- a/templates/component-contract/csharp/scripts/test-gen-contract.sh +++ b/templates/component-contract/csharp/scripts/test-gen-contract.sh @@ -128,7 +128,8 @@ expect_fail GEN-014 "$INFRA_CSPROJ" "s## Date: Wed, 28 Oct 2026 09:58:26 -0400 Subject: [PATCH 3/4] docs(csharp): temporary patches to generated code, tracked until the generator fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A component sometimes cannot wait for a generator release. The guide's new §9.1 says how to bridge it without losing the "never edit gen-src/" property: first a fix in the spec, a workaround from src/ or x-manual; only then a tracked patch, filed against the generator first and retired when the pin reaches the fix. The starter ships the tooling: - scripts/apply-gen-patches.sh re-applies gen-patches/*.patch after a regen and tells apart applied, already present (the generator now emits the fix: retire it) and FAILED (the code moved under it). --check is the gate: every patch applied, headed by its Generator-Issue, listed in the index, and modifying existing gen-src/ files only. - gen-patches/README.md is the index of active and retired patches, with the make and retire steps. - The spec repo's existing post-generate.sh hook calls the script per group, so patches go back on during the regen itself. - A gen-patches gate in verification.snippet.yml (build needs it), a CI step, audit check CS-26, and a Known failure modes pointer for the generator's consumer-QA reviewer. The kit test drives the script through every state it reports. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- docs/component-repos.md | 2 + docs/csharp-component-guide.md | 72 ++++++++-- docs/csharp-component.html | 7 +- scripts/test-csharp-contract-template.sh | 54 +++++++- .../csharp/.github/workflows/gen-contract.yml | 3 + templates/component-contract/csharp/README.md | 5 +- .../docs/generated-code-expectations.md | 3 + .../csharp/gen-patches/README.md | 48 +++++++ .../csharp/scripts/apply-gen-patches.sh | 128 ++++++++++++++++++ .../csharp/verification.snippet.yml | 7 +- 11 files changed, 314 insertions(+), 17 deletions(-) create mode 100644 templates/component-contract/csharp/gen-patches/README.md create mode 100755 templates/component-contract/csharp/scripts/apply-gen-patches.sh diff --git a/README.md b/README.md index a051fe6..f46c0e1 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ See [`examples/hello-orders/`](examples/hello-orders/) for a complete worked exa | **Handbooks** ([`handbooks/`](handbooks/)) | 6 authoritative documents: REST API, AsyncAPI, Arazzo, vendor extensions, AI access policy framework, and the generator's project file. Together these define the full spec-authoring contract. | | **Samples** ([`samples/`](samples/)) | 4 canonical YAML templates — endpoints, async messages, scenarios, recipes — that every authored file should pattern-match against. | | **Schemas** ([`schemas/`](schemas/)) | Working Spectral rulesets and custom functions for validation under `schemas/spectral/`. CI lints the bundled example against them. [`schemas/README.md`](schemas/README.md) also covers how to run Spectral so a crash cannot pass as a clean spec, and how to turn the ruleset on against specs that predate it. | -| **Templates** ([`templates/`](templates/)) | The `project-init/` skeleton, the AI Access Policy template, and the starter generated-code contract for C# component repos (`component-contract/csharp/`: 16 rules, a mutation test, the expectations skeleton, the CI workflow). | +| **Templates** ([`templates/`](templates/)) | The `project-init/` skeleton, the AI Access Policy template, and the starter generated-code contract for C# component repos (`component-contract/csharp/`: 16 rules, a mutation test, the expectations skeleton, the CI workflow, and the tooling that re-applies tracked temporary patches to generated code after every regen). | | **Claude assets** | 5 Claude Code sub-agents and 20 authoring skills (`/specfuse-authoring:design-scenario`, `/specfuse-authoring:design-async`, `/specfuse-authoring:design-recipe`, etc.) that automate spec design. Distributed as the `specfuse-authoring` plugin in the `specfuse/specfuse` marketplace; `init` wires the plugin and scaffolds the contract the skills read into `.specfuse/authoring/`. | | **Bundled example** ([`examples/hello-orders/`](examples/hello-orders/)) | A 61-file complete Specfuse project — 2 domains, 3 entities, 1 state-transition event, 1 cross-domain scenario, 2 setup recipes, filled AI access policy, CI workflow. Serves as the kit's regression net. | diff --git a/docs/component-repos.md b/docs/component-repos.md index e74d5da..9e4262b 100644 --- a/docs/component-repos.md +++ b/docs/component-repos.md @@ -127,6 +127,8 @@ A component can guard its generated tree mechanically: a contract file (`gen-con For C#, the kit ships a starter: [`templates/component-contract/csharp/`](../templates/component-contract/csharp/), 16 rules green on a fresh seed, each with a mutation case, and the full walkthrough is [`csharp-component-guide.md`](csharp-component-guide.md) §6. `add-component` will seed it once [#181](https://github.com/specfuse/authoring/issues/181) lands; until then its README has the copy-and-sed steps. Other languages keep any such check small and specific to the component. +When generated code is wrong and the fix cannot wait for a generator release, the same guide's §9.1 covers the stop-gap: a workaround from hand-written code where one exists, otherwise a tracked patch that `post-generate.sh` re-applies after every regen until the fix ships. Its `apply-gen-patches.sh` is language-neutral. + ## Where to go next - [`csharp-component-guide.md`](csharp-component-guide.md): the C# component in full, with an audit checklist for Claude diff --git a/docs/csharp-component-guide.md b/docs/csharp-component-guide.md index 7a3ec32..64b0778 100644 --- a/docs/csharp-component-guide.md +++ b/docs/csharp-component-guide.md @@ -224,7 +224,7 @@ The generated `TestSeed` (stable GUIDs and a pre-wired entity graph) and the `A. ## 5. Extending generated code -Never edit a file under `gen-src/`. A wrong generated file is fixed in the spec or in the generator, then regenerated; an edit disappears on the next run. Four patterns cover everything else, all in `src/`, all in the generated type's own namespace. +Never edit a file under `gen-src/`. A wrong generated file is fixed in the spec or in the generator, then regenerated; an edit disappears on the next run. The one exception, a tracked patch while a generator fix is pending, is §9.1. Four patterns cover everything else, all in `src/`, all in the generated type's own namespace. ### 5.1 `partial` classes @@ -321,7 +321,7 @@ A review finding that recurs becomes a rule, not a longer checklist. The bar for ### 6.4 Wire it as a gate -`.specfuse/verification.yml` (`specfuse init ` creates it) carries a `code` gate set. That key is what the Specfuse loop runs as its exit oracle and what the generator's harness runs against every regen. The starter's `verification.snippet.yml` is the shape: `gen-contract` first (seconds, no toolchain), `build` with `needs: [gen-contract]`, `tests` with `needs: [build]`, so a structural breach never pays for the .NET chain. CI runs the same order: the starter's `.github/workflows/gen-contract.yml` runs the contract and its mutation test on every PR with no SDK; the `dotnet build` / `dotnet test` job sits beside it. +`.specfuse/verification.yml` (`specfuse init ` creates it) carries a `code` gate set. That key is what the Specfuse loop runs as its exit oracle and what the generator's harness runs against every regen. The starter's `verification.snippet.yml` is the shape: `gen-contract` and `gen-patches` first (seconds, no toolchain), `build` with `needs: [gen-contract, gen-patches]`, `tests` with `needs: [build]`, so a structural breach never pays for the .NET chain. CI runs the same order: the starter's `.github/workflows/gen-contract.yml` runs the contract, its mutation test and the patch check on every PR with no SDK; the `dotnet build` / `dotnet test` job sits beside it. ## 7. Tests and coverage @@ -334,11 +334,11 @@ A review finding that recurs becomes a rule, not a longer checklist. The bar for Put the rules an agent will need in the first thirty lines of `CLAUDE.md` (or `AGENTS.md`), before anything else, because it reads them before it reads the error. The seed from `add-component` carries rules 1, 2 and 4; add 3 and 5 when you seed the contract (§6). An older repo writes all five in: -1. `*/gen-src/**` and every `*.g.cs` are generated from `../`; the next regen overwrites them. **Never edit them.** If a task seems to need it, stop: the fix belongs in the spec or the generator. Say so and note that a regen is required. +1. `*/gen-src/**` and every `*.g.cs` are generated from `../`; the next regen overwrites them. **Never edit them.** If a task seems to need it, stop: the fix belongs in the spec or the generator. Say so and note that a regen is required. The only exception is a tracked patch (§9.1), and an agent proposes one rather than writing it. 2. Before writing a model, DTO, repository, mapper, service or controller by hand, check `gen-src/`. It probably exists. 3. Hand-written code goes in `src/`: `partial` classes in the generated namespace, concrete classes deriving from the generated `*Base`, `partial` mapper hooks. Never implement a generated `I{X}Handler` or `I{Tag}ApplicationService` directly. 4. The layering (§2.2) and where the seams live (§4). -5. The regen flow (§9), and that `./scripts/check-gen-contract.sh` is the first thing to run after any regen. +5. The regen flow (§9), and that `./scripts/check-gen-contract.sh` and `./scripts/apply-gen-patches.sh --check` are the first things to run after any regen. ## 9. The regen flow @@ -348,8 +348,8 @@ Put the rules an agent will need in the first thirty lines of `CLAUDE.md` (or `A cd ../hello-orders-specs ./scripts/specfuse/generate.sh Domain Infrastructure Api # one jar run per group, bundled once ``` - `generate.sh` refuses a destination repo with uncommitted changes. -3. **Run the contract, then build.** `./scripts/check-gen-contract.sh` first: a red rule is a generator defect or a hand-written file in the wrong place, and either is cheaper to see before the build. Then `dotnet build` and the unit tests. Fix the hand-written `src/` that the regen broke (a renamed member, a new abstract method on a base, a new placeholder contract); never the generated side. + `generate.sh` refuses a destination repo with uncommitted changes. With `scripts/post-generate.sh` wired in the spec repo (§9.1), each group's tracked patches go back on as soon as it is generated. +3. **Run the contract, then build.** `./scripts/check-gen-contract.sh` and `./scripts/apply-gen-patches.sh --check` first: a red rule is a generator defect or a hand-written file in the wrong place, and either is cheaper to see before the build. Then `dotnet build` and the unit tests. Fix the hand-written `src/` that the regen broke (a renamed member, a new abstract method on a base, a new placeholder contract); never the generated side. 4. **Review by file name, not by path.** A regen rewrites the whole tree, so diff size is not a signal. Diff changed models by file name (a moved file reads as delete plus add), diff each enum's **values**, diff operation parameter lists, trace every hand-written type that mirrors a generated one, and read `CHANGELOG.md` where the generator writes one. For C# it tracks Domain entities, value objects and services and the Test group's event builders, not controllers, DTOs or EF configuration, so it complements the file-by-file review rather than replacing it. Removals are the highest-risk content of a regen and nothing mechanical catches them. 5. **One PR per component**, containing the regen and only its required follow-ups. CI runs the contract and the mutation test, then the build. 6. **Merge, then advance the baseline** in the spec repo from the merged default branch, never from a local regen: @@ -360,7 +360,53 @@ Put the rules an agent will need in the first thirty lines of `CLAUDE.md` (or `A ``` and open a spec-repo PR with only the changed `.specfuse/state/.json` files ([`component-repos.md` §6](component-repos.md#6-advance-the-statedir-baseline-only-from-merged-component-code)). -When generated output is wrong, file it against the generator with the generated C# that is expected, never a proposed template change; the generator agent locates the template from the output shape. When the spec is wrong, the component files a `specfuse:spec-issue` in the spec repo. +When generated output is wrong, file it against the generator with the generated C# that is expected, never a proposed template change; the generator agent locates the template from the output shape. If the fix cannot wait, §9.1. When the spec is wrong, the component files a `specfuse:spec-issue` in the spec repo. + +### 9.1 Temporary patches, until the generator is fixed + +Sometimes generated code is wrong and the fix cannot wait for a generator release: it does not compile, or it does the wrong thing in a method you cannot override. "Never edit `gen-src/`" still holds in spirit: whatever you change must survive the next regen, be visible in review, and go away the moment the generator is fixed. A tracked patch does all three. + +**First, look for a way that needs no patch.** In this order: + +1. **The spec is wrong.** Fix it there and regenerate, or file a `specfuse:spec-issue` in the spec repo. +2. **Work around it from `src/`** with the §5 patterns: a `partial` member, an override on the generated `*Base` or `Default{Entity}Service`, a replacement registered after the generated call, a mapper hook. These survive every regen untouched. Mark each with a `// Workaround for clabonte/generator#NNNN` comment so it is found when the fix lands. +3. **Take the operation out of generation** with `x-manual` (§5.4) when the defect is confined to one operation and the hand-written version is small. +4. **Only then, patch `gen-src/`.** + +Whichever you choose, file the defect against the generator first, with the generated C# you expected (§9). The patch names that issue; a patch without one is never removed. + +**How a patch works.** `gen-patches/NNNN-.patch` is a `git diff` of generated files, headed by `Generator-Issue:`, `Generator-Version:` and `Reason:` lines. `gen-patches/README.md` is the index: every active patch, its issue, and what breaks without it. The starter ships the index and `scripts/apply-gen-patches.sh`, which re-applies the patches after a regen and reports one line per patch: + +| Report | Meaning | Do | +|---|---|---| +| `applied` | the patch went back on cleanly | commit it with the regen | +| `already present` | right after a regen, the generator now emits the fix itself | confirm, then retire the patch | +| `FAILED` | the generated code changed around it | re-derive the patch from the new output, or retire it if the defect is gone | + +`--check` is the gate: every patch is applied, carries its header, is in the index, and only modifies existing files under `gen-src/`. A regen PR that forgot to re-apply a patch fails it. + +**Re-apply on every regen, automatically.** The spec repo's `generate.sh` runs a project-owned `scripts/post-generate.sh` after each group. Have it call the component's script: + +```bash +#!/bin/bash +# /scripts/post-generate.sh +set -euo pipefail +if [ "$2" = "C#" ] && [ -x "$3/scripts/apply-gen-patches.sh" ]; then + "$3/scripts/apply-gen-patches.sh" "$4" +fi +``` + +A `FAILED` patch exits non-zero and stops the run, so a regen never finishes with a patch silently dropped. + +**Making one.** On a clean regen branch, edit the generated file, write the patch with its header and add the index row; the seeded `gen-patches/README.md` has the exact commands. Commit the patch, the patched files, the index row and a **Known failure modes** entry in `docs/generated-code-expectations.md` together. That entry is how the generator's consumer-QA reviewer learns the defect is known and tracked instead of reporting it again. A coding agent proposes a patch and waits; it never creates one on its own (§8). + +**Keep patches small and away from the public surface.** + +- Modify, never create or delete: a new type goes in `src/`. The path manifests record what the generator emits, and a patch that adds or removes a file makes them disagree with the tree. +- Leave the header block alone: `GEN-002` and `GEN-003` still apply to a patched file. +- The regen's `CHANGELOG.md` is written before the patch goes back on, while the baseline `advance-state.sh` captures comes from your merged, patched code. A patch that changes a tracked surface (a Domain entity, value object or service signature) therefore shows up in every regen's `CHANGELOG.md`. Say so in the PR rather than chasing it. + +**Retiring one.** When the issue is fixed and the generator the spec repo runs (the kit's pin, or the project's `.specfuse/generator.lock`) reaches that release, regenerate and expect `already present`. Confirm the output is right without the patch, delete the patch file, move its row to **Retired**, and remove its expectations entry and any `src/` workaround tied to it. Review the index at every pin bump: a patch whose issue is closed but that still reports `applied` means the fix did not cover your case, so comment on or reopen the issue. ## 10. The audit checklist @@ -480,9 +526,9 @@ grep -n -i '^#\+ *\(Standing expectations\|Integration contract\|Known failure m ``` Five lines. -**CS-20 · `.specfuse/verification.yml` has a `code` gate set with `gen-contract`, `build` (`needs: [gen-contract]`) and `tests` (`needs: [build]`).** +**CS-20 · `.specfuse/verification.yml` has a `code` gate set with `gen-contract`, `gen-patches`, `build` (`needs: [gen-contract, gen-patches]`) and `tests` (`needs: [build]`).** ```bash -grep -n -A2 'name: *\(gen-contract\|build\|tests\)$' .specfuse/verification.yml +grep -n -A2 'name: *\(gen-contract\|gen-patches\|build\|tests\)$' .specfuse/verification.yml ``` **CS-21 · CI runs the contract before the build on every pull request.** @@ -514,10 +560,16 @@ grep -n -i 'gen-src\|never edit\|partial\|Base\|check-gen-contract' CLAUDE.md AG grep -n 'generate.sh\|advance-state.sh\|\.\./' README.md | head ``` +**CS-26 · Every temporary patch to generated code is applied, indexed, and names a generator issue.** +```bash +./scripts/apply-gen-patches.sh --check && grep -h '^Generator-Issue:' gen-patches/*.patch 2>/dev/null +``` +Exit 0, and every issue listed is still open: a closed one is a patch to retire (§9.1). + ## Where to go next - [`component-repos.md`](component-repos.md): the language-neutral rules, the commit policy and the baseline step in full - [`add-component.md`](add-component.md): the seeded host for a new C# repo - [`Project_File.md`](../handbooks/Project_File.md): `destination`, `basePackage`, `cleanGenerated`, `tenancy`, the C# artifact catalogue -- [`templates/component-contract/csharp/`](../templates/component-contract/csharp/): the starter contract, its mutation test and the expectations skeleton +- [`templates/component-contract/csharp/`](../templates/component-contract/csharp/): the starter contract, its mutation test, the expectations skeleton and the patch tooling - [`csharp-component.html`](csharp-component.html): this guide as an interactive map, with the checklist you can tick off and copy diff --git a/docs/csharp-component.html b/docs/csharp-component.html index 5a9808b..6127aed 100644 --- a/docs/csharp-component.html +++ b/docs/csharp-component.html @@ -353,7 +353,7 @@

C# Component Map

"regen": [ {"t": "The spec change merges in the spec repo", "who": "yours", "body": "A component PR never lands before the spec change it was generated from. Otherwise this repo's default branch depends on a spec that is not on the spec repo's default branch."}, {"t": "Regenerate on a disposable branch of this repo", "who": "auto", "body": "From the spec repo root. cleanGenerated wipes gen-src/ before writing, so this repo must be on a branch you can throw away with no uncommitted work; generate.sh refuses a dirty destination. Name the groups; prefer them over all.", "cmd": "cd ../hello-orders-specs\n./scripts/specfuse/generate.sh Domain Infrastructure Api"}, - {"t": "Run the contract, then build", "who": "auto", "body": "The contract first: seconds, no toolchain, and a red rule is either a generator defect or a hand-written file in the wrong place. Then the build and the unit tests. Fix the hand-written src/ a regen broke (a renamed member, a new abstract method on a base, a new placeholder contract); never the generated side.", "cmd": "./scripts/check-gen-contract.sh\ndotnet build --nologo -warnaserror\ndotnet test --no-build --filter \"Category!=Integration\""}, + {"t": "Run the contract, then build", "who": "auto", "body": "The contract and apply-gen-patches.sh --check first (tracked patches to generated code go back on through the spec repo's post-generate.sh hook; see the guide, 9.1): seconds, no toolchain, and a red rule is either a generator defect or a hand-written file in the wrong place. Then the build and the unit tests. Fix the hand-written src/ a regen broke (a renamed member, a new abstract method on a base, a new placeholder contract); never the generated side.", "cmd": "./scripts/check-gen-contract.sh\ndotnet build --nologo -warnaserror\ndotnet test --no-build --filter \"Category!=Integration\""}, {"t": "Review by file name, not by path", "who": "yours", "body": "A regen rewrites the whole tree, so diff size is not a signal. Diff changed models by file name (a moved file reads as delete plus add), diff each enum's values, diff operation parameter lists, trace every hand-written type that mirrors a generated one, and read each project's CHANGELOG.md. Removals are the highest-risk content of a regen and nothing mechanical catches them."}, {"t": "One PR per component", "who": "yours", "body": "The regen and only its required follow-ups. CI runs the contract and the mutation test with no SDK, then the build and tests."}, {"t": "Merge, then advance the baseline from the merged default branch", "who": "auto", "body": "The spec repo's .specfuse/state/.json is what the next regen's CHANGELOG is diffed against, so it must describe what shipped here. Never commit it from a spec branch or a local regen. The script keeps a group's new baseline only when this repo is clean, on its default branch and level with origin.", "cmd": "cd ../hello-orders-specs\n./scripts/specfuse/advance-state.sh --repo Backend --dry-run\n./scripts/specfuse/advance-state.sh --repo Backend"} @@ -398,12 +398,13 @@

C# Component Map

{"id": "CS-17", "section": "Extension discipline", "guide": "#54-replace-a-generated-default", "title": "Custom services and decorators are registered after the generated AddServices() / AddRepositories() calls.", "verify": "grep -n 'AddServices\\|AddRepositories\\|AddScoped/dev/null | head"}, - {"id": "CS-25", "section": "Agent instructions and workflow", "guide": "#9-the-regen-flow", "title": "The README documents the sibling layout, the regen commands and the baseline step.", "verify": "grep -n 'generate.sh\\|advance-state.sh\\|\\.\\./' README.md | head"} + {"id": "CS-25", "section": "Agent instructions and workflow", "guide": "#9-the-regen-flow", "title": "The README documents the sibling layout, the regen commands and the baseline step.", "verify": "grep -n 'generate.sh\\|advance-state.sh\\|\\.\\./' README.md | head"}, + {"id": "CS-26", "section": "Agent instructions and workflow", "guide": "#91-temporary-patches-until-the-generator-is-fixed", "title": "Every temporary patch to generated code is applied, indexed in gen-patches/README.md, and names a still-open generator issue.", "verify": "./scripts/apply-gen-patches.sh --check && grep -h '^Generator-Issue:' gen-patches/*.patch 2>/dev/null"} ], "prompt": "Read docs/csharp-component-guide.md from the Specfuse authoring kit (or the copy at ).\nAudit this repository against every check in §10, in order. For each check, run the\nverification command, then report one line: the check ID, PASS or FAIL, and the\nevidence (a path, a line, a command output). Do not fix anything yet. When the audit\nis complete, list the FAILs grouped by the section that explains the fix, and\npropose the smallest change for each. Wait for my approval before editing." diff --git a/scripts/test-csharp-contract-template.sh b/scripts/test-csharp-contract-template.sh index c66c483..6b50dfc 100755 --- a/scripts/test-csharp-contract-template.sh +++ b/scripts/test-csharp-contract-template.sh @@ -49,7 +49,7 @@ cp "$KIT/docs/generated-code-expectations.md" docs/ cp "$KIT/.github/workflows/gen-contract.yml" .github/workflows/ sed -i.bak "s/__Prefix__/$PREFIX/g" gen-contract.yml docs/generated-code-expectations.md rm -f gen-contract.yml.bak docs/generated-code-expectations.md.bak -chmod +x scripts/check-gen-contract.sh scripts/test-gen-contract.sh +chmod +x scripts/check-gen-contract.sh scripts/test-gen-contract.sh scripts/apply-gen-patches.sh # ── A minimal component tree ─────────────────────────────────────────────── cat > Directory.Build.props <<'EOF' @@ -176,4 +176,56 @@ variant_green "a test double in a *UnitTests project" git rm -q .gitignore variant_green "no .gitignore at all" +# ── Temporary patches: every state apply-gen-patches.sh must tell apart ─── +echo "── temporary patches" +expect_patches() { # [args...] + local code="$1" want="$2" out rc=0 + shift 2 + out="$(./scripts/apply-gen-patches.sh "$@" 2>&1)" || rc=$? + if [ "$rc" -ne "$code" ] || ! grep -qF -- "$want" <<<"$out"; then + echo "FAIL: apply-gen-patches.sh $*: wanted exit $code and '$want', got exit $rc:" >&2 + printf '%s\n' "$out" >&2 + exit 1 + fi + echo " ok ${*:-(apply)}: $want" +} + +ORDER="$PREFIX.Domain/gen-src/Entities/Order.g.cs" +PATCH=gen-patches/0001-order-id-init.patch +mkdir -p gen-patches +cp "$KIT/gen-patches/README.md" gen-patches/ +expect_patches 0 "no patches" +sed -i.bak 's/{ get; set; }/{ get; init; }/' "$ORDER" && rm -f "$ORDER.bak" +{ printf 'Generator-Issue: clabonte/generator#1\nGenerator-Version: 0.19.0\nReason: Id must be init-only\n\n' + git diff -- "$ORDER"; } > "$PATCH" +printf '| `0001-order-id-init.patch` | clabonte/generator#1 | 0.19.0 | Id is mutable |\n' >> gen-patches/README.md +git add -A +git commit -q -m "patch" + +expect_patches 0 "ok $PATCH" --check +./scripts/check-gen-contract.sh --quiet >/dev/null || { echo "FAIL: the contract rejects a tree with gen-patches/" >&2; exit 1; } + +git show HEAD~1:"$ORDER" > "$ORDER" # a regen wiped the fix +expect_patches 1 "NOT APPLIED" --check +expect_patches 0 "" "$PREFIX.Api" # out of scope: left alone +expect_patches 1 "NOT APPLIED" --check +expect_patches 0 "applied $PATCH" +expect_patches 0 "ok" --check + +expect_patches 0 "already present $PATCH" # the generator now emits the fix + +git show HEAD~1:"$ORDER" | sed 's/public partial class Order/public sealed partial class Order/' > "$ORDER" +expect_patches 1 "FAILED $PATCH" # the code around it changed +expect_patches 1 "STALE" --check +git checkout -q -- "$ORDER" + +grep -vF '0001-order-id-init.patch' gen-patches/README.md > gen-patches/README.tmp && mv gen-patches/README.tmp gen-patches/README.md +expect_patches 1 "not listed in gen-patches/README.md" --check +git checkout -q -- gen-patches/README.md + +sed -i.bak '/^Generator-Issue:/d' "$PATCH" && rm -f "$PATCH.bak" +expect_patches 1 "no 'Generator-Issue:' line" --check +git checkout -q -- "$PATCH" +git reset -q --hard HEAD~1 + echo "OK: the C# starter contract is green on a fresh seed and every rule bites." diff --git a/templates/component-contract/csharp/.github/workflows/gen-contract.yml b/templates/component-contract/csharp/.github/workflows/gen-contract.yml index 95982d7..5ba05fb 100644 --- a/templates/component-contract/csharp/.github/workflows/gen-contract.yml +++ b/templates/component-contract/csharp/.github/workflows/gen-contract.yml @@ -36,3 +36,6 @@ jobs: - name: Prove the rules bite run: ./scripts/test-gen-contract.sh + + - name: Temporary patches to generated code are applied and tracked + run: ./scripts/apply-gen-patches.sh --check diff --git a/templates/component-contract/csharp/README.md b/templates/component-contract/csharp/README.md index ccf5fbb..8ed7765 100644 --- a/templates/component-contract/csharp/README.md +++ b/templates/component-contract/csharp/README.md @@ -13,6 +13,8 @@ and `add-component` will seed them automatically once | `scripts/gen_contract_checks.py` | `scripts/` | The two project-specific checks the generic engine cannot express: `generated_base_adopted` and `generated_handler_interface_implemented_directly`. | | `scripts/check-gen-contract.sh` | `scripts/` | Runs the contract through the shared engine (`specfuse.loop.gen_contract`, specfuse-loop 0.30+). | | `scripts/test-gen-contract.sh` | `scripts/` | Mutation test: breaks the tree one rule at a time and asserts the rule reports it. | +| `scripts/apply-gen-patches.sh` | `scripts/` | Re-applies the tracked temporary patches in `gen-patches/` after a regen; `--check` is the gate that they are all in place. | +| `gen-patches/README.md` | `gen-patches/` | The index of active and retired patches, and how to make and retire one. | | `docs/generated-code-expectations.md` | `docs/` | The prose half, with the five section names the generator's QA reviewer reads. | | `verification.snippet.yml` | merge into `.specfuse/verification.yml` | The `code` gate set: contract, build, tests. | | `.github/workflows/gen-contract.yml` | `.github/workflows/` | CI: contract plus mutation test on every PR, no .NET SDK needed. | @@ -33,7 +35,8 @@ cp "$KIT/scripts/"* scripts/ cp "$KIT/docs/generated-code-expectations.md" docs/ cp "$KIT/.github/workflows/gen-contract.yml" .github/workflows/ sed -i.bak 's/__Prefix__/HelloOrders/g' gen-contract.yml docs/generated-code-expectations.md && rm -f *.bak docs/*.bak -chmod +x scripts/check-gen-contract.sh scripts/test-gen-contract.sh +mkdir -p gen-patches && cp "$KIT/gen-patches/README.md" gen-patches/ +chmod +x scripts/check-gen-contract.sh scripts/test-gen-contract.sh scripts/apply-gen-patches.sh python3 -m pip install 'specfuse-loop>=0.30' # or: pipx install specfuse ./scripts/check-gen-contract.sh # must be green ./scripts/test-gen-contract.sh # every rule must bite diff --git a/templates/component-contract/csharp/docs/generated-code-expectations.md b/templates/component-contract/csharp/docs/generated-code-expectations.md index 902ff3d..c988f83 100644 --- a/templates/component-contract/csharp/docs/generated-code-expectations.md +++ b/templates/component-contract/csharp/docs/generated-code-expectations.md @@ -77,6 +77,9 @@ here breaks the app even when the generated tree itself compiles: ## Known failure modes — re-check each regen +Every active patch in `gen-patches/` has an entry here naming the patch and +its generator issue, so a reviewer reads the defect as known and tracked. + diff --git a/templates/component-contract/csharp/gen-patches/README.md b/templates/component-contract/csharp/gen-patches/README.md new file mode 100644 index 0000000..286d5c6 --- /dev/null +++ b/templates/component-contract/csharp/gen-patches/README.md @@ -0,0 +1,48 @@ +# Temporary patches to generated code + +Every file here is a stop-gap for a generator defect that cannot be worked +around from `src/`. Each one is re-applied after every regen by +`scripts/apply-gen-patches.sh` (the spec repo's `scripts/post-generate.sh` calls +it) and checked on every pull request with `--check`. A patch lives until the +generator fix ships **and** the pin reaches it; then it is retired, not kept "just +in case". + +The full procedure, with when a patch is the wrong tool, is in the Specfuse +authoring kit's C# component guide, §9.1. + +## Active + +A patch missing from this table fails `--check`. + +| Patch | Generator issue | Since generator | What breaks without it | +|---|---|---|---| + +## Retired + +| Patch | Generator issue | Fixed in | Retired in | +|---|---|---|---| + +## Making a patch + +1. Regenerate on a clean branch, so `gen-src/` is exactly what the generator wrote. +2. Edit the generated file(s). Modify only: never create, delete or rename a generated file. +3. Write the patch, header first, then the diff: + + ```bash + { printf 'Generator-Issue: clabonte/generator#NNNN\nGenerator-Version: 0.19.0\nReason: \n\n' + git diff -- ; } > gen-patches/NNNN-.patch + ``` + +4. Add a row to **Active**, and an entry under **Known failure modes** in + `docs/generated-code-expectations.md` naming the patch. +5. `./scripts/apply-gen-patches.sh --check` is green. Commit the patch, the patched + files, this table and the expectations entry together. + +## Retiring a patch + +When `apply-gen-patches.sh` reports **already present** right after a regen, or +the issue is closed and the pin has reached the fix: + +1. Confirm the regenerated code is right without the patch. +2. Delete the patch file and move its row to **Retired**. +3. Remove its **Known failure modes** entry, and any workaround that depended on it. diff --git a/templates/component-contract/csharp/scripts/apply-gen-patches.sh b/templates/component-contract/csharp/scripts/apply-gen-patches.sh new file mode 100755 index 0000000..c6a2779 --- /dev/null +++ b/templates/component-contract/csharp/scripts/apply-gen-patches.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# +# Re-apply this repo's temporary patches to generated code, or prove they are +# all in place. +# +# A patch in gen-patches/ is a stop-gap for a generator defect that cannot be +# worked around from src/: a `git diff` of gen-src/ files, headed by the +# generator issue that will make it unnecessary. A regen with cleanGenerated +# wipes gen-src/, so every patch has to go back on after every regen until the +# fix ships in the generator and the pin reaches it. The spec repo's +# scripts/post-generate.sh calls this once per regenerated group; CI runs it +# with --check. +# +# ./scripts/apply-gen-patches.sh # apply every patch +# ./scripts/apply-gen-patches.sh HelloOrders.Api # only patches touching these project folders +# ./scripts/apply-gen-patches.sh --check # every patch is applied and tracked (the gate) +# +# Per patch, applying reports one of: +# applied it went on cleanly +# already present the tree already has it. Right after a regen, that means the +# generator now emits the fix: retire the patch (exit 0, warns) +# FAILED neither: the generator changed the code around it. Re-derive +# the patch from the new output, or retire it if the defect is gone +# +# Every patch must carry a `Generator-Issue:` line before its first `diff --git`, +# be listed by file name in gen-patches/README.md, and only modify existing files +# under a gen-src/ folder: a patch never creates or deletes a generated file. +# +# Exit: 0 all good, 1 a patch failed or is untracked, 2 cannot run. +# +# Seeded from the Specfuse authoring kit (templates/component-contract/csharp/) +# and owned by this repo from then on. + +set -uo pipefail +cd "$(git -C "$(dirname "${BASH_SOURCE[0]}")" rev-parse --show-toplevel)" || exit 2 + +DIR=gen-patches +INDEX="$DIR/README.md" +CHECK=false +FOLDERS=() +for a in "$@"; do + case "$a" in + --check) CHECK=true ;; + -h|--help) sed -n '2,31p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;; + -*) echo "apply-gen-patches: unknown option $a" >&2; exit 2 ;; + *) FOLDERS+=("${a%/}") ;; + esac +done + +shopt -s nullglob +PATCHES=("$DIR"/*.patch) +if [ ${#PATCHES[@]} -eq 0 ]; then + echo "apply-gen-patches: no patches in $DIR/" + exit 0 +fi + +# Does every file the patch touches sit under one of the named folders? +in_scope() { + [ ${#FOLDERS[@]} -eq 0 ] && return 0 + local f ok + while IFS= read -r f; do + ok=false + for d in "${FOLDERS[@]}"; do + case "$f" in "$d"/*) ok=true ;; esac + done + $ok || return 1 + done < <(git apply --numstat "$1" | cut -f3) + return 0 +} + +# Header, index and shape problems, one line each, empty when the patch is well formed. +problems() { + local p="$1" name f + name="$(basename "$p")" + sed '/^diff --git /,$d' "$p" | grep -q '^Generator-Issue: *[^ ]' \ + || echo "no 'Generator-Issue:' line before the first diff" + [ -f "$INDEX" ] && grep -qF "$name" "$INDEX" \ + || echo "not listed in $INDEX" + git apply --summary "$p" | grep -Eq '^ *(create|delete) mode' \ + && echo "creates or deletes a file; a patch only modifies existing generated files" + while IFS= read -r f; do + case "$f" in */gen-src/*) ;; *) echo "touches $f, which is not under a gen-src/ folder" ;; esac + done < <(git apply --numstat "$p" | cut -f3) +} + +status=0 +retire=() +for p in "${PATCHES[@]}"; do + in_scope "$p" || continue + issue="$(sed '/^diff --git /,$d' "$p" | sed -n 's/^Generator-Issue: *//p' | head -1)" + bad="$(problems "$p")" + if [ -n "$bad" ]; then + echo " FAILED $p" + while IFS= read -r line; do echo " $line"; done <<<"$bad" + status=1 + continue + fi + if $CHECK; then + if git apply --reverse --check "$p" 2>/dev/null; then + echo " ok $p ($issue)" + elif git apply --check "$p" 2>/dev/null; then + echo " NOT APPLIED $p ($issue): run ./scripts/apply-gen-patches.sh" + status=1 + else + echo " STALE $p ($issue): the generated code changed under it; re-derive or retire it" + status=1 + fi + else + if git apply --check "$p" 2>/dev/null; then + git apply "$p" && echo " applied $p ($issue)" + elif git apply --reverse --check "$p" 2>/dev/null; then + echo " already present $p ($issue)" + retire+=("$p") + else + echo " FAILED $p ($issue): does not apply to the regenerated code" + git apply --check "$p" 2>&1 | sed 's/^/ /' | head -5 + status=1 + fi + fi +done + +if [ ${#retire[@]} -gt 0 ]; then + echo "" + echo "If this run follows a fresh regen, the generator now emits these changes itself." + echo "Confirm the issue is fixed, then retire each patch (gen-patches/README.md, 'Retiring a patch'):" + printf ' %s\n' "${retire[@]}" +fi +exit "$status" diff --git a/templates/component-contract/csharp/verification.snippet.yml b/templates/component-contract/csharp/verification.snippet.yml index a78aff7..e788013 100644 --- a/templates/component-contract/csharp/verification.snippet.yml +++ b/templates/component-contract/csharp/verification.snippet.yml @@ -15,8 +15,13 @@ code: # file:line, no judgement calls. Seconds, no toolchain. command: "./scripts/check-gen-contract.sh" + - name: gen-patches + # Every tracked temporary patch to generated code is applied, indexed and + # names its generator issue (gen-patches/README.md). Seconds, no toolchain. + command: "./scripts/apply-gen-patches.sh --check" + - name: build - needs: [gen-contract] + needs: [gen-contract, gen-patches] # Add -warnaserror once the build is clean; that is a team choice, not # something the generated code requires. command: "dotnet build --nologo" From 41ee9d1e29517f5ada601da5fa06921e705c4dc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Labont=C3=A9?= Date: Wed, 28 Oct 2026 11:10:56 -0400 Subject: [PATCH 4/4] docs(csharp): a Patches view on the interactive page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page now walks the temporary-patch process the guide's §9.1 describes, as its own view between Regen flow and Contract: six steps from "look for a way that needs no patch" through filing the issue, making the patch, the post-generate.sh re-apply, the --check gate and retirement, plus what each apply-gen-patches.sh report means and the rules that keep a patch small. Co-Authored-By: Claude Opus 5.5 --- docs/csharp-component.html | 45 +++++++++++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/docs/csharp-component.html b/docs/csharp-component.html index 6127aed..3a28059 100644 --- a/docs/csharp-component.html +++ b/docs/csharp-component.html @@ -4,7 +4,8 @@ Licensed under the Apache License, Version 2.0. See LICENSE. Interactive companion to docs/csharp-component-guide.md: the layout, build -wiring, seams, extension patterns, regen flow, starter contract and audit +wiring, seams, extension patterns, regen flow, temporary patches, starter +contract and audit checklist of a C# component repo, as a map you click through. The Markdown guide is the source of truth; scripts/check-csharp-page.py keeps the embedded data (#guide-data) in step with the guide's check IDs, the starter contract's @@ -15,7 +16,7 @@ C# Component Map - + @@ -219,6 +220,7 @@

C# Component Map

{"id": "seams", "name": "Seams", "sub": "What generated code needs from you"}, {"id": "extend", "name": "Extend", "sub": "Four patterns, one trap"}, {"id": "regen", "name": "Regen flow", "sub": "Spec, component, baseline"}, + {"id": "patches", "name": "Patches", "sub": "Until the generator is fixed"}, {"id": "contract", "name": "Contract", "sub": "The 16 starter rules"}, {"id": "checklist", "name": "Checklist", "sub": "Audit this repo"} ], @@ -350,6 +352,15 @@

C# Component Map

"trap": "Editing DefaultOrderService in gen-src/ instead is the hand edit the whole layout exists to forbid: it compiles, passes review once, and is gone on the next regen.", "check": "CS-17"} ], + "patchFlow": [ + {"t": "Look for a way that needs no patch", "who": "yours", "body": "In order: fix the spec and regenerate; work around it from src/ with a partial member, an override on the generated *Base or Default{Entity}Service, a replacement registered after the generated call, or a mapper hook, marked // Workaround for clabonte/generator#NNNN; take the operation out of generation with x-manual. Each of these survives every regen untouched. Only when none fits, patch gen-src/."}, + {"t": "File the defect against the generator", "who": "yours", "body": "Before writing the patch: the generated C# you expected, never a proposed template change. The patch names this issue in its header, and a patch with no issue is never removed."}, + {"t": "Make the patch on a clean regen branch", "who": "yours", "body": "Edit the generated file: modify only, never create or delete one, and leave the header block alone (GEN-002, GEN-003). Write the diff with its header into gen-patches/, add a row to gen-patches/README.md, and a Known failure modes entry in docs/generated-code-expectations.md so the generator's reviewer reads the defect as known. Commit all of it together. A coding agent proposes a patch and waits; it never writes one on its own.", "cmd": "{ printf 'Generator-Issue: clabonte/generator#NNNN\\nGenerator-Version: 0.19.0\\nReason: \\n\\n'\n git diff -- ; } > gen-patches/NNNN-.patch"}, + {"t": "Re-applied on every regen", "who": "auto", "body": "generate.sh runs the spec repo's own scripts/post-generate.sh after each group; it calls this repo's apply-gen-patches.sh with the group's project folder. A patch that no longer applies exits non-zero and stops the run, so a regen never finishes with a patch silently dropped.", "cmd": "#!/bin/bash\n# /scripts/post-generate.sh \nset -euo pipefail\nif [ \"$2\" = \"C#\" ] && [ -x \"$3/scripts/apply-gen-patches.sh\" ]; then\n \"$3/scripts/apply-gen-patches.sh\" \"$4\"\nfi"}, + {"t": "Gated on every pull request", "who": "auto", "body": "The gen-patches gate (build needs it) and the CI step run --check: every patch applied, headed by its Generator-Issue, listed in the index, and touching existing gen-src/ files only. A regen PR that forgot to re-apply a patch fails here, in seconds, with no SDK.", "cmd": "./scripts/apply-gen-patches.sh --check"}, + {"t": "Retire it when the fix reaches the pin", "who": "yours", "body": "Once the issue is fixed and the generator the spec repo runs (the kit's pin, or the project's .specfuse/generator.lock) reaches that release, regenerate and expect already present. Confirm the output is right without the patch, delete it, move its row to Retired, and remove its expectations entry and any src/ workaround tied to it. Review the index at every pin bump: a closed issue whose patch still reports applied means the fix missed your case."} + ], + "regen": [ {"t": "The spec change merges in the spec repo", "who": "yours", "body": "A component PR never lands before the spec change it was generated from. Otherwise this repo's default branch depends on a spec that is not on the spec repo's default branch."}, {"t": "Regenerate on a disposable branch of this repo", "who": "auto", "body": "From the spec repo root. cleanGenerated wipes gen-src/ before writing, so this repo must be on a branch you can throw away with no uncommitted work; generate.sh refuses a dirty destination. Name the groups; prefer them over all.", "cmd": "cd ../hello-orders-specs\n./scripts/specfuse/generate.sh Domain Infrastructure Api"}, @@ -419,7 +430,7 @@

C# Component Map

const esc = s => String(s).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); const main = $("#main"), nav = $("#nav"); - const ui = { view: "layout", node: "backend", proj: "Domain", withOptional: true, seam: "program", pattern: "base", regen: 0, rule: 0, openCheck: null }; + const ui = { view: "layout", node: "backend", proj: "Domain", withOptional: true, seam: "program", pattern: "base", regen: 0, patch: 0, rule: 0, openCheck: null }; let ticks = {}; try { ticks = JSON.parse(localStorage.getItem("csharp-component.checks") || "{}") || {}; } catch (_) { ticks = {}; } const saveTicks = () => { try { localStorage.setItem("csharp-component.checks", JSON.stringify(ticks)); } catch (_) {} }; @@ -553,6 +564,29 @@

${esc(n.title)}

${esc(n.body)}

${facts(n.facts)}

What a green build does not prove

A regen can change a field's name or an enum's wire value while every hand-written type that mirrors it still compiles. Diff changed models by file name, each enum's values, every operation's parameter list, and trace every hand-written mirror. Wrong generated output goes to the generator as the C# that was expected, never as a template change; a wrong spec goes to the spec repo as a specfuse:spec-issue.

`; } + /* ---------- Patches ---------- */ + function viewPatches() { + const r = D.patchFlow[ui.patch]; + return `

Patches

+

Sometimes generated code is wrong and the fix cannot wait for a generator release. "Never edit gen-src/" still holds in spirit: the change must survive the next regen, be visible in review, and go away the moment the generator is fixed. A tracked patch in gen-patches/ does all three, and it is the last resort, not the first.

+
+
    ${D.patchFlow.map((x, i) => `
  1. `).join("")}
+
${r.who === "yours" ? "you" : "a script does this"}

${esc(r.t)}

${esc(r.body)}

${r.cmd ? `${esc(r.cmd)}` : ""} +

Checklist CS-26 · ${guideLink("#91-temporary-patches-until-the-generator-is-fixed")}

+
+

What apply-gen-patches.sh reports

+ + + + +
ReportMeaningDo
appliedthe patch went back on cleanlycommit it with the regen
already presentright after a regen, the generator now emits the fix itselfconfirm, then retire the patch
FAILEDthe generated code changed around itre-derive the patch from the new output, or retire it if the defect is gone
+

With --check the same script is the gate, reporting ok, NOT APPLIED or STALE per patch.

+

Keep patches small and away from the public surface

    +
  • Modify, never create or delete: a new type goes in src/. The path manifests record what the generator emits, and a patch that adds or removes a file makes them disagree with the tree.
  • +
  • The regen's CHANGELOG.md is written before the patch goes back on, while the baseline advance-state.sh captures comes from your merged, patched code. A patch to a Domain entity, value object or service signature therefore shows up in every regen's changelog: say so in the PR rather than chasing it.
  • +
  • The starter ships scripts/apply-gen-patches.sh and the gen-patches/README.md index; the script is language-neutral.
`; + } + /* ---------- Contract ---------- */ function viewContract() { const r = D.rules[ui.rule]; @@ -600,7 +634,7 @@

${esc(n.title)}

${esc(n.body)}

${facts(n.facts)} function render() { renderNav(); - const v = { layout: viewLayout, wiring: viewWiring, seams: viewSeams, extend: viewExtend, regen: viewRegen, contract: viewContract, checklist: viewChecklist }[ui.view] || viewLayout; + const v = { layout: viewLayout, wiring: viewWiring, seams: viewSeams, extend: viewExtend, regen: viewRegen, patches: viewPatches, contract: viewContract, checklist: viewChecklist }[ui.view] || viewLayout; main.innerHTML = v(); if (location.hash.replace("#", "") !== ui.view) history.replaceState(null, "", "#" + ui.view); } @@ -612,7 +646,7 @@

${esc(n.title)}

${esc(n.body)}

${facts(n.facts)} }); main.addEventListener("click", e => { - const t = e.target.closest("[data-node],[data-proj],[data-optional],[data-seam],[data-pattern],[data-regen],[data-rule],[data-open],[data-act]"); + const t = e.target.closest("[data-node],[data-proj],[data-optional],[data-seam],[data-pattern],[data-regen],[data-patch],[data-rule],[data-open],[data-act]"); if (!t) return; const d = t.dataset; if (d.node) ui.node = d.node; @@ -621,6 +655,7 @@

${esc(n.title)}

${esc(n.body)}

${facts(n.facts)} else if (d.seam) ui.seam = d.seam; else if (d.pattern) ui.pattern = d.pattern; else if (d.regen) ui.regen = +d.regen; + else if (d.patch) ui.patch = +d.patch; else if (d.rule) ui.rule = +d.rule; else if (d.open) ui.openCheck = ui.openCheck === d.open ? null : d.open; else if (d.act === "reset") { ticks = {}; saveTicks(); }