diff --git a/.agents/skills/purview-architecture-testing/SKILL.md b/.agents/skills/purview-architecture-testing/SKILL.md index 0619225f..55d0ca8b 100644 --- a/.agents/skills/purview-architecture-testing/SKILL.md +++ b/.agents/skills/purview-architecture-testing/SKILL.md @@ -9,6 +9,7 @@ license: MIT metadata: author: Purview-Dev version: "2.0" + repository: purview-dev/engineering-playbook --- # Purview architecture testing @@ -83,7 +84,7 @@ debugging the package. Then add a test that the documentation matches the policy, so the table in the wiki is falsifiable too. Full worked example: -[Project Responsibilities](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Project-Responsibilities.md). +[Project Responsibilities](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/foundations/Project-Responsibilities.md). ## 3. Read project files, and assemblies @@ -180,4 +181,4 @@ project classified so every dependency rule still applies to it. Prefer a waiver - `purview-architecture` — what the projects are for, and how they are named - `purview-buildsdk-architecture-tests` — the package's own skill, authoritative on its API -- [Architecture as Tests](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Architecture-As-Tests.md) +- [Architecture as Tests](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/foundations/Architecture-As-Tests.md) diff --git a/.agents/skills/purview-architecture/SKILL.md b/.agents/skills/purview-architecture/SKILL.md index c04f3d61..0c435966 100644 --- a/.agents/skills/purview-architecture/SKILL.md +++ b/.agents/skills/purview-architecture/SKILL.md @@ -10,6 +10,7 @@ license: MIT metadata: author: Purview-Dev version: "1.0" + repository: purview-dev/engineering-playbook --- # Purview architecture @@ -173,4 +174,4 @@ small telemetry — it does not get none. See `purview-telemetry`. - `purview-telemetry` — instrumentation at every scale - `purview-persistence` — storage and GUID-key guidance, only when persistence is real - [Engineering Principles](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Engineering-Principles.md) -- [Adopting This At Your Scale](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Adopting-This-At-Your-Scale.md) +- [Adopting This At Your Scale](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/governance/Adopting-This-At-Your-Scale.md) diff --git a/.agents/skills/purview-architecture/references/contract-strategy.md b/.agents/skills/purview-architecture/references/contract-strategy.md index e281a410..62b5a17f 100644 --- a/.agents/skills/purview-architecture/references/contract-strategy.md +++ b/.agents/skills/purview-architecture/references/contract-strategy.md @@ -230,7 +230,7 @@ combines" stays a review obligation, because a dependency matrix cannot express ## See also -- [Contracts and Data Exchange](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Contracts-And-Data-Exchange.md) +- [Contracts and Data Exchange](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/data/Contracts-And-Data-Exchange.md) — `Create` and `Hydrate`, and the schema as the source of truth for a wire shape - [zodsharp](https://github.com/purview-dev/zodsharp) — authoritative on `[ZodSchema]` and JSON Schema export diff --git a/.agents/skills/purview-architecture/references/project-responsibilities.md b/.agents/skills/purview-architecture/references/project-responsibilities.md index d74d2cf4..dc4e67cc 100644 --- a/.agents/skills/purview-architecture/references/project-responsibilities.md +++ b/.agents/skills/purview-architecture/references/project-responsibilities.md @@ -3,7 +3,7 @@ The catalogue in brief. The authoritative definition is [`.config/architecture-policy.json`](https://github.com/purview-dev/engineering-playbook/blob/main/.config/architecture-policy.json), and the full page — with diagrams — is -[Project Responsibilities](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Project-Responsibilities.md). +[Project Responsibilities](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/foundations/Project-Responsibilities.md). Do not restate either here or in another skill; link to them. ## The catalogue diff --git a/.agents/skills/purview-buildsdk-adoption/SKILL.md b/.agents/skills/purview-buildsdk-adoption/SKILL.md index b1f222c5..bb24fa32 100644 --- a/.agents/skills/purview-buildsdk-adoption/SKILL.md +++ b/.agents/skills/purview-buildsdk-adoption/SKILL.md @@ -100,9 +100,14 @@ silently gets no runner, no coverage, no `InternalsVisibleTo` and no category. ## 5. Agent skills are a separate, explicit step -The SDK does not install agent skills, and nothing it does during a build writes to an agent -directory. It reads which skills each referenced package ships and recommends the command that -installs them; the skills CLI does the installing. See `purview-buildsdk-skill-installation`. +Nothing the SDK does during a restore or a build touches agent skills. Installing, updating and +cleaning them up are separate targets a person runs by name — `InstallPurviewSkills`, +`UpdatePurviewSkills`, `CheckPurviewSkills`, `FixPurviewSkills` — and the ones that change the +repository refuse to run inside a build (`PRSKL0009`). Do not hook them into one. The skills CLI does +the installing, through `bun x` by default; with `PurviewSkillsAgents` unset it auto-detects agents. + +If the repository uses `just`, add recipes for them — see `purview-buildsdk-skills-justfile`. For what each +target does and the `PRSKL*` diagnostics, see `purview-buildsdk-skill-installation`. ## Constraints diff --git a/.agents/skills/purview-buildsdk-configuration/SKILL.md b/.agents/skills/purview-buildsdk-configuration/SKILL.md index d82768b3..b540704b 100644 --- a/.agents/skills/purview-buildsdk-configuration/SKILL.md +++ b/.agents/skills/purview-buildsdk-configuration/SKILL.md @@ -41,7 +41,7 @@ in `Directory.Build.props` unless the setting is deliberately project-specific. | `TestingFramework` | `TUnit` | `TUnit`, `Xunit` or `None` | | `SubstituteFramework` | `TUnitMocks` | `TUnitMocks`, `NSubstitute` or `None` | | `TestDataFramework` | `Bogus` | `Bogus` or `None` | -| `TargetFramework` | `net10.0` | `netstandard2.0` when `IsRoslynComponent=true` | +| `TargetFramework` | `net10.0;net11.0` | `netstandard2.0` when `IsRoslynComponent=true` | | `IsPackable` | `false` | Opts a project into the packable defaults | | `UsePackageJsonVersion` | `true` | `true`, `false` or `Strict` — version from `package.json` | | `ExcludePurviewTelemetry` | `false` | Drops the telemetry source generator | @@ -50,6 +50,11 @@ in `Directory.Build.props` unless the setting is deliberately project-specific. | `PurviewBrowserSafe` | *(unset)* | On a class library a WebAssembly client downloads: trim, AOT and browser-unsupported APIs fail the build | | `IsBffProject` | name ends `Bff` | Classifies a BFF host; `false` opts out | | `PurviewBrowserSafeReferenceCheckAsError` | `false` | Makes `PRBLZ0001` (a client reference that is not browser-safe) an error | +| `MemorySafetyRules` | `2` | C# 15 memory-safety tier on `net11.0`+: `0` off, `1` preview syntax, `2` updated rules | +| `PurviewRuntimeAsync` | `true` | `runtime-async=on` on `net11.0`+ | +| `IsClientSideProject` | *(computed)* | Whether the project's assemblies can reach a browser; overrides the `ServerShared` classification | +| `PurviewAutoServerSharedReference` | `true` | Adds a sibling `ServerShared` reference to a non-client-side project | +| `PurviewMultiThreadedBuild` | `true` | Bootstraps a `Directory.Build.rsp` enabling MSBuild `-mt` | The full set, grouped by area and with the behaviour each one changes, is in [references/properties.md](references/properties.md). Read it rather than guessing a property name — @@ -89,11 +94,15 @@ is the SDK's answer, and it stays correct when the rules change. The exported se ## Repository bootstrap -The SDK bootstraps two repository-wide files from the package: `.editorconfig` and `global.json`. +The SDK bootstraps three repository-wide files from the package: `.editorconfig`, `global.json` and +`Directory.Build.rsp`. - `PurviewRepoBootstrapMode` — `IfMissing` (default), `Always`, `WarnOnDrift`, `Never` - `DisableAutoCopySdkFiles` — the master off switch - `PurviewRepoBootstrapCopyRetries` / `...RetryDelayMilliseconds` / `...FailureAsError` +- `BootstrapMultiThreadedBuildRspToRepoRoot` / `PurviewMultiThreadedBuild` — the response file that + enables MSBuild `-mt` (and `-nr:false`, which avoids a `RoslynCodeTaskFactory` cache left by a + non-`-mt` build) Every project in a solution writes those same destinations, so the write is race-tolerant by design: an existing file is never overwritten unless asked, each write is staged into a temporary @@ -105,6 +114,18 @@ the repository; that is gone, along with `EnableAgentFolderInPackage`, `AgentPac and the `PurviewAgentFolder*` properties. Skills have exactly one installer, the skills CLI — see `purview-buildsdk-skill-installation`. +The skill targets read their settings when they run, so these can go in `Directory.Build.props` or +on the command line, and a repository-wide choice belongs in `Directory.Build.props`: + +| Property | Default | Controls | +| -- | -- | -- | +| `PurviewSkillsRunner` | `bun` (`bun x skills`) | Which package runner launches the skills CLI | +| `PurviewSkillsAgents` | empty — the CLI auto-detects | Agents to install to; when set, the only agent directories `CheckPurviewSkills` allows | +| `PurviewSkillsDryRun` | `false` | Install and update print commands instead of running them | + +On a command line, `dotnet msbuild -p:` splits values on `,` and `;` — write +`-p:PurviewSkillsAgents=claude-code%2Ccodex`. For `just` recipes, see `purview-buildsdk-skills-justfile`. + ## Guidance when changing SDK configuration 1. Preserve existing defaults unless the task explicitly changes product behaviour. diff --git a/.agents/skills/purview-buildsdk-configuration/references/properties.md b/.agents/skills/purview-buildsdk-configuration/references/properties.md index 1fb1f81b..82f84d77 100644 --- a/.agents/skills/purview-buildsdk-configuration/references/properties.md +++ b/.agents/skills/purview-buildsdk-configuration/references/properties.md @@ -21,7 +21,7 @@ consuming project supplies nothing. | Property | Default | Behaviour | | -- | -- | -- | -| `TargetFramework` | `net10.0` | Applied when neither `TargetFramework` nor `TargetFrameworks` is set. A project that explicitly declares `IsRoslynComponent=true` defaults to `netstandard2.0` | +| `TargetFramework` | `net10.0;net11.0` | Applied when neither `TargetFramework` nor `TargetFrameworks` is set. A project that explicitly declares `IsRoslynComponent=true` defaults to `netstandard2.0` | | `IsRoslynComponent` | *(unset)* | When explicitly `true`: a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, `TreatWarningsAsErrors=true`, `Deterministic=true`, extended analyzer rules, SourceLink with `EmbedUntrackedSources=true`, no dependency file, telemetry exclusion, and `PrivateAssets=all` on `Microsoft.CodeAnalysis.*` references | | `IsRoslynComponentOnly` | `true` for Roslyn components | Analyzer-only package: `IncludeBuildOutput=false`, `IncludeSymbols=false`, portable PDB packed beside the analyzer under `analyzers/dotnet/cs/`. Set `false` for a dual-role component using normal library symbol packaging | | `DisableRoslynCompilerDefaultsValidation` | `false` | Opts out of `ValidateRoslynComponentCompilerSettings`, which fails the pack of a packable Roslyn component missing `LangVersion`, `Nullable`, `TreatWarningsAsErrors` or `EnforceExtendedAnalyzerRules` | @@ -36,6 +36,19 @@ consuming project supplies nothing. | `DisablePurviewTestContextRuleSet` | `false` | Makes test and shared-testing projects enforce the production API-surface rules too | | `PurviewPolicyExemptNoWarn` | *(derived)* | The SDK-injected `NoWarn` entries `ValidatePurviewStylePolicy` accepts | +## .NET 11 features and ServerShared + +| Property | Default | Behaviour | +| -- | -- | -- | +| `MemorySafetyRules` | `2` | C# 15 memory-safety tier on `net11.0`+ targets: `0` off, `1` preview pointer syntax (sets `LangVersion=preview` when none was chosen), `2` `updated-memory-safety-rules`. Unknown values fail with `PRCFG0001`. Never touches `AllowUnsafeBlocks` | +| `PurviewRuntimeAsync` | `true` | `runtime-async=on` on `net11.0`+ targets. `UseRuntimeAsync=false` also opts out | +| `PurviewMultiThreadedBuild` | `true` | Bootstraps a managed `Directory.Build.rsp` with `-mt`/`-nr:false`; `false` writes `-mt:false` | +| `BootstrapMultiThreadedBuildRspToRepoRoot` | `true` | Whether that bootstrapped response file is written at all | +| `IsClientSideProject` | *(computed)* | `true` for a WebAssembly client, `PurviewBrowserSafe` library, Razor class library, or `*.Client`/`*.Browser`/`*.Wasm`/`*.WebAssembly`/`*.Components` name. Explicit value wins | +| `PurviewAutoServerSharedReference` | `true` | Adds `../ServerShared*/ServerShared*.csproj` to a non-client-side project | +| `PurviewServerSharedReferenceCheckAsError` | `false` | Makes `PRSHR0001`/`PRSHR0002` errors | +| `DisablePurviewServerSharedReferenceCheck` | `false` | Turns `PRSHR0001`/`PRSHR0002` off | + ## Testing | Property | Default | Behaviour | @@ -119,6 +132,9 @@ reference was judged browser-safe when it cannot say so itself. | `RepositoryEditorConfigFilePath` | *(derived)* | Overrides the destination `.editorconfig` | | `BootstrapGlobalJsonToRepoRoot` | `true` | | | `RepositoryGlobalJsonFilePath` | *(derived)* | Overrides the destination `global.json` | +| `BootstrapMultiThreadedBuildRspToRepoRoot` | `true` | Creates a managed `Directory.Build.rsp` enabling `-mt` | +| `RepositoryDirectoryBuildRspFilePath` | *(derived)* | Overrides the destination `Directory.Build.rsp` | +| `PurviewMultiThreadedBuild` | `true` | `false` rewrites a managed rsp to `-mt:false` | | `PurviewBuildSdkVersionForGlobalJson` | restored package version, else the version it was packed as | | | `PurviewRepoBootstrapCopyRetries` | `3` | | | `PurviewRepoBootstrapCopyRetryDelayMilliseconds` | `500` | | @@ -136,7 +152,7 @@ nothing else was left for the mirror to deliver. Covered in full by `purview-buildsdk-skill-installation`: `PurviewSkillsRunner`, `PurviewSkillsRunnerCandidates`, `PurviewSkillsCommand`, `PurviewSkillsPackage`, `PurviewSkillsAgents`, `PurviewSkillsSource`, `PurviewSkillsInstallRoot`, `PurviewSkillsRepoRoot`, -`EnablePurviewSkillRecommendations`, `PurviewSkillRecommendationsInCI`. +`PurviewSkillsDryRun`, and the `PurviewSkillsAgentDirectory` item group. ## Compiler-visible properties @@ -148,7 +164,11 @@ Exported to analyzers and generators as `build_property.`: - `ExcludePurviewTelemetry`, `ExcludeMSTelemetryExtension` - `EnableAssemblyNameGeneration`, `DisableAutoInternalsVisibleTo`, `DisableGenerateAssemblyInfoClass`, `DisableGenerateAssemblyIdentifierClass`, `AssemblyIdentifierClassName` -- `IsCSharpProject`, `IsTestProject`, `IsSharedTestingProject`, `IsSharedProject` +- `IsCSharpProject`, `IsTestProject`, `IsSharedTestingProject`, `IsSharedProject`, + `IsServerSharedProject`, `IsClientSideProject` +- `PurviewAutoServerSharedReference`, `DisablePurviewServerSharedReferenceCheck`, + `PurviewServerSharedReferenceCheckAsError` +- `MemorySafetyRules`, `PurviewRuntimeAsync`, `PurviewMultiThreadedBuild` - `TestingType`, `TargetProjectName` - `IsContainerProject`, `IsSdkProject`, `SdkProjectName`, `IsWebProject`, `IsWebSdkProject`, `IsWorkerSdkProject`, `IsAspireHostProject`, `IsCLIProject` @@ -163,6 +183,6 @@ Exported to analyzers and generators as `build_property.`: - `PurviewSuppressCopyRetryWarnings` - `PurviewSkillsSourceFolder`, `PurviewSkillsAgents`, `PurviewSkillsInstallRoot`, `PurviewRepositoryRootMarkers`, - `PurviewSkillsRunner` + `PurviewSkillsRunner`, `PurviewSkillsDryRun` - `PurviewBuildSdkVersionForGlobalJson`, `CurrentYear`, `AutoGeneratedAssemblyInfoFile`, `AutoGeneratedAssemblyIdentifierFile` diff --git a/.agents/skills/purview-buildsdk-project-detection/SKILL.md b/.agents/skills/purview-buildsdk-project-detection/SKILL.md index 8573ca40..bd80b3f5 100644 --- a/.agents/skills/purview-buildsdk-project-detection/SKILL.md +++ b/.agents/skills/purview-buildsdk-project-detection/SKILL.md @@ -1,6 +1,6 @@ --- name: purview-buildsdk-project-detection -description: Explain why Purview.BuildSdk classified a project as test, shared, shared-testing, CLI, web, worker, Aspire host, container, Roslyn component, Blazor WebAssembly client, Razor class library, Blazor host or BFF, and why it added a package, a project reference, a namespace or an analyzer automatically. Use when SDK behaviour looks surprising, when a test project gets no runner or no InternalsVisibleTo, or when reasoning about RootNamespace, AssemblyName and TargetProjectName. +description: Explain why Purview.BuildSdk classified a project as test, shared, shared-testing, ServerShared, client-side, CLI, web, worker, Aspire host, container, Roslyn component, Blazor WebAssembly client, Razor class library, Blazor host or BFF, and why it added a package, a project reference, a namespace or an analyzer automatically. Use when SDK behaviour looks surprising, when a test project gets no runner or no InternalsVisibleTo, or when reasoning about RootNamespace, AssemblyName and TargetProjectName. license: MIT metadata: package: Purview.BuildSdk @@ -25,7 +25,8 @@ dotnet msbuild -getProperty:PurviewLogicalProjectName -getProperty:Roo The values worth inspecting first: `MSBuildProjectName`, `NamespacePrefix`, `PurviewLogicalProjectName`, `RootNamespace`, `TestingType`, `TargetProjectName`, `SdkProjectName`, -`IsTestProject`, `IsSharedProject`, `IsSharedTestingProject`, `IsContainerProject`, `IsCLIProject`, +`IsTestProject`, `IsSharedProject`, `IsServerSharedProject`, `IsClientSideProject`, +`IsSharedTestingProject`, `IsContainerProject`, `IsCLIProject`, `IsWebSdkProject`, `IsAspireHostProject`, `IsBlazorWebAssemblyProject`, `IsRazorClassLibraryProject`, `IsBlazorHostProject`, `IsBffProject`. @@ -52,11 +53,16 @@ a non-test project grants access to `{AssemblyName}.{TestType}Tests` for each na suffix outside it still builds, runs and gets a category — it just cannot see `internal` members. The 53 names are in [references/test-types.md](references/test-types.md). -### Shared and shared-testing projects +### Shared, shared-testing and ServerShared projects -Recognised by **exact name**, never by substring. The two lists are in +Recognised by **exact name**, never by substring. The three lists are in [references/detection.md](references/detection.md). Derived: `IsSharedProject`, -`IsSharedTestingProject`. +`IsSharedTestingProject`, `IsServerSharedProject`. + +`ServerShared` is separate from `Shared` for a reason: a `Shared` project is referenced by almost +everything, including code that ends up in a browser, so server-only helpers get their own name and +are referenced automatically only by a project that cannot be downloaded. `IsClientSideProject` is the +classification that decides it. ### SDK and content based diff --git a/.agents/skills/purview-buildsdk-project-detection/references/detection.md b/.agents/skills/purview-buildsdk-project-detection/references/detection.md index 85fde09c..51243c96 100644 --- a/.agents/skills/purview-buildsdk-project-detection/references/detection.md +++ b/.agents/skills/purview-buildsdk-project-detection/references/detection.md @@ -5,6 +5,20 @@ `Shared`, `SharedFramework`, `SharedInfrastructure`, `SharedInfra`, `SharedUtilities`, `SharedUtils`, `SharedLibrary`, `SharedLib`, `SharedHelpers` +## ServerShared project names (exact) + +`ServerShared`, `ServerSharedFramework`, `ServerSharedInfrastructure`, `ServerSharedInfra`, +`ServerSharedUtilities`, `ServerSharedUtils`, `ServerSharedLibrary`, `ServerSharedLib`, +`ServerSharedHelpers` + +A `ServerShared` project is referenced automatically by every non-client-side project, and never by a +WebAssembly client, a `PurviewBrowserSafe` library, a Razor class library, a project named with a +client-side segment (`*.Client`, `*.ClientShared`, `*.Browser`, `*.Wasm`, `*.WebAssembly`, +`*.Components`, or exactly `Components`/`ClientShared`), or a `Shared`/`SharedTesting` project. +`IsClientSideProject` overrides the classification. `PRSHR0001` reports a client-side reference and +`PRSHR0002` a `Shared` reference; `PurviewBrowserSafe="true"` on the reference records an accepted +decision. + ## Shared testing project names (exact) `SharedTestingFramework`, `SharedTestingInfrastructure`, `SharedTestingInfra`, @@ -64,7 +78,8 @@ dual-role component that wants normal library symbol packaging. | Container | `InvariantGlobalization`, `PublishAot`, Linux Docker defaults, container tooling packages | | Web SDK | `Microsoft.AspNetCore.OpenApi.Generated` added to `InterceptorsNamespaces`, unless marked as a separate web-project mode | | Aspire host | `OutputType=Exe` | -| Blazor WebAssembly client | not packable, no container defaults, no automatic `Shared` reference, the browser-safe reference check - see [blazor.md](blazor.md) | +| Blazor WebAssembly client | not packable, no container defaults, no automatic `Shared`/`ServerShared` reference, the browser-safe reference check - see [blazor.md](blazor.md) | +| ServerShared project | referenced automatically by every non-client-side project; never by a WebAssembly client, browser-safe library, Razor class library, client-side name, or `Shared`/`SharedTesting` project | | Blazor host | an automatic reference to its `.Client` - see [blazor.md](blazor.md) | | BFF | no packages, no OpenAPI interceptor unless it exposes an API, `PDS0008`/`PDS0009` - see [blazor.md](blazor.md) | | `PurviewBrowserSafe` library | `IsAotCompatible`, `SupportedPlatform browser`, trim/AOT/`CA1416` as errors - see [blazor.md](blazor.md) | diff --git a/.agents/skills/purview-buildsdk-repository-review/SKILL.md b/.agents/skills/purview-buildsdk-repository-review/SKILL.md index ce1fd294..2be4c08f 100644 --- a/.agents/skills/purview-buildsdk-repository-review/SKILL.md +++ b/.agents/skills/purview-buildsdk-repository-review/SKILL.md @@ -1,6 +1,6 @@ --- name: purview-buildsdk-repository-review -description: Review a repository that uses Purview.BuildSdk for naming, placement and test-structure alignment, and report what the SDK can and cannot do automatically as a result. Use when asked to audit, rationalise or health-check a repo against the SDK's conventions, when onboarding an existing codebase onto Purview.BuildSdk, or when deciding whether a layout quirk is worth changing. +description: Review a repository that uses Purview.BuildSdk for naming, placement, test-structure and agent-skill-management alignment, and report what the SDK can and cannot do automatically as a result. Use when asked to audit, rationalise or health-check a repo against the SDK's conventions, when onboarding an existing codebase onto Purview.BuildSdk, or when deciding whether a layout quirk is worth changing. license: MIT metadata: package: Purview.BuildSdk @@ -89,7 +89,24 @@ go stale: is not any more. - A package the SDK already adds, restated in a project file, is maintenance cost with no effect. -## 6. Explain consequences, not just mismatches +## 6. Check how agent skills are managed + +Only relevant when the repository has a `skills-lock.json`, an agent skills directory, or a package +reference that ships skills: + +- **Anything that runs `InstallPurviewSkills`, `UpdatePurviewSkills` or `FixPurviewSkills` + implicitly** — an `AfterTargets`/`BeforeTargets` hook, a `*DependsOn` property, a git hook, a + `just` recipe dependency — is structural debt: the SDK fails it with `PRSKL0009`, and skill changes + should only happen when somebody asks. +- **Hand-written skill scripts** (`update-agent-skills.ts`, `ensure-universal-skills.ts`, + `package.json` `skills:*` entries) are an incremental opportunity: the SDK targets replace them, and + pin package skills to their package's commit where the scripts install latest. See + `purview-buildsdk-skills-justfile`. +- **Drift**: run `dotnet msbuild -t:RecommendPurviewSkills` and quote any `PRSKL0010`/`PRSKL0011`. +- **Layout**: if `PurviewSkillsAgents` is set, run `-t:CheckPurviewSkills` and quote any `PRSKL0015`. + If it is not set, every agent directory is legitimate — that is acceptable variance, not a finding. + +## 7. Explain consequences, not just mismatches For each finding, name the specific automatic behaviour that is lost: `RootNamespace`, `AssemblyName`, `PackageId`, `TargetProjectName`, the automatic target-project reference, the diff --git a/.agents/skills/purview-buildsdk-settings-docs-sync/SKILL.md b/.agents/skills/purview-buildsdk-settings-docs-sync/SKILL.md new file mode 100644 index 00000000..0933fb63 --- /dev/null +++ b/.agents/skills/purview-buildsdk-settings-docs-sync/SKILL.md @@ -0,0 +1,188 @@ +--- +name: purview-buildsdk-settings-docs-sync +description: Change or document a Purview.BuildSdk setting without leaving part of the surface behind. Use when flipping a .NET 11 compiler feature default (Runtime Async, memory-safety rules), adding or renaming a property, or making sure every skill and doc explains how to set required settings and versions, the target framework(s), and the compiler-visible (build_property.*) settings. Repository-local guidance for purview-dev/build-sdk. +license: MIT +metadata: + scope: repository-local + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Keeping settings, docs and skills in step + +> **Repository-local.** This skill lives in `.agents/skills/`, which is gitignored, not packed and not +> published. It is deliberately **not** linked from `AGENTS.md`: `RepositorySkillsSelfTests` fails any +> AGENTS.md link that points into `.agents/skills/`, because a fresh clone does not have this folder. +> The published skills are authored under `skills/` and are a different thing entirely. + +## The rule this exists for + +A property is written down in several places, and only some of them are checked by a test. The failure +mode this repository keeps hitting is a change that lands in one place and stops there — the property +declaration, its validation, `README.md`, the wiki page and the shipped skills drift apart, and the +only evidence is a consumer following a doc that still describes last release's default. + +So: **treat a settings change as a sweep, not an edit.** Work the surfaces below, then prove it with +the validation commands at the end. + +## When to use this + +- Flipping a .NET 11 compiler feature default (`MemorySafetyRules`, `PurviewRuntimeAsync`). +- Adding, renaming, re-defaulting or removing a user-facing property. +- Making required settings, versions, target frameworks or compiler-visible settings clearer in the + docs and the shipped skills. +- Auditing whether a property is documented everywhere before you change it. + +For auditing a *consumer* repository, use `purview-buildsdk-repository-review`. For what a project +detection rule does, use `purview-buildsdk-project-detection`. + +## The surfaces — one property, many homes + +Find the row and make sure every file agrees. + +| Surface | File | What changes | +| -- | -- | -- | +| Runtime default (all projects) | `src/src/BuildSdk/Sdk/Props/Defaults.props` | The default when unset, plus the explanatory comment. Sets the value in `Sdk.props`, which is also what makes it compiler-visible. | +| Per-TFM default | `src/src/BuildSdk/Sdk/Targets/Modern.targets` | The `net11.0`+ feature selection. Must agree with `Defaults.props`. | +| Compiler-visible description | `src/src/BuildSdk/Sdk/Sdk.props` | The `CompilerVisibleProperty` ``, which is what an analyzer author reads. | +| Pinning test | `src/tests/BuildSdk.IntegrationTests/Dotnet11FeaturesTests.cs` | The test that fails when the default changes silently. | +| Changelog | `CHANGELOG.md` | A `[Unreleased]` entry: `### Changed` for behaviour, `### Documentation` for docs-only. | +| README | `README.md` | The feature table row, the property table(s), the quick start, and the `.NET 11 features` section + tiers paragraph. | +| Wiki | `docs/wiki/Configuration-Reference.md`, `Getting-Started.md`, `Version-Detection.md`, `Project-Type-Detection.md` | The property tables, the required-settings and target-framework sections, and the compiler-visible section. | +| Shipped skills | `skills/purview-buildsdk-*/SKILL.md` and `references/*.md` | The property tables and the sections below. | +| AGENTS.md | `AGENTS.md` | The "authored set, and what each one answers" table, when a skill's scope grows. | + +Two traps worth naming: + +- **The default lives in two places.** `Defaults.props` defaults the property for every project (which + is what exposes it to the compiler); `Modern.targets` re-defaults it per-TFM. Change one and the + other silently disagrees. +- **`Sdk.props` vs `Sdk.targets` decides *where* a value may be set.** A property read during + `Sdk.props` evaluation must be set before the import; everything else can be set in the project. Say + which it is, in the docs, next to the property. + +A **feature** bigger than a single property gets its own guide as well. SourceLink is the worked +example, because the decision is per repository host: add a wiki page `docs/wiki/.md` and +register it in `_Sidebar.md` **and** `Home.md`, add a `## ` section to `README.md`, and — when +it is configuration — a `references/.md` under +`skills/purview-buildsdk-configuration/` that the `SKILL.md` links. Fix any wrong package or property +name you find while documenting: the SourceLink pages used to name `Microsoft.SourceLink.AzureDevOps.Git`, +which does not exist. + +## Worked example — flip a .NET 11 compiler feature default + +1. `Defaults.props` — change the default and the comment. Say **why** (an experimental compiler feature + is opt-in), not just what. +2. `Modern.targets` — change the same default in the per-TFM block and its header comment. +3. `Sdk.props` — the `CompilerVisibleProperty` `` ("0 off (default), …"). +4. `Dotnet11FeaturesTests.cs` — make the default test assert the switch is **absent**, rename it to say + so, and add an explicit opt-in test. De-couple any other test that asserted the switch was on by + default (for example, the project-features-survive test). +5. `README.md` — the `.NET 11 opt-ins` row, the `.NET 11 features` table row and the tiers paragraph, + and the property table row. +6. `docs/wiki/Configuration-Reference.md` — the General row **and** the `.NET 11 features` table. +7. Skills — the `MemorySafetyRules`/`PurviewRuntimeAsync` rows in + `skills/purview-buildsdk-configuration/SKILL.md` and `references/properties.md`. +8. `CHANGELOG.md` — a `[Unreleased]` `### Changed` entry that names the old default, the new one, how to + opt in, and what is unchanged. + +Keep `AllowUnsafeBlocks` (and anything else) at its compiler default: a feature flip must not quietly +change a second setting. + +## Required settings and versions — where it must be said + +Four values, stated as a table wherever a reader lands first: + +| Setting | Home | +| -- | -- | +| `NamespacePrefix` (required, pre-import) | `Directory.Build.props`, above the `` | +| SDK version | `global.json` → `msbuild-sdks.Purview.BuildSdk` (`PurviewBuildSdkVersionForGlobalJson` decides what the bootstrap writes) | +| Product version | repo-root `package.json` → `version` (`UsePackageJsonVersion`, `RootPackageJson`) | +| Package versions | `Directory.Packages.props` (CPM; a `PackageVersion` is a minimum) | + +Keep it identical in all four homes: `README.md` quick start, `docs/wiki/Configuration-Reference.md`, +`docs/wiki/Getting-Started.md`, and the `purview-buildsdk-adoption` and +`purview-buildsdk-configuration` skills. + +## Target framework(s) — where it must be said + +Every skill and doc that talks about configuring a project must explain how to set the target +framework(s), consistently: + +- the default set is `net10.0;net11.0` (`Current`); +- an explicit `TargetFramework`/`TargetFrameworks` must be declared **unconditionally** (the SDK reads + it from the project XML, so a `Condition` is not seen — `PurviewConditionedProjectDeclaration`); +- a curated `PurviewTargetFrameworkSet` (`Current`, `Latest`, `Supported`, `Broad`, `All`) or a + redefined `PurviewTargetFrameworks` (pre-import, in `Directory.Build.props`); +- a Roslyn component keeps `netstandard2.0` and ignores the sets (`PurviewTargetFrameworkSetIgnored`). + +The canonical home is `skills/purview-buildsdk-configuration/SKILL.md` and its +`references/properties.md`; the other skills point at it rather than restating the whole thing. + +## Compiler-visible settings — where it must be said + +The `CompilerVisibleProperty` → `build_property.*` mechanism, plus a one-line +`AnalyzerConfigOptionsProvider.GlobalOptions.TryGetValue("build_property.X", …)` example, belongs in: + +- `skills/purview-buildsdk-configuration/SKILL.md` and `references/properties.md`, +- `skills/purview-buildsdk-project-detection/SKILL.md` (the analyzers section), +- `README.md`, `docs/wiki/Configuration-Reference.md` and `docs/wiki/Project-Type-Detection.md`. + +Say both halves: **consume** the exported property instead of re-deriving SDK behaviour, and **add a +new one** by listing it as a `CompilerVisibleProperty` item (with a ``) in `Sdk.props` — +nothing else exposes an MSBuild property to the compiler. + +## Skills sweep + +Each published skill that touches project configuration gets the change; a skill that is only about +skills tooling does not. + +| Skill | What to update | +| -- | -- | +| `purview-buildsdk-configuration` | required settings, target framework(s), compiler-visible properties, the property table, `references/properties.md` | +| `purview-buildsdk-adoption` | the required-settings table and `global.json` example, the target-framework step | +| `purview-buildsdk-project-layout` | the target-framework section | +| `purview-buildsdk-project-detection` | the target-framework subsection, the `build_property.*` note | +| `purview-buildsdk-repository-review` | the read list (`global.json`, `package.json`), the target-framework finding | +| `purview-buildsdk-testing` | the test-project target-framework paragraph | +| `purview-buildsdk-skill-tests`, `-skill-installation`, `-skills-justfile` | **not** project configuration — leave them alone | + +Also update each skill's frontmatter `description` so the change makes the skill **fire** on the right +question ("setting the target framework(s)", "pinning the SDK version in global.json"). + +## Validate + +```bash +just build +just skills-validate # PRSKL0005, the pack-time check +just version-sync-check # the version pins must still match package.json +just lint-check # CSharpier over the repo + +# Targeted and fast (MTP: --treenode-filter, never --filter): +dotnet test --project src/BuildSdk.slnx --configuration Debug \ + --treenode-filter "/*/*/Dotnet11FeaturesTests/*" --ignore-exit-code 8 +dotnet test --project src/BuildSdk.slnx --configuration Debug \ + --treenode-filter "/*/*/(TargetFrameworkSetTests)|(CompilerVisiblePropertyTests)|(CoreDefaultsTests)/*" --ignore-exit-code 8 +dotnet test --project src/BuildSdk.slnx --configuration Debug \ + --treenode-filter "/*/*/(RepositorySkillTests)|(RepositorySkillsSelfTests)|(PurviewSkillValidationTests)/*" --ignore-exit-code 8 +``` + +State the commands you ran before declaring the change done. + +## Guardrails + +- **Do not publish repository-local advice.** Anything under `skills/` ships to every consumer through + `npx skills add purview-dev/build-sdk`; guidance about working *in this repository* stays here, in + `.agents/skills/`, or in `AGENTS.md`. +- **Do not add a link into `.agents/skills/` from `AGENTS.md`** — it is gitignored, so the link is + broken in a fresh clone and `RepositorySkillsSelfTests` fails the build. +- **Do not give a local skill an authored skill's name.** + `AuthoredSkillsAreNotInstalledIntoTheAgentDirectories` fails when a folder here collides with one + under `skills/`. +- **Preserve a default unless the task explicitly changes product behaviour.** A docs task is not + licence to re-default anything. +- **Do not restate a default in a consumer project file** — recommend deleting the repetition instead. +- **Never force-add `.agents/skills/`.** Everything the CLI writes is reproducible from + `skills-lock.json`; `InstalledSkillsAreNotTracked` fails if any of it is committed. +- Keep changes small, use Conventional Commits, and do not mix an unrelated refactor into the sweep. + diff --git a/.agents/skills/purview-buildsdk-skill-installation/SKILL.md b/.agents/skills/purview-buildsdk-skill-installation/SKILL.md index 66c20a33..5c2694dc 100644 --- a/.agents/skills/purview-buildsdk-skill-installation/SKILL.md +++ b/.agents/skills/purview-buildsdk-skill-installation/SKILL.md @@ -1,6 +1,6 @@ --- name: purview-buildsdk-skill-installation -description: Install, list and diagnose the agent skills a Purview.BuildSdk package ships. Use for the InstallPurviewSkills, ListPurviewSkills and RecommendPurviewSkills MSBuild targets, for any PRSKL0001-PRSKL0014 diagnostic, for choosing a package runner (npx, pnpm dlx, bunx, yarn dlx, deno), for skills-lock.json, or when a package ships skills but no agent can see them. +description: Install, update, list, check and diagnose the agent skills a Purview.BuildSdk package ships. Use for the InstallPurviewSkills, UpdatePurviewSkills, CheckPurviewSkills, FixPurviewSkills, ListPurviewSkills, RecommendPurviewSkills and VerifyPurviewSkills MSBuild targets, for updating skills after a package upgrade, for choosing agents with PurviewSkillsAgents, for any PRSKL diagnostic, for choosing a package runner (bun x, npx, pnpm dlx, bunx, yarn dlx, deno), for skills-lock.json, or when a package ships skills but no agent can see them. license: MIT metadata: package: Purview.BuildSdk @@ -9,7 +9,8 @@ metadata: # Installing the skills a package ships -For *authoring* skills, use `purview-buildsdk-skill-tests`. +For *authoring* skills, use `purview-buildsdk-skill-tests`; for `just` recipes that run these +targets, `purview-buildsdk-skills-justfile`. ## The one thing to understand first @@ -30,10 +31,22 @@ restore, a build, a design-time build or a test run. ```bash dotnet msbuild -t:ListPurviewSkills # diagnostics only, installs nothing dotnet msbuild -t:InstallPurviewSkills # delegates to the skills CLI +dotnet msbuild -t:UpdatePurviewSkills # re-add every skills-lock.json source: refresh, and add new skills +dotnet msbuild -t:CheckPurviewSkills # only the PurviewSkillsAgents directories exist +dotnet msbuild -t:FixPurviewSkills # remove the CLI's links from any other agent directory ``` -Neither target has `BeforeTargets` or `AfterTargets`. If skills are appearing or changing on their -own, something has been wired up that should not be. +They run **only when named**. None has `BeforeTargets` or `AfterTargets`, nothing in the SDK depends +on them, and `InstallPurviewSkills`, `UpdatePurviewSkills` and `FixPurviewSkills` refuse to run when +something else pulls them into a build, a restore or a design-time build (`PRSKL0009`). Do not hook +them into a build, a `*DependsOn` property or a git hook. If skills are changing on their own, +something has been wired up that should not be. + +Name **the solution**. Skills belong to the repository, so each target runs once however many +projects the solution holds: every project hands the work to the same elected leader, and MSBuild +runs it once. Package resolution reads every project in the solution, so a package only one project +references is still found. A single project works too, but sees only its own packages. For `just` +recipes that run these targets, see `purview-buildsdk-skills-justfile`. ## How a skill is resolved, and why the version is always right @@ -48,6 +61,57 @@ So the resolved ref is the commit the *restored package version* was built from. `PackageReference` gets that version's skills, not whatever is on the default branch. Compatibility is exact by construction rather than by convention. +## Updating + +```bash +# after bumping the PackageReference +dotnet restore +dotnet msbuild -t:UpdatePurviewSkills +``` + +`UpdatePurviewSkills` re-adds every unique source recorded in `skills-lock.json` with `skills add`: + +- a source a restored package ships is re-added at **that package's commit**, naming every skill + that package version ships — the same pinned `add` `PRSKL0011` recommends, so the skills match the + package version referenced; +- every other source is re-added at its latest with `--skill "*"`. + +That is the whole update. `skills add` reinstalls every skill it selects, so one re-add both +refreshes the installed skills and adds any the source has gained; `skills update` would only +re-fetch what the lockfile already lists, and is never run. `-p:PurviewSkillsDryRun=true` prints every +command and runs none. Each failed source is named (`PRSKL0008`). + +`InstallPurviewSkills` also works as an update for package skills alone: it re-adds each at its +package's commit. Do not reach for a bare `skills update` instead; see below. + +## Choosing agents + +`PurviewSkillsAgents` is **empty by default**, which passes no `--agent` and lets the CLI auto-detect +the agents on the machine. Set it to choose — in a project file `universal`, +or on a command line with the separator escaped or the value quoted, because `dotnet msbuild -p:` +itself splits on `,` and `;`: + +```bash +dotnet msbuild -t:UpdatePurviewSkills -p:PurviewSkillsAgents=claude-code%2Ccodex +``` + +Each agent becomes its own `--agent` flag; the CLI does not split a comma-separated value. + +When `PurviewSkillsAgents` is set, `CheckPurviewSkills` fails (`PRSKL0015`) for any agent skill +directory at the repository root that belongs to an agent it does not name. `.agents/skills` is always +allowed — it is the CLI's canonical store — and the authored `skills/` folder is never touched. +`FixPurviewSkills` removes links (symlinks, junctions) from those directories, never their targets, +and then any folder left empty; a real file is kept and reported, because it is not provably the +CLI's. The agent-to-directory map is the `PurviewSkillsAgentDirectory` item group, taken from the +CLI's own agent table; an agent it does not know is `PRSKL0016`, fixed by adding an item: + +```xml + +``` + +With `PurviewSkillsAgents` empty, both targets say so and do nothing: every agent directory is then a +legitimate destination. + ## Installing from an explicit source For a skill you want but have no `PackageReference` for: @@ -66,58 +130,78 @@ The caller owns the ref here — append `#` to pin it. This path needs no r | `PRSKL0001` | `project.assets.json` is missing | Restore. Resolution reads the restore graph and deliberately does not trigger a restore of its own | | `PRSKL0002` | The package's nuspec has no GitHub repository URL, or no commit | The package cannot be pinned. It is reported rather than installed from a guessed branch | | `PRSKL0003` | No repository root resolved | Pass `-p:PurviewSkillsInstallRoot=` | -| `PRSKL0004` | No skills CLI and no package runner found | Install Node, or set `PurviewSkillsCommand` | +| `PRSKL0004` | No skills CLI and no package runner found | Install Bun or Node, or set `PurviewSkillsCommand` | | `PRSKL0005` | An authored skill is malformed | See `purview-buildsdk-skill-tests` | -| `PRSKL0010` | A skill a referenced package ships is missing | Warning. `npx skills add ...` | +| `PRSKL0006` | Retired, with `PurviewSkillsUpdateMode` | Remove the property; update no longer runs `skills update` | +| `PRSKL0007` | `UpdatePurviewSkills` found no `skills-lock.json` | Install first with `-t:InstallPurviewSkills` | +| `PRSKL0008` | Re-adding a lockfile source failed | Read the CLI output above it | +| `PRSKL0009` | Install, update or fix was pulled into a build, a restore or a design-time build | Remove the hook; run the target on its own | +| `PRSKL0010` | A skill a referenced package ships is missing | Warning. `bun x skills add ...` | | `PRSKL0011` | An installed skill is pinned to a different ref than the referenced package | Warning. Install the package's own commit | | `PRSKL0012` | A skill cannot be verified | Message | | `PRSKL0013` | An orphaned lockfile entry | Message | | `PRSKL0014` | Recorded in the lockfile but never restored | Message | +| `PRSKL0015` | An agent skill directory exists for an agent `PurviewSkillsAgents` does not name | `-t:FixPurviewSkills`, then remove anything it kept by hand | +| `PRSKL0016` | `PurviewSkillsAgents` names an agent with no known skills directory | Warning. Add a `PurviewSkillsAgentDirectory` item | +| `PRSKL0017` | Projects in the solution reference one skill-shipping package at different versions | Warning. Align the versions; only one version's skills can be installed | + +## Nothing about skills runs during a build -## Recommendations run during an ordinary build +This used to be the opposite: a `RecommendPurviewSkillsOnBuild` hook ran on `AfterTargets="Build"`. +It was removed after measurement — on a 39-project solution it read and regex-scanned every project's +`project.assets.json` (≈500 KB, 256 libraries each), made thousands of failing `Directory.Exists` +calls, read the lockfile and walked the repository root, on every build, to print advice that a +repository managing its own skills had already acted on. All of its inputs are repository-scoped, so +the per-project cost bought nothing. -This is the **one** skill feature that does. `RecommendPurviewSkillsOnBuild` runs after `Build` and -delegates to `RecommendPurviewSkills`, which can also be invoked by name: +The work is now paid only when somebody names a target: ```bash -dotnet msbuild -t:RecommendPurviewSkills +dotnet msbuild -t:RecommendPurviewSkills # report, never fail +dotnet msbuild -t:VerifyPurviewSkills # report, and fail on PRSKL0010/0011 ``` `EvaluatePurviewSkillState` joins the skills each restored package ships against the CLI's own `skills-lock.json`. Both inputs are local files, so it stays offline and spawns no process. -Four things keep it quiet, and all of them are load-bearing: +`VerifyPurviewSkills` is the gate to call from CI. It escalates only `PRSKL0010` (missing) and +`PRSKL0011` (drifted) to errors: both are factual, both are fixed by one command, and neither can fire +without a referenced package that really ships skills. `PRSKL0012` stays a warning because a package +published without repository metadata is not the consumer's to fix, and `PRSKL0013`/`PRSKL0014` stay +messages. Failing over something the caller cannot clear is how a gate gets switched off. + +Three things shape the output: -- Design-time builds, CI builds, non-default TFM passes and - `EnablePurviewSkillRecommendations=false` are skipped outright. - `PurviewSkillRecommendationsInCI=true` re-enables CI. -- A fingerprint stamp at `/.purview/skill-recommendations.cache` means a given state is - reported once per repository, not once per project and again on every rebuild. The explicit target - ignores the stamp. - Missing and drifted are **warnings** (fixable in one command); unverifiable and orphaned are - **messages**. No amount of skill advice can break a build using `TreatWarningsAsErrors`. + **messages**. `VerifyPurviewSkills` turns missing and drifted into failures, and promotes + unverifiable to a warning. - A correctly installed set produces no output at all. +- There is no deduplication stamp any more. `EnablePurviewSkillRecommendations`, + `PurviewSkillRecommendationsInCI` and `PurviewSkillRecommendationStampPath` were retired with the + hook: a stamp existed so a per-build hook could report one state once per repository, and a target + a person invokes should answer every time it is asked. Orphan detection is **owner-scoped on purpose**: a lockfile cannot record who asked for an entry, so only an entry under an owner the project references — but from a repository it no longer references — is reported. A skill from any other owner is the developer's own, and is never mentioned. -### Drift never recommends `npx skills update` +### Drift never recommends a bare `skills update` -`update` installs the *newest* published skill, which is only correct when the newest skill happens -to describe the referenced package version. `PRSKL0011` names the referenced package's own commit in -a pinned `npx skills add` instead. +Re-adding a source without a ref installs the *newest* published skill, which is only correct when +the newest skill happens to describe the referenced package version. `PRSKL0011` names the referenced +package's own commit in a pinned `skills add` instead — which is what `-t:InstallPurviewSkills` and +`-t:UpdatePurviewSkills` run for package skills. ## Package runners The CLI is published to npm, and every runner spells "run this npm package" differently. -`PurviewSkillsRunner` selects one, defaulting to `npx` because npx ships with Node itself. +`PurviewSkillsRunner` selects one, defaulting to `bun` (`bun x skills`). | Runner | Invocation | | -- | -- | +| `bun` *(default)* | `bun x skills@1` | | `npx` | `npx -y skills@1` | | `bunx` | `bunx skills@1` | -| `bun` | `bun x skills@1` | | `pnpm` | `pnpm dlx skills@1` | | `yarn` | `yarn dlx skills@1` — Yarn 2+ only | | `deno` | `deno run -A npm:skills@1` | @@ -156,11 +240,9 @@ two-pass behaviour and its home-directory bound are both load-bearing. The CLI owns the agent-specific destinations and their discovery conventions, which differ per agent and change independently of this SDK. It also owns `skills-lock.json`, so a repository can restore -the same set later with `npx skills experimental_install`. - -`PurviewSkillsAgents` (default `*`) narrows the target agents. +the same set later with `bun x skills experimental_install`. -The lockfile is read **directly** rather than by parsing `npx skills list --json`, because that +The lockfile is read **directly** rather than by parsing `skills list --json`, because that listing omits the `ref` — and the ref is the whole compatibility question. Reading a file also needs no Node tooling, which is what keeps recommendations working on a machine that can build .NET but has no Node. There is deliberately no competing lockfile. diff --git a/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md b/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md index e90b695c..d5561745 100644 --- a/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md +++ b/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md @@ -44,7 +44,9 @@ an agent directory somewhere that is not the repository. A property a dependency computes is still empty in the depending target's `Condition`. That is why the unresolved-root guard sits on the `EvaluatePurviewSkillState` task rather than on -`RecommendPurviewSkills` — and equally why the build hook's CI and design-time gating works at all. +`RecommendPurviewSkills`, and why the explicit-only guard (`PRSKL0009`) is a dependency target with +its own `Error` rather than a `Condition` on `InstallPurviewSkills`, `UpdatePurviewSkills` or +`FixPurviewSkills`. ## The managed port diff --git a/.agents/skills/purview-buildsdk-skills-justfile/SKILL.md b/.agents/skills/purview-buildsdk-skills-justfile/SKILL.md new file mode 100644 index 00000000..2da36a29 --- /dev/null +++ b/.agents/skills/purview-buildsdk-skills-justfile/SKILL.md @@ -0,0 +1,177 @@ +--- +name: purview-buildsdk-skills-justfile +description: Add or update Justfile recipes that run Purview.BuildSdk's agent-skill targets - skills-update, skills-check, skills-fix, skills-install, skills-recommend and skills-verify. Use when a repository wants a just recipe to update, install, check or clean up its agent skills, when replacing hand-written skill scripts (update-agent-skills.ts, ensure-universal-skills.ts, package.json skills:* scripts) with the SDK targets, or when an existing skills recipe runs the wrong project, the wrong runner or the wrong agents. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Justfile recipes for the agent-skill targets + +The recipes are thin: each one names one SDK target. The behaviour lives in the targets, which are +covered by `purview-buildsdk-skill-installation`; this skill is about wiring them into a `Justfile` +correctly. + +## The rule that matters most + +**Every skill recipe is run by a person, on purpose.** `InstallPurviewSkills`, `UpdatePurviewSkills` +and `FixPurviewSkills` change the repository, and the SDK refuses to run them inside a build, a +restore or a design-time build (`PRSKL0009`). Keep the Justfile consistent with that: + +- never make a skills recipe a dependency of `build`, `test`, `default` or any other recipe; +- never call `skills-update`, `skills-install` or `skills-fix` from a git hook (`lefthook.yml`, + `.husky/`) or a CI job that commits; +- `skills-verify` is the one recipe CI may call: it is read-only and fails on missing or drifted skills. + +## Recipes + +Put them in their own file beside the `Justfile`, and import it from the `Justfile`: + +```just +# Justfile +import "Justfile.skills.just" +``` + +The only line that differs between repositories is `skills_project`. Work it out from the repository +as described in [Choosing `skills_project`](#choosing-skills_project) — do not ask for it, and do not +leave a placeholder: + +```just +skills_project := solution_file # or a path, when the Justfile has no such variable + +# Update agent skills: re-add every skills-lock.json source, refreshing installed skills and adding new ones +[group('Agent skills')] +skills-update *args: + dotnet msbuild "{{ skills_project }}" "-t:UpdatePurviewSkills;FixPurviewSkills" -v:n {{ args }} + +# Check that only the skill directories of the agents in PurviewSkillsAgents exist +[group('Agent skills')] +skills-check *args: + dotnet msbuild "{{ skills_project }}" -t:CheckPurviewSkills -v:n {{ args }} + +# Remove the skills CLI's links from agent directories PurviewSkillsAgents does not select +[group('Agent skills')] +skills-fix *args: + dotnet msbuild "{{ skills_project }}" -t:FixPurviewSkills -v:n {{ args }} + +# Install the skills the referenced packages ship (pass -p:PurviewSkillsSource=owner/repo for others) +[group('Agent skills')] +skills-install *args: + dotnet msbuild "{{ skills_project }}" -t:InstallPurviewSkills -v:n {{ args }} + +# Report missing, drifted or unverifiable skills without changing anything +[group('Agent skills')] +skills-recommend *args: + dotnet msbuild "{{ skills_project }}" -t:RecommendPurviewSkills -v:n {{ args }} + +# Fail when a referenced package's skills are missing or drifted - the CI gate +[group('Agent skills')] +skills-verify *args: + dotnet msbuild "{{ skills_project }}" -t:VerifyPurviewSkills -v:n {{ args }} +``` + +Only add the recipes the repository will use. `skills-update` and `skills-check` are the usual pair. + +### Choosing `skills_project` + +Take the first of these that applies: + +1. **The `Justfile` already names the solution in a variable** — `solution_file`, `solution`, `sln` + or similar, typically used by `build`, `test` and `restore`. Reference it: + `skills_project := solution_file`. An imported file shares the importing file's variables, so the + skill recipes then follow the solution if it is ever renamed or moved, with nothing to keep in step. +2. **An existing recipe runs `dotnet build`, `test` or `restore` on a literal solution path.** Use that + path. If several recipes repeat it, offer to lift it into a `solution_file` variable and reference + that, but do not restructure the `Justfile` unasked. +3. **No `Justfile`, or one that never names a solution.** Search for `*.slnx` and `*.sln`, ignoring + `bin/`, `obj/`, `node_modules/` and `artifacts/`. If there is exactly one, use its path relative to + the `Justfile`. With several, prefer the one the CI workflow or README builds; ask only if that + still leaves more than one. +4. **No solution at all.** Name a project that imports the SDK and references every package that + ships skills, and say in a comment that it sees only its own packages. + +An existing `Justfile.skills.just` pointed at a hand-picked project gets the same treatment: replace +the project with the solution, chosen as above. Remove any comment explaining why that project was +picked, because the reason no longer holds. + +### Details that break a recipe if missed + +- **Name the solution.** Every skill target runs once for the whole solution, however many projects + it holds: each project hands the work to the same elected leader, and MSBuild runs it once. Package + resolution reads every project in the solution, so a package only a test project references is + still pinned. There is no project to choose and nothing to keep in step as projects come and go. + A single project also works, but sees only that project's packages. (SDK releases before this + behaviour ran the target once per project; there, name one project that references every package + that ships skills.) +- **Restore first.** The targets read `project.assets.json` and never restore on their own + (`PRSKL0001`). A fresh clone needs `dotnet restore` (or `just restore`) before `skills-update`. +- **Quote a multi-target `-t:`.** `"-t:UpdatePurviewSkills;FixPurviewSkills"` — unquoted, `sh` treats + the `;` as the end of the command and runs `FixPurviewSkills` as a separate, failing command. +- **Keep `*args` last** so a caller can pass properties: `just skills-update -p:PurviewSkillsDryRun=true`. +- `FixPurviewSkills` after `UpdatePurviewSkills` does nothing unless `PurviewSkillsAgents` is set, so + it is safe in the recipe either way. + +## Runner and agents belong in MSBuild, not in the recipe + +Set repository-wide choices once in `Directory.Build.props`, not as flags repeated in each recipe, +so a plain `dotnet msbuild -t:...` behaves the same as the recipe: + +```xml + + + bun + + universal + +``` + +One-off overrides go on the command line. `dotnet msbuild -p:` splits values on `,` and `;`, so +escape the separator: `just skills-update -p:PurviewSkillsAgents=claude-code%2Ccodex`. + +Useful properties to pass through `*args`: + +| Property | Effect | +| -- | -- | +| `PurviewSkillsDryRun=true` | Print every CLI command; run none | +| `PurviewSkillsRunner=npx` | Launch the CLI through another runner (`bunx`, `pnpm`, `yarn`, `deno`) | +| `PurviewSkillsSource=owner/repo#ref` | `skills-install` from an explicit source with no package behind it | + +## Replacing hand-written skill scripts + +Repositories often carry their own scripts for this. The SDK targets replace them: + +| Hand-written | Replace with | +| -- | -- | +| `scripts/update-agent-skills.ts`, `"skills:update"` | `skills-update` (`UpdatePurviewSkills`) | +| `--update` / `--no-update` | Nothing: the re-add refreshes installed skills and adds new ones, so there is no separate `skills update` step to switch | +| `--dry-run` | `-p:PurviewSkillsDryRun=true` | +| `scripts/ensure-universal-skills.ts check`, `"skills:check"` | `skills-check` with `PurviewSkillsAgents=universal` | +| `scripts/ensure-universal-skills.ts fix`, `"skills:fix"` | `skills-fix` | +| a hard-coded `-a universal` | `PurviewSkillsAgents` — or leave it empty to auto-detect | + +Two behaviours differ, deliberately — mention them when migrating: + +- A source a referenced package ships is re-added **at that package's commit**, not at latest, so its + skills keep matching the package version. Other sources are re-added at latest, as before. +- Update is `skills add`, not `skills update`, so it also installs skills a source has **gained**: a + package source gets every skill its referenced version ships, and any other source is re-added with + `--skill "*"`. + +After switching, delete the scripts and their `package.json` entries, and remove `package.json` +itself if nothing else uses it. Run `just skills-update -p:PurviewSkillsDryRun=true` and compare the +printed commands with what the old script ran before deleting anything. + +## Updating an existing Justfile + +1. Read the whole `Justfile` first: match its group names, comment style, and any `set shell` + (a PowerShell shell still needs the `-t:` value quoted). +2. Keep recipe names the repository already uses; change only what they run. If `skills-check` + already means "report", rename it to `skills-recommend` rather than silently changing its meaning, + and say so. A recipe pointed at a hand-picked "small" project, to avoid running once per project, + can point at the solution instead. +3. Check nothing depends on a changing recipe: `just --list`, then search the Justfile, CI workflows + and hook configuration for the recipe name. +4. Verify: `just --list` shows the group, `just skills-update -p:PurviewSkillsDryRun=true` prints the + commands, and `just skills-check` passes or names a real problem. diff --git a/.agents/skills/purview-component-compatibility/SKILL.md b/.agents/skills/purview-component-compatibility/SKILL.md new file mode 100644 index 00000000..b017681a --- /dev/null +++ b/.agents/skills/purview-component-compatibility/SKILL.md @@ -0,0 +1,116 @@ +--- +name: purview-component-compatibility +description: >- + Use when developing, reviewing or releasing a published component - a NuGet package, or an npm + package - and choosing which dependency version to declare, when a consumer reports a version + conflict or an unexpected upgrade, or when a component's supported target frameworks change. Applies + the golden rule that a published component declares the LOWEST compatible version rather than the + latest, walks the Purview component hierarchy to find each floor, and verifies it by building + against it. Not for applications: they take the latest compatible version instead. +license: MIT +metadata: + author: Purview-Dev + version: "1.0" + repository: purview-dev/engineering-playbook +--- + +# Purview component compatibility + +A published component makes a promise with every version it declares: *this is the least I require of +you*. Raise that number and every consumer is pulled up with you, whether or not they wanted it. + +**The golden rule: declare the lowest version that compiles, passes your tests, and is supported on +every target framework you claim.** The version you happen to have installed is never the answer, and +neither is the latest. + +## Which direction? + +| What you are publishing | Declare | Why | +| -- | -- | -- | +| an application, a test suite, a tool — it runs, and nobody installs it | the **latest** compatible version | you own the runtime, and a version you skip is a fix you did not take | +| a component — a NuGet or npm package somebody else installs | the **lowest** compatible version | dependency resolution takes the highest minimum in the graph, so your floor becomes theirs | + +This skill is about the second row. If the repository only ever runs, stop here and take the latest. + +## Why a floor matters + +NuGet and npm resolve a graph to the **highest** minimum among all constraints. If a package declares +`Testcontainers` 4.16 because 4.16 is what its author had installed, every consumer is forced onto 4.16, +including the one whose own constraint is 4.0 and whose runtime is pinned to it. The consumer cannot +refuse an upgrade the package never needed to ask for, and the symptom appears in *their* build, not +yours. + +## The workflow + +### 1. Name the component and its frameworks + +The floor is per target framework, not per package. Write down every `TargetFramework` the component +publishes; a multi-targeting component has a different floor on each, and the one that matters is the +oldest framework still supported, not the newest one you build against. + +### 2. Read the hierarchy, do not remember it + +The dependencies — and their minimums — are in the published `.nuspec`, not in anyone's memory: + +```bash +node scripts/dependencies.mjs Purview.Containers +node scripts/dependencies.mjs Purview.Architecture.Testing --tfm net11.0 +node scripts/dependencies.mjs Purview.Containers --walk +``` + +A bare version is a **minimum**; a bracketed one (`[1.2.3]`, `[1.0,2.0)`) is a range. The script prints +the distinction, because it is the whole rule: a minimum you raise is a minimum every consumer inherits. + +`references/purview-components.md` records which Purview package depends on which, and which target +frameworks each family supports. Where the two disagree the registry wins — that file is the map, the +`.nuspec` is the territory. + +### 3. Fix the floor, per framework + +For each dependency, find the oldest version that: + +- exposes every API the component calls (the compiler decides this, not the changelog); +- passes the component's own tests and its consumer-verification suite; +- is supported on every framework in that dependency group. + +Then declare that version. Not `latest`, not floating (`1.*`), and never the version that resolved on +your machine by accident. + +### 4. Prove the floor + +A floor nobody builds against is a guess. Add a CI leg that restores the component with every dependency +held at its declared floor and runs the suite — the ordinary leg keeps resolving the latest, which is +what a consumer will actually get. `references/lowest-compatible.md` has the mechanics. + +### 5. Keep the ceiling visible, and the floor written down + +A component's README carries a badge for the current release and a sentence naming the floor it +supports. Badges are generated from the registry, so they cannot go stale; a version written into prose +is wrong within one release. + +## Pitfalls that produce a floor nobody meant + +| Pitfall | What it looks like | The fix | +| -- | -- | -- | +| Declaring what is installed | `dotnet add package X` writes the latest; right for an application, wrong for a component | edit the version down, then prove it in step 4 | +| Transitive uplift | a dependency pulls in a newer transient package than your floor allows | override it deliberately — the persistence sample does this for a security-patched package — and comment why | +| One framework's floor | a `net10.0-windows` group needs a higher floor than `net10.0` | read each group; never average them | +| Prerelease in the graph | a family that ships prereleases depends on them at the same version | pin the family to one property so the floor moves together | +| Analyzer and source-generator packages | compile-time only, so they rarely constrain a consumer | still declare a real floor; they appear in the graph and in transitive audits | +| Floating versions | `1.*` resolves differently on every restore | banned: a floor is a number | + +## Checks before you answer + +- Is each declared version the lowest that passes, or the one that happened to be installed? +- Does every declared target framework have a floor that compiles on that framework? +- Was the floor proved by a build, or only by your machine? +- Does any page state a version number? It should be a badge or a link to the registry. +- If a floor was raised, is that a breaking change the version number admits to? + +## See also + +`references/lowest-compatible.md` for the CI mechanics and the override patterns, and +`references/purview-components.md` for the Purview hierarchy. The rule itself, and the application-side +half of it, is stated in the Engineering Playbook's Dependency Versions page; the +`purview-engineering-principles` skill summarises it as one of the eleven conventions. + diff --git a/.agents/skills/purview-component-compatibility/references/lowest-compatible.md b/.agents/skills/purview-component-compatibility/references/lowest-compatible.md new file mode 100644 index 00000000..ccdb3b69 --- /dev/null +++ b/.agents/skills/purview-component-compatibility/references/lowest-compatible.md @@ -0,0 +1,93 @@ +# Proving the floor + +The rule is "declare the lowest compatible version". This file is the mechanics: how to find the floor, +how to prove it in a build, and the patterns that keep it from silently rising. + +## What "compatible" means, precisely + +A version is a valid floor when all three hold: + +1. **It compiles against every API the component uses.** The compiler decides this, not a changelog. If + you cannot say which member forced the version up, you have not found the floor. +2. **The component's own tests pass against it**, including the consumer-verification suite, which + builds a throwaway consumer rather than the component's own test project. +3. **It is supported on every target framework in that dependency group.** A floor is per group, and a + multi-targeting component can have several. + +## Finding it + +```bash +dotnet list package --outdated # what you declared against what exists +dotnet list package --include-transitive # what actually resolved +dotnet nuget why # which path pulled a version in +``` + +Then work downward: lower the declared version, build, and read the compiler. The first version that +compiles *and* passes is the floor. Do not stop at the first failure and pick the version above it — +that is how a floor ends up one minor above what the code needs. + +## Making the floor a build leg + +Keep one property per family (so a family cannot be bumped halfway) and make each conditional on a floor +switch. Nothing here is NuGet-specific magic: it is MSBuild, and central package management already +gives you the single place to put it. + +```xml + + LATEST + FLOOR + +``` + +```bash +dotnet restore -p:DependencyFloor=true +dotnet build -p:DependencyFloor=true --no-restore +dotnet test -p:DependencyFloor=true --no-build +``` + +`LATEST` and `FLOOR` are literals in the file; the point is that they are two properties, not two files. +Run the ordinary leg at `LATEST` and a second, slower leg at `FLOOR`, and give the floor leg its own CI +job so a failure names the floor rather than the build. + +Where a component multi-targets, add the framework dimension and run the pair per framework: + +```bash +for tfm in net8.0 net9.0 net10.0; do + dotnet build -p:DependencyFloor=true -f "$tfm" +done +``` + +## Transitive pins + +A floor can be raised by something you never declared. Transitive pinning puts those versions where you +can see them: + +```xml + + true + +``` + +Then an override is a deliberate line with a reason beside it — the persistence sample in this +repository does exactly that for a security-patched package the SQL client drags in. An override without +a comment is indistinguishable from an accident, and the next person deletes it. + +## The ceiling, and the API surface + +- The floor leg proves the **bottom**; the ordinary leg proves the **top**. Both, or you know neither. +- An API-compatibility check against the previous release catches the other direction of the same + mistake: a floor can stay put while the public surface grows a member that only the newest dependency + provides. `Microsoft.DotNet.ApiCompat.Task` wires that into the build; `Microsoft.DotNet.ApiCompat.Tool` + runs it from a pipeline. +- A prerelease dependency is a floor with an expiry: it cannot be consumed by anyone who has not opted + into prereleases. Say so in the package's README rather than letting the error message explain it. + +## What not to do + +| Temptation | Why it is wrong | +| -- | -- | +| `dotnet add package X` and commit | it writes the latest; correct for an application, wrong for a component | +| floating versions (`1.*`) | resolves differently on every restore, so the floor is nobody's decision | +| "it restores on my machine" | that proves one graph, on one date, with one SDK | +| raising a floor in a patch release | the consumer's build changes without their version changing | +| one floor leg for all frameworks | the framework with the lowest floor is the one nobody tested | diff --git a/.agents/skills/purview-component-compatibility/references/purview-components.md b/.agents/skills/purview-component-compatibility/references/purview-components.md new file mode 100644 index 00000000..6e13b659 --- /dev/null +++ b/.agents/skills/purview-component-compatibility/references/purview-components.md @@ -0,0 +1,60 @@ +# The Purview component hierarchy + +Which Purview package depends on which, and which target frameworks each family supports, so a harvest +or a floor decision starts from the shape rather than from a memory of it. + +Every row was read from the registry with `scripts/dependencies.mjs`, which reads the published +`.nuspec`. **The registry is the authority; this file is the map.** Re-run the script before acting on +any row — a family that multi-targets changes its floors on every release. + +## The families + +| Family | Packages | Target frameworks | Depends on | +| -- | -- | -- | -- | +| Build SDK | `Purview.BuildSdk` | `netstandard2.0` (an MSBuild SDK) | nothing | +| Test-support rule sets | `Purview.Skills.Testing`, `Purview.Architecture.Testing`, `Purview.Architecture.Testing.Cli` | `net10.0`; `Architecture.Testing` also `net11.0` | `TUnit.Core`, `TUnit.Assertions`, `JsonSchema.Net` | +| Telemetry | `Purview.Telemetry.SourceGenerator` | `netstandard2.0` (an analyzer) | nothing | +| Containers | `Purview.Containers` + `.Core`, `.Docker`, `.Wsl`, `.MsSql`, `.PostgreSql` | `net10.0`; `.Wsl` adds `net10.0-windows10.0.19041` | its own family at the same version, `Microsoft.Extensions.Telemetry.Abstractions`, `Testcontainers` | +| Results | `Purview.Results`, `.AspNetCore`, `.ZodSharp`, `.ZodSharp.AspNetCore` | `net10.0`, `net11.0` | nothing; the adapters depend on the core and on ZodSharp | +| ZodSharp | `Purview.ZodSharp`, `.AspNetCore`, `.SystemTextJson`, `.NewtonsoftJson` | `net9.0`, `net10.0`, `net11.0` | `Microsoft.Extensions.Options`, `Microsoft.Extensions.DependencyInjection.Abstractions` | +| Value Objects | `Purview.ValueObjects` | `net8.0`, `net9.0`, `net10.0`, `net11.0` | nothing | +| Source Generator Framework | `Purview.SourceGeneratorFramework`, `.Testing`, `.Testing.TUnit` | `netstandard2.0` | Roslyn (`Microsoft.CodeAnalysis.CSharp`), `System.Collections.Immutable`, `System.Reflection.Metadata` | +| Aspire ResourceKit | `Purview.Aspire.ResourceKit` | `net8.0`, `net9.0`, `net10.0` | `Aspire.Hosting`, OpenTelemetry exporter and hosting, `Microsoft.Extensions.*` | +| Astroc4 | `@purview-dev/astroc4` (npm, not NuGet) | an Astro/Starlight plugin | `likec4`, `@astrojs/starlight`, `astro` as peers | + +## Reading the shape + +Three patterns are worth internalising, because each one changes what "the floor" means: + +- **A family ships at one version.** `Purview.Containers.Core` is required at the umbrella's own version, + and `Purview.Results.ZodSharp` requires `Purview.Results` and `Purview.ZodSharp` at their current ones. + Declare the family with one property, so its floor moves as a unit. +- **An analyzer has no consumer-side floor worth arguing about.** `Purview.BuildSdk`, + `Purview.Telemetry.SourceGenerator` and `Purview.SourceGeneratorFramework` target `netstandard2.0` and do + their work at compile time. They still appear in a transitive audit, so they still get a real version. +- **A multi-targeting component has a different floor per framework**, and ZodSharp shows it most + clearly: the `net11.0` group declares older `Microsoft.Extensions.*` minimums than the `net10.0` group, + because the lower framework carries the newer requirements. Reading one group and applying it to + another is how a floor is set wrong in the direction that breaks consumers. + +## What the script prints + +```text +Purview.Containers 1.0.0-prerelease.11 + net10.0: Purview.Containers.Core >= 1.0.0-prerelease.11 + net10.0: Purview.Containers.Docker >= 1.0.0-prerelease.11 + net10.0: Purview.Containers.Wsl >= 1.0.0-prerelease.11 + net10.0: Microsoft.Extensions.Telemetry.Abstractions >= 10.11.0 + net10.0: Testcontainers >= 4.16.0 + Purview.Containers.Core 1.0.0-prerelease.11 + net10.0: Microsoft.Extensions.Telemetry.Abstractions >= 10.11.0 +``` + +A bare version is a minimum; brackets would mean a range. The numbers above are what the registry said +when this page was written — the shape is the part that lasts, which is why the command is here and the +versions are not presented as targets. + +## Related components that are not Purview packages + +`AspireC4.Hosting` is published under its own name and versions on its own schedule, so it is not part of +a Purview family floor. Read it the same way, with the same script. diff --git a/.agents/skills/purview-component-compatibility/scripts/dependencies.mjs b/.agents/skills/purview-component-compatibility/scripts/dependencies.mjs new file mode 100644 index 00000000..3069ef62 --- /dev/null +++ b/.agents/skills/purview-component-compatibility/scripts/dependencies.mjs @@ -0,0 +1,239 @@ +#!/usr/bin/env node +/** + * Prints a package's dependency groups exactly as NuGet declares them, so the floor a component + * inherits can be read rather than guessed. + * + * The golden rule of package development is to declare the LOWEST compatible version, and that decision + * needs the real graph: which dependency, at what minimum, on which target framework. This script reads + * it from the registry - the `.nuspec` the published package carries - instead of from a table that + * would be stale by the next release. + * + * Zero dependencies: it uses the platform `fetch`, so it runs anywhere Node 18 or later does. + * + * node dependencies.mjs Purview.Containers + * node dependencies.mjs Purview.Architecture.Testing --tfm net11.0 + * node dependencies.mjs Purview.Containers --walk # recurse into Purview.* dependencies + * node dependencies.mjs Purview.Results --json + * + * An npm package is not a NuGet id; read it with `npm view versions --json`. + * + * Exit codes: 0 on success, 1 when the package, version or network is not available. + */ + +/** The NuGet flat-container root: the same host that serves the package's own `.nuspec`. */ +const REGISTRY = "https://api.nuget.org/v3-flatcontainer"; + +/** How deep `--walk` follows dependencies before it stops. */ +const MAX_DEPTH = 3; + +/** True when a version string carries a prerelease label. */ +function isPrerelease(version) { + return version.includes("-"); +} + +/** Every published version of a package, oldest first. */ +async function versionsOf(id) { + const response = await fetch(`${REGISTRY}/${id.toLowerCase()}/index.json`); + if (!response.ok) { + throw new Error(`${id} is not on nuget.org (HTTP ${response.status}).`); + } + + const body = await response.json(); + return body.versions; +} + +/** The version to describe: the one asked for, else the newest stable, else the newest anything. */ +function chooseVersion(available, requested) { + if (requested) { + const match = available.find((version) => version.toLowerCase() === requested.toLowerCase()); + if (!match) { + throw new Error(`'${requested}' is not a published version. Newest is ${available.at(-1)}.`); + } + + return match; + } + + const stable = available.filter((version) => !isPrerelease(version)); + return stable.at(-1) ?? available.at(-1); +} + +/** The package's `.nuspec`, which is what a consumer's restore actually reads. */ +async function readNuspec(id, version) { + const name = id.toLowerCase(); + const response = await fetch(`${REGISTRY}/${name}/${version.toLowerCase()}/${name}.nuspec`); + if (!response.ok) { + throw new Error(`No .nuspec for ${id} ${version} (HTTP ${response.status}).`); + } + + return response.text(); +} + +/** The value of one XML attribute on a tag, or null. */ +function attribute(tag, name) { + const match = new RegExp(`${name}\\s*=\\s*"([^"]*)"`).exec(tag); + return match ? match[1] : null; +} + +/** + * How a nuspec version string constrains a consumer: a bare version is a MINIMUM, and a bracketed one is + * a range. This is the distinction the whole rule turns on, because a minimum you raise is a minimum + * every consumer inherits. + */ +function classify(range) { + if (!range) { + return "any"; + } + + return range.startsWith("[") || range.startsWith("(") ? "range" : "minimum"; +} + +/** The dependency elements inside a fragment, normalized. */ +function dependenciesIn(fragment) { + const dependencies = []; + const pattern = /]*)\/?>/g; + let match; + + while ((match = pattern.exec(fragment)) !== null) { + const tag = match[1]; + const version = attribute(tag, "version"); + dependencies.push({ id: attribute(tag, "id"), version, constraint: classify(version) }); + } + + return dependencies; +} + +/** The dependency groups a nuspec declares, one per target framework. */ +function dependencyGroups(nuspec) { + const groups = []; + const pattern = /]*)>([\s\S]*?)<\/group>/g; + let match; + + while ((match = pattern.exec(nuspec)) !== null) { + groups.push({ + framework: attribute(match[1], "targetFramework") ?? "(any)", + dependencies: dependenciesIn(match[2]), + }); + } + + if (groups.length === 0) { + const flat = dependenciesIn(nuspec); + if (flat.length > 0) { + groups.push({ framework: "(any)", dependencies: flat }); + } + } + + return groups; +} + +/** A printable name for a target framework, spelled the way the ecosystem usually writes it. */ +function shortFramework(framework) { + return framework + .replace(/^\.NETStandard/i, "netstandard") + .replace(/^\.NETCoreApp/i, "net") + .replace(/^\.NETFramework/i, "netframework") + .toLowerCase(); +} + +/** Prints one package's groups, recursing into Purview components when asked. */ +async function describe(id, requestedVersion, options, depth, seen) { + const version = chooseVersion(await versionsOf(id), requestedVersion); + const groups = dependencyGroups(await readNuspec(id, version)); + const indent = " ".repeat(depth); + + console.log(`${indent}${id} ${version}`); + + for (const group of groups) { + const framework = shortFramework(group.framework); + if (options.tfm && framework !== options.tfm.toLowerCase()) { + continue; + } + + if (group.dependencies.length === 0) { + console.log(`${indent} ${framework}: no dependencies`); + continue; + } + + for (const dependency of group.dependencies) { + const relation = dependency.constraint === "minimum" ? ">=" : dependency.constraint; + console.log( + `${indent} ${framework}: ${dependency.id} ${relation} ${dependency.version ?? "any"}`, + ); + } + + if (options.walk && depth < MAX_DEPTH) { + for (const dependency of group.dependencies) { + const isPurview = /^purview\./i.test(dependency.id ?? ""); + if (!(options.all || isPurview) || seen.has(dependency.id.toLowerCase())) { + continue; + } + + seen.add(dependency.id.toLowerCase()); + await describe(dependency.id, null, options, depth + 1, seen); + } + } + } + + return { id, version, groups }; +} + +/** Parses the command line. An unknown argument is a mistake worth failing on. */ +function parseArguments(argv) { + const options = { packages: [], tfm: null, walk: false, all: false, json: false }; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + + if (argument === "--walk" || argument === "-w") { + options.walk = true; + } else if (argument === "--all") { + options.all = true; + } else if (argument === "--json") { + options.json = true; + } else if (argument === "--tfm") { + options.tfm = argv[(index += 1)]; + if (!options.tfm) { + throw new Error("--tfm needs a value, e.g. --tfm net10.0."); + } + } else if (argument.startsWith("-")) { + throw new Error(`Unknown argument '${argument}'.`); + } else { + options.packages.push(argument); + } + } + + if (options.packages.length === 0) { + throw new Error( + "Name at least one package, e.g. `node dependencies.mjs Purview.Containers`.\n" + + "A package may carry a version: `Purview.Containers@1.0.0-prerelease.11`.", + ); + } + + return options; +} + +/** The command. */ +async function main() { + const options = parseArguments(process.argv.slice(2)); + const results = []; + + for (const entry of options.packages) { + const separator = entry.indexOf("@", 1); + const id = separator === -1 ? entry : entry.slice(0, separator); + const version = separator === -1 ? null : entry.slice(separator + 1); + + results.push(await describe(id, version, options, 0, new Set([id.toLowerCase()]))); + + if (!options.walk && !options.json) { + console.log(""); + } + } + + if (options.json) { + console.log(JSON.stringify(results, null, "\t")); + } +} + +main().catch((error) => { + console.error(`error: ${error.message}`); + process.exitCode = 1; +}); diff --git a/.agents/skills/purview-containers-capabilities/SKILL.md b/.agents/skills/purview-containers-capabilities/SKILL.md new file mode 100644 index 00000000..5e3c197d --- /dev/null +++ b/.agents/skills/purview-containers-capabilities/SKILL.md @@ -0,0 +1,137 @@ +--- +name: purview-containers-capabilities +description: >- + Use when configuring Testcontainers-style capabilities on Purview.Containers Docker or WSLC: + entrypoint, user, labels, exec overrides, file copy, resource mappings, one-shot exit waits, + managed networks and DNS aliases, Dockerfile image builds, reusable containers, cancellation + deadlines, and the WSLC SDK versus CLI capability boundary. +metadata: + product: Purview.Containers + repository: purview-dev/containers +--- + +# Capabilities on Docker and WSLC + +Docker uses Testcontainers for .NET. WSLC ordinarily uses its managed SDK (3.0.5): lifecycle, images, +named networks and aliases, Linux capabilities and host loopback are SDK-backed; entrypoint/user/labels, +reuse, image builds and copy need the `wslc` CLI on PATH. The CLI targets the same +SDK-owned session and image store, not the user's default CLI session. There is no Docker dependency +on the WSLC path and no silent runtime fallback when an operation is unsupported. + +## Resolve once for related resources + +```csharp +var backend = await ContainerBackends.ResolveAsync(ct); +await using var network = await backend.CreateNetworkAsync(cancellationToken: ct); +await using var container = new ContainerBuilder(backend) + .WithImage("alpine:latest") + .WithNetwork(network.Name) + .WithNetworkAliases("worker") + .WithEntrypoint("/bin/sh") + .WithCommand("-c", "echo ready; sleep 300") + .WithUser("1000:1000") + .WithLabel("suite", "example") + .WithWaitStrategy(Wait.ForLogMessage("ready")) + .Build(); +await container.StartAsync(ct); +``` + +Declare networks/images before their containers so reverse-order async disposal removes containers +first. Containers on a custom bridge can resolve its aliases. The default WSLC bridge still lacks +container-name DNS. `ConnectionMode.Container` remains unsupported by the built-in connection-string +providers; build the service URL from its explicit network alias and internal service port. + +## Files and one-shot jobs + +`WithResourceMapping(hostSource, absoluteContainerTarget)` copies before init starts. +`CopyAsync(hostSource, absoluteContainerTarget, ct)` copies while running. +A file source names the destination file; a directory source copies its contents under the target. +`ReadFileAsync(absoluteContainerFile, ct)` returns exact bytes. Copy is preferable to a bind mount +when the daemon cannot access the test host's files. + +```csharp +await using var job = new ContainerBuilder(backend) + .WithImage("alpine:latest") + .WithEntrypoint("/bin/sh") + .WithCommand("-c", "exit 7") + .WithWaitStrategy(Wait.ForExitCode(7)) + .Build(); +await job.StartAsync(ct); +long exitCode = await job.WaitForExitAsync(ct); +``` + +An unexpected exit fails immediately with `ContainerExitCodeException`, not readiness retries. + +Exec supports environment and working-directory overrides on both backends: + +```csharp +var result = await container.ExecAsync(["sh", "-c", "printf '%s' \"$VALUE\""], new ExecOptions +{ + Environment = new Dictionary { ["VALUE"] = "literal value" }, + WorkingDirectory = "/tmp", + Timeout = TimeSpan.FromSeconds(10), +}, ct); +``` + +Commands are argv, not automatically a shell script. Invoke a shell explicitly when needed. +Interactive stdin is not exposed by capture-based exec and is rejected. + +## Builds and reuse + +```csharp +await using var image = await backend.BuildImageAsync(new ImageBuildConfiguration +{ + ContextDirectory = Path.GetFullPath("test-image"), + Name = $"suite-image:{Guid.NewGuid():N}", + Dockerfile = "Dockerfile", + Timeout = TimeSpan.FromMinutes(5), +}, ct); +// Pass image.Name to WithImage; dispose all its containers before image. +``` + +`WithName("stable-name").WithReuse()` keeps a container instead of deleting it on disposal. +It requires an explicit name and cannot use auto-remove. Docker follows Testcontainers' experimental +reuse semantics and disables normal reaper cleanup. WSLC reopens a matching named container only in +the owning session; its configuration fingerprint must match. WSLC reuse is NOT cross-process. +`DeleteAsync(ct)` explicitly removes either reusable container. Reuse does not isolate test data. +Delete before disposing the wrapper; reopen a retained container with matching reuse settings to +delete it later. +The WSLC fingerprint checks configuration, not the contents of mapped files or mutable image tags; +delete/recreate when those inputs change. + +## Cancellation and defaults + +| Operation | Default | Override | +| --- | --- | --- | +| Full startup, including image and readiness | 5 minutes | `WithStartupTimeout` | +| Exec and exit wait | 1 minute | `WithExecTimeout`, `ExecOptions.Timeout` | +| Stop including escalation | 15 seconds | `WithStopTimeout` | +| Cleanup/delete | 30 seconds | `WithCleanupTimeout` | +| Copy/read/log snapshot | 1 minute | `WithOperationTimeout` | +| Network create/delete | 1 minute | `NetworkConfiguration.Timeout` | +| Build | 5 minutes | `ImageBuildConfiguration.Timeout` | + +Always forward caller tokens. Deadlines are positive finite durations. Operation cancellation/deadlines +throw `OperationCanceledException`; readiness exhaustion throws `ContainerTimeoutException`. +Live logs need an enumeration cancellation token and intentionally have no automatic deadline. +Startup failure/cancellation performs bounded cleanup before propagating the original exception; +create a new container instance to retry. + +Do NOT claim "zero hangs": synchronous native WSLC calls cannot be forcibly interrupted safely. +Managed gate waits, polling and CLI commands are bounded. Native SDK calls already entered can +outlive a token deadline. CLI cancellation kills the owned command tree, not server-side operations +already submitted. Custom readiness callbacks must still stop their own work on cancellation. +Docker exec cancellation stops the client wait, not necessarily the remote process: Docker has no +exec-process kill endpoint. WSLC signals its owned exec process. Dispose the container to end remaining +processes and never assume cancellation rolls back application side effects. + +CLI-only creation currently rejects privileged mode and multi-line environment values. Ordinary +SDK-backed privileged containers are unaffected, and `WithCapabilityAdd`/`WithCapabilityDrop` map to +the managed SDK (or `--cap-add`/`--cap-drop` on the CLI path). Never put secrets in names or labels; CLI errors +omit raw command/build output to prevent accidental disclosure. + +## Related skills + +Use `purview-containers-quickstart` for installation/selection, +`purview-containers-lifecycle` for lifecycle ownership, and +`purview-containers-testing` for test execution. diff --git a/.agents/skills/purview-containers-consumer-requirements/SKILL.md b/.agents/skills/purview-containers-consumer-requirements/SKILL.md index 28a7ee46..977b67f3 100644 --- a/.agents/skills/purview-containers-consumer-requirements/SKILL.md +++ b/.agents/skills/purview-containers-consumer-requirements/SKILL.md @@ -15,13 +15,19 @@ metadata: | Package | Target frameworks | Notes | | --- | --- | --- | -| `Purview.Containers` | `net10.0`, any platform | the umbrella: `Core` plus both backends | -| `Purview.Containers.Core` | `net10.0`, any platform | backend-neutral abstractions | -| `Purview.Containers.` | `net10.0`, any platform | service modules; restore anywhere, need a backend to run | -| `Purview.Containers.Wsl` | `net10.0` **and** `net10.0-windows10.0.19041.0` | a portable facade plus the Windows implementation. **Everything below is its contract.** | -| `Purview.Containers.Docker` | `net10.0`, any platform | needs a reachable Docker daemon, not a Windows target framework | +| `Purview.Containers` | `net10.0` and `net11.0`, any platform | the umbrella: `Core` plus both backends | +| `Purview.Containers.Core` | `net10.0` and `net11.0`, any platform | backend-neutral abstractions | +| `Purview.Containers.` | `net10.0` and `net11.0`, any platform | service modules; restore anywhere, need a backend to run | +| `Purview.Containers.Wsl` | `net10.0`, `net11.0`, `net10.0-windows10.0.19041.0` and `net11.0-windows10.0.19041.0` | portable facades plus the Windows implementations. **Everything below is its contract.** | +| `Purview.Containers.Docker` | `net10.0` and `net11.0`, any platform | needs a reachable Docker daemon, not a Windows target framework | -A consumer of any backend other than WSL needs only a portable `net10.0` project. +A consumer of any backend other than WSL needs only a portable `net10.0` or `net11.0` project. .NET 8 and +.NET 9 are unsupported. + +WSLC features missing from the managed SDK (entrypoint/user/labels, reuse, copy +and Dockerfile builds) additionally require `wslc` on PATH; networks/aliases, Linux capabilities and +host loopback use the managed SDK 3.0.5. The ordinary SDK-only path is unchanged. +Use `purview-containers-capabilities` for the feature/cancellation boundary. ## Most projects need nothing @@ -51,11 +57,11 @@ value you have not chosen. | # | Requirement | Why | | --- | --- | --- | -| 1 | **.NET 10 or later** | the package ships `lib/net10.0/` (facade) and `lib/net10.0-windows10.0.19041/` (implementation) | +| 1 | **.NET 10 or .NET 11** (.NET 8 and .NET 9 are unsupported) | the package ships `lib/net10.0/` and `lib/net11.0/` (facades) plus `lib/net10.0-windows10.0.19041/` and `lib/net11.0-windows10.0.19041/` (implementations) | | 2 | **A Windows target framework that names the OS version** — *only* if you want the implementation bound directly | the `Microsoft.WSL.Containers` projection ships Windows assets only | | 3 | **64-bit** — `PlatformTarget` `x64`/`arm64`, or a matching `RuntimeIdentifier` | the native `wslcsdk.dll` ships for `win-x64` and `win-arm64` only | | 4 | **`WindowsSdkPackageVersion` ≥ `10.0.26100.80`** | the projection is compiled against `Microsoft.Windows.SDK.NET` `10.0.26100.79`, and the pack the SDK resolves for a `10.0.19041.0` target framework is older | -| 5 | **A Windows 10/11 host with WSL Containers**, to actually run anything | the library drives WSLC; it never installs or updates it for you | +| 5 | **Windows 11 or Windows Server 2025 with WSL Containers**, to actually run anything | the library drives WSLC; it never installs or updates it for you; a Windows target framework does not imply Windows 10 runtime support | ### Always spell out the Windows version @@ -67,7 +73,7 @@ value you have not chosen. `net10.0-windows` resolves its platform version to **7.0**, older than the `10.0.19041.0` the implementation was built against, so NuGet selects no compile assets at all. (The `Microsoft.WSL.Containers` package is itself `net8.0-windows10.0.19041.0`, which a -`net10.0-windows10.0.19041.0` project consumes without issue.) +`net10.0-windows10.0.19041.0` or `net11.0-windows10.0.19041.0` project consumes without issue.) ## What the package does for you @@ -94,7 +100,7 @@ package, and use `PURVIEW_CONTAINERS_BACKEND` to pin which one runs. | Error | Raised by | Means | Fix | | --- | --- | --- | --- | -| `PCC0001` | the shipped guard target | the target framework is neither .NET 10+ on Windows `10.0.19041.0`+ nor a platform-neutral .NET 10+ framework | retarget as above | +| `PCC0001` | the shipped guard target | the target framework is neither .NET 10 nor .NET 11 on Windows `10.0.19041.0`+ nor a platform-neutral .NET 10/.NET 11 framework | retarget as above | | `PCC0002` | the shipped guard target | the consumer is not 64-bit | remove `PlatformTarget`, set it to `x64`/`arm64`, or choose a matching `RuntimeIdentifier` | | `CS1705` naming `Microsoft.Windows.SDK.NET` | the compiler | the resolved Windows SDK targeting pack is older than the projection's | pin `WindowsSdkPackageVersion` to `10.0.26100.80` | | `CS0234`/`CS0246` for `Microsoft.WSL`/`Purview` types | the compiler | the package contributed no compile assets — an unsupported target framework, or an unverified escape hatch | fix the target framework; `PCC0001` says the same thing more clearly | @@ -111,9 +117,10 @@ Use it to keep one solution building in CI, not as a way to run WSLC off Windows The repository proves every row above rather than asserting it: - `Wsl.UnitTests/ConsumerRequirementsTests.cs` guards the **shape** inside the normal unit run — the - Windows build targets `.NETCoreApp,Version=v10.0`, targets `Windows10.0.19041.0`, and declares only - that OS platform. If the target framework drifts, that test fails, and this page, the package - READMEs and the `buildTransitive` defaults all have to move with it. + Windows build targets `.NETCoreApp,Version=v11.0`, targets `Windows10.0.19041.0`, and declares only + that OS platform. `Wsl.UnitTests/BuildFeatureTests.cs` guards that the net11.0 build has no + compiler-generated async state machines (runtime-async is on). If either drifts, the test fails, and + this page, the package READMEs and the `buildTransitive` defaults all have to move with it. - `just verify-consumers` proves the **behaviour**: it packs, then builds throwaway consumer projects for every documented outcome, including the guard errors (`PlatformTarget=x86` → `PCC0002`; `net8.0-windows` → `PCC0001`; `net11.0-windows` without an OS version → `PCC0001`). It sits outside diff --git a/.agents/skills/purview-containers-lifecycle/SKILL.md b/.agents/skills/purview-containers-lifecycle/SKILL.md index 96da38e3..aef8001b 100644 --- a/.agents/skills/purview-containers-lifecycle/SKILL.md +++ b/.agents/skills/purview-containers-lifecycle/SKILL.md @@ -4,7 +4,7 @@ description: >- Use when reasoning about a Purview.Containers container's lifecycle or readiness - StartAsync, StopAsync and DisposeAsync semantics, cleanup guarantees and why there is no restart, the Wait strategy family and started-versus-ready, and WSL Containers networking, port mapping and the - absence of inter-container DNS. + default-bridge DNS limitations and managed network aliases. metadata: product: Purview.Containers repository: purview-dev/containers @@ -19,47 +19,69 @@ public interface IContainer : IAsyncDisposable { Task StartAsync(CancellationToken ct = default); Task StopAsync(CancellationToken ct = default); + Task DeleteAsync(CancellationToken ct = default); + Task WaitForExitAsync(CancellationToken ct = default); ValueTask DisposeAsync(); // restart is intentionally NOT offered } ``` -**There is no `RestartAsync`.** WSLC has no restart operation, and stop-then-start is not equivalent, -so offering one would be a method that quietly means something different per backend. +**There is no `RestartAsync` in the shared contract.** Do not infer native runtime capabilities from +that API choice. WSLC's `ContainerState` runs `Invalid → Created → Running → Exited → Deleted`. | Operation | Behaviour | | --- | --- | | `StartAsync` | creates the session if needed, ensures the image per the pull policy, creates the container, starts it, then waits. A missing init binary throws `ArgumentException 0x80070057` with the OCI message, surfaced as `WslContainerStartupException`. | -| `StopAsync` | `Stop(SIGTERM, grace)`, then `Stop(SIGKILL, …)` if still running. **Idempotent** — WSLC accepts stopping an already-stopped container. | +| `StopAsync` | Signal init with SIGTERM, poll up to 10 seconds, then SIGKILL and up to 5 seconds more, bounded by `StopTimeout`. Cancellation propagates; no blocking native `Container.Stop`. | | `DeleteAsync` | `Delete(Force)`. A double delete throws `0x80010108` (`RPC_E_DISCONNECTED`) and is swallowed. | -| `DisposeAsync` | stop if running → delete → release the WinRT object. **Idempotent.** | +| `DisposeAsync` | normally stop, delete and release handles; reusable containers are retained. **Idempotent.** | ## Cleanup guarantees These are the ones that matter when a test throws: -- `DisposeAsync` runs in a `finally` by callers, **and** the container object is registered for - cleanup by the runtime, so disposal happens even if a test throws after `StartAsync`. +- Use `await using` so failures run `DisposeAsync`. The process-exit hook owns session cleanup, + not a per-container disposal registry. +- `EnableCleanUp` defaults to **`true`** (Testcontainers' `WithCleanUp`): `DisposeAsync` stops and + deletes the container; Docker also removes its anonymous volumes. Named `WithVolumeMount` volumes + are shared data and survive (WSLC volumes live on the session). `.WithCleanUp(false)` stops the + container and keeps it — and its volumes — for inspection; remove it with `DeleteAsync` before + disposal, or with the backend's own tooling. - `EnableAutoRemove` defaults to **`false`**: the library owns deletion. WSLC auto-removal would delete a one-shot container before its state or output could be inspected — precisely when you need - them. Opt in with `.WithAutoRemove()`. + them. Opt in with `.WithAutoRemove()`. It is independent of cleanup and wins when the init process + exits. +- `WithReuse()` implies cleanup off, and `WithReuse().WithCleanUp(true)` is rejected at build time. +- Named volumes are **retained by default**, matching Docker. Set + `ContainerCleanup.CleanUpVolumesOnExit = true`, or `PURVIEW_CONTAINERS_CLEAN_VOLUMES=true` before the + first container is created, to delete the volumes the active backend created when the process exits or + its session terminates. `ContainerBackends.CleanUpAsync()` / `IContainerBackend.CleanUpAsync()` does it + explicitly; pre-existing volumes, the shared image store and host bind mounts are never touched. On WSLC, + `WslContainerRuntimeOptions.CleanUpVolumesOnTerminate` overrides the shared switch. - A **partial startup failure** — image pull, create, start or wait — tears down through the same `DisposeAsync` path. So does a **cancelled** `StartAsync` or wait. -- A **container crash** fires `InitProcess.Exited`; the container is then `Exited` and `DisposeAsync` - deletes it. +- Builder-produced containers serialize lifecycle operations; disposal cancels an in-flight startup + before waiting for cleanup. A failed-start wrapper is terminal: build a new one to retry. +- A **container crash** is observable through live state/exit-code reads, even if an exit event is + missed; normal disposal deletes it. - A **session crash** marks the runtime session dead, and subsequent operations throw `WslContainerSessionTerminatedException` rather than failing obscurely. - **Test-process termination** disposes the shared session through a process-exit hook, freeing the - name. A *crashed* process leaves an orphaned session that only reserves its name; sweep it with - `wslc --session system session terminate`. + name. A *crashed* process leaves an orphaned session; the runtime sweeps `purview-*` (and legacy + `wslc-*`) sessions whose creator PID is gone before the next session starts, deletes their + per-session storage directories, removes stale containers whose generated name embeds a dead PID and + prunes unused anonymous volumes. `WslContainerRuntime.SweepOrphanedSessionsAsync()` does the session + sweep explicitly; `CleanUpAsync()` also removes container residue. ## Secrets and names Container names are generated uniquely as `{service}-{processId}-{random8}` and **never contain -credentials**; session names are `wslc-{pid}-{random8}`. Diagnostics redact secrets through a +credentials**; session names are `purview-{pid}-{random8}`. Diagnostics redact secrets through a `Secret` wrapper whose `ToString()` returns ``. +Application output can contain secrets; do not assume log snapshots or timeout diagnostics are safe +to publish. CLI failures omit raw command/build output. ## Wait strategies @@ -92,10 +114,10 @@ Details worth knowing: - Waits run **inside** `StartAsync()`, after the container starts. `StartAsync` only returns when every strategy is ready, or throws `ContainerTimeoutException`. -- The default timeout is the container's `StartupTimeout` — 5 minutes, overridable with - `.WithStartupTimeout(…)` or per-strategy. -- A timeout diagnostic includes the container name, image, state, mapped ports, the strategy type, - the last check error, and a bounded, secret-redacted tail of stdout/stderr. +- `StartupTimeout` is a five-minute total managed budget for backend resolution, image, creation, + start and readiness. A strategy may shorten that budget, never extend it. +- A timeout diagnostic includes the container name, image, state, mapped ports, strategy and a bounded + log tail. Application logs can contain secrets; do not print them indiscriminately. - The container buffers output from process start, so a log message emitted **before** the waiter's first check still matches. - HTTP TLS validation is **on** by default. `AllowInsecureTls()` disables it for that request handler @@ -122,11 +144,11 @@ Verified WSLC behaviour, and the parts that surprise people: Npgsql and StackExchange.Redis fall back to IPv4; `Microsoft.Data.SqlClient` **hangs** without falling back. - Containers in the same session **can** reach each other by IP under `Bridged`. -- There is **no name or DNS resolution between containers**. `http://:8080/` and +- On the **default bridge** there is no name/DNS resolution between containers. `http://:8080/` and `http://:8080/` fail with "bad address"; `/etc/hosts` holds only localhost and the container's own `IP `. -- There are **no managed network objects** — `Microsoft.WSL.Containers` has no `network - create/connect`, unlike the CLI. +- Use `IContainerBackend.CreateNetworkAsync` and `WithNetwork`/`WithNetworkAliases` for managed + networks with alias DNS on both backends. WSLC uses the managed SDK network API in the SDK-owned session. How the library models it: `GetMappedPublicPort(containerPort)` reads `Inspect().Ports["/tcp"][0].HostPort`; `IContainer.NetworkIp` is the bridge IP after start @@ -134,14 +156,26 @@ How the library models it: `GetMappedPublicPort(containerPort)` reads `IpAddress`; `null` before start or without a network); TCP and HTTP waits connect to `127.0.0.1:`. -**What not to model.** Do not invent Docker-style `Network` objects — the semantics are just "bridged -with IPs". Do not claim container-name resolution works; it does not. Containers are wired -together by passing a dependency's `NetworkIp` into configuration or environment: a module that owns a -dependency starts it first and injects the address from `ContainerBase.ConfigureForStart` (the Azure -Service Bus Emulator does this with its SQL Server sidecar). A general multi-container environment -builder is still deferred. +Built-in modules can still wire dependencies by `NetworkIp` through `ConfigureForStart`. Managed +networks add explicit alias DNS; they do not automatically add `ConnectionMode.Container` providers. +Dispose attached containers before the owned network. + +## Cancellation, exit waits and reuse + +Defaults are one minute for exec/exit waits and copy/read/log snapshots, 15 seconds for stop and +30 seconds for cleanup. Use `WithExecTimeout`, `WithStopTimeout`, `WithCleanupTimeout` and +`WithOperationTimeout` to override. `ExecOptions.Timeout` overrides one exec call. +Cancellation/operation deadlines throw `OperationCanceledException`, never a successful exec result. +Readiness exhaustion throws `ContainerTimeoutException`. Live log tails need a caller token. + +Native synchronous WSLC calls cannot be interrupted safely. Managed waits and CLI processes are +bounded, but do not claim hard zero-hang guarantees for a blocked native runtime. +`Wait.ForExitCode(expected)` handles one-shot jobs; `WaitForExitAsync(ct)` returns the actual code. +`WithName(...).WithReuse()` retains a named container on disposal, with cleanup implied off; `DeleteAsync(ct)` explicitly removes +it. WSLC reuse ends with the owning session; Docker follows Testcontainers reuse semantics. ## Related skills `purview-containers-quickstart` for packages, backends and connection strings; -`purview-containers-testing` for how a suite is organised and why it runs serially. +`purview-containers-testing` for how a suite is organised and why it runs serially; +`purview-containers-capabilities` for networks, files, builds and the SDK/CLI boundary. diff --git a/.agents/skills/purview-containers-quickstart/SKILL.md b/.agents/skills/purview-containers-quickstart/SKILL.md index 6d439f76..396d8959 100644 --- a/.agents/skills/purview-containers-quickstart/SKILL.md +++ b/.agents/skills/purview-containers-quickstart/SKILL.md @@ -49,10 +49,11 @@ dotnet add package Purview.Containers Three things make it work, and nothing else is required: -- **A platform-neutral target framework** (`net10.0`). `Purview.Containers.Wsl` is multi-targeted; a - `net10.0` project binds its **portable facade**, which runs the WSLC implementation on Windows and - reports `wsl` unavailable everywhere else. A Windows target framework binds the implementation - directly — and then cannot run on a Linux CI runner. +- **A platform-neutral target framework** (`net10.0` or `net11.0`; .NET 8 and .NET 9 are unsupported). + `Purview.Containers.Wsl` is multi-targeted; a platform-neutral project binds its **portable facade**, + which runs the WSLC implementation on Windows and reports `wsl` unavailable everywhere else. A Windows + target framework binds the implementation directly — and then cannot run on a Linux CI runner. The + `net11.0` assets are compiled with `runtime-async=on`. - **The umbrella package**, bringing `Core` and both backends. - **No registration line and no environment variable.** Each backend package ships `buildTransitive` assets that generate a module initializer in your assembly, so the process discovers the registered @@ -147,8 +148,9 @@ string same = container.GetConnectionString(ConnectionMode.Host); | `Host` | test host → container, via the mapped host port (`127.0.0.1:{port}`) | both backends | | `Container` | container → container, via the container network | **not supported** — throws `ConnectionStringModeNotSupportedException` | -Container-to-container is deliberately deferred: WSLC has no managed networks or inter-container DNS, -and the Docker backend does not attach containers to a shared network. +Built-in container-to-container connection-string providers remain deferred. Both backends now support +explicit managed networks and aliases (`CreateNetworkAsync`, `WithNetwork`, `WithNetworkAliases`). +Use an explicit alias/service port to construct a container-to-container URL. **Use `127.0.0.1`, never `localhost`.** `localhost` resolves to `::1` first on .NET, and while most clients fall back to IPv4, `Microsoft.Data.SqlClient` **hangs** on `::1` without falling back — which @@ -179,3 +181,4 @@ provider producing an empty string throws `ConnectionStringNotAvailableException `purview-containers-lifecycle` for start/stop/dispose semantics, wait strategies and networking; `purview-containers-testing` for suite layout, serial execution and CI; and `purview-containers-consumer-requirements` for the target-framework contract and the `PCC` guards. +Use `purview-containers-capabilities` for copy, builds, managed networks, reuse and cancellation defaults. diff --git a/.agents/skills/purview-containers-testing/SKILL.md b/.agents/skills/purview-containers-testing/SKILL.md index 5d8be988..87d122e1 100644 --- a/.agents/skills/purview-containers-testing/SKILL.md +++ b/.agents/skills/purview-containers-testing/SKILL.md @@ -3,7 +3,7 @@ name: purview-containers-testing description: >- Use when organising or running a test suite that uses Purview.Containers - the Unit and Integration tiers, why test modules must run serially under WSL Containers, pinning or skipping a backend, - running the Docker suites in CI and the WSL suites on a self-hosted runner, and verifying the + running Docker and WSLC contract suites in hosted CI and the full WSLC matrix, and verifying the consumer contract. metadata: product: Purview.Containers @@ -21,7 +21,7 @@ tier is a property of the project rather than of a hand-written attribute: | --- | --- | --- | | `*.UnitTests` | `Unit` | none — logic, parsing, configuration, wait-strategy units, in-process fakes | | `Wsl.*` and module `*.IntegrationTests` | `Integration` | a **WSLC host** | -| `Docker.IntegrationTests`, `Modules.DockerIntegrationTests` | `Integration` | any **Docker daemon**; these target `net10.0`, so they run on Linux too | +| `Docker.IntegrationTests`, `Modules.DockerIntegrationTests` | `Integration` | any **Docker daemon**; these target `net10.0`, so they run on Linux too and keep the lowest supported framework under test | The shared pipeline filters to `/*/*/*/*[Category=Unit]`, so **the standard pipeline never starts a container**. The Docker suites are the exception: the PR workflow adds an `integration-docker` job on @@ -32,7 +32,7 @@ Windows and WSL — the same modules a developer runs on WSLC locally. just test # every discovered project just test '/*/*/*/*[Category=Unit]' # unit only just test '/*/*/*/*[Category=Integration]' --max-parallel-test-modules 1 -just test src/tests/Docker.IntegrationTests/Docker.IntegrationTests.csproj +dotnet test --project src/tests/Docker.IntegrationTests/Docker.IntegrationTests.csproj --treenode-filter '/*/*/*/*' ``` Filtering uses TUnit's `--treenode-filter`, not `--filter`: this runs on Microsoft.Testing.Platform, @@ -66,16 +66,16 @@ PURVIEW_CONTAINERS_BACKEND=wsl # or docker ``` Both halves skip themselves when their runtime is unreachable: the Docker suites when no daemon -responds, the WSLC suites when the host lacks the WSL Containers components. That is what lets a -machine without WSLC still run the unit tier — and it is also why a mis-provisioned CI runner reports -*successes with skips* rather than real coverage. Print `wsl --version` and `wslc version` as the -job's first step so that failure is visible immediately. +responds, the WSLC suites when the host lacks the WSL Containers components. Set +`PURVIEW_CONTAINERS_REQUIRE_BACKEND=wsl|docker` in CI to fail instead of silently skipping coverage. +Print `wsl --version` and `wslc version` in WSLC jobs so provisioning failures are visible. ## CI shape - **Docker half** — runs on every pull request, on `ubuntu-latest`. -- **WSL half** — cannot: it needs a Windows host with WSL Containers, which no GitHub-hosted runner - provides. It is a **manual** workflow on a self-hosted runner: +- **WSLC contract** — runs on every pull request on `windows-2025`, with nested virtualisation and + WSL Containers installed by the workflow. +- **Full WSLC matrix** — runs nightly on `windows-2025`, or on demand on a hosted or self-hosted runner: ```bash gh workflow run "Integration (WSL Containers)" --ref main @@ -83,18 +83,23 @@ job's first step so that failure is visible immediately. ``` It runs `Wsl.IntegrationTests` then each service-module suite **one project at a time**, so the - shared WSLC image store is never contended. Inputs: `ref` and `filter` (a treenode filter). + shared WSLC image store is never contended. Use `scope: contract` for the backend contract alone. Self-hosted runner prerequisites: a **Windows x64** runner labelled `self-hosted` and `wslc`, with WSL Containers installed (`wsl --install --no-distribution`) and the pinned .NET SDK. Tell `actionlint` about the custom label in `.github/actionlint.yaml`. -> The shared pipeline runs on `ubuntu-latest`, so it builds the portable `net10.0` projects **and** +> The shared pipeline runs on `ubuntu-latest`, so it builds the portable `net10.0`/`net11.0` projects **and** > the Windows-targeted test projects on Linux. That works only because `src/Directory.Build.props` > sets `EnableWindowsTargeting=true`; without it the SDK reports `NETSDK1100`. ## Expectations +The repository's `src/tests/BackendContractTests.cs` is compiled into both backend integration +suites. It exercises exec settings, binary pre-start/runtime copies, delayed and failed one-shot +exits, network alias DNS, image builds, reuse, cancellation and bounded readiness cleanup. Run both +complete suites when changing a shared contract; a unit-only run cannot prove backend behaviour. + `Wsl.IntegrationTests` runs many real containers in one session and can take several minutes, because WSLC serialises container operations. Slow-test warnings while it runs are expected, not a signal. diff --git a/.agents/skills/purview-engineering-principles/SKILL.md b/.agents/skills/purview-engineering-principles/SKILL.md index ba4dd9bd..24fe707c 100644 --- a/.agents/skills/purview-engineering-principles/SKILL.md +++ b/.agents/skills/purview-engineering-principles/SKILL.md @@ -4,8 +4,9 @@ description: >- Use when setting up, reviewing or rationalising a .NET repository against the Purview engineering conventions - convention over configuration, derived identity, architecture as tests, warnings as errors, proving consumer-facing claims, documentation ownership, test data builders, agent - legibility, and tiering verification by risk. Each rule names the failure it prevents and whether a - build or a reviewer enforces it. + legibility, tiering verification by risk, the version policy for applications and for published + components, and the conditional persistence rules. Each rule names the failure it prevents and + whether a build or a reviewer enforces it. metadata: product: Purview engineering playbook repository: purview-dev/engineering-playbook @@ -13,7 +14,7 @@ metadata: # Purview engineering principles -Ten rules, each with the failure it prevents and the mechanism that makes it true. The distinction +Eleven rules, each with the failure it prevents and the mechanism that makes it true. The distinction that matters throughout: **a convention a build can check is checked by the build; a convention only a reviewer can check is labelled a review obligation and never described as enforced.** A rule that claims enforcement it does not have is worse than no rule, because people stop looking. @@ -100,8 +101,9 @@ repository's own tests already are the consumer. ## 7. Documentation is a build artefact of the repository -The wiki lives in the repository it describes, in `docs/wiki`, and the repository owns its -`mkdocs.yml`, its navigation and its `edit_uri`. +The wiki lives in the repository it describes, in `docs/wiki`, as plain Markdown. The repository owns no +site configuration at all: `_Sidebar.md` is the navigation, `Home.md` is the hub, and the site that +publishes them lives in one place outside the repository. Documentation that lives elsewhere is updated elsewhere. A page with no owner and no review path is wrong within one release. @@ -154,6 +156,23 @@ hand-written attribute. *At your scale:* one tier is fine until the slow tests exist. Then split by project name, not by attribute. +## 11. A version is a range, not a number + +An application takes the latest version its runtime supports. A published component declares the +**lowest** version that compiles, passes its tests and satisfies the contract it documents, on each +target framework it claims — because dependency resolution is a set of lower bounds, so the floor a +component declares becomes the floor every consumer inherits. + +A page states no release number. It shows a badge generated from the registry, or links to the +registry, so a version cannot be wrong within a release. + +*Enforced by:* a lowest-compatible CI leg that restores against the declared floor, and an +API-compatibility check per target framework. Nothing enforces the floor of a package that has no +consumer yet — that tier arrives with the first release. + +*At your scale:* with one application there is nothing to earn yet; take the latest and let +`dotnet list package --outdated` report the rest. The golden rule starts at the first published package. + ## Applying these in review Three questions catch most of it: @@ -164,8 +183,16 @@ Three questions catch most of it: 3. **Could an agent — or a new joiner — find this without being told?** If not, it goes in `AGENTS.md` or the wiki. +## The conditional persistence rules + +Three further rules apply only when a repository actually stores data: a value known before storage is +derived with UUIDv5, every other GUID key uses one strategy per store, and EF Core is the persistence +boundary. They are not part of the eleven because a stateless CLI or a pure library must not inherit them. +`purview-persistence` owns them and stays silent when persistence is not real. + ## Related skills `purview-architecture` for project-shape decisions, `purview-architecture-testing` for turning a boundary into a failing build, `purview-telemetry` and `purview-persistence` for the per-concern -guidance. +guidance, and `purview-component-compatibility` for deciding and verifying a published component's +lowest compatible dependency versions. diff --git a/.agents/skills/purview-org-harvest/SKILL.md b/.agents/skills/purview-org-harvest/SKILL.md new file mode 100644 index 00000000..2c05ea19 --- /dev/null +++ b/.agents/skills/purview-org-harvest/SKILL.md @@ -0,0 +1,197 @@ +--- +name: purview-org-harvest +description: >- + Use when a Purview repository's conventions may have fallen behind the organisation - + inventorying every public purview-dev repository at main, deciding which repository is + best-in-class for each concern before adopting anything, and recording the result in a dated + snapshot. Always reads the remote: a local clone is a cache, and it may be stale, renamed or + missing entirely. +license: MIT +metadata: + author: Purview-Dev + version: "1.0" + repository: purview-dev/engineering-playbook +--- + +# Purview organisation harvest + +Seventeen public repositories, each with its own wiki, its own `skills/` folder and its own `AGENTS.md`. +They are not equally up to date, and the one you have cloned is not necessarily the one that has the +answer. Two failures follow, and this skill exists to prevent both. + +**Copying from a stale clone.** A clone is a cache with no expiry. The convention you read there may +be two months behind the one the owner now documents, and nothing about the local file says so. + +**Copying from the wrong owner.** "How do we ship?" is answered by `build`. "How do we keep a boundary +honest?" is answered by `architests`. Asking a library that does neither produces a confident answer +with no evidence, which is worse than no answer. + +So: establish membership, read at `main`, name one owner per concern, write down what you found. + +## 1. Scope + +Name two things before reading anything: + +| Input | Example | +| -- | -- | +| The repository being changed | `containers` | +| The concern in question | "how a test reaches a real database" | + +A harvest without a named concern becomes a tour, and a tour produces a list of differences nobody +acted on. The concern is what makes "best-in-class" a decision rather than a preference. + +## 2. Discover membership + +`gh repo list` is the membership authority. A catalogue, a README table, an `llms.txt` and a local +folder listing are all convenience indexes: they are maintained by hand, so they under-count, and the +omission is silent. + +```bash +gh auth status +gh repo list purview-dev --limit 200 \ + --json name,visibility,isArchived,pushedAt,defaultBranchRef +``` + +Drop `isArchived` and anything that is not `PUBLIC`. **If `gh auth status` fails, stop.** Degrading to +the local clone is the exact failure this skill prevents, and it degrades silently. + +The filter denies by default: a repository must be exactly `PUBLIC` and exactly not archived to be +recorded, so a visibility you have never seen fails closed. `gh repo list` also only lists what your +token can see, which is why the token's visibility and the file's contents are two separate questions: +the section below is about the second one. + +A local clone is admissible only when its `HEAD` equals the SHA the snapshot recorded: + +```bash +git -C ../containers rev-parse HEAD +``` + +That is a speed optimisation, never a substitute. + +## 3. Read it at `main` + +Every repository is read at `main`, and the commit is recorded. The snapshot is what makes the claim +checkable a month later, when nobody remembers which commit the analysis saw. + +```bash +node scripts/inventory.mjs # drift between the organisation and the snapshot +node scripts/inventory.mjs --write # regenerate .config/org-inventory.json +node scripts/inventory.mjs --repo containers +``` + +The script is deterministic and dependency-free: it shells out to `gh`, so it reuses the CLI's own +authentication, and it runs the same on Windows and on a Linux agent — it is Node, not a shell script, +so there is no PowerShell edition and no Bash edition to keep in step. It writes +`.config/org-inventory.json` in the repository you run it from — generated, never hand-edited. + +To inspect a decision without a network call, replay a recorded harvest with `--from` and `--details`. +That is the same script, not a variant, so what you review is what runs. + +## 4. Harvest in two tiers + +Seventeen repositories is not a reading list; it is a budget. Two tiers keep it affordable. + +| Tier | Call | Answers | +| -- | -- | -- | +| 1 — shape | `gh api repos/purview-dev//git/trees/main?recursive=1`, for all seventeen | Which folders exist, how many pages the wiki has, whether `skills/` is authored, whether `AGENTS.md` exists. Cheap enough for everything. | +| 2 — content | `gh api -H 'Accept: application/vnd.github.raw' repos/purview-dev//contents/`, **only for the owner of the concern** | What the convention actually says. | + +Tier 1 tells you where the evidence is. Tier 2 reads it, in the one repository that wrote it down. + +```bash +gh api repos/purview-dev/architests/git/trees/main?recursive=1 --jq '.tree[].path' +gh api -H 'Accept: application/vnd.github.raw' \ + repos/purview-dev/architests/contents/docs/wiki/Architecture-As-Tests.md +``` + +More invocations, and what each one is for: `references/harvest-commands.md`. + +## 5. Classify: one owner per concern + +Pick exactly one owner per concern, from `references/best-in-class.md`. Two signs the classification +is wrong: + +- **Two owners for one concern.** `build-sdk` and this playbook's `purview-engineering-principles` + both answer "review this repository". That overlap is a finding to resolve, not a tie to break by + preferring whichever you read first. +- **An owner that has never run the thing it documents.** Check the tree before trusting the page. + +## 6. Divergence register + +Write the differences down before changing anything, with an adopt-or-reject for each. A divergence +with no decision recorded is the same divergence next quarter. + +| Column | Answers | +| -- | -- | +| Concern | What is being compared | +| Owner | The repository the convention comes from, and the path it is stated at | +| Here | What this repository does now | +| Decision | Adopt, or reject with the reason | +| Mechanism | The build rule, test or review obligation that keeps it true | + +Rejecting is a legitimate outcome and is usually the right one at a smaller scale. What is not +legitimate is adopting a convention whose failure the repository cannot have. + +## 7. Adopt + +A convention arrives as a set of changes that land together, or it is a claim with no mechanism: + +```text +the page states the rule, why, how it is enforced, and what a smaller repository does instead +src/ demonstrates it, or says why it does not +the snapshot records the commit it was read at +the skill points at the owner, and never paraphrases it +``` + +**Link to the owner; do not restate it.** A paraphrase is a second source of truth, and the copy with +no owner is the one that loses. + +## 8. Verify + +```bash +just build # zero warnings +just test # architecture, skill and documentation rules +just lint-check +node scripts/inventory.mjs --print +``` + +If the harvest changed what the snapshot should record, regenerate it and commit the diff. The +snapshot being current is a review obligation rather than a build failure — a build that goes red on +a Tuesday because a clock passed is a build people disable. + +## Private repositories + +A private repository may inform a maintainer's judgement. It is never evidence, and it is never +written down. This is not a preference, and it is not a heuristic an agent gets to weigh against a +good argument. Five statements, in order of how often they are broken: + +1. A committed snapshot contains public, non-archived repositories only. The writer denies by default + and refuses to persist anything else, and a test asserts it. +2. A private repository may be read when `gh` is authenticated for it, and what it teaches may shape + a rule. +3. Nothing published names it: not the snapshot, not the page, not the skill, not a workflow, not a + config, not a commit message. Attribute the rule to "a Purview application repository", or to + nothing. +4. **If a rule cannot be justified without the private source, it is not published.** +5. **If you cannot say where a name came from, do not write the name.** A repository the snapshot + does not list is either private or gone, and both fail the same test. + +The fourth is the one an agent rationalises around, because the evidence is often excellent. A rule +whose only justification is out of reach of the people it is published to cannot be reviewed by them, +which makes it folklore with a citation they cannot follow. + +## Checks before you answer + +- Did you read membership from the organisation rather than from a catalogue or a folder? +- Is every claim about a repository true at the SHA the snapshot recorded, not at your clone? +- Did you name exactly one owner per concern, and resolve any overlap rather than duplicating it? +- Did you reject anything? A harvest with no rejections usually means nothing was understood. +- Does every change you propose have a mechanism, or is it labelled a review obligation? +- Is every repository you named public? + +## See also + +- `references/best-in-class.md` — which repository owns which concern, and where to read it +- `references/harvest-commands.md` — the `gh` invocations, and the trap in each one +- `purview-engineering-principles` — the conventions themselves, once you know which owner to read +- `purview-architecture` — the project-shape decisions a harvest most often touches diff --git a/.agents/skills/purview-org-harvest/references/best-in-class.md b/.agents/skills/purview-org-harvest/references/best-in-class.md new file mode 100644 index 00000000..9ab69ccd --- /dev/null +++ b/.agents/skills/purview-org-harvest/references/best-in-class.md @@ -0,0 +1,77 @@ +# Best-in-class, by concern + +One owner per concern. Read the owner, not the nearest repository that happens to have a wiki. + +Every row was read from the organisation with `gh repo list purview-dev` and +`gh api repos//git/trees/main?recursive=1`. The page counts and skill counts are the shape at +the time of writing, not a promise: they are what the snapshot records, and the snapshot is +regenerated rather than trusted. + +## The matrix + +| Concern | Owner | Read it at | +| -- | -- | -- | +| Repository shape, MSBuild defaults, analyzers, skill authoring | `build-sdk` | `docs/wiki`, `templates/`, `skills/purview-buildsdk-*` | +| Pipeline, release, pack validation | `build` | `docs/wiki`, `skills/purview-build-*` | +| Architecture policy: dependency direction, naming rules | `architests` | `docs` (nine pages; `Policy-Reference.md` is the reference, `Extending.md` the how-to), `skills/purview-architests-*` | +| Local dependencies in tests | `containers` | `docs/wiki`, `skills/purview-containers-*` | +| TypeScript contracts generated from C# | `zodsharp` | `docs/wiki`, `skills/purview-zodsharp-*` | +| Logging, tracing, metrics | `telemetry-sourcegenerator` | `docs/wiki`, `skills/purview-telemetry-*` | +| Event streams and event storage | `event-sourcing` | `docs/wiki` — **no skills folder** | +| Operation outcomes without exceptions | `results` | `docs/wiki`, `skills/purview-results-*` | +| Validated identity types | `value-objects` | `docs` — **no `docs/wiki`** | +| Aspire hosting resources | `aspire-resourcekit` | `docs/wiki`, `skills/purview-resourcekit-*` | +| Architecture diagrams generated from source | `aspirec4` | `docs/wiki`, `skills/purview-aspirec4-*` | +| LikeC4 diagrams inside an Astro or Starlight site | `astroc4` | `docs/wiki`, `skills/purview-astroc4-*` — an npm package, not .NET | +| Source generator authoring | `sourcegenerator-framework` | `docs/wiki`, `skills/purview-sourcegenerator-*` | +| Skill-set design, and the dated-snapshot pattern | `pm-skills` | `skills/pm-*` — **no documentation folder at all** | +| OpenAPI generated from XML comments | `openapi-xmlcomments` | `docs/wiki`, `skills/purview-openapi-xmlcomments-*` | +| The reusable conventions themselves | `engineering-playbook` | `docs/wiki`, `skills/purview-*` | +| The public project catalogue | `purview-dev.github.io` | `src/src/data/projects/*.yml` | + +`purview-dev.github.io` is the catalogue, and a catalogue is an index rather than a membership list: +an entry is added by hand, so a repository missing from it is silent. `gh repo list` is the authority. + +## The overlaps worth knowing about + +**`build-sdk` and `engineering-playbook` both answer "review this repository".** They are not +duplicates, and the split is worth stating because a harvest will hit it immediately: + +- `build-sdk` owns *what the tooling does* — project detection, the props and targets, the analyzer + set, the packaged test-support rule sets. +- `engineering-playbook` owns *why a repository is shaped that way, and what it costs* — the rule, + the failure it prevents, and what a smaller repository does instead. + +A question about a property name goes to `build-sdk`. A question about whether a project should exist +goes here. + +**`containers` and `aspire-resourcekit` both look like "local dependencies".** `containers` owns the +test-time resource — a real SQL Server, PostgreSQL or queue — and `aspire-resourcekit` owns the +development-time orchestration. A test that needs a database is `containers`; an AppHost that needs +one is `aspire-resourcekit`. + +**`aspirec4` and `astroc4` both draw LikeC4 diagrams.** `aspirec4` owns the diagram generated from a +running Aspire resource graph; `astroc4` owns putting a `.c4` model inside an Astro or Starlight site — +the fence transform and the LikeC4 syntax highlighting. A question about the .NET host is `aspirec4`; a +question about the documentation site is `astroc4`, which is the one owner here that is not a .NET +package, so it is read for its TypeScript conventions rather than for its MSBuild ones. + +## Reading a repository you have not cloned + +Tier 1 first — the shape, for everything: + +```bash +gh api repos/purview-dev//git/trees/main?recursive=1 --jq '.tree[].path' +``` + +Then Tier 2 — the content, for the one owner: + +```bash +gh api -H 'Accept: application/vnd.github.raw' \ + repos/purview-dev//contents/docs/wiki/_Sidebar.md +``` + +`_Sidebar.md` is the fastest index of a wiki, because the repository's own documentation rule says a +page that is not in it does not exist. Where a repository publishes no wiki, its `AGENTS.md` is the +next best entry point, and where it has neither, read `skills/*/SKILL.md` — the trigger in each +`description` says what the repository thinks it is for. diff --git a/.agents/skills/purview-org-harvest/references/harvest-commands.md b/.agents/skills/purview-org-harvest/references/harvest-commands.md new file mode 100644 index 00000000..f4b46537 --- /dev/null +++ b/.agents/skills/purview-org-harvest/references/harvest-commands.md @@ -0,0 +1,121 @@ +# Harvest commands + +Every invocation here is safe to run read-only, and each one is followed by the trap that cost +somebody time. Both shells are shown because the harvest runs on a developer's Windows machine and on +a Linux agent, and the quoting rules are not the same. + +## Membership + +```bash +gh auth status +gh repo list purview-dev --limit 200 --json name,visibility,isArchived,pushedAt,defaultBranchRef +``` + +```powershell +gh auth status +gh repo list purview-dev --limit 200 --json name,visibility,isArchived,pushedAt,defaultBranchRef | + ConvertFrom-Json | Sort-Object name | + Format-Table name, visibility, isArchived, pushedAt +``` + +**Trap: the default limit is 30.** Without `--limit` the list is silently short, and a short list is +worse than a failed one because the omission never announces itself. + +**Trap: `gh repo list` shows what your token can see.** An organisation member sees private +repositories too; filter to `visibility -eq 'PUBLIC'` and `-not isArchived` before recording anything. + +## The SHA a convention was read at + +```bash +gh api repos/purview-dev//git/ref/heads/main --jq .object.sha +``` + +**Trap: a renamed repository resolves by redirect.** Asking for a repository under its former name +answers `purview-dev/purview-dev.github.io` and exits 0, so a stale name never fails. Compare the +returned `full_name` with the name you asked for. + +## Tier 1: the shape + +```bash +gh api repos/purview-dev//git/trees/main?recursive=1 --jq '.tree[].path' +``` + +**Trap: `truncated`.** GitHub caps a recursive tree, and the response says so with `"truncated": true` +rather than erroring. A truncated tree under-counts skills and pages, so check the flag before +believing a zero. + +**Trap: the tree lists blobs and trees.** Filter to `type == "blob"` when counting files, or a folder +whose name matches your pattern is counted as its contents. + +## Tier 2: the content + +```bash +gh api -H 'Accept: application/vnd.github.raw' \ + repos/purview-dev//contents/docs/wiki/_Sidebar.md +``` + +**Trap: `contents` returns JSON unless you ask for raw.** Without the `Accept` header the response is +a base64 `content` field, and the analysis quietly proceeds on a JSON document. + +**Trap: `contents` caps at 1000 files for a directory listing.** Use the tree API for enumerating and +`contents` for reading one file. + +## Windows PowerShell, specifically + +**Embedded double quotes are stripped from a native command's arguments.** This looks correct and is +not: + +```powershell +gh repo list purview-dev --jq '.[] | [.name, (.pushedAt // "-")] | @tsv' +``` + +`gh` receives `// -` and answers `failed to parse jq expression ... unexpected token`. Either keep the +jq program free of string literals, or do the shaping in PowerShell: + +```powershell +$repos = gh repo list purview-dev --limit 200 --json name,visibility | ConvertFrom-Json +$repos | Sort-Object name | ForEach-Object { '{0}|{1}' -f $_.name, $_.visibility } +``` + +**A filtered result is a scalar when it is one item.** `(... | Where-Object { ... }).Count` returns +nothing rather than `1`, so `.Count` in a summary line reads as empty. Wrap it: `@(...).Count`. + +## The snapshot + +```bash +node skills/purview-org-harvest/scripts/inventory.mjs +node skills/purview-org-harvest/scripts/inventory.mjs --write +node skills/purview-org-harvest/scripts/inventory.mjs --repo containers --write +``` + +It resolves the snapshot path from `git rev-parse --show-toplevel`, so it writes into the repository +you are standing in rather than into the one the script was installed from. That is what lets another +repository adopt it unchanged. + +`--repo` merges on write, so reading one repository does not erase the other sixteen. + +## Replaying a harvest, with no network + +Two flags replace the two `gh` calls. This is how the repository's own tests exercise the script on +every platform without touching GitHub, and how a reviewer reproduces what a harvest decided: + +```bash +node skills/purview-org-harvest/scripts/inventory.mjs \ + --from recorded-membership.json \ + --details recorded-details.json \ + --print +``` + +`--from` takes exactly what `gh repo list --json name,visibility,isArchived,pushedAt,defaultBranchRef` +printed. `--details` takes an object keyed by repository name, each with the `commitSha` a tier-1 +harvest found and the `paths` the tree contained. A repository that is in the membership and missing +from the details is an error, not a silent omission — a replay that quietly skipped a repository would +look like a clean run. + +Two behaviours worth knowing before you trust a run: + +- **A snapshot carrying a byte-order mark is accepted.** It is the one file in this workflow a person + is likely to open and re-save, and a BOM is not a corruption. +- **A snapshot containing anything that is not public and unarchived is refused**, and nothing is + written. This includes an entry a hand edit left behind, which the merge would otherwise carry + forward: delete it and re-run `--write`. diff --git a/.agents/skills/purview-org-harvest/scripts/inventory.mjs b/.agents/skills/purview-org-harvest/scripts/inventory.mjs new file mode 100644 index 00000000..affc46fa --- /dev/null +++ b/.agents/skills/purview-org-harvest/scripts/inventory.mjs @@ -0,0 +1,466 @@ +#!/usr/bin/env node +/** + * Snapshots every public, non-archived `purview-dev` repository at `main` into + * `.config/org-inventory.json`, and reports the drift between that snapshot and the organisation. + * + * The snapshot is what makes the harvest rule checkable offline: `OrgInventoryTests` reads the file + * the pipeline has, never the network. This script is the only thing that writes it, so a repository + * cannot appear in the snapshot by being remembered. + * + * Zero dependencies: it shells out to `gh` and so reuses the CLI's own authentication rather than + * asking for a token. + * + * node inventory.mjs # print the drift against the snapshot (default) + * node inventory.mjs --write # rewrite the snapshot from the organisation + * node inventory.mjs --repo containers # narrow to one repository; merges on --write + * node inventory.mjs --out # write somewhere else, e.g. while adopting the skill + * node inventory.mjs --help + * + * Exit codes: 0 on success, including when drift is found - a stale snapshot is a review obligation, + * not a red pull request for whoever happened to push next. 1 when `gh` is missing, unauthenticated, + * or the organisation cannot be read. + */ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +/** The organisation this playbook describes. */ +const ORG = "purview-dev"; + +/** The branch a convention is read at. A local clone is a cache; this is the authority. */ +const BRANCH = "main"; + +/** Bumped when a field is added or its meaning changes. */ +const SCHEMA_VERSION = 1; + +/** Past this age the snapshot is reported as stale. Advisory: nothing fails on a clock. */ +const STALE_AFTER_DAYS = 30; + +/** Files a repository may put at its root to guide an agent. Recorded in this order. */ +const AGENT_GUIDANCE_FILES = ["AGENTS.md", "CLAUDE.md", ".github/copilot-instructions.md", "llms.txt"]; + +/** Directories a published documentation site is read from, most specific first. */ +const DOCS_DIRECTORIES = ["docs/wiki", "docs"]; + +/** + * The concern each repository is best-in-class for, so a harvest reads one owner instead of sixteen + * repositories. `references/best-in-class.md` is the long form of this table and is the file to edit + * first; this map only records the answer in the snapshot. + */ +const CONCERNS = new Map([ + ["architests", ["architecture policy", "dependency and naming rules"]], + ["aspire-resourcekit", ["Aspire hosting resources"]], + ["aspirec4", ["architecture diagrams from source"]], + ["astroc4", ["architecture diagrams in an Astro site"]], + ["build", ["pipeline", "release"]], + ["build-sdk", ["repository shape", "analyzers and style", "MSBuild defaults", "skill authoring"]], + ["containers", ["local dependencies in tests"]], + ["engineering-playbook", ["the conventions themselves"]], + ["event-sourcing", ["event streams", "event storage"]], + ["openapi-xmlcomments", ["OpenAPI from XML comments"]], + ["pm-skills", ["skill-set design"]], + ["purview-dev.github.io", ["the public project catalogue"]], + ["results", ["operation outcomes"]], + ["sourcegenerator-framework", ["source generator authoring"]], + ["telemetry-sourcegenerator", ["logging", "tracing", "metrics"]], + ["value-objects", ["validated identity types"]], + ["zodsharp", ["TypeScript contracts from C#"]], +]); + +/** Runs `gh` and returns stdout, turning a CLI failure into an actionable message. */ +function gh(args) { + try { + return execFileSync("gh", args, { + encoding: "utf8", + maxBuffer: 256 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + const detail = (error.stderr ?? "").toString().trim() || error.message; + throw new Error( + `gh ${args.join(" ")} failed.\n${detail}\n\n` + + "This script reads the organisation, not a local clone. Run `gh auth status`, then retry.", + ); + } +} + +/** Calls one GitHub REST endpoint and parses the JSON response. */ +function api(endpoint) { + return JSON.parse(gh(["api", endpoint])); +} + +/** The root of the repository the command was run in, so the skill works from any repository. */ +function repositoryRoot() { + try { + return execFileSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + } catch { + throw new Error("Not inside a git repository. Run this from a clone, or pass --out ."); + } +} + +/** Membership, as the organisation reports it. The catalogue is a convenience index, not the list. */ +function readMembership(source) { + const listed = source + ? JSON.parse(readFileSync(source, "utf8")) + : JSON.parse( + gh([ + "repo", + "list", + ORG, + "--limit", + "200", + "--json", + "name,visibility,isArchived,pushedAt,defaultBranchRef", + ]), + ); + + return ( + listed + // Deny by default. The filter is "exactly public and not archived" rather than "not + // private", so a visibility value this script has never seen fails closed instead of + // falling through into a file that is committed to a public repository. + .filter((repository) => repository.visibility === "PUBLIC" && repository.isArchived === false) + .sort((left, right) => left.name.localeCompare(right.name, "en")) + ); +} + +/** The recorded commit and file list for each repository, when a run replays a previous harvest. */ +function readDetails(source) { + return new Map(Object.entries(JSON.parse(readFileSync(source, "utf8")))); +} + +/** The Markdown pages directly inside a docs directory. */ +function countPages(paths, directory) { + const prefix = `${directory}/`; + return paths.filter((path) => { + if (!path.startsWith(prefix)) { + return false; + } + + const remainder = path.slice(prefix.length); + return remainder.endsWith(".md") && !remainder.includes("/"); + }).length; +} + +/** A skill is a folder under `skills/` that carries a `SKILL.md`. */ +function authoredSkills(paths) { + const names = paths + .filter((path) => /^skills\/[^/]+\/SKILL\.md$/.test(path)) + .map((path) => path.split("/")[1]); + + return [...new Set(names)].sort((left, right) => left.localeCompare(right, "en")); +} + +/** Everything the snapshot records about one repository, read at `BRANCH`. */ +function describeRepository(repository, details) { + // The filter above already excluded these, and this is the second lock on the same door: a + // private repository must never reach the snapshot, so the guard is here as well as there. + if (repository.visibility !== "PUBLIC" || repository.isArchived !== false) { + throw new Error( + `${ORG}/${repository.name} is ${repository.visibility} and archived ${repository.isArchived}. ` + + "The snapshot records public, unarchived repositories only, so nothing was written.", + ); + } + + const defaultBranch = repository.defaultBranchRef?.name ?? BRANCH; + if (defaultBranch !== BRANCH) { + throw new Error( + `${ORG}/${repository.name} has default branch '${defaultBranch}', not '${BRANCH}'. ` + + "The snapshot pins one branch, so either the rule or the repository has to change.", + ); + } + + const recorded = details?.get(repository.name); + if (details && !recorded) { + throw new Error( + `${repository.name} has no recorded detail, so --details cannot describe it. ` + + "Replay a membership the details file was captured with, or run without --details.", + ); + } + + const commitSha = recorded?.commitSha ?? api(`repos/${ORG}/${repository.name}/git/ref/heads/${BRANCH}`).object.sha; + const paths = recorded?.paths ?? readTree(repository.name); + const docsDir = DOCS_DIRECTORIES.find((candidate) => + paths.some((path) => path.startsWith(`${candidate}/`)), + ); + + return { + name: repository.name, + visibility: repository.visibility.toLowerCase(), + archived: repository.isArchived, + commitSha, + pushedAt: repository.pushedAt, + docsDir: docsDir ?? null, + docsPages: docsDir ? countPages(paths, docsDir) : 0, + skills: authoredSkills(paths), + agentGuidance: AGENT_GUIDANCE_FILES.filter((file) => paths.includes(file)), + concerns: CONCERNS.get(repository.name) ?? [], + }; +} + +/** The blob paths of one repository at `BRANCH`. */ +function readTree(name) { + const tree = api(`repos/${ORG}/${name}/git/trees/${BRANCH}?recursive=1`); + + if (tree.truncated) { + process.stderr.write( + `warning: ${name}'s tree is truncated, so its skills and docs are undercounted\n`, + ); + } + + return tree.tree.filter((entry) => entry.type === "blob").map((entry) => entry.path); +} + +/** The snapshot document. */ +function snapshotOf(repositories) { + return { + schemaVersion: SCHEMA_VERSION, + org: ORG, + branch: BRANCH, + fetchedAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), + repositories, + }; +} + +/** The snapshot on disk, or null when the repository has not committed one yet. */ +function readSnapshot(path) { + if (!existsSync(path)) { + return null; + } + + // The snapshot is the one file in this workflow a person is likely to open and save by hand, and + // an editor that writes a byte-order mark produces a file JSON.parse rejects with a message that + // names neither the file nor the cause. + return JSON.parse(readFileSync(path, "utf8").replace(/^\uFEFF/, "")); +} + +/** A field's value in the form the drift report prints. */ +function render(value) { + if (value === null || value === undefined) { + return "none"; + } + + if (Array.isArray(value)) { + return value.length === 0 ? "none" : value.join(", "); + } + + return String(value); +} + +/** A field's value in the form the drift report compares: structured values compare as one string. */ +function fingerprint(value) { + return JSON.stringify(value ?? null); +} + +/** The fields whose change is worth reporting, in the order a reader cares about them. */ +const COMPARED_FIELDS = [ + "commitSha", + "docsDir", + "docsPages", + "skills", + "agentGuidance", + "concerns", + "visibility", + "archived", +]; + +/** The differences between the organisation and the snapshot. */ +function drift(existing, observed) { + const findings = []; + const before = new Map((existing?.repositories ?? []).map((entry) => [entry.name, entry])); + const after = new Map(observed.map((entry) => [entry.name, entry])); + + for (const [name, entry] of after) { + if (!before.has(name)) { + findings.push(`${name}: not in the snapshot`); + continue; + } + + const previous = before.get(name); + for (const field of COMPARED_FIELDS) { + if (fingerprint(previous[field]) === fingerprint(entry[field])) { + continue; + } + + findings.push(`${name}: ${field} ${render(previous[field])} -> ${render(entry[field])}`); + } + } + + for (const name of before.keys()) { + if (!after.has(name)) { + findings.push(`${name}: in the snapshot but no longer a public repository`); + } + } + + return findings; +} + +/** How long ago the snapshot was taken, in whole days, or null when it carries no usable date. */ +function ageInDays(fetchedAt) { + const fetched = Date.parse(fetchedAt ?? ""); + return Number.isNaN(fetched) ? null : Math.floor((Date.now() - fetched) / 86_400_000); +} + +/** Parses the command line. Unknown arguments are a mistake worth failing on. */ +function parseArguments(argv) { + const options = { write: false, repos: [], out: null, from: null, details: null, help: false }; + + for (let index = 0; index < argv.length; index++) { + const argument = argv[index]; + if (argument === "--write") { + options.write = true; + } else if (argument === "--print") { + options.write = false; + } else if (argument === "--help" || argument === "-h") { + options.help = true; + } else if (argument === "--repo") { + const value = argv[++index]; + if (!value) { + throw new Error("--repo needs a repository name."); + } + + options.repos.push(value); + } else if (argument === "--out") { + const value = argv[++index]; + if (!value) { + throw new Error("--out needs a path."); + } + + options.out = value; + } else if (argument === "--from") { + const value = argv[++index]; + if (!value) { + throw new Error("--from needs a path to a recorded `gh repo list` response."); + } + + options.from = value; + } else if (argument === "--details") { + const value = argv[++index]; + if (!value) { + throw new Error("--details needs a path to a recorded detail file."); + } + + options.details = value; + } else { + throw new Error(`Unknown argument '${argument}'. Run with --help.`); + } + } + + return options; +} + +/** The documented usage. */ +const HELP = `Snapshot every public, non-archived ${ORG} repository at ${BRANCH}. + + node inventory.mjs print the drift between the organisation and the snapshot + node inventory.mjs --write rewrite the snapshot from the organisation + node inventory.mjs --repo narrow to one repository; merges when writing + node inventory.mjs --out write somewhere other than .config/org-inventory.json + node inventory.mjs --from replay a recorded "gh repo list --json" response instead of + asking the organisation + node inventory.mjs --details replay recorded commits and file lists, so a run touches no + network at all; pair it with --from + node inventory.mjs --help this text + +The snapshot is generated, never hand-edited. A stale snapshot is a review obligation rather than a +build failure, so drift prints and the exit code stays 0.`; + +/** Runs the command. */ +function main() { + const options = parseArguments(process.argv.slice(2)); + if (options.help) { + console.log(HELP); + return; + } + + const snapshotPath = options.out ?? join(repositoryRoot(), ".config", "org-inventory.json"); + const existing = readSnapshot(snapshotPath); + + let observed = readMembership(options.from); + if (options.repos.length > 0) { + const known = new Set(observed.map((repository) => repository.name)); + for (const name of options.repos) { + if (!known.has(name)) { + throw new Error( + `'${name}' is not a public, non-archived ${ORG} repository. ` + + `Run without --repo to include all ${observed.length}.`, + ); + } + } + + observed = observed.filter((repository) => options.repos.includes(repository.name)); + } + + const details = options.details ? readDetails(options.details) : null; + const described = observed.map((repository) => describeRepository(repository, details)); + + if (options.write) { + const merged = new Map((existing?.repositories ?? []).map((entry) => [entry.name, entry])); + for (const entry of described) { + merged.set(entry.name, entry); + } + + const written = snapshotOf( + [...merged.values()].sort((left, right) => left.name.localeCompare(right.name, "en")), + ); + + // The merge carries forward whatever the file already held, which is how a hand-edited entry + // would survive a regeneration. Refuse to write a snapshot that records anything else. + const notPublic = written.repositories.filter( + (entry) => entry.visibility !== "public" || entry.archived !== false, + ); + if (notPublic.length > 0) { + throw new Error( + "Refusing to write a snapshot that records a repository which is not public and " + + `unarchived: ${notPublic.map((entry) => entry.name).join(", ")}. ` + + "Delete the entries and re-run --write.", + ); + } + + mkdirSync(dirname(snapshotPath), { recursive: true }); + writeFileSync(snapshotPath, `${JSON.stringify(written, null, "\t")}\n`, "utf8"); + console.log( + `wrote ${written.repositories.length} repositories to ${snapshotPath} at ${written.fetchedAt}`, + ); + return; + } + + const findings = drift(existing, described); + const age = ageInDays(existing?.fetchedAt); + + console.log(`${ORG}: ${observed.length} public, non-archived repositories at ${BRANCH}`); + if (existing) { + console.log( + `snapshot: ${existing.repositories?.length ?? 0} repositories, fetched ${existing.fetchedAt}` + + (age === null ? "" : ` (${age} day${age === 1 ? "" : "s"} old)`), + ); + } else { + console.log(`no snapshot at ${snapshotPath} - run with --write to create one`); + } + + if (findings.length === 0) { + console.log("no drift"); + } else { + console.log(`${findings.length} difference${findings.length === 1 ? "" : "s"}:`); + for (const finding of findings) { + console.log(` ${finding}`); + } + } + + if (age !== null && age > STALE_AFTER_DAYS) { + console.log( + `warning: the snapshot is older than ${STALE_AFTER_DAYS} days. ` + + "Re-run with --write and review the diff.", + ); + } +} + +try { + main(); +} catch (error) { + console.error(`error: ${error.message}`); + process.exitCode = 1; +} diff --git a/.agents/skills/purview-persistence/SKILL.md b/.agents/skills/purview-persistence/SKILL.md index 4667fa29..7d6ad911 100644 --- a/.agents/skills/purview-persistence/SKILL.md +++ b/.agents/skills/purview-persistence/SKILL.md @@ -1,14 +1,15 @@ --- name: purview-persistence description: >- - Use when a .NET project actually persists data and you are choosing a storage approach, deciding - whether to abstract EF Core, or selecting a GUID primary-key strategy. Gives provider-specific - guidance — SQL Server and PostgreSQL differ and must not be treated alike — and stays silent for - projects that do not persist anything. + Use when a .NET project actually persists data and you are choosing a storage approach, integrating + or abstracting EF Core, deriving a well-known identifier, or selecting a GUID primary-key strategy. + Gives provider-specific guidance — SQL Server and PostgreSQL differ and must not be treated alike — + and stays silent for projects that do not persist anything. license: MIT metadata: author: Purview-Dev - version: "1.0" + version: "1.2" + repository: purview-dev/engineering-playbook --- # Purview persistence @@ -40,7 +41,39 @@ development and integration tests — including `MsSql`, `PostgreSql`, `MySql`, `RabbitMq`, `Azurite` and an Azure Key Vault emulator. Prefer a real container over an in-memory provider: the in-memory provider does not enforce the constraints that break you in production. -## 3. GUID keys — only if GUIDs are keys +## 3. EF Core integration + +When EF Core is the store, keep the integration in four places: + +- **`DbContext` is the boundary.** Use it directly; do not add `IRepository` or a unit-of-work wrapper + for testability. `Purview.Containers` gives you a real database in tests. +- **Choose the provider where the context is registered.** Carry the choice as context-options data, not + a `Database.ProviderName` branch at model-build time. +- **Map value objects once.** Configure converters and comparers in the model, not per property. A + value-object key needs a converter-aware value generator. +- **Keep design time and runtime in step.** Both paths make the same provider, strategy and + `ReplaceService` choices, or the migration snapshot describes a different model. + +Migrations are code, reviewed and applied by a deliberate step; the API does not migrate at startup in +production. The full rule, composition sample and checks are in `references/ef-core-integration.md`. + +## 4. Identifiers — derived or generated + +Two decisions live here, and they are independent. Ask them in this order. + +### The deterministic-identifier gate + +Derive an identifier only when its **value must be known before storage** — a system tenant, a seeded +reference row, an installation's service principal, or an ingested object whose provider key is stable +and never recycled. Then derive it with UUIDv5 over an installation-scoped namespace and configure +`ValueGeneratedNever()`, so the sequential convention below leaves it alone. A project with random keys +and no seeding has no use for UUIDv5. An outbox row that merely needs an id before `SaveChanges` takes +the generated strategy below, not a derived one. + +The full rule, catalogue, naming rules, migration caveat and tests are in +`references/well-known-identifiers.md`. + +### The generated-key decision Resolve these before recommending anything; most are answered by the repository: @@ -72,11 +105,14 @@ Use the framework or database capability when it is sufficient — `Guid.CreateV generator is a maintenance obligation and a correctness risk; introduce one only against a measured requirement. -**There is no Purview GUID generation package.** Do not reference one, and do not create one without -first establishing a concrete, measured need that the built-in options cannot meet. If such a -capability is added later, this skill is where the guidance for recommending it belongs. +**No NuGet package provides this pattern.** A reviewed generator such as `FastGuid` supplies a +store-ordered implementation, but the strategy witness, options extension, value generator with +value-converter support, and model-finalising convention are repository-local: copy them, do not depend +on a package that claims to own the wiring. Introduce it only for a measured requirement: a second +provider, or an identifier needed before `SaveChanges`. Until the second provider exists, a built-in +capability is the answer. -## 4. Value objects as keys +## 5. Value objects as keys `Purview.ValueObjects` gives strongly-typed identifiers that stop an `OrderId` being passed where a `CustomerId` belongs. They need an EF Core value converter; configure it once in the model rather @@ -85,7 +121,7 @@ than per property. This is about type safety at the boundary, not about the generation strategy — the two decisions are independent. -## 5. Migrations and schema +## 6. Migrations and schema - Migrations are code: reviewed, tested against a real container, and applied by a deliberate step — not by the application at startup in production. @@ -96,7 +132,14 @@ independent. ## Checks before you answer - Have you confirmed persistence is actually required? +- If the project uses EF Core, are you recommending the `DbContext` directly rather than a repository + wrapper added for testability? +- Does the design-time path make the same provider and strategy choices as the runtime path? +- Does every generated value have one owner — application, store or derivation — and no second writer? +- If you are deriving an identifier, is its value genuinely known before storage, and is it keyed + `ValueGeneratedNever()`? - If you are recommending a GUID strategy, do you know the provider *and* its version? +- If you are proposing the strategy/convention, is there a second provider or a pre-save identifier? - Are you proposing an abstraction you can justify by something other than testability? - Have you preferred a built-in capability over a custom generator? diff --git a/.agents/skills/purview-persistence/references/ef-core-integration.md b/.agents/skills/purview-persistence/references/ef-core-integration.md new file mode 100644 index 00000000..77c75731 --- /dev/null +++ b/.agents/skills/purview-persistence/references/ef-core-integration.md @@ -0,0 +1,83 @@ +# EF Core integration + +Read this when the project actually uses EF Core. The identifier decisions are in `guid-generation.md` +and `well-known-identifiers.md`; this page is the rest of the integration. + +## The rule + +1. **`DbContext` is the boundary.** Read and write through it directly, or through a narrow query type + when a query deserves a name. Do not add `IRepository` or a unit-of-work wrapper for testability. + `DbContext` already is both, and a real container is a more faithful test than a substitute. +2. **The provider is chosen where the context is registered.** Carry the choice as context-options data; + never branch on `Database.ProviderName` while the model is being built. The model is built once and + cached, so that branch serves one provider's model to the other. +3. **Map a value object once.** Configure the converter and comparer in the model, not on each property. + The `Purview.ValueObjects` generator emits the converters when EF Core is referenced. A value-object + key also needs a converter-aware value generator, or the property receives a raw `Guid`. +4. **One owner per generated value.** The application supplies it, the store generates it, or it is + derived — never two. A derived identifier is `ValueGeneratedNever()`; a store default or computed + column is left to the store. +5. **Design time and runtime agree.** The design-time factory or host registration makes the same + provider, strategy and `ReplaceService` choices as the application. Otherwise the migration snapshot + describes a model the application does not run. +6. **The model cache key covers every input that varies the model.** Provider, strategy, tenant and + design time. EF's service-provider cache and model cache are separate; both need a key that matches + the decision. +7. **Migrations are code.** Persistence owns the definitions; a deliberate step applies them. The API + does not migrate at startup in production. +8. **Integration tests use a real store.** The in-memory provider does not enforce foreign keys, unique + indexes, computed columns or SQL semantics. Use `Purview.Containers` and a `.IntegrationTests` + project. +9. **The connection string is configuration.** Keep it out of source, out of `appsettings.json` in the + repository, and out of telemetry. A redaction test is worth more than a convention. + +## The composition + +The provider, the strategy and the service replacements belong together, where the context is +registered: + +```csharp +services.AddDbContext>((serviceProvider, options) => +{ + ConfigureStore(options, connectionString); + + options + .UseSequentialGuids() + .ReplaceService() + .ReplaceService>(); +}); + +services.AddSingleton>(); +``` + +- `ConfigureStore` is the only place that names `UseSqlServer` or `UseNpgsql`. +- `UseSequentialGuids` adds an options extension; the model reads it instead of asking about the provider. +- `IModelCacheKeyFactory` is replaced because the service-provider hash alone does not key the model + cache. +- The application-facing factory is registered separately, because EF's internal registration is not + reachable from an outbox writer or a correlation-id generator. + +## Migrations + +- Generation and application are separate: `dotnet ef migrations add` in development, a controlled + `Migrator` step in a deployment. +- A migration and `HasData` are compiled and replayed, so they need a literal GUID. A derived identifier + cannot be computed there; print the constant from the derivation and guard it with a test. +- Expect the schema to be ahead of or behind the code during a rollout, and design the change to be + compatible in both directions. +- Never let the API apply migrations at startup in production: a deploy then mutates the schema without + review and without a clean rollback. + +## Checks to write + +| Check | Shape | +| -- | -- | +| Model covers every GUID key | Each non-foreign-key GUID key is generated on add with the expected factory | +| Foreign keys are not generated | A foreign-key GUID keeps the value copied from its principal | +| Explicit configuration wins | A `ValueGeneratedNever()` key keeps its supplied value through `SaveChanges` | +| Two strategies, one context type | Different strategies produce different service providers and models | +| Design time matches runtime | Both models produce the same migration snapshot | +| Real store semantics | A unique index, a foreign key and a computed column behave as the model says in a container | + +The Engineering Playbook's *EF Core Integration* page states the same rules with the sample as evidence; +the runnable implementation is `samples/Acme.Persistence` in that repository, run with `just samples`. diff --git a/.agents/skills/purview-persistence/references/guid-generation.md b/.agents/skills/purview-persistence/references/guid-generation.md index 432053a2..fb644bb8 100644 --- a/.agents/skills/purview-persistence/references/guid-generation.md +++ b/.agents/skills/purview-persistence/references/guid-generation.md @@ -62,14 +62,22 @@ Never use `NEWSEQUENTIALID()` output as a secret. | Provider | Needs value before insert | Locality matters | Recommendation | | -- | -- | -- | -- | -| SQL Server | No | Yes | `NEWSEQUENTIALID()` (EF Core's default for `Guid` keys) | -| SQL Server | Yes | Yes | EF Core `SequentialGuidValueGenerator` | +| SQL Server | No | Yes | `NEWSEQUENTIALID()` as a store default, or EF Core's client-side `SequentialGuidValueGenerator` | +| SQL Server | Yes | Yes | EF Core `SequentialGuidValueGenerator`, or a store-ordered `FastGuid` strategy when both providers share a model | | SQL Server | Either | No | `Guid.NewGuid()` | | PostgreSQL | Yes | Yes | `Guid.CreateVersion7()` with `ValueGeneratedNever()` | | PostgreSQL | No | Yes | `uuidv7()` on 18+, otherwise generate v7 in the application | | PostgreSQL | Either | No | `Guid.NewGuid()` or `gen_random_uuid()` | +| SQL Server **and** PostgreSQL, one context type | Either | Yes | A store-specific strategy carried as context-options data. One strategy per provider; see below. | | Any | Either | Either, but must be unpredictable | Random v4 | +The two-provider row is the one that needs code: an `ISequentialGuidProvider` witness, an options +extension and a model-finalising convention. Copy them from the Engineering Playbook's *GUID Key +Strategy* page. Never expect one byte-ordered generator to order for both stores. + +A value whose identity is known before storage does not belong in this table. Derive it with UUIDv5 and +key it `ValueGeneratedNever()`; see `well-known-identifiers.md`. + ## Prefer built-in capability In order of preference: @@ -77,17 +85,19 @@ In order of preference: 1. A framework capability — `Guid.CreateVersion7()`, EF Core's value generators. 2. A database capability — `NEWSEQUENTIALID()`, `uuidv7()`, `gen_random_uuid()`. 3. A reviewed, tested implementation of a published standard, when neither of the above is available - on your target versions. + on your target versions — `FastGuid` is the one the sample uses for store-ordered values. 4. A bespoke generator — only against a measured requirement the first three cannot meet. A hand-written sequential GUID generator is easy to get subtly wrong — endianness, clock regression, counter rollover within a millisecond, and thread safety are all live hazards, and the failure mode is duplicate or badly-ordered keys found in production. -## There is no Purview GUID package +## No NuGet package provides this -No Purview library provides GUID generation today. Do not reference one and do not invent one. +No NuGet package provides the strategy/convention pattern. It is repository-local: copy the strategy +witness, options extension, value generator and model-finalising convention from the Engineering +Playbook's *GUID Key Strategy* page. Do not depend on a package that claims to own it, and do not +create one without a measured requirement the built-in options cannot meet. -If a concrete need emerges that the built-in options cannot meet — established by measurement, not by -anticipation — that is the point at which creating one is worth discussing. Until then this guidance -is the deliverable, and this page is where a recommendation for such a package would be documented. +The abstraction is earned by a second provider, or by an identifier needed before `SaveChanges` on +both — not by anticipation. Until then a built-in capability is the answer. diff --git a/.agents/skills/purview-persistence/references/postgresql.md b/.agents/skills/purview-persistence/references/postgresql.md index cd557ecc..603987d6 100644 --- a/.agents/skills/purview-persistence/references/postgresql.md +++ b/.agents/skills/purview-persistence/references/postgresql.md @@ -43,6 +43,49 @@ builder.Property(o => o.Id).ValueGeneratedNever(); Without that, EF Core may treat the key as store-generated and skip your value. +### Application-generated, store-ordered strategy + +When one context type serves both PostgreSQL and SQL Server, or the identifier must exist before +`SaveChanges` on both, use a store-specific strategy carried as context-options data. The PostgreSQL +implementation returns `FastGuid.NewPostgreSqlGuid()`; a model-finalising convention applies it to +every GUID key and leaves explicitly keyed derived identifiers alone. + +```csharp +public sealed class PostgreSqlSequentialGuidProvider : ISequentialGuidProvider +{ + public static string Name => "PostgreSql"; + + public static Guid NewGuid() => FastGuid.NewPostgreSqlGuid(); + + public static Guid NewGuid(DateTime timestampUtc) => + FastGuid.NewPostgreSqlGuid(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static DateTime GetTimestamp(Guid value) => FastGuid.PostgreSql.GetTimestamp(value); + + public static Guid MinValueFor(DateTime timestampUtc) => + FastGuid.PostgreSql.MinGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static Guid MaxValueFor(DateTime timestampUtc) => + FastGuid.PostgreSql.MaxGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); +} +``` + +- PostgreSQL compares `uuid` in plain big-endian byte order, so both `FastGuid.NewPostgreSqlGuid()` + and `Guid.CreateVersion7()` are genuinely sequential here. `FastGuid` is also correct for SQL Server, + which is the point of the strategy abstraction. +- Set `ValueGeneratedNever()` through the convention; never let EF fetch a store-generated value that + does not exist. + +#### RFC caveat + +`FastGuid.NewPostgreSqlGuid()` values are **not** RFC 9562 UUIDs. They are ordinary GUIDs with a +PostgreSQL-ordered layout, so PostgreSQL's `uuid_extract_timestamp()` will not read them even though +they are valid `uuid` values. Use the provider's `GetTimestamp` (above) instead. If another system +needs a standard timestamp, generate `Guid.CreateVersion7()` there and accept that the layout differs. + +The full sample is the Engineering Playbook's *GUID Key Strategy* page. The abstraction is earned by +the second provider, not by anticipation. + ### Database-generated UUIDv7 — PostgreSQL 18+ PostgreSQL 18 added a native `uuidv7()` function. Earlier versions do not have it; on 13–17 you have @@ -87,6 +130,7 @@ Random and unpredictable. Fine at low to moderate write volume, which covers mos | .NET 8 or earlier, high write volume | A reviewed UUIDv7 implementation, or accept v4 after measuring | | Rows inserted outside the application, PostgreSQL 18+ | `uuidv7()` as a column default | | Rows inserted outside the application, PostgreSQL 13–17 | `gen_random_uuid()` | +| One model serving PostgreSQL and SQL Server | A store-specific `FastGuid` strategy carried as context-options data — never one byte-ordered generator for both | | Key must be unpredictable | `Guid.NewGuid()` or `gen_random_uuid()` — v7 leaks creation time | | Low or moderate volume | v4 — the locality difference will not be your bottleneck | diff --git a/.agents/skills/purview-persistence/references/sqlserver.md b/.agents/skills/purview-persistence/references/sqlserver.md index c7282955..22629721 100644 --- a/.agents/skills/purview-persistence/references/sqlserver.md +++ b/.agents/skills/purview-persistence/references/sqlserver.md @@ -27,8 +27,13 @@ ALTER TABLE [Orders] ADD CONSTRAINT [DF_Orders_Id] DEFAULT NEWSEQUENTIALID() FOR [Id]; ``` -EF Core, for a `Guid` key on SQL Server, configures this by default — `ValueGeneratedOnAdd` maps to -`NEWSEQUENTIALID()` through the default value-generation strategy. +EF Core does **not** map `ValueGeneratedOnAdd` to `NEWSEQUENTIALID()` by default. For a `Guid` key on +SQL Server the provider generates the value client-side with `SequentialGuidValueGenerator` (below). +Configure a store default explicitly when the database must own the value: + +```csharp +builder.Property(o => o.Id).HasDefaultValueSql("NEWSEQUENTIALID()"); +``` Trade-offs: @@ -53,6 +58,44 @@ builder.Property(o => o.Id) Use this when the identifier must exist before `SaveChanges`. It keeps most of the index locality while letting the application own the value. +### Application-generated, store-ordered strategy + +When one context type serves both SQL Server and PostgreSQL, or the identifier must exist before +`SaveChanges` on both, a single built-in generator is not enough: the two stores order GUIDs +differently. The answer is a store-specific strategy carried as context-options data — a +`static abstract` witness whose SQL Server implementation returns `FastGuid.NewSqlServerGuid()`, plus a +value generator and a model-finalising convention that applies it to every GUID key. + +```csharp +public sealed class SqlServerSequentialGuidProvider : ISequentialGuidProvider +{ + public static string Name => "SqlServer"; + + public static Guid NewGuid() => FastGuid.NewSqlServerGuid(); + + public static Guid NewGuid(DateTime timestampUtc) => + FastGuid.NewSqlServerGuid(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static DateTime GetTimestamp(Guid value) => FastGuid.SqlServer.GetTimestamp(value); + + public static Guid MinValueFor(DateTime timestampUtc) => + FastGuid.SqlServer.MinGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static Guid MaxValueFor(DateTime timestampUtc) => + FastGuid.SqlServer.MaxGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); +} +``` + +- The increasing part must sit where SQL Server sorts it. The trailing six bytes dominate + `uniqueidentifier`; a byte-ordered UUIDv7 does not. +- The generator must hand the value through the property's value converter, or a `readonly record + struct OrderId` key receives a raw `Guid`. +- Apply it at convention precedence, so explicit `ValueGeneratedNever()`, a store default and a + computed column all win. + +The full sample is the Engineering Playbook's *GUID Key Strategy* page. The abstraction is earned by +the second provider; on one SQL Server provider `NEWSEQUENTIALID()` or EF Core's generator is enough. + ### `Guid.NewGuid()` — random Correct, portable and unpredictable; poor index locality. @@ -88,7 +131,7 @@ reaching for a sequential generator. | Situation | Use | | -- | -- | | High insert volume, key may be database-generated | `NEWSEQUENTIALID()` | -| High insert volume, identifier needed before persisting | EF Core `SequentialGuidValueGenerator` | +| High insert volume, identifier needed before persisting | EF Core `SequentialGuidValueGenerator`, or a store-ordered `FastGuid` strategy when both providers share a model | | Key must be unpredictable (public surface, security boundary) | `Guid.NewGuid()`, with a non-clustered PK | | Low or moderate volume | `Guid.NewGuid()` — do not optimise what does not hurt | | You want a sortable timestamp in the key and accept the locality cost | `Guid.CreateVersion7()` | diff --git a/.agents/skills/purview-persistence/references/well-known-identifiers.md b/.agents/skills/purview-persistence/references/well-known-identifiers.md new file mode 100644 index 00000000..372d5855 --- /dev/null +++ b/.agents/skills/purview-persistence/references/well-known-identifiers.md @@ -0,0 +1,93 @@ +# Deriving well-known identifiers with UUIDv5 + +Read this only when an identifier's **value must be known before storage**: a system tenant, a seeded +reference row, an installation's service principal, or an ingested object whose provider key is stable +and never recycled. A project with random keys and no seeding has no use for UUIDv5 — say nothing and +move on. An outbox row that merely needs an id before `SaveChanges` takes a generated strategy instead. + +## The rule + +1. Derive the value: `Uuid5.Create(namespace, name)` is RFC 4122 version 5 (SHA-1), byte-identical for + the same namespace and name in every process, language and restart. +2. Scope the namespace to the isolation boundary — one installation or one deployment. Never a global + constant shared by every deployment. +3. Fix the name as a canonical constant of the identity kind. Not a display name, not user data, not a + secret. +4. Once persisted, the value is authoritative. Never recompute it and update the row. +5. Key it explicitly with `ValueGeneratedNever()`, so a sequential-GUID convention never generates over + a value the application already knows. + +## The catalogue + +Write the known identities down in one place. + +| Identity | Namespace | Name | Derived when | Immutable | +| -- | -- | -- | -- | -- | +| System tenant of an installation | installation id | `system-tenant` | first bootstrap | yes | +| System principal of an installation | installation id | `system-service-principal` | first bootstrap | yes | +| Seeded reference row | installation id | `:` | seed step | yes | +| Ingested external object (optional) | installation id | `:` | first ingest | yes | + +Derive the ingested row only when the provider guarantees the external key is stable and never +recycled. If it can be recycled, keep the local key sequential and make the external key a unique +column. + +## Naming rules + +- Lowercase ASCII, hyphen-separated, no whitespace, no locale-dependent casing. +- Never from a display name, email, URL, title, or anything a user can edit. +- Never from a secret, and never as a capability token. A name is guessable. +- Renaming a name creates a new identity. Renaming a persisted one is a data migration. + +## Bootstrap sequence + +Read the persisted identity; if present it wins, and a configured value must match or the operation +fails. If absent, insert it. Derive dependants from the persisted id. Save once. A second run changes +nothing. + +The guard matters as much as the insert: + +```csharp +if (persisted is not null && persisted.Id != configured) +{ + throw new InvalidOperationException( + $"Configured installation id '{configured.Value}' does not match the persisted id '{persisted.Id.Value}'."); +} +``` + +## Migrations cannot call the helper + +`HasData` and migration SQL need a literal GUID. A small script prints ready-to-paste constants from +the same derivation, and a test asserts every printed constant equals `Uuid5.Create(...)`. Changing the +namespace or a name after a migration has shipped is a data migration, not an edit. + +## Checks to write + +| Check | Shape | +| -- | -- | +| RFC 4122 conformance | Fixed vectors for DNS/URL/OID/X500 and the empty name; exact GUIDs | +| Determinism | Same pair twice equal; different names differ | +| Version and variant bits | Byte 6 high nibble `0x5`; byte 8 high bits `10` | +| Cross-language equality | Python `uuid.uuid5` or Node `crypto` reproduces the vectors | +| Catalogue uniqueness | The catalogue's constants are distinct | +| Bootstrap idempotency | Seeding twice changes zero rows; a mismatched configured id throws | +| Explicit keying | The key is `ValueGenerated.Never`, and the sequential convention leaves it alone | + +```text +uuid5(6ba7b810-9dad-11d1-80b4-00c04fd430c8, "example.com") = cfbff0d1-9375-5685-968c-48ce8b15ae17 +uuid5(6ba7b811-9dad-11d1-80b4-00c04fd430c8, "www.example.com") = b63cdfa4-3df9-568e-97ae-006c5b8fd652 +uuid5(6ba7b812-9dad-11d1-80b4-00c04fd430c8, "1.2.3.4") = 67448b45-6d15-536b-bef7-8a78c3d10ac6 +uuid5(6ba7b814-9dad-11d1-80b4-00c04fd430c8, "CN=Bob") = 0840e90d-9ca0-5f14-970e-26294d44cb1d +``` + +The hash path is big-endian. `Guid.ToByteArray()` and `new Guid(byte[])` use a mixed-endian layout and +produce a different hash; that is the most common way a "UUIDv5" implementation is wrong. + +**UUIDv5 is not sequential.** Never use it for a high-write table's primary key. It is for a small, +known set of identities; everything else takes `guid-generation.md`. + +RFC 4122 is the version-5 specification; RFC 9562 is the current UUID specification and retains the +algorithm. The .NET BCL has no UUIDv5 API, so the helper is a small hand-written function. + +The Engineering Playbook's *Well-Known Identifiers* page carries the full helper, catalogue and +bootstrap samples, and their test shapes. diff --git a/.agents/skills/purview-sourcegenerator-attribute-data-models/SKILL.md b/.agents/skills/purview-sourcegenerator-attribute-data-models/SKILL.md new file mode 100644 index 00000000..714ddbff --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-attribute-data-models/SKILL.md @@ -0,0 +1,146 @@ +--- +name: purview-sourcegenerator-attribute-data-models +description: >- + Use when declaring or fixing a [Generate] attribute-data model in Purview.SourceGeneratorFramework - + mapping constructor and named arguments with [Argument]/[Property], defaults, reusing a shape with + [NestedModel], reading an ADM0001-ADM0015 diagnostic, or testing that a model reads the values an + attribute application supplies. +metadata: + product: Purview.SourceGeneratorFramework + repository: purview-dev/sourcegenerator-framework +--- + +# Attribute-data models + +`AttributeDataModelGenerator` turns a `readonly partial record struct` marked `[Generate(typeof(TAttribute))]` +into a parser for `TAttribute`'s `AttributeData`: `Exists`, `Empty`, `TargetAttribute`, the guarded +`FromAttributeData`/`TryFromAttributeData`/`AllAttributeData` overloads, and `FromAttributeDataUnchecked`. +The generated model is a value type, so it is safe to hold in an incremental pipeline (an `AttributeData` +is not). + +The one thing to keep in mind: **the generated code looks names up case-sensitively at run time, and a +name it cannot find quietly yields the default value.** A mapping mistake compiles and produces `null`/`0`. +The naming analyzer (`ADM0011`–`ADM0015`) exists to report those mistakes where they are declared; do not +suppress it without understanding the finding, and test the model against real applications. + +## Mapping each property + +Each primary-constructor parameter becomes a property, PascalCased (`message_template` → `MessageTemplate`). +Its sources are read in this order, first hit wins: + +| Declaration | Reads | +| --- | --- | +| *(no mapping attribute)* | the named argument with the property's name: `[Log(EventId = 7)]` | +| `[Property]` / `[Property(Name = "X")]` | the named argument `X` (default: the property name) | +| `[Argument("messageTemplate")]` | the constructor parameter **named** `messageTemplate` | +| `[Argument]` | the constructor parameter named after the property, camel-cased: `MessageTemplate` → `messageTemplate` | +| `[Argument(1)]` | the constructor argument at **position** 1 | +| `[Argument(...)] [Property]` | the named argument first, then the constructor argument | +| `[NestedModel]` | another generated model, read from the same attribute (below) | +| `[GenericTypeArgument]` | a generic type argument of the attribute class, as a `TypeIdentity` | +| `[Exclude]` | nothing; the parameter is left out of the model | + +Methodology: + +- **Map a constructor parameter by name, not by index.** A name survives a new overload or a reordered + parameter list; an index silently starts reading a different parameter. +- **Map both sources when the attribute accepts both.** `[Argument("name")] [Property] string? Name` reads + `[A(name: "x")]`, `[A("x")]` and `[A(Name = "x")]`. The named argument is read first because it is assigned + after the constructor runs, so it is the attribute instance's effective value. +- **Only map `[Property]` to a public settable property or field.** A property with a private or `internal` + setter can never be a named argument; read it through the constructor parameter that sets it (`ADM0012`). +- **Match case exactly.** `[Argument("MessageTemplate")]` never matches a parameter called + `messageTemplate` (`ADM0011`). +- **Give every property that might be absent a `DefaultValue`**: `[Property(DefaultValue = -1)] int EventId`. + A missing argument takes it; without one, it takes `default(T)`. `Empty` always uses `default(T)`. +- **Use `IsEnum = true` with a `string` property** when the enum type is not available to the generator; a + bare member-name default (`DefaultValue = "Inherit"`) is expanded to the fully-qualified member. +- **Never name a property `Exists`, `Empty`, `TargetAttribute`, `AllAttributeData`, `FromAttributeData`, + `TryFromAttributeData` or `FromAttributeDataUnchecked`** — they are generated (`ADM0014`, generation is + skipped). Keyword names (`Event`, `Class`) are fine. + +## Reusing a shape with `[NestedModel]` + +A `[NestedModel]` parameter is populated by the nested model's `FromAttributeDataUnchecked`, from the +**outer** attribute's arguments, with no `TargetAttribute` check. The outer attribute does not have to derive +from the nested model's target; it only has to use the same constructor parameter and property names. + +```csharp +[Generate(typeof(LogAttribute))] +readonly partial record struct LogAttributeData( + [Argument("level")] [Property(DefaultValue = 99)] int Level, + [Argument("messageTemplate")] [Property] string? MessageTemplate, + [Argument("eventId")] [Property(DefaultValue = -1)] int EventId, + [Property] string? Name +); + +// InfoAttribute has messageTemplate, eventId and Name, but no level. +[Generate(typeof(InfoAttribute))] +readonly partial record struct InfoAttributeData([NestedModel] LogAttributeData Log); +``` + +- The nested model's `Exists` is always `true`; a mapping the outer attribute does not declare (`level` + here) takes its `DefaultValue`. `ADM0013` (info) lists such mappings, so an intended absence is visible + and an accidental one is caught. +- Map nested properties **by name**. A constructor index means a different parameter on each attribute + (`ADM0015`). +- `MatchByInheritance = true` is for using a model on its own against derived attributes. A nested model + does not need it. +- Call `FromAttributeData`/`TryFromAttributeData` from generator code. `FromAttributeDataUnchecked` skips the + type check and is for nested models. + +## Diagnostics + +| Rule | Severity | Meaning and fix | +| --- | --- | --- | +| `ADM0001` | Error | The `[Generate]` target cannot be resolved. Use `typeof`, a resolvable full name, or a type-library `…FullName` constant. | +| `ADM0002` | Error | Unsupported property type (array, pointer, function pointer). | +| `ADM0003` | Error | `[Argument("name")]`/`[Argument(index)]` names no constructor parameter of the target. | +| `ADM0004` | Error | A `[NestedModel]` type is not itself a `[Generate]` model. | +| `ADM0005` | Error | A `DefaultValue` cannot be emitted for the property type. | +| `ADM0006` | Error | A non-nullable reference property has no default. Add a `DefaultValue` or make it nullable. | +| `ADM0007` | Error | `AutoDiscover` needs the `typeof` target overload. | +| `ADM0008` | Error | A `[GenericTypeArgument]` property must be `TypeIdentity`. | +| `ADM0009` | Error | An `IsEnum` property must be `string`/`string?`. | +| `ADM0010` | Error | An `ISymbol`/`Type` property is not cacheable; use `TypeIdentity` or a string. | +| `ADM0011` | Warning | A mapped name matches an attribute member only when case is ignored, so it is never read. Fix the case. | +| `ADM0012` | Warning | The mapped named argument is not a public settable member of the attribute, or a bare `[Argument]` implies a constructor parameter it lacks. Map the member that actually carries the value. | +| `ADM0013` | Info | A nested model property reads only names the outer attribute lacks, so it always takes its default. Expected for a deliberately absent value; otherwise rename to match. | +| `ADM0014` | Error | A property has a generated member's name. Rename the parameter. | +| `ADM0015` | Warning | A nested model reads a constructor index that names a different parameter on the outer attribute. Map it by name. | + +## Testing a model + +Test the attribute side and the model side separately, against what Roslyn actually produces. Use +`AttributeDataFactory` from `Purview.SourceGeneratorFramework.Testing` to compile an application, and the +`AttributeDataAssertions` from the TUnit package to pin each argument by ordinal, parameter name or named +argument, including how it was supplied (`AttributeArgumentForm`): + +```csharp +var options = new MyTestOptions { AdditionalNamespaces = ["My.Attributes"] }; +var attribute = AttributeDataFactory.FromUsage("[Info(\"custom {Value}\", Name = \"N\")]", options); + +await Assert.That(attribute).HasConstructorArgument("messageTemplate", "custom {Value}"); +await Assert.That(attribute).HasConstructorArgument("eventId", -1, AttributeArgumentForm.Defaulted); +await Assert.That(attribute).HasNamedArgument("Name", "N"); + +var model = InfoAttributeData.FromAttributeData(attribute); +await Assert.That(model.Log.MessageTemplate).IsEqualTo("custom {Value}"); +await Assert.That(model.Log.Level).IsEqualTo(99); +``` + +Cover, for each model: positional, by-name and named-argument applications; an application omitting every +optional argument (proves the `DefaultValue`s); for a nested model, an outer attribute that lacks a mapped +member; and the nested target's own `FromAttributeData` returning `Empty` for the outer attribute. +`FromUsage` places the application on a declaration its `AttributeUsage` allows and throws on a compile +error, so a misspelt argument name fails the test. `FromSource` reads applications from a full source. + +Generated-code assertions (`Contains("FromAttributeDataUnchecked(attributeData)")`) prove *what* the +generator emits; the tests above prove the model *reads the right values*. Prefer the latter. + +## Related skills + +`purview-sourcegenerator-testing` for the runner layer, `AttributeDataFactory` and `AttributeArguments`; +`purview-sourcegenerator-tunit-tests` for the assertion extensions; `purview-sourcegenerator-pipeline` for +holding models in an incremental pipeline; `purview-sourcegenerator-diagnostics` for the other analyzer +families. diff --git a/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md b/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md index ee363c97..68e79d3c 100644 --- a/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md +++ b/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md @@ -234,7 +234,7 @@ Guidelines: - The `Microsoft.CodeAnalysis.*` version used to compile the analyser/generator sets the minimum compiler-host requirement. - The consumer's `TargetFramework` does not determine analyser compatibility. - Choose the oldest Roslyn version that contains the APIs you need. -- Common baselines: Roslyn 4.8 for VS 17.8 / .NET 8, 4.12 for VS 17.12 / .NET 9, 5.0 for VS 2026 18.0 / .NET 10. +- Common baselines: Roslyn 4.8 for VS 17.8 / .NET 8, 4.12 for VS 17.12 / .NET 9, 5.0 for VS 2026 18.0 / .NET 10, 5.12 for .NET 11. - Ship one `netstandard2.0` analyser/generator binary unless you have a deliberate multi-version strategy. - Do not mistake multi-targeting for automatic analyser asset selection. - Use `PrivateAssets="all"` for Roslyn development dependencies. diff --git a/.agents/skills/purview-sourcegenerator-diagnostics/SKILL.md b/.agents/skills/purview-sourcegenerator-diagnostics/SKILL.md index 21d9522c..b80ebe74 100644 --- a/.agents/skills/purview-sourcegenerator-diagnostics/SKILL.md +++ b/.agents/skills/purview-sourcegenerator-diagnostics/SKILL.md @@ -1,7 +1,7 @@ --- name: purview-sourcegenerator-diagnostics description: >- - Use when a Purview.SourceGeneratorFramework build reports a PSGFR, PSGF, PRSGD or TLB diagnostic, + Use when a Purview.SourceGeneratorFramework build reports a PSGFR, PSGF, PRSGD, TLB or ADM diagnostic, when choosing which code fix applies, or when a source generator, diagnostic analyzer or code fix provider is silently not running at all. metadata: @@ -106,8 +106,21 @@ Opt out with `PurviewSourceGeneratorFrameworkAnalyzerValidation=false` (`PRSGD00 ## Type-library and attribute-model diagnostics The bundled generators carry their own families: `TypeLibraryValidationAnalyzer` -(`TLB0001`–`TLB0019`, plus `TLB0021`) and the attribute-data-model validation analyzers. They are -documented on their own wiki pages, `Type-Library.md` and `Attribute-Data-Models.md`. +(`TLB0001`–`TLB0019`, plus `TLB0021`) and the attribute-data-model analyzers. They are documented on their +own wiki pages, `Type-Library.md` and `Attribute-Data-Models.md`. + +The attribute-data-model rules come from three analyzers: + +| Rules | Analyzer | Covers | +| --- | --- | --- | +| `ADM0001`–`ADM0009` | `AttributeDataModelValidationAnalyzer` | the target, property types, defaults, explicit constructor members | +| `ADM0010` | `AttributeDataModelSymbolPropertyAnalyzer` | non-cacheable `ISymbol`/`Type` properties | +| `ADM0011`–`ADM0015` | `AttributeDataModelNamingAnalyzer` | mapped names the generated code would never find, generated-member name clashes, `[NestedModel]` mappings the outer attribute does not line up with | + +Treat the naming rules as real defects: the generated code reads names case-sensitively and silently falls +back to a default, so a naming mistake never fails the build on its own. `ADM0013` is `Info` because a +nested model may deliberately read a value the outer attribute does not have; check each one. The +`purview-sourcegenerator-attribute-data-models` skill has the full table and the fix for each rule. ## Code fixes @@ -139,4 +152,5 @@ Most rules above have a fix in `Purview.SourceGeneratorFramework.CodeFixers`: ## Related skills `purview-sourcegenerator-codewriter` for the emission patterns the `CodeWriter` rules push you -towards, and `purview-sourcegenerator-testing` for proving a pipeline actually caches. +towards, `purview-sourcegenerator-testing` for proving a pipeline actually caches, and +`purview-sourcegenerator-attribute-data-models` for the `ADM` rules. diff --git a/.agents/skills/purview-sourcegenerator-packaging/SKILL.md b/.agents/skills/purview-sourcegenerator-packaging/SKILL.md index 92350db8..a34f72c4 100644 --- a/.agents/skills/purview-sourcegenerator-packaging/SKILL.md +++ b/.agents/skills/purview-sourcegenerator-packaging/SKILL.md @@ -76,10 +76,14 @@ identity. Both are `IsRoslynComponent`; only the generator references the framew - The referenced component is returned as **its own merged assembly** and is never IL-merged into the dependent one — that would duplicate its types, including `InternalsVisibleTo`-visible internals, inside a second analyzer in the same host. -- The framework assembly is declared as copy-to-output content beside the generator's `bin`, and - content with `CopyToOutputDirectory` flows transitively through `ProjectReference`. That is what - lets Visual Studio load the dependent code-fix provider from its own `bin` without a - `FileNotFoundException` for the framework assembly. +- The framework assembly is copied beside the generator's `bin` (a plain copy, not copy-to-output + content). A dependent component that references the generator as an assembly gets it copied + beside its own `bin` by ordinary assembly-reference resolution, which is what lets Visual Studio + load the dependent code-fix provider without a `FileNotFoundException` for the framework assembly. +- A project that references a component only as an analyzer (`ReferenceOutputAssembly="false"`) + never receives the framework assembly, and neither does any application downstream of it. Since + 1.2.2 it no longer leaks into app build output, `deps.json`, publish output, single-file bundles or + Blazor WebAssembly `_framework`, where it broke Native AOT and WebAssembly AOT publishing. - Visual Studio's project system resolves an `OutputItemType=Analyzer` project reference to the referenced project's **unmerged** `bin` assembly and adds it to the compiler's analyzers — the command-line build does not. `Purview.BuildSdk` removes that item before adding the resolved @@ -181,10 +185,12 @@ marker is the preferred configuration. | 4.8 | VS 2022 17.8 | C# 12 / .NET 8 | | 4.12 | VS 2022 17.12 | C# 13 / .NET 9 | | 5.0 | VS 2026 18.0 | C# 14 / .NET 10 | +| 5.12 | VS 2026 18.x | C# 15 / .NET 11 | -**This framework is built against Roslyn 5.0**, so a host loading it must be Roslyn 5.0 or later -(.NET 10 SDK / Visual Studio 2026 18.0). The testing packages multi-target `net8.0`–`net10.0`, and -Roslyn 5.x ships `net8.0`/`net9.0` assets, so those test targets still load the runner. +**This framework is built against Roslyn 5.9**, so a host loading it must ship Roslyn 5.9 or later +(.NET 11 SDK / Visual Studio 2026). The .NET 11 SDK ships Roslyn 5.12. The testing packages +multi-target `netstandard2.0`, `net10.0`, and `net11.0`, and Roslyn 5.9 ships `net10.0`/ +`netstandard2.0` assets, so both test targets load the runner. ## A generator project to start from diff --git a/.agents/skills/purview-sourcegenerator-testing/SKILL.md b/.agents/skills/purview-sourcegenerator-testing/SKILL.md index aaeff09e..5590a896 100644 --- a/.agents/skills/purview-sourcegenerator-testing/SKILL.md +++ b/.agents/skills/purview-sourcegenerator-testing/SKILL.md @@ -3,8 +3,9 @@ name: purview-sourcegenerator-testing description: >- Use when writing or fixing tests for source generators, diagnostic analyzers, code fixes or refactorings built on Purview.SourceGeneratorFramework - picking the right runner and base class, - configuring SourceGeneratorTestOptions, querying produced code with CodeQuery, and asserting - incremental pipeline caching stage by stage. + configuring SourceGeneratorTestOptions, querying produced code with CodeQuery, testing attribute-data + models against real AttributeData with AttributeDataFactory, and asserting incremental pipeline caching + stage by stage. metadata: product: Purview.SourceGeneratorFramework repository: purview-dev/sourcegenerator-framework @@ -167,16 +168,55 @@ an assertion, nor calling a helper that is handed the result — an extension on parameter: both hand the same instance back, so `await Assert.That(result).SomeAssertion()` and `result.AssertNoGenerationExceptions()` are not flagged. +## Testing attribute applications and attribute-data models + +A generator that reads attributes has two halves to test: what Roslyn gives it for an application, and what +its parsing (usually a generated attribute-data model) makes of that. `AttributeDataFactory` produces the +real `AttributeData` without running a generator; `AttributeArguments` describes it. + +```csharp +var options = new MyTestOptions { AdditionalNamespaces = ["My.Attributes"] }; + +// One application. Placed on a class, or on the first declaration its AttributeUsage allows. +AttributeData attribute = AttributeDataFactory.FromUsage("[Log(\"text\", eventId: 7, Name = \"N\")]", options); + +// Every application on a type, or on one of its members. +var onMember = AttributeDataFactory.FromSource(source, "Sample.IEvents", "Raised", options); + +var arguments = AttributeArguments.From(attribute); +arguments.TryGetConstructorArgument(0, out var byOrdinal); // #0 message: "text" +arguments.TryGetConstructorArgument("eventId", out var byName); // Form = NamedParameter +arguments.TryGetNamedArgument("Name", out var named); // Form = NamedArgument +arguments.ToString(); // [LogAttribute(#0 message: "text", #1 eventId: 7 (by name), Name = "N")] + +var model = LogAttributeData.FromAttributeData(attribute); +``` + +- References and usings come from the options: `AdditionalAssemblyTypes` for the assembly declaring the + attribute, `AdditionalNamespaces` for its namespace, or declare the attribute in `AdditionalSources`. +- A compilation error throws `InvalidOperationException` listing the errors, so a misspelt parameter name + fails the test rather than producing an application with a missing argument. +- `AttributeArgumentForm` tells `Positional`, `NamedParameter` (`message: "x"`), `Defaulted` (an optional + parameter that was not written) and `NamedArgument` (`Name = "x"`) apart, which `AttributeData` alone + cannot. +- `AttributeArgument.ValueEquals` compares numbers across types, enums by value or member name, `typeof` + by `Type`/`TypeIdentity`/name, and arrays element-wise. Names are case-sensitive, as generated models are. +- The TUnit assertions over these (`HasConstructorArgument`, `HasNamedArgument`, …) are in + `purview-sourcegenerator-tunit-tests`; what to cover for a model is in + `purview-sourcegenerator-attribute-data-models`. + ## Best practices +- **Test attribute parsing against real `AttributeData`** (`AttributeDataFactory`), not only by asserting + on emitted parsing code: emitted code can look right and still read the wrong name. - **Deterministic output**: `WriteAutoGeneratedHeader` is timestamp-free; assert with `ContainsGeneratedCode`/`GeneratesCode` (whitespace-flattened) or `CodeQuery`, never with timestamps. - **Generator references**: to use a generated type in the test project AND pass the generator type to a runner, reference the generator project twice — once `OutputItemType="Analyzer"` and once as a normal reference. - **Multi-target**: build generators against the Roslyn version that supports the test matrix; the - framework is built against Roslyn 5.0 (net8.0/net9.0 assets keep a .NET 8–10 matrix loading); keep - `System.Collections.Immutable` version pinned to the shared one. + framework is built against Roslyn 5.9 (`net10.0`/`netstandard2.0` assets keep a .NET 10–11 matrix + loading); keep `System.Collections.Immutable` version pinned to the shared one. - **Scope validation**: keep `PurviewSourceGeneratorFrameworkValidateCodeWriterScopes` enabled; it makes undisposed `CodeWriter` scopes fail tests. - **Prefer `CodeQuery` over string matching** for structural assertions (members, signatures, namespaces). diff --git a/.agents/skills/purview-sourcegenerator-tunit-tests/SKILL.md b/.agents/skills/purview-sourcegenerator-tunit-tests/SKILL.md index bc711c59..2a3a0612 100644 --- a/.agents/skills/purview-sourcegenerator-tunit-tests/SKILL.md +++ b/.agents/skills/purview-sourcegenerator-tunit-tests/SKILL.md @@ -3,7 +3,8 @@ name: purview-sourcegenerator-tunit-tests description: >- Use when writing TUnit tests for source generators, diagnostic analyzers, code fixes or refactorings built on Purview.SourceGeneratorFramework - choosing the correct TUnit base class and - method, customising SourceGeneratorTestOptions, using the TUnit assertion extensions, and + method, customising SourceGeneratorTestOptions, using the TUnit assertion extensions (including + attribute-argument assertions for testing attribute-data models), and modernising existing tests. metadata: product: Purview.SourceGeneratorFramework.Testing.TUnit @@ -125,6 +126,21 @@ when awaited. ``` - **`DiagnosticAssertions`** — `HasDiagnostic(descriptor|id)`, `HasDiagnostics(count)`, `DoesNotHaveDiagnostic`, `HasNoDiagnostics`, `HasNoErrorDiagnostics` on generator/analyzer/code-fix results. +- **`AttributeDataAssertions`** — on an `AttributeData` (typically from `AttributeDataFactory`): + `HasConstructorArgument(ordinal, expected)`, `HasConstructorArgument(parameterName, expected[, form])`, + `DoesNotHaveConstructorArgument(parameterName)` (defaulted or absent), `HasNamedArgument(name, expected)`, + `DoesNotHaveNamedArgument(name)`. Each returns the matched `AttributeArgument`, and a failure lists every + argument of the application with how it was supplied. Pin the attribute side with these before asserting + on the model, so a failure says which half is wrong: + ```csharp + var attribute = AttributeDataFactory.FromUsage("[Info(\"custom {Value}\")]", options); + + await Assert.That(attribute).HasConstructorArgument("messageTemplate", "custom {Value}"); + await Assert.That(attribute).HasConstructorArgument("eventId", -1, AttributeArgumentForm.Defaulted); + await Assert.That(attribute).DoesNotHaveNamedArgument("Name"); + + await Assert.That(InfoAttributeData.FromAttributeData(attribute).Log.MessageTemplate).IsEqualTo("custom {Value}"); + ``` - **`TypeIdentityAssertions`** — `HasSymbol(TypeIdentity)` / `HasSymbol("Namespace.Type")`. - **`GeneratedCodeAssertionsExtensions`** — `GeneratesCode(expected)`, `ContainsGeneratedCode(expected)` (whitespace-flattened string comparison). diff --git a/.agents/skills/purview-telemetry-benchmarks/SKILL.md b/.agents/skills/purview-telemetry-benchmarks/SKILL.md new file mode 100644 index 00000000..8614abd4 --- /dev/null +++ b/.agents/skills/purview-telemetry-benchmarks/SKILL.md @@ -0,0 +1,289 @@ +--- +name: purview-telemetry-benchmarks +description: >- + Maintainer workflow for this repository's BenchmarkDotNet suite - running the .NET 10, .NET 11 and + .NET Framework 4.8 jobs, reading a -report-github.md, refreshing docs/wiki/Performance.md and + README.md from a fresh run, writing the analysis consumers read, and adding a benchmark family. + Use when asked to run, update, interpret, or extend the benchmark results. This is a developer + skill for this repository; it is never published to consumers. +metadata: + product: Purview Telemetry Source Generator + audience: maintainers + repository: purview-dev/telemetry-sourcegenerator +--- + +# Running and documenting the telemetry benchmarks + +This skill is for developers **working on** the generator. It covers the whole loop: run the suite, +read the reports, update the two performance documents, and write the analysis a consumer needs to +make a decision. It is not consumer guidance, and it is not published - this repository's +consumer-facing skills live in `skills/` and are installed with `npx skills add`. + +Load this skill before you quote, edit, or add a performance number. + +## Non-negotiables + +1. **Every published number comes from one fresh Release run.** Never copy a stale table, never + estimate, never interpolate between runtimes. If you cannot run the suite, say so instead of + editing the docs. +2. **Debug numbers are meaningless.** `just benchmark` and `just benchmark-docs` pin `Release` + deliberately; do not substitute `just build`-style Debug output. +3. **Never mix rows from different runs, machines or SDKs** in one table. A table is one report. +4. **Never cancel a benchmark run.** The full suite takes tens of minutes. To shorten the loop, + filter to one family - do not kill a run half-way and keep its numbers. +5. **Observable instruments are not benchmarked by design** (`ObservableCounter`, + `ObservableGauge`, `ObservableUpDownCounter`): they register a callback once and have no + per-operation hot path. Do not add them, and do not remove the explanatory paragraph that says so. +6. **`BenchmarkDotNet.Artifacts/` is gitignored.** It is a local scratch folder, not a published + artifact, so a doc must not link to it on GitHub. The CSV/HTML files stay on the machine that ran + them; the report numbers are what get committed. + +## The suite at a glance + +| Benchmark class | What it measures | Feeds | +| --- | --- | --- | +| `ActivityBenchmarks` | Generated vs hand-written Activity telemetry, `HasListener` true/false | README § Activities, wiki § Activities | +| `LoggerBenchmarks` | Generated v1 (`LoggerMessage.Define`) and v2 (`ThreadLocalState`) vs manual, `HasLogging` true/false | README § Logging, wiki § Logging | +| `MultiTargetVsSingleTargetBenchmarks` | Activity-only vs Activity+Logging+Metrics from one call, `HasListener` | README § Multi-Target, wiki § Multi-target | +| `LoggerMultiTargetBenchmarks` | Logging-only vs the full multi-target stack, v1/v2/manual | wiki § Logger multi-target | +| `MetricsBenchmarks` | Counter / UpDownCounter / Histogram, generated vs manual, 0 and 1 tag | README § Metrics, wiki § Metrics | +| `TagListBenchmarks` | Inline `KeyValuePair` (< 4 tags) vs stack-allocated `TagList` (4+ tags) | README § Metrics, wiki § Metrics | +| `GeneratorModelBenchmarks` | The generator's own `closed record` models vs a value-based `[Union]` struct, per method count | wiki § Generator model overhead | + +The first six measure code a consumer runs. `GeneratorModelBenchmarks` measures the generator's own +data model and is contributor-facing: it backs the recorded decision in +`docs/wiki/Platform-Support.md` to keep the `closed record` hierarchies. Do not promote it into the +README's consumer performance section. + +Exact table shapes, section headings and column headers for each family live in +[`references/performance-doc-shapes.md`](references/performance-doc-shapes.md). + +## Supported runtimes and jobs + +One invocation measures all three legs, because every class declares one `[SimpleJob]` per runtime: + +| Attribute | Runtime | Notes | +| --- | --- | --- | +| `[SimpleJob(RuntimeMoniker.Net10_0)]` | .NET 10 | The line `Directory.Packages.props` targets. | +| `[SimpleJob(RuntimeMoniker.Net11_0)]` | .NET 11 | Needs BenchmarkDotNet **0.16.0-preview.2+**; 0.15.8's `RuntimeMoniker` stops at `Net10_0`. | +| `[SimpleJob(RuntimeMoniker.Net48)]` | .NET Framework 4.8 | Windows only. The `netstandard2.0` asset path - the only way to compare it against the modern runtimes. | + +- The suite is *launched* with `--framework net11.0`; that only selects the BenchmarkDotNet host. The + job attributes decide what is measured, so the report carries a section per runtime. +- `RuntimeMoniker.Net47` is deliberately absent: its generated host project fails to restore on every + run and only ever produced `NA` rows. Do not add it back. +- `RuntimeMoniker.Net12_0` already exists in the pinned preview package, so a future runtime needs no + dependency change - but re-check `BenchmarkDotNet.Annotations.dll` before claiming a moniker exists. +- The project pins its own `BenchmarkDotNet` version; it is not in `Directory.Packages.props`. + `benchmarks/` sits outside `src/`, so it does not import `Purview.BuildSdk`. + +Prerequisites: the SDK pinned in `global.json`, plus the .NET 10 and .NET 11 runtimes installed +(each job resolves its own runtime), and Windows for the `net48` leg. + +## Run it + +```bash +# Full suite, all three runtimes, checked-in Release configuration (tens of minutes) +just benchmark + +# Same, then prints the docs-update checklist +just benchmark-docs + +# Short-job smoke test, own artifacts folder - never a source for published numbers +just benchmark-quick +``` + +Direct invocation, when you need to filter or pass extra arguments: + +```bash +dotnet run --project ./benchmarks/Purview.Telemetry.Benchmarks/Purview.Telemetry.Benchmarks.csproj \ + --configuration Release --framework net11.0 -- --filter "*ActivityBenchmarks*" +``` + +- `--filter` matches the fully-qualified class name; `"*MultiTarget*"` also catches + `LoggerMultiTargetBenchmarks`, so prefer the exact class name when you want one family. +- `--list flat` lists every benchmark without running anything. +- `Program.cs` appends `--filter *` when the arguments select nothing, so `--artifacts ` or + `--job short` alone no longer drops into the interactive prompt. A `--filter` or `--list` you pass + is forwarded untouched. +- `--artifacts ` redirects output; `just benchmark-quick` uses `BenchmarkDotNet.Artifacts/quick` + so a smoke run cannot overwrite the full suite's report. +- `benchmark-quick` also passes `--runtimes net11.0` while the classes declare their own jobs. Treat + it as "short job, smoke only" and read the report header to see which runtimes actually ran - never + assume its scope, and never lift a number from it. + +## Read a report + +Each benchmark class writes three files to `BenchmarkDotNet.Artifacts/results/`: + +``` +Purview.Telemetry.Benchmarks.Benchmarks.-report-github.md # the table to copy +Purview.Telemetry.Benchmarks.Benchmarks.-report.csv # Error/StdDev/Median live here +Purview.Telemetry.Benchmarks.Benchmarks.-report.html # full detail, per-method +``` + +The `-report-github.md` opens with a fenced environment block: BenchmarkDotNet version, OS build, CPU, +RAM, SDK, then one line per job (`[Host]`, `Job-XXXXX`) with its exact runtime version. That block is +the source for both documents' environment headers, and it is the only honest answer to "which +runtimes did this run measure?". + +Column semantics: + +- **Ratio** is relative to the row marked `Baseline = true` in that class and parameter group, not to + the manual implementation in general. `0.98` means 2% faster than the baseline. +- **Allocated** `-` means no allocation on that path. Do not claim "zero allocations" for a row that + shows bytes; `Gen0`/`Gen1` are the GC counts that come with them. +- **Alloc Ratio** appears only where allocations differ between rows. +- `Error`, `StdDev`, `Median`, `RatioSD` are hidden by `[HideColumns]` to keep the GitHub table + narrow. Read them from the CSV or HTML before calling a difference real. +- The bold row is the baseline. Keep that bolding when you copy a table. +- A run that reports "Benchmarks with issues" or `NA` rows is a broken run. Fix the cause (usually a + missing runtime or a host-project restore failure) and re-run; do not document the gap. + +## Update the documents + +Do these in order, from the same run. Nothing below is optional because "it looked the same". + +1. **`docs/wiki/Performance.md`** - the full cross-runtime page. + - `## Environment`: replace the fenced block with the fresh report header (BenchmarkDotNet, OS, CPU, + SDK, and one line per measured runtime). + - One section per family, in the existing order, each with a row per runtime and per parameter + value. Keep the `**Source:**` line naming the class. + - `GeneratorModelBenchmarks` has no section yet: add `## Generator model overhead` on the first + refresh that carries its report, and keep it wiki-only. + - Rewrite each `**Interpretation:**` paragraph from the new numbers (see "Write the analysis"). + - Delete stale caveats the new run makes untrue - the "`.NET 11.0` is not measured yet", + "`.NET 8`/`.NET 9` rows were removed", and ".NET Framework produced no results" notes - and add a + caveat only for a limitation that still exists. + - Leave the `## Observable instruments` paragraph alone unless the generator's design changes. +2. **`README.md`** - `## Performance`, the consumer-facing digest. + - One environment line (machine + SDK) matching the fresh run; it currently reads + "Benchmarked on ... .NET SDK ...". + - One table per family, deliberately narrower than the wiki: the headline runtime only. Keep the + runtime in the heading and in the environment line consistent - if the headline runtime changes, + change the heading, the sentence and the environment line together. + - Keep the prose that tells a consumer what the number means ("generated activities match + hand-written code with identical allocations"), refreshed against the new ratios. +3. **`benchmarks/README.md`** - only when the suite itself changed: the family table, the "runtimes and + jobs" table, the per-family detail sections and the project-structure tree all list the classes and + the monikers. +4. **`docs/wiki/Platform-Support.md`** - when `GeneratorModelBenchmarks` numbers back (or contradict) + the recorded decision to keep the `closed record` models, update that row's reasoning. It currently + says the tables predate the benchmark; once they do not, say so. +5. **`AGENTS.md`** - the "Benchmarking and performance documentation" section carries the commands, the + three runtimes and the moniker constraints. It changes only when the suite changes. +6. **`CHANGELOG.md`** - only when a result changes a documented decision or adds a family, not for a + routine refresh of the numbers. + +Never edit `BenchmarkDotNet.Artifacts/**` by hand, and never commit it. + +## Write the analysis + +The tables are data; the paragraph after them is the product. A consumer reads one sentence to decide +whether generated telemetry is safe to adopt. + +Cover these four things, in this order: + +1. **What is compared** - the baseline and the alternatives, and whether the parameter (listener + present, logging enabled) represents the production path. +2. **The delta** - time ratio and allocation side by side, for the runtime you are quoting. "Within 2%" + and "identical allocation" are different claims; make both or neither. +3. **Why** - name the mechanism when you know it: framework work dominating Activity creation, the + `IsEnabled` short-circuit, `[MethodImpl(AggressiveInlining)]` folding the generated call into the + caller, the inline `KeyValuePair` vs `TagList` threshold at 4 tags. +4. **The caveat** - machine, SDK, and the fact that numbers on another host will differ. + +Rules of thumb: + +- Sub-10 ns means are noise-dominated. Check `Error`/`StdDev` in the CSV before attributing a 0.02 ns + difference to generated code; usually the honest sentence is "indistinguishable". +- Do not compare a runtime's row to another runtime's row as a claim about generated code - the runtime + dominates. Compare generated vs manual *within* a runtime. +- Do not compare this run's numbers to the previous run's as a regression unless the machine and SDK are + the same; state the comparison basis if you do. +- Every ratio you quote must exist in the report. If you want to say "~7%", the rows must say + `1.07`. +- A generated-vs-manual gap that is *flat* across scenarios the method does different amounts of work in — the + same mean for a 0-tag counter and a 6-tag histogram, say — is a harness shape problem before it is a + generator problem. Check how each side is held (interface or concrete) before writing an overhead claim. +- Keep the consumer's frame: absolute nanoseconds for the hot path, allocations per call, and whether + the generated code is on par with "what you would write by hand". + +Anti-patterns to avoid: "blazing fast", "no overhead" for a measured 1.1x, quoting a Debug run, quoting +the `HasListener = false` row as the production cost, and describing a family the run did not measure. + +## Add or change a benchmark family + +1. `Telemetry/` - the interface(s) the generator consumes (`[ActivitySource]`, `[Logger]`, `[Meter]`, + or the multi-target combination). Give the v1 logging variant its own interface + (`GenerationMode = LoggerGenerationMode.V1`) if the family compares generations. +2. `Manual/` - the hand-written baseline, written the way a careful developer would: `LoggerMessage.Define` + for logging, `Create*` for instruments, `ActivitySource.StartActivity` for activities. A weak baseline + invalidates the comparison. +3. `BenchmarkHelpers.cs` - a factory that builds every implementation for a fair comparison: one logger and + one `SimpleMeterFactory` shared across the manual and generated instances, an always-sampling + `ActivityListener` for the listener-present parameter. +4. `Benchmarks/Benchmarks.cs` - required shape: + - `[MemoryDiagnoser]`, `[HideColumns("Error", "StdDev", "Median", "RatioSD")]`, and the three + `[SimpleJob]` attributes above, in that order. + - `[Params(true, false)]` for `HasListener`/`HasLogging`; do not silently measure only one side. + - Exactly one `Baseline = true`, on the row a consumer would compare against (usually manual). + - `Description` strings that read as a sentence in the table - they are what the docs copy. + - A `` that says what question the benchmark answers and why the pair is fair. This is where + the mechanism the analysis will cite is recorded. + - `[GlobalSetup]` / `[GlobalCleanup]` dispose meters, loggers and listeners. + - **Both sides of a comparison must be consumed through the same shape.** Hold the generated *and* the + hand-written implementation as the same interface (that is why `ManualMetricsTelemetry` and + `ManualLoggerMessageTelemetry` implement the benchmark interfaces). At nanosecond scale a concrete call is + inlined into the benchmark loop and an interface call is not, so a generated-through-interface vs + hand-written-through-concrete pair measures dispatch and reports it as generator overhead — a flat per-call + delta that ignores the work the method does. +5. Register it: `benchmarks/README.md` (family table + project tree), `docs/wiki/Performance.md` (new + section with one row per runtime), and `README.md` only if it is a consumer hot path. +6. Validate as described below, then run the full suite - a new family cannot be documented from a + filtered run that never executed it on all three legs. + +`[SimpleJob]` on the class, not `[SimpleJob]` on a host runtime: a host-runtime job carries no `Runtime` +characteristic and fails BenchmarkDotNet's `GetSupportedBenchmarks` validation instead of measuring. + +## `GeneratorModelBenchmarks` specifics + +- It replicates the generator's internal models inside the benchmark project, because the generator's + records are internal to the analyzer assembly. Keep the replica faithful to + `src/src/SourceGenerator/Records/` when either shape changes, and say so in the class remarks. +- It exists to answer one question: is the per-method allocation and virtual dispatch of the record + hierarchy measurable against a pass that also parses symbols, builds templates and writes source? A + delta of tens of nanoseconds per method against a generation pass is not a throughput story. +- Report it in `docs/wiki/Performance.md` only, with a row per runtime and per `MethodCount`. A + `.NET Framework` result that is *slower* for the struct union is a real outcome, not a mistake - it is + part of why the records stayed. + +## Validate before committing + +- `just lint` - CSharpier, no writes. `just lint-fix` to apply. Any C# you touched in `benchmarks/` is + formatted by the same check. +- `dotnet build ./benchmarks/Purview.Telemetry.Benchmarks/Purview.Telemetry.Benchmarks.csproj --configuration Release` + - a compile check of a new or edited family that costs seconds instead of a benchmark run. Note that + `just build` does **not** cover this project: it is outside `src/` and not in the main solution, so a + broken benchmark class passes the normal build. +- Re-read both documents against the report you were given: every row traces to the report, every + environment line matches the report header, no stale caveat survives, and no table mixes runs. +- Re-read the interpretation paragraphs for claims the report does not support. +- A docs-only change needs no test run; a change to the suite needs `just benchmark` (full) before its + numbers are documented. +- Commit as `docs:` for a numbers refresh, `perf:` or `test:` for a suite change, per the conventional + commit rules in `AGENTS.md`. + +## Traps that have bitten before + +- `just build` is green while `benchmarks/` does not compile - the project is not in the solution. +- `--filter "*MultiTarget*"` matches two classes; the report then covers only part of what the docs claim. +- A comparison whose generated side is held as an interface and whose hand-written side is held as its concrete + type reports interface dispatch as generated overhead. `MetricsBenchmarks` and `LoggerBenchmarks` implement the + interface on the manual baseline to avoid it; a new family must do the same. +- `net48` silently disappears on a non-Windows machine, and with it the `netstandard2.0` comparison. +- A missing .NET 10 or .NET 11 runtime turns a leg into `NA` rows rather than a failure. +- `BenchmarkDotNet.Artifacts/` is gitignored, so a "raw results" link to it on GitHub is a dead link. +- `benchmark-quick` output looks like a result but is a short job; publishing it overstates precision. +- The README headings pin a runtime (".NET 10.0"). Refreshing the table without refreshing the heading + is how a doc ends up claiming one runtime while showing another. diff --git a/.agents/skills/purview-telemetry-benchmarks/references/performance-doc-shapes.md b/.agents/skills/purview-telemetry-benchmarks/references/performance-doc-shapes.md new file mode 100644 index 00000000..8efae4d7 --- /dev/null +++ b/.agents/skills/purview-telemetry-benchmarks/references/performance-doc-shapes.md @@ -0,0 +1,117 @@ +# Performance document shapes + +The exact shapes the two performance documents use today. Preserve them: a consumer reads the same +table layout release after release, and a diff should show changed numbers, not a reshaped page. + +## `docs/wiki/Performance.md` + +Order of sections: + +1. `# Performance` - one sentence: what the page is and which project produced it. +2. `## Reproducing` - the `dotnet run` command, the artifacts path and file types, and the + `[!IMPORTANT]` Release-only callout. +3. `## Environment` - a fenced block copied verbatim from a `-report-github.md` header, then any caveat + notes that are still true. +4. One `##` section per family, in this order: `Activities`, `Logging`, `Multi-target`, + `Logger multi-target`, `Metrics`, `Generator model overhead`, `Observable instruments`. + `Generator model overhead` has no table yet — write it on the first refresh that includes a + `GeneratorModelBenchmarks` report. +5. `## Raw results` - only what is actually published; the artifacts folder is gitignored, so this + section must not claim a GitHub link to it exists. +6. `## See also` - links to the README summary and the related wiki pages. + +Each family section: + +```markdown +## Activities + +**Source:** `ActivityBenchmarks` + +One paragraph: what is compared against what, and what the parameter does. + +| Method | Runtime | HasListener | Mean | Ratio | Allocated | +| --- | --- | --- | --- | --- | --- | +| **Manual: start + complete** | **.NET 10.0** | **True** | **220.37 ns** | **1.00** | **1008 B** | +| Generated: start + complete | .NET 10.0 | True | 216.69 ns | 0.98 | 1008 B | +... + +**Interpretation:** the four-part analysis - comparison, delta, mechanism, caveat. +``` + +Conventions in the tables: + +- Column order: `Method | Runtime | | Mean | Ratio | Allocated`, plus `Alloc Ratio` where + allocations differ, and `Gen0`/`Gen1` where the report shows them. +- Rows are grouped by runtime, with the `.NET 10.0` block first, then `.NET 11.0`, then + `.NET Framework 4.8`; within a block the parameter groups of one class sit together. +- The baseline row of each block is bold, including its bold `1.00` ratio. +- Means carry the report's unit and precision (`ns`, `µs`, `ms`); allocations carry `B` or `-`. +- Method names are the report's `Description` strings, shortened only where the report already + shortened them. + +Family-specific parameters: + +| Section | Parameters | Columns | +| --- | --- | --- | +| Activities | `HasListener` | `Mean | Ratio | Allocated` | +| Logging | `HasLogging` | `Mean | Ratio | Allocated` | +| Multi-target | `HasListener` | `Mean | Ratio | Allocated | Alloc Ratio` | +| Logger multi-target | `HasListener` | `Mean | Ratio | Allocated` | +| Metrics | - (two tables: instruments, then tag counts) | `Mean` per scenario | +| Generator model overhead | `MethodCount` | `Mean | Ratio | Gen0 | Gen1 | Allocated | Alloc Ratio` | + +The Metrics section is the one deliberate exception: it is two scenario tables (`MetricsBenchmarks` +generated-vs-manual, then `TagListBenchmarks` by tag count) followed by the tag-count threshold +explanation, because a column-per-implementation table would be 3x wider than the page. + +## `README.md` section + +```markdown +## Performance + +Benchmarked on , .NET SDK . See the [Performance](https://purview.dev/docs/telemetry-sourcegenerator/performance/) page for full cross-runtime results. + +### Activities (.NET 10.0) + +| Scenario | HasListener | Manual | Generated | Ratio | +| --- | --- | --- | --- | --- | +| start + complete | False | 0.53 ns | 0.58 ns | 1.11x | +... +``` + +- The heading carries the headline runtime in parentheses. It must match the rows beneath it. +- Columns squeeze the wiki table into scenario rows: a scenario column, the parameter column, one column + per implementation, and `Ratio`. +- Allocations are folded into the cell when they matter (`220 ns / 1008 B`), not given their own column. +- One short sentence per family states the conclusion a consumer needs; it is not a shortened copy of + the wiki's interpretation. +- Metrics keeps its `Notes` column for the `TagList` threshold. + +## Environment header + +Copy it, do not compose it: + +``` +BenchmarkDotNet v0.16.0-preview.2, Windows 11 (10.0.26300.9974) +13th Gen Intel Core i9-13900KF 3.00GHz, 1 CPU, 32 logical and 24 physical cores +Memory: 63.77 GB Total, 35.44 GB Available +.NET SDK 11.0.100 + [Host] : .NET 11.0.0 (11.0.0, 11.0.26.53018), X64 RyuJIT x86-64-v3 + Job-GVKUBM : .NET 10.0.15 (10.0.15, 10.0.1526.53008), X64 RyuJIT x86-64-v3 + Job-IHFIKV : .NET 11.0.0 (11.0.0, 11.0.26.53018), X64 RyuJIT x86-64-v3 + Job-UGENHH : .NET Framework 4.8 (4.8.10008.0), X64 RyuJIT VectorSize=256 +``` + +Drop the `Job-XXXXX` ids if you prefer, but keep the exact runtime versions, the SDK version, the CPU and +the OS build - those are what make two runs comparable. The README's one-line environment must agree with +this block's CPU and SDK. + +## Interpretation paragraph checklist + +- [ ] Names the baseline and the alternatives. +- [ ] Gives the time delta **and** the allocation delta for the production path. +- [ ] Names the mechanism when it is known (framework cost, inlining, `IsEnabled` short-circuit, tag + threshold). +- [ ] States the caveat (machine/SDK) or the boundary of the claim. +- [ ] Contains no number that is absent from the report. +- [ ] Would still be true if the reader only ever saw the table, not the report. diff --git a/.agents/skills/purview-telemetry-design/SKILL.md b/.agents/skills/purview-telemetry-design/SKILL.md index c8df5f81..a0e7d862 100644 --- a/.agents/skills/purview-telemetry-design/SKILL.md +++ b/.agents/skills/purview-telemetry-design/SKILL.md @@ -58,7 +58,9 @@ Default to the OpenTelemetry naming convention (`NamingConvention.OpenTelemetry` - ActivitySource names: preserve assembly casing (e.g., `MyApp`). - Activity names: descriptive, sentence-case-ish, no spaces: `placing-order`. - Log message templates: human-readable, with `{PascalCase}` placeholders. -- Metric instrument names: hierarchical, dot-separated, lowercase: `myapp.orders.placed`. +- Metric instrument names: dot-separated, snake_case words. The generator prefixes them with the interface name, not + the meter name: `IOrderTelemetry.OrdersPlaced` becomes `order.orders_placed`. Opt in to a meter-name prefix with + `MeterNameGenerationType.OpenTelemetry`. - Tag/baggage keys: `snake_case`. Only use `NamingConvention.Legacy` for exact v3 backward compatibility. @@ -89,6 +91,42 @@ Rules: | `[ExcludeTargets(Targets.Activities)]` | Excludes the parameter from Activity generation only. | | `[ExpandEnumerable(maximumValueCount: N)]` | Expands an array/IEnumerable into indexed log properties. | +## Union-typed parameters + +A parameter whose type is a union is recorded as its **case value**, never as the union struct, which has no meaningful +`ToString`. The generator emits one private, aggressively inlined accessor per union type and reads each case through +the union's own `TryGetValue` overloads when it declares them (a `bool HasValue` property plus one `TryGetValue(out T)` +per case), so the union struct is never boxed to read it. A union that holds no value records `null`. Works on every +supported target, `net48` included: detection is by symbol, not by framework. + +```csharp +public union OrderOutcome(OrderPlaced, OrderRejected); + +[Activity] +Activity? PlacingOrder([Tag] OrderOutcome outcome); +``` + +Three kinds of union are recognised: + +| Union | How the case list is found | +| --- | --- | +| C# 15 `union` declaration | the language's own generated constructors; read through `Value` | +| Hand-written `[System.Runtime.CompilerServices.Union]` | its `IUnionMembers` provider, otherwise its constructors; the non-boxing pattern is *detected* | +| Purview.Unions `[GenerateUnion]` | the surfaces that are in **source**, in this order: the attribute's `typeof` list, partial static factory signatures, a nested `IUnionMembers` provider, then the constructors | + +A `[GenerateUnion]` union declares nothing but the attribute in its own source — the `[Union]` attribute, constructors, +`HasValue` and `TryGetValue` are emitted by the unions generator into generated output, which this generator never sees. +The accessor is therefore emitted against members that will exist in the final compilation, and it always uses the +non-boxing pattern, because that generator emits it for every surface. Cases are normalised as that generator exposes +them (`int?` → `int`, nullable annotation stripped). A `[GenerateUnion]` target with no case list visible in source is +recorded as an ordinary value, not as a union: Purview.Unions reports `UNI0003` for it. + +If another generator also needs the case list, a nested `IUnionMembers` provider in your own source is the portable +route — both generators can read it. There is no reference from this package to Purview.Unions. + +Prefer a union over `object` for a tag, baggage or log value that can be one of a fixed set of types: the call site +keeps exhaustive matching, and the generated accessor knows every case instead of discovering it at run time. + ## Testing Inject the interface in your service and mock it in tests. The generated interface has no implementation code in the interface itself, so standard mocking frameworks (NSubstitute, Moq, TUnitMocks) work directly. diff --git a/.agents/skills/purview-telemetry-diagnostics/SKILL.md b/.agents/skills/purview-telemetry-diagnostics/SKILL.md index fec24d27..b2f0c704 100644 --- a/.agents/skills/purview-telemetry-diagnostics/SKILL.md +++ b/.agents/skills/purview-telemetry-diagnostics/SKILL.md @@ -25,8 +25,9 @@ diagnostics in four families: | `TSG4xxx` | metrics | Every rule is tracked in `AnalyzerReleases.Shipped.md` / `AnalyzerReleases.Unshipped.md` with its ID, -category and severity, validated by the Roslyn release-tracking analyzers on every build. A new or -changed diagnostic must be recorded there in the same change. +category and severity, validated by the Roslyn release-tracking analyzers on every build. A new +rule, or a changed category or severity, must be recorded there in the same change. The files do not +carry the message, so a reworded message is recorded in the CHANGELOG instead. ## General — `TSG1xxx` @@ -36,13 +37,13 @@ changed diagnostic must be recorded there in the same change. | `TSG1001` | Error | A method on a multi-target interface has no explicit generation attribute — inference is not supported there. | | `TSG1002` | Error | Two attributes from the same target family on one method. Only one Activity, Logging or Metrics attribute per method. | | `TSG1003` | Error | Two methods on the interface share a name. | -| `TSG1004` | Error | Generic interfaces are not supported. | -| `TSG1005` | Error | Generic methods are not supported. | +| `TSG1004` | Error | A generic interface cannot be generated: only a top-level generic interface whose type parameters the generated class can mirror is supported. A nested generic interface, or a type parameter carrying the `allows ref struct` anti-constraint, is not. | +| `TSG1005` | Error | A generic method cannot be generated: a type parameter carrying `allows ref struct`, or a **metric** measurement that is one of the method's own type parameters (the instrument is a field, and a field cannot name a method's type parameter). The method is emitted as a throwing stub. | | `TSG1006` | Warning | `ExcludeTargets` names a target not present on the method. | | `TSG1007` | Warning | `ExcludeTargets` leaves a target with an empty or invalid parameter set. | | `TSG1008` | Warning | An `Activity`-typed parameter on a method with no `[Activity]`/`[Event]`/`[Context]`. | | `TSG1010` | Error | A method carries an attribute for a target family the interface does not register. | -| `TSG1011` | Error | The compilation targets neither .NET 8+ nor .NET Framework 4.8+. Define `PURVIEW_TELEMETRY_NON_NULLABLE` to opt out. | +| `TSG1011` | Error | The compilation targets neither .NET 10+ nor .NET Framework 4.8+, so nothing is generated. There is no opt-out: target `net10.0`+ or `net48`+, and reference the package only from those targets of a multi-targeted library. | ## Logging — `TSG2xxx` @@ -58,6 +59,12 @@ changed diagnostic must be recorded there in the same change. | `TSG2007` | Warning | A scoped log declares a `LogLevel`; it is ignored. | | `TSG2008` | Warning | `[ExpandEnumerable]` has a `MaximumValueCount` above the recommended default of 5. | | `TSG2021` | Error | A log method returns something other than `void` or `IDisposable`. (`Activity` is allowed when the method is also an Activity method.) | +| `TSG2022` | Error | A logging parameter is declared `params`. Take an array and mark it `[ExpandEnumerable]` instead. Parity with `SYSLIB1028`. | +| `TSG2023` | Error | A logging parameter is `ref`/`out`/`in`/`scoped`, or a ref struct such as `Span`; the generated state struct cannot capture it. Parity with `SYSLIB1029`. | +| `TSG2024` | Error | A `[TagProvider]` names a type that cannot be resolved. The parameter is still logged, without the provider's extra tags. | +| `TSG2025` | Error | A `[TagProvider]` names a method that does not exist on the provider type. The parameter is still logged, without the extra tags. | +| `TSG2026` | Error | A `[TagProvider]` method has an unusable signature: it must be a visible static method taking one parameter the annotated parameter can be passed to, and returning `IEnumerable>` (or the `string` variant, or a type implementing one). The .NET 11 `ITagCollector` collector shape is deliberately not accepted. | +| `TSG2027` | Warning | `[TagProvider]` is on a parameter of a method that produces no log entry, so the provider would never run. | ## Activities — `TSG3xxx` @@ -83,6 +90,8 @@ changed diagnostic must be recorded there in the same change. | `TSG3017` | Error | Status-description parameters on an Activity or Context method — Events only. | | `TSG3021` | Info | An `[Event]` records an exception under the OpenTelemetry rules but is not named `"exception"`. | | `TSG3022` | Warning | An Activity method returns non-nullable `Activity`. | +| `TSG3023` | Warning | `UseActivitySourceFactory` is set but `System.Diagnostics.ActivitySourceFactory` (.NET 11, or DiagnosticSource 11+) cannot be resolved; a static `ActivitySource` is generated instead. Target `net11.0` or remove the option. | +| `TSG3024` | Warning | `Version` or `TelemetrySchemaUrl` is set on `[assembly: ActivitySourceGeneration(...)]` without `UseActivitySourceFactory`. A static `ActivitySource` has nowhere to put them, so they are ignored; add the factory or remove them. | ## Metrics — `TSG4xxx` @@ -98,6 +107,9 @@ changed diagnostic must be recorded there in the same change. | `TSG4007` | Error | An observable metric returns `bool`. | | `TSG4008` | Error | An `AutoCounter` does not return `void`. | | `TSG4009` | Warning | The instrument name matches the instrument type name. Name what is measured, not the instrument. | +| `TSG4010` | Warning | `[Gauge]` needs `System.Diagnostics.Metrics.Gauge` (.NET 9, or System.Diagnostics.DiagnosticSource 9+). The compilation cannot resolve it, so no instrument is generated and the method throws `NotSupportedException`. | +| `TSG4011` | Warning | `HistogramBucketBoundaries` needs `System.Diagnostics.Metrics.InstrumentAdvice` (.NET 9, or System.Diagnostics.DiagnosticSource 9+). The histogram is generated without the boundaries. | +| `TSG4012` | Warning | `HistogramBucketBoundaries` does not parse as a comma-separated list of ascending, positive numbers (for example `"0.005, 0.01, 0.1, 1"`). No boundaries are generated. | ## The ones people actually hit @@ -156,11 +168,69 @@ measured the cost. `[AutoCounter]` — equivalently `[Counter(AutoIncrement = true)]` — must declare no measurement parameter. The measurement is fixed at 1. +### `TSG1004` — generic interface cannot be generated + +A plain generic interface works: the generated class is generic over the same type parameters, with the same +constraints, and the DI extension registers the open generic (`typeof(IFoo<>)`). What does not work is a **nested** +generic interface (the enclosing type's type arguments cannot be repeated on the class) or a type parameter with the +`allows ref struct` anti-constraint (the class cannot repeat it). Move the interface out of its enclosing type, or +drop the anti-constraint. + +### `TSG1005` — generic method cannot be generated + +Generic methods work, including constraint mirroring (`struct`, `class`, `class?`, `notnull`, `unmanaged`, `new()`, +type-parameter and type constraints) and multiple type parameters. Two shapes are still refused, and the method is +emitted as a throwing stub so the interface stays implemented: + +- a type parameter with the `allows ref struct` anti-constraint, which cannot be repeated on the generated + declaration; +- a **metric** method whose measurement is one of its own type parameters. An instrument is a field, and a field cannot + name a method's type parameter — declare that type parameter on the interface instead, which makes the class generic + and lets the instrument field use it. + +### `TSG2024` / `TSG2025` / `TSG2026` — `[TagProvider]` cannot be used + +`[TagProvider(typeof(T), "M")]` needs a visible static method `M` on `T` that takes one parameter the annotated +parameter can be passed to, and returns `IEnumerable>` (the `string`-valued variant, an +array, or a `List<...>` of either also work). `2024` is the type, `2025` the method, `2026` the signature. In every +case the parameter is still logged — only the contributed tags are missing. The .NET 11 collector shape +(`static void M(ITagCollector, T)`) is reported as `TSG2026`: implementing `ITagCollector` in generated code would drag +`Microsoft.Extensions.Compliance.Classification` into the consumer's build. + +### `TSG2027` — `[TagProvider]` where nothing logs + +The attribute sits on a parameter of a method that produces no log entry — commonly an `[Activity]`-only method on a +multi-target interface. Move it to the logging method. + +### `TSG1011` — unsupported target framework + +Raised for a compilation that targets neither `net10.0`+ nor `net48`+ (typically `netstandard2.0`, or the dropped `net8.0`/`net9.0`). +It cannot be suppressed by a define; no telemetry is generated for that target. A library that has +to keep such a target references the generator only from its supported ones: + +```xml + +``` + +The telemetry interfaces then need the same condition (or an `#if`), because the attributes are +generated only where the generator runs. + ### `TSG1003` — duplicate method names The method name becomes a member on the generated implementation class, so two methods cannot share one. Rename. +## No diagnostic, but the log message is wrong + +The log reads `Method: Param = {Param}` despite `[Info("…")]`, and the event id is 0. Versions 5.0.0 +to 5.1.0 drop every argument of `[Trace]`, `[Debug]`, `[Info]`, `[Warning]`, `[Error]` and +`[Critical]` except the level: template, event id, `Name` and `GenerationMode`. Upgrade, or on those +versions use `[Log(LogLevel.Information, "…")]`, which is unaffected. Do not fall back to +`[LoggerMessage]` or `ILogger.Log*`. To confirm, set `EmitCompilerGeneratedFiles` and read the template +in the generated `*.Logging.g.cs` under `obj/*/generated/`. + ## Related skills `purview-telemetry-quickstart` to get an interface wired up, `purview-telemetry-design` for diff --git a/.agents/skills/purview-telemetry-migration/SKILL.md b/.agents/skills/purview-telemetry-migration/SKILL.md index b9a01fad..c88a7d1b 100644 --- a/.agents/skills/purview-telemetry-migration/SKILL.md +++ b/.agents/skills/purview-telemetry-migration/SKILL.md @@ -33,11 +33,14 @@ using Purview.Telemetry; [Logger] public interface IOrderServiceLogs { - [Info] + [Info("Order {OrderId} placed for {CustomerName}")] void OrderPlaced(int orderId, string customerName); } ``` +A literal message template and a literal event id carry over. Without a template the generator writes +`OrderPlaced: OrderId = {OrderId}, CustomerName = {CustomerName}`. + The original class is rewritten to use the interface: ```csharp @@ -78,4 +81,21 @@ If the refactoring does not cover a call pattern, convert it manually: | `Histogram.Record(...)` | `[Histogram]` | | `UpDownCounter.Add(...)` | `[UpDownCounter]` | +## Keeping the message, event id and name + +Each level attribute takes the original template, event id and log name, positionally, by parameter +name, or as named arguments: + +```csharp +[Info("Order {OrderId} placed")] // logger.LogInformation("Order {OrderId} placed", id) +[Warning(7, "Retrying {Attempt}")] // logger.LogWarning(new EventId(7), "Retrying {Attempt}", n) +[Error(MessageTemplate = "Failed {OrderId}", EventId = 9, Name = "OrderFailed")] +``` + +**Check the generated text after migrating.** Versions 5.0.0 to 5.1.0 ignore every argument on +`[Trace]`, `[Debug]`, `[Info]`, `[Warning]`, `[Error]` and `[Critical]` except the level, with no +diagnostic: the template falls back to the default form and the event id is 0. On those versions use +`[Log]`, which is unaffected: `[Log(LogLevel.Information, "Order {OrderId} placed")]`. Named +`EventId = n` needs the release after 5.1.0; earlier ones accept only the constructor form. + See [references/REFERENCE.md](references/REFERENCE.md) for detailed before/after examples and common conversions. diff --git a/.agents/skills/purview-telemetry-quickstart/SKILL.md b/.agents/skills/purview-telemetry-quickstart/SKILL.md index 1743ffe3..7717b528 100644 --- a/.agents/skills/purview-telemetry-quickstart/SKILL.md +++ b/.agents/skills/purview-telemetry-quickstart/SKILL.md @@ -14,21 +14,30 @@ This skill helps you bootstrap telemetry generation in a C# project using `Purvi ## Installation -Add the analyzer package to your project: +Add the analyzer package to your project. `dotnet add package` writes the latest version; never copy a version +number from documentation, it goes stale: + +```bash +dotnet add package Purview.Telemetry.SourceGenerator +``` + +Then make the reference analyzer-only (keep the `Version` that `dotnet add package` wrote, or set it in +`Directory.Packages.props` under Central Package Management): ```xml - + all analyzers ``` -Also add runtime dependencies for the telemetry types you use: - -- `System.Diagnostics.DiagnosticSource` for Activities -- `Microsoft.Extensions.Logging.Abstractions` for ILogger -- `System.Diagnostics.Metrics` for metrics (built-in on .NET 6+) +Consumers can target .NET Framework 4.8 or .NET 10 and later, .NET 11 included, and the generated code works under +Native AOT and in trimmed or AOT-compiled Blazor WebAssembly. Runtime dependencies for the telemetry types you use: +- Activities: `System.Diagnostics.DiagnosticSource` (in-box on .NET). +- Logging: `Microsoft.Extensions.Logging.Abstractions`. It is in the shared framework on .NET 11, so do not reference + it there (NuGet reports `NU1510`). Add `Microsoft.Extensions.Telemetry.Abstractions` for the faster v2 logging. +- Metrics: `System.Diagnostics.Metrics` (in-box on .NET 10+). Register `services.AddMetrics()` for the `IMeterFactory`. ## Define a telemetry interface Create a `public interface` and decorate it with class-level attributes. The generator creates the implementation and a DI registration extension. @@ -105,6 +114,16 @@ The generator creates an extension method named `Add{InterfaceNameWithoutI}()`: services.AddOrderServiceTelemetry(); ``` +On .NET 11 you can opt in to creating activity sources from the injected `ActivitySourceFactory`. This changes the +generated constructor, so also register the factory: + +```csharp +[assembly: ActivitySourceGeneration("MyApp.Orders", UseActivitySourceFactory = true)] + +// in the host +services.AddTracing(); +``` + Inject the interface into your service: ```csharp @@ -136,5 +155,5 @@ Libraries need no configuration: they publish their names through the attribute, ## Next steps -- To migrate existing `ILogger`, `ActivitySource`, or metrics code, use `telemetry-sourcegenerator-migration`. -- For design guidance and best practices, use `telemetry-sourcegenerator-design`. +- To migrate existing `ILogger`, `ActivitySource`, or metrics code, use the `purview-telemetry-migration` skill. +- For design guidance and best practices, use the `purview-telemetry-design` skill. diff --git a/.agents/skills/purview-telemetry/SKILL.md b/.agents/skills/purview-telemetry/SKILL.md index 6d831731..d2f42d78 100644 --- a/.agents/skills/purview-telemetry/SKILL.md +++ b/.agents/skills/purview-telemetry/SKILL.md @@ -9,6 +9,7 @@ license: MIT metadata: author: Purview-Dev version: "1.0" + repository: purview-dev/engineering-playbook --- # Purview telemetry @@ -120,4 +121,4 @@ Instrumentation is identical across all three rows. Only the right-hand column g ## See also - `purview-architecture` — establishing project context and scale before deciding -- [Purview.Telemetry.SourceGenerator](https://github.com/purview-dev/purview-telemetry-sourcegenerator) +- [Purview.Telemetry.SourceGenerator](https://github.com/purview-dev/telemetry-sourcegenerator) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 8080adf4..1978504b 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,21 +3,21 @@ "isRoot": true, "tools": { "csharpier": { - "version": "1.3.0", + "version": "1.4.0", "commands": [ "csharpier" ], "rollForward": false }, "dotnet-inspect": { - "version": "0.26.0", + "version": "0.27.0", "commands": [ "dotnet-inspect" ], "rollForward": false }, "purview.build": { - "version": "0.8.0", + "version": "0.9.1", "commands": [ "purview-build" ], diff --git a/.config/lefthook.yml b/.config/lefthook.yml index ae5b5caf..479f5616 100644 --- a/.config/lefthook.yml +++ b/.config/lefthook.yml @@ -1,12 +1,15 @@ pre-commit: jobs: + - name: aspire version check + run: just ts-validate + - name: csharpier format check run: just lint-check - # - name: oxlint check - # run: just ts-lint + - name: oxfmt check + run: just ts-lint commit-msg: jobs: - name: conventional commits - run: bunx commitlint --edit {1} + run: bun x commitlint --edit {1} diff --git a/.config/purview-build.json b/.config/purview-build.json index 6dbe328c..a6400d09 100644 --- a/.config/purview-build.json +++ b/.config/purview-build.json @@ -30,18 +30,18 @@ ], // RequireExplicitContent defaults to true, so this is the exhaustive declaration of what each // package ships: a package with no rule here, or an entry matching none of its globs, fails - // validation. The lib/ paths are literal net10.0 and must be updated alongside the target - // framework in src/Directory.Build.props - the stale net8.0 entries they replace are what - // broke this manifest. $(TFM) is deliberately not used: AspireC4.Hosting ships a TFM-less - // build/ folder, so the token expands to include "any" and the rule could never be met. + // validation. $(TFM) expands to every target framework the package ships, so the lib/ rules + // follow Purview.BuildSdk's set (net10.0;net11.0 today) with nothing to edit when it changes. "RequiredContent": { - // The Aspire integration: the library, the registry/DSL source generator that ships beside - // it, and the props file that wires the generator up in a consuming AppHost. + // The Aspire integration: the library, the registry/DSL source generator, and the props + // file that wires the generator up in a consuming AppHost. The generator is not a package + // of its own: AspireC4.csproj references src/src/SourceGenerators as an analyzer, and pack + // places it under analyzers/dotnet/cs/ automatically. This rule is what proves it shipped. "aspirec4.hosting": [ "analyzers/dotnet/cs/Aspire.Hosting.AspireC4.SourceGenerators.dll", "build/AspireC4.Hosting.props", - "lib/net10.0/Aspire.Hosting.AspireC4.dll", - "lib/net10.0/Aspire.Hosting.AspireC4.xml", + "lib/$(TFM)/Aspire.Hosting.AspireC4.dll", + "lib/$(TFM)/Aspire.Hosting.AspireC4.xml", "likec4-logo.png", "README.md" ], @@ -49,8 +49,8 @@ // the separate analyzer-only AspireC4.EntityFrameworkCore package below, and the opt-in // build-time dump as AspireC4.EntityFrameworkCore.ModelDump. "aspirec4.hosting.entityframeworkcore": [ - "lib/net10.0/Aspire.Hosting.AspireC4.EntityFrameworkCore.dll", - "lib/net10.0/Aspire.Hosting.AspireC4.EntityFrameworkCore.xml", + "lib/$(TFM)/Aspire.Hosting.AspireC4.EntityFrameworkCore.dll", + "lib/$(TFM)/Aspire.Hosting.AspireC4.EntityFrameworkCore.xml", "likec4-logo.png", "README.md" ], diff --git a/.editorconfig b/.editorconfig index f8b30509..229c475d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,4 +1,4 @@ -# Purview.BuildSdk 1.3.0 - managed file, do not edit +# Purview.BuildSdk 2.0.1 - managed file, do not edit # # This file is written by Purview.BuildSdk and is replaced whenever the SDK version changes. The version # on the first line is how the SDK recognises a file it owns: a .editorconfig without that marker is diff --git a/.gitignore b/.gitignore index 67df78da..cce55d5a 100644 --- a/.gitignore +++ b/.gitignore @@ -669,19 +669,8 @@ sketch BenchmarkDotNet.Artifacts/ -.tools/purview-build/** +# AspireC4 generated diagram output needs no entry here: AspireC4 writes a .gitignore beside what it +# generates, naming only its own files and leaving the exported images visible. This repository relies +# on that rather than restating it, so a regression in it shows up as untracked files. -# AspireC4 generated diagram output (architecture diagram, ER diagram, and the ER viewer's bundled -# JS dependencies). Regenerated on every AppHost run, and the LikeC4 model embeds ephemeral localhost -# ports, so tracking it guarantees a dirty tree after every run. -# -# The exported images are the exception, and are tracked deliberately: they are the artefact the -# export exists to produce, and a diagram that is reviewable in a pull request is worth having in -# source control. Written only when the diagram actually changes, so they do not churn — and the -# stamp beside them ignores the ephemeral ports that would otherwise redraw them on every run. -# Note the trailing /* rather than /: git will not descend into an excluded directory, so the -# children have to be excluded individually for the re-include below to be reachable. -**/.aspirec4/* -!**/.aspirec4/images/ -# The stamp recording what the images were rendered from is a local cache, not documentation. -**/.aspirec4/images/.aspirec4-* \ No newline at end of file +.claude/ \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index a8cbbd12..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "aspire.enableSettingsFileCreationPromptOnStartup": false, - "cSpell.words": [ - "aspirec4" - ] -} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 6ea5a802..f64f3909 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,8 +10,11 @@ guide lives in [`CONTRIBUTING.md`](CONTRIBUTING.md). generates live [LikeC4](https://likec4.dev) architecture diagrams from the Aspire resource graph. It ships: - The `AspireC4.Hosting` library (the Aspire integration). -- An incremental Roslyn source generator (`AspireC4.SourceGenerators`) that validates registry constants and - `.c4` DSL files against `.WithTag()`, `.WithKind()`, `.WithLikeC4Group()`, and `.WithMetadata()` call sites. +- An incremental Roslyn source generator (`src/src/SourceGenerators/`, assembly + `Aspire.Hosting.AspireC4.SourceGenerators`) that validates registry constants and `.c4` DSL files against + `.WithTag()`, `.WithKind()`, `.WithLikeC4Group()`, and `.WithMetadata()` call sites. It is **not** a + package of its own: `AspireC4.csproj` references it as an analyzer, so pack includes it in + `AspireC4.Hosting` under `analyzers/dotnet/cs/` automatically, and `.config/purview-build.json` asserts it. - An optional Entity Framework Core ER diagram: the `AspireC4.Hosting.EntityFrameworkCore` AppHost library, the analyzer-only `AspireC4.EntityFrameworkCore` package and the `AspireC4.EntityFrameworkCore.ModelDump` build-time tool. It is **additive** — `WithEntityFrameworkDiagram()` extends the builder `AddAspireC4()` @@ -20,16 +23,22 @@ generates live [LikeC4](https://likec4.dev) architecture diagrams from the Aspir ### Packages and target frameworks -| Package | Project | Target framework | Shipped as | +| Package | Project | Target frameworks | Shipped as | |---|---|---|---| -| `AspireC4.Hosting` | `src/src/AspireC4/` | `net10.0` | `lib/net10.0/` + `analyzers/dotnet/cs/` + `build/` | -| `AspireC4.Hosting.EntityFrameworkCore` | `src/src/EntityFrameworkCore/` | `net10.0` | `lib/net10.0/` | +| `AspireC4.Hosting` | `src/src/AspireC4/` | `net10.0`, `net11.0` | `lib/net10.0/` + `lib/net11.0/` + `analyzers/dotnet/cs/` + `build/` | +| `AspireC4.Hosting.EntityFrameworkCore` | `src/src/EntityFrameworkCore/` | `net10.0`, `net11.0` | `lib/net10.0/` + `lib/net11.0/` | | `AspireC4.EntityFrameworkCore` | `src/src/EntityFrameworkCore.Analyzers/` | `netstandard2.0` | `analyzers/dotnet/cs/` only (no runtime assembly) | | `AspireC4.EntityFrameworkCore.ModelDump` | `src/src/EntityFrameworkCore.ModelDump/` | `net10.0` | `tools/` + `build/` | -AppHosts therefore target `net10.0`. `src/Directory.Build.props` owns the framework; the two Roslyn -projects override it to `netstandard2.0`. The packed content is pinned to `lib/net10.0/...` in -`.config/purview-build.json`, so a framework change has to update that manifest too. +The target frameworks are **owned by Purview.BuildSdk** (its `Current` set, `net10.0;net11.0`); the two +Roslyn projects get `netstandard2.0` from the SDK. Never set `TargetFramework`/`TargetFrameworks` in +`src/Directory.Build.props`: the SDK reads a project's TFM from the project file text, so a directory-level +value only shadows its list and breaks `pack` (`NU5026`). Projects that must stay single-target — the +AppHost, the sample services and the ModelDump tool — declare `net10.0` in their own `.csproj`. The +ModelDump tool also sets `RollForward=LatestMajor`, because it loads the consuming project's assembly and +must run on a runtime at least that new. The packed content in `.config/purview-build.json` uses +`lib/$(TFM)/...`, which the validator expands to every framework a package ships, so a change to the +SDK's set needs no manifest edit. ### Layout @@ -51,7 +60,8 @@ projects override it to `netstandard2.0`. The packed content is pinned to `lib/n | `samples/typescript-app-host/` | TypeScript AppHost sample (`apphost.mts`, generated `.aspire/modules/`) | | `assets/likec4-extensions/` | Extra LikeC4 DSL used by the samples | | `assets/images/` | LikeC4 logos/icons used by the samples | -| `.agents/` | Bundled agent skills/agents/prompts (see [Skills](#skills)) | +| `.agents/` | Agent skills installed from other repositories by the skills CLI, plus agents/prompts (see [Skills](#skills)) | +| `docs/wiki/` | Wiki source; `Requirements.md` owns consumer requirements (see [Documentation hierarchy](#documentation-hierarchy)) | ### Package READMEs @@ -61,6 +71,37 @@ one about its own package, and update it when that package's public API or setup link the root `README.md` into a project with `Link="Sdk/README.md"`, which would override it. Use absolute URLs in package READMEs, because relative links do not resolve on nuget.org. +### Documentation hierarchy + +Each fact has **one owning page**; every other page summarises it in a sentence or two and links to +the owner rather than restating the detail. Restated detail drifts, which is how the docs came to claim +an Aspire minimum the packages did not declare. + +| Topic | Owner | Summarised in | +|---|---|---| +| What a consuming application needs (SDK, target frameworks, Aspire minimum, container and runtime needs) | `docs/wiki/Requirements.md` | root `README.md`, `docs/wiki/Home.md`, `docs/wiki/Getting-Started.md`, package READMEs, the `purview-aspirec4-*` skills | +| How this repository is built (SDK pin, C# 15, .NET 11 features adopted and avoided, package bands) | `docs/wiki/Toolchain.md` | this file's [Toolchain](#toolchain) section | +| Generated output, source control (`.gitignore` files) and extending the model with hand-written `.c4` | `docs/wiki/Generated-Output.md` | root `README.md`, `src/src/AspireC4/README.md`, `docs/wiki/Migration.md`, the `purview-aspirec4-configuration` skill | +| How EF domain types (value objects, enums, unions) and relationships are recognised | `docs/wiki/Entity-Framework-Diagrams.md` | root `README.md`, the EF package READMEs, the `purview-aspirec4-entityframework` skill, the EF section below | +| A feature's behaviour and options | its wiki reference page | root `README.md` (the complete reference), the package README of the package that owns it | + +The wiki is layered in `docs/wiki/_Sidebar.md` and `Home.md` as **Using AspireC4** (Requirements, +Getting Started), **Reference**, and **Working on AspireC4** (Toolchain, Contributing, Release Flow). A +new page goes into one of those groups in both files. + +Two consumer-facing floors come from `Directory.Packages.props`, and changing either is a breaking +change that updates `Requirements.md` in the same commit: + +- `AspireVersion` — the `Aspire.Hosting` version the libraries declare, and so the **Aspire minimum** + (13.6.5 today). Repeated in the AppHost snippets listed under "Keeping this page true" in + `Requirements.md`. +- `RoslynCompilerVersion` — the compiler both shipped generators are built against (5.9.0), which is why + consumers must **build with the .NET 11 SDK** even when the AppHost targets `net10.0`. An older + compiler reports `CS9057` and skips the generator silently. + +Do not write AspireC4's own release number into prose or examples; use the NuGet badge or a +`` placeholder, because a written version is wrong within one release. + ### Documentation screenshots Screenshots embedded in the root `README.md`, the wiki pages under `docs/wiki/` and the package READMEs @@ -74,8 +115,10 @@ them. Use a relative path from the root README, and absolute `raw.githubusercont | Tool | Purpose | Version source | |---|---|---| -| .NET SDK | Build/test | `global.json` | -| Target framework | **net10.0** for all shipping and test projects (`src/Directory.Build.props`); Roslyn components override to `netstandard2.0` | `src/Directory.Build.props` | +| .NET SDK | Build/test — **11.0.100 GA**, `rollForward: latestFeature`, `allowPrerelease: false` | `global.json` | +| Target frameworks | **net10.0;net11.0** for the libraries and test projects (Purview.BuildSdk's `Current` set; Roslyn components get `netstandard2.0`); single-target `net10.0` declared per project for the AppHost, sample services and ModelDump tool. **.NET 8 and .NET 9 are unsupported.** | Purview.BuildSdk + project files | +| Language version | **C# 15 (`15.0`, not `preview`)** | `src/Directory.Build.props` | +| Container test SDK | `mcr.microsoft.com/dotnet/sdk:11.0`, matching `global.json` | `tests/Docker/` | | Bun | JS scripts, commit hooks, TS sample | `package.json` → `packageManager` | | just | Task runner (single entry point) | system install | | Docker | Integration tests, local diagram viewer | system install | @@ -84,8 +127,16 @@ them. Use a relative path from the root README, and absolute `raw.githubusercont The repository uses `Purview.BuildSdk` (pinned in `global.json` under `msbuild-sdks`) through `src/Directory.Build.props` / `src/Directory.Build.targets`. The SDK classifies projects, adds references, -sets testing/telemetry defaults, and copies the bundled `.agents/**` skills into this repo. Use the -`project-placement-defaults` and `sdk-project-behavior-and-detection` skills when reasoning about its behaviour. +sets testing/telemetry defaults, and bootstraps `.editorconfig`, `global.json` and `Directory.Build.rsp`. +It never touches agent skills during a build: they change only when someone runs `just skills-update` +(see `Justfile.skills.just`). Use the `purview-buildsdk-project-layout` and +`purview-buildsdk-project-detection` skills when reasoning about its behaviour. + +Package versions track the **lowest** target framework, not the SDK: runtime and Microsoft packages stay in +the `10.0.x` band (`10.0.15` today) so a `net10.0` build never acquires a .NET 11-only binary. Build-only +tooling (SourceLink, CSharpier, `dotnet-inspect`) may track the SDK. `docs/wiki/Toolchain.md` records the +full matrix and which .NET 11 features are deliberately not used; update it with any TFM, SDK or package-band +change. ## Commands @@ -160,21 +211,29 @@ just refresh-icons # Regenerate the LikeC4 icon manifest (scripts/generate-icon just ts-restore # aspire restore for the TypeScript sample just ts-run # aspire run for the TypeScript sample just ts-lint # oxfmt check on apphost.mts +just ts-test # TypeScript test suite (bun test) +just ts-validate # global.json Aspire SDK version vs the TypeScript sample +just ts-validate-fix # Rewrite the TypeScript sample to match global.json just scrub # Remove bin/obj, clean, restore, shutdown build server ``` ### Running a single test +Tests run on Microsoft.Testing.Platform (selected in `global.json`), so filtering is a tree-node path: +`////`, with `*` as a wildcard. + ```sh dotnet test src/tests/AspireC4.UnitTests/AspireC4.UnitTests.csproj \ - -- --filter "FullyQualifiedName~MyTestMethod" + --treenode-filter "/*/*/ImageExportStampTests/*" ``` +`--filter` is the VSTest option; under MTP it matches nothing and the run reports "Zero tests ran". + ## Non-negotiables - Always run `just test` (unit + integration) to verify changes. Unit tests alone are not sufficient. - Never claim a task complete unless all tests pass (`just test` exits green, zero failures). -- Never skip tests (`--filter` overrides, `[Skip]`) without explicit user permission. +- Never skip tests (`--treenode-filter` overrides, `[Skip]`) without explicit user permission. - Always run `just lint-check` (or `just lint-fix`) before committing — CSharpier is enforced in CI and by the `pre-commit` Lefthook hook. - Never add `Co-authored-by` trailers to commit messages. @@ -193,18 +252,29 @@ dotnet test src/tests/AspireC4.UnitTests/AspireC4.UnitTests.csproj \ own namespace) with `#pragma warning disable IDE0130` where required. - Central package management: all NuGet versions live in the **root** `Directory.Packages.props`. Never add a `Version` attribute directly in a `.csproj`; add a `PackageVersion` to `Directory.Packages.props` first. +- C# 15 is available on both legs, but the `net10.0` leg cannot see types the .NET 11 runtime adds. Declare + an **internal** polyfill, guarded by `#if !NET11_0_OR_GREATER`, beside the project that needs one rather + than lowering the language version — `src/src/EntityFrameworkCore/Polyfills/CompilerServices.cs` is the + pattern. Roslyn resolves these by full name, so it works for consumers on .NET 10 and .NET 11 alike. +- Use `closed` for an internal hierarchy whose descendants are complete and switched over, as + `EfModelDumpRead` does; it turns "a new case silently falls through" into a build failure. +- Introduce a union only when a value genuinely is one of a fixed set of types. Follow the compiler's + **non-boxing access pattern** (`HasValue` + `TryGetValue`) so value-type cases are not boxed during pattern + matching — see `docs/wiki/Toolchain.md`. ## Git hooks and commits Lefthook (`.config/lefthook.yml`) installs two hooks: -- `pre-commit` — runs `just lint-check` (CSharpier over the repo root). Rejects mis-formatted commits. -- `commit-msg` — runs `bunx commitlint --edit` to enforce Conventional Commits. +- `pre-commit` — runs `just ts-validate` (the Aspire version in `global.json` against the TypeScript + sample), `just lint-check` (CSharpier over the repo root) and `just ts-lint` (oxfmt over + `apphost.mts`). Rejects the commit on any failure. +- `commit-msg` — runs `bun x commitlint --edit` to enforce Conventional Commits. Commit rules (enforced by `commitlint.config.mts`): - Format: `(): `. -- Types: `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`. +- Types: `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`, `qol`. - Subject: lower-case, no trailing period, max 100 characters; body lines max 100 characters. - Breaking changes: `!` after type/scope, or a `BREAKING CHANGE:` footer. - Bypass temporarily with `git commit --no-verify` only for work-in-progress; never bypass on `main`-bound commits. @@ -245,6 +315,10 @@ Test authoring requirements (from the source-generator test authoring agent in ` - Build dependencies/SUTs through `CreateXXX` helpers with optional parameters; never instantiate inside a test. - Pass a `CancellationToken` wherever a downstream method accepts one. +The TypeScript suite (`bun test`, under `scripts/`) is run by +`Repository.UnitTests/TypeScriptTests.cs`. Purview.Build has no JavaScript test module, so that bridge is +what makes `just test` and the shared pipeline execute it; `just ts-test` runs the same suite directly. + ### Integration tests and the project runner Run `AspireC4.IntegrationTests` through `just test-integration` or `just test`, **not** from an IDE test @@ -304,16 +378,35 @@ The ER diagram draws from three sources, in precedence order. Every context carries an `EfModelFidelity`, so an approximate model is never mistaken for a complete one, and `EfModelLoader.Merge` de-duplicates by fully-qualified type name, preferring the higher -fidelity. `EfColumn.StoreType` is always the real database type; a column's **domain type** — a value -object or an enum — is carried in `EfColumn.DomainTypeName` with its `EfColumn.DomainTypeKind` -(`ValueObject`, `ComplexValueObject`, `Enum`), and shown beside the store type according to +fidelity. `EfColumn.StoreType` is always the real database type; a column's **domain type** is carried +in `EfColumn.DomainTypeName` with its `EfColumn.DomainTypeKind` (`ValueObject`, `ComplexValueObject`, +`Enum`, `Union`), and shown beside the store type according to `AspireC4EfDiagramOptions.DomainTypeDisplay` (default `DomainTypeFirst`: `AssetId (guid)`, -`ActorType (int, enum)`). Recognising a scalar value object — a non-generic struct with a public -`Value` member, as the Purview value-object generator emits — is also what makes its foreign keys, and -so its relationships, resolve. A complex value object, marked -`[ValueObject(EFMapping = ComplexType)]`, is flattened into its owner's table. `EfColumnTypeDisplay` -owns the split, and every renderer keeps the database type in its annotation slot when it is not the -headline. +`ActorType (int, enum)`). `EfColumnTypeDisplay` owns the split and the `enum`/`union` markers. + +The recognition rules are **identical in the analyzer (`StoreTypeMap`) and the metadata reader**, and +`EfValueObjectRoundTripTests` reads one compiled model through both to keep them so — change one, change +both: + +- **Scalar value object:** Purview `[Scalar]`/`[Scalar]`/`[Scalar(typeof(T))]` (matched by name; the + wrapped type from the attribute or the named property), `IScalarValueObject`, else a + non-generic struct with a public `Value` not typed `object`. Struct or class. The attribute path is + load-bearing: a generator never sees another generator's output, so the analyzer cannot see the + `Value` Purview's generator writes. +- **Complex:** `[ValueObject]` by `EFMapping`, else `[ValueObjectDefaults]`, else `ComplexType` (Purview's + default); EF `[ComplexType]`; `[Owned]`. `Json` → one `json` column. Flattened members carry + `EfColumn.ComplexPropertyName`/`ComplexTypeName`, which the viewer groups by. +- **Collections** of primitives, value objects, enums or complex types are one `json` column, never a + navigation. **Unions** (`ITypeSymbol.IsUnion` — experimental, so isolated in a guarded method — or + `[Union]`/`IUnion`) are checked before the value-object shape. +- **Relationships** come from either end: the dependent's reference navigation, or the principal's + collection alone, using EF's foreign-key names and adding EF's shadow foreign key when none matches. + +`EfDomainTypeKind` is read leniently (`EfDomainTypeKindConverter`): an unknown kind from a newer analyzer +becomes `null` rather than failing the parse, which would empty the whole model. A new kind is therefore +safe to add. The dump projector uses the real model, so fluent `ComplexProperty`, `OwnsOne`/`OwnsMany`, +`ToJson`, `HasConversion` and `HasColumnType` reach the diagram only through it. The user-facing rules +live in `docs/wiki/Entity-Framework-Diagrams.md#value-objects-enums-and-unions`. ## LikeC4 DSL work @@ -329,7 +422,7 @@ When writing or validating `.c4`/`.likec4` files, load the `likec4-dsl` skill ## Release process -- Version source of truth: `package.json` `version` (e.g. `13.5.3.1`). The CD workflow +- Version source of truth: `package.json` `version` (e.g. `13.6.5-preview.1`). The CD workflow (`.github/workflows/release.yml`, delegating to the `purview-dev/build` reusable `purview-release.yml` with `release-mode: NuGet`) reads and validates it, builds, tests, packs, builds release notes from conventional commits, and creates a GitHub Release with the `.nupkg`/`.snupkg`. It does **not** push to nuget.org. @@ -378,7 +471,7 @@ npx skills add purview-dev/aspirec4 | Skill | Answers | | -- | -- | -| `skills/purview-aspirec4-quickstart/` | installing, `AddAspireC4`, what happens on startup, Docker sidecar versus local CLI, and when to add the ER diagram as well | +| `skills/purview-aspirec4-quickstart/` | requirements (.NET 11 SDK, Aspire minimum), installing, `AddAspireC4`, what happens on startup, Docker sidecar versus local CLI, and when to add the ER diagram as well | | `skills/purview-aspirec4-configuration/` | every `AspireC4DiagramOptions` property and its fluent method | | `skills/purview-aspirec4-resources/` | `WithLikeC4Details`, `WithLikeC4Reference`, `WithLikeC4Group`, and exclusion | | `skills/purview-aspirec4-entityframework/` | `WithEntityFrameworkDiagram`, the three model-discovery sources, renderers, domain types, and ER image export | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f3d7ce31..af5c5d8d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -83,6 +83,14 @@ Two distinct brands exist in this repository. Use them consistently: | `just lint-fix` | Auto-fix formatting with CSharpier | | `just pack` | Build and pack NuGet artifacts into `artifacts/nuget/` | +### TypeScript + +| Recipe | Description | +|---|---| +| `just ts-test` | Run the TypeScript test suite with Bun | +| `just ts-validate` | Check the TypeScript sample's Aspire version against `global.json` | +| `just ts-validate-fix` | Rewrite the TypeScript sample to the Aspire version in `global.json` | + ### Container runtime tests (local only) The sample AppHost uses `Purview.Containers.Aspire` to verify bind mounts before containers @@ -116,12 +124,16 @@ helpers configured on your machine must be installed and on `PATH`, including wh ### Filtering a single test +Tests run on Microsoft.Testing.Platform, so the filter is a tree-node path +(`////`, `*` is a wildcard). `--filter` is the VSTest option and +matches nothing here. + ```sh dotnet test src/tests/AspireC4.UnitTests/AspireC4.UnitTests.csproj \ - -- --filter "FullyQualifiedName~MyTestMethod" + --treenode-filter "/*/*/MyClassTests/*" dotnet test src/tests/AspireC4.IntegrationTests/AspireC4.IntegrationTests.csproj \ - -- --filter "FullyQualifiedName~MyTestMethod" + --treenode-filter "/*/*/MyClassTests/*" ``` --- @@ -147,7 +159,7 @@ CSharpier runs automatically on every `git commit` via Lefthook. Commits with fo | Hook | What it does | |---|---| -| `pre-commit` | Runs `just lint-check` (CSharpier over the repo root). Rejects the commit if any file is mis-formatted. | +| `pre-commit` | Runs `just ts-validate` (the Aspire version in `global.json` against the TypeScript sample) `just lint-check` (CSharpier over the repo root) and `just ts-lint` (oxfmt over `apphost.mts`). Rejects the commit on any failure. | | `commit-msg` | Runs `commitlint` to enforce [conventional commit](#commit-messages) format. | Lefthook installs when you run `just init`. To verify it is active: @@ -195,6 +207,7 @@ Commit messages must follow [Conventional Commits](https://www.conventionalcommi | `chore` | Maintenance, tooling, dependency updates | | `style` | Formatting, whitespace (code not logic) | | `revert` | Reverting a previous commit | +| `qol` | Quality-of-life improvement to the developer experience | **Rules:** @@ -264,11 +277,25 @@ config.Item("AppHost:BrowserToken").Returns("value"); | `EntityFrameworkCore.UnitTests` | d2 model builder, d2 DSL generator and descriptor loader — no Docker required | | `EntityFrameworkCore.Analyzers.UnitTests` | EF model generator, plus the generator→assembly→AppHost round-trip | | `SourceGenerators.UnitTests` | The registry/DSL strict-validation generator | -| `Repository.UnitTests` | Repository-level rules, including the authored skills in `skills/` | - -Every project targets `net10.0` except the two Roslyn components (`SourceGenerators` and -`EntityFrameworkCore.Analyzers`), which target `netstandard2.0`. The framework is set once in -`src/Directory.Build.props`, which is also where the `Aspire.Hosting.AspireC4` namespace prefix lives. +| `Repository.UnitTests` | Repository-level rules, including the authored skills in `skills/`. Also runs the TypeScript suite via `bun test`, because Purview.Build has no JavaScript test module. | + +The libraries and test projects multi-target `net10.0;net11.0` — Purview.BuildSdk's `Current` set of the +releases still in support. The two Roslyn components (`SourceGenerators` and +`EntityFrameworkCore.Analyzers`) get `netstandard2.0` from the SDK. The AppHost, the sample services and +the ModelDump tool are single-target and declare `net10.0` in their own project file, because Aspire +starts them with `dotnet run` and the tool ships once under `tools/`. + +The C# 15 language version and the `Aspire.Hosting.AspireC4` namespace prefix live in +`src/Directory.Build.props`; the SDK is pinned in `global.json`. Never set +`TargetFramework`/`TargetFrameworks` in the directory-level props file — the SDK reads a project's TFM +from the project file text and a directory-level value shadows its list, which breaks `pack`. .NET 8 +and .NET 9 are not supported, and package versions stay in the `10.0.x` servicing band to match the +lowest target framework. See [Toolchain and support matrix](docs/wiki/Toolchain.md) before changing any +of it. + +Two versions in `Directory.Packages.props` are floors every consumer inherits: `AspireVersion` is the +Aspire minimum, and `RoslynCompilerVersion` is why consumers must build with the .NET 11 SDK. Raising +either is a breaking change, and [Requirements](docs/wiki/Requirements.md) is updated in the same commit. Integration tests require Docker to be running. They pull `ghcr.io/likec4/likec4` on first run. diff --git a/Directory.Packages.props b/Directory.Packages.props index c602529c..d378e0da 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,11 +3,11 @@ 5.9.0 5.9.0 - 1.76.0 - 1.2.2 - 5.0.6 - 13.6.4 - 1.0.0-prerelease.10 + 1.83.0 + 2.0.0 + 6.0.0 + 13.6.5 + 1.0.0-prerelease.13 @@ -18,7 +18,7 @@ - + @@ -29,18 +29,18 @@ Sample EF Core data projects only. Relational carries the fluent API the generator interprets (ToTable, HasDefaultSchema, HasColumnName, HasColumnType). --> - - + + - + - + @@ -48,6 +48,6 @@ - + diff --git a/Justfile b/Justfile index e8ba4dc6..6b00f40e 100644 --- a/Justfile +++ b/Justfile @@ -1,9 +1,15 @@ set quiet +import "Justfile.skills.just" + export TESTINGPLATFORM_EXITCODE_IGNORE := "8" export DOTNET_CLI_TELEMETRY_OPTOUT := "1" export DOTNET_SKIP_FIRST_TIME_EXPERIENCE := "1" +export DOTNET_NOLOGO := "1" export DO_NOT_TRACK := "1" +# The .NET 11 CLI keeps the MSBuild server on by default; stating it keeps local and CI builds on +# the same path rather than depending on a machine-wide setting. +export DOTNET_CLI_USE_MSBUILD_SERVER := "1" _root := "./" _solution := "src/AspireC4.slnx" @@ -105,7 +111,8 @@ build configuration=config_default *args: clean configuration=config_default *args: dotnet clean {{ _solution }} --configuration {{ configuration }} {{ args }} -# Run all tests (unit + integration) +# Run all tests (unit + integration). The TypeScript suite runs too, through +# Repository.UnitTests/TypeScriptTests, so the shared pipeline executes it in the same pass. [group('.NET')] test filter=default_test_filter *args: dotnet test {{ _solution }} -c {{ config_default }} --treenode-filter "{{ filter }}" {{ args }} @@ -156,6 +163,21 @@ ts-run: ts-lint: cd {{ _typescriptAppHost }} && bun run lint +# Verify the Aspire SDK version in global.json matches the TypeScript sample +[group('Typescript')] +ts-validate: + bun scripts/validate-aspire-version.mts + +# Rewrite the TypeScript sample's Aspire version to match global.json +[group('Typescript')] +ts-validate-fix: + bun scripts/validate-aspire-version.mts --fix + +# Run the TypeScript test suite directly +[group('Typescript')] +ts-test: + bun test + # ── Icon manifest ───────────────────────────────────────────────────────────── # Regenerate the LikeC4 icon manifest from the upstream GitHub repository diff --git a/Justfile.skills.just b/Justfile.skills.just new file mode 100644 index 00000000..bfcea2a5 --- /dev/null +++ b/Justfile.skills.just @@ -0,0 +1,11 @@ +skills_project := _solution + +# Update agent skills: re-add every skills-lock.json source, refreshing installed skills and adding new ones +[group('Agent skills')] +skills-update *args: + dotnet msbuild "{{ skills_project }}" "-t:UpdatePurviewSkills;FixPurviewSkills" -v:n {{ args }} + +# Check that only the skill directories of the agents in PurviewSkillsAgents exist +[group('Agent skills')] +skills-check *args: + dotnet msbuild "{{ skills_project }}" -t:CheckPurviewSkills -v:n {{ args }} diff --git a/README.md b/README.md index f1d4fc81..66821ccc 100644 --- a/README.md +++ b/README.md @@ -33,28 +33,22 @@ architecture diagram and owns the dashboard integration. Everything else hangs o The same split applies to dependencies: the ER diagram needs the projects that own your `DbContext` types to be registered with `AddProject(...)`. -### Target frameworks +## Requirements -| Package | Target framework | Shipped as | -| --- | --- | --- | -| `AspireC4.Hosting` | `net10.0` | `lib/net10.0/` library | -| `AspireC4.Hosting.EntityFrameworkCore` | `net10.0` | `lib/net10.0/` library | -| `AspireC4.EntityFrameworkCore` | `netstandard2.0` | `analyzers/dotnet/cs/` — analyzer-only, no runtime assembly | -| `AspireC4.EntityFrameworkCore.ModelDump` | `net10.0` | `tools/` — a build-time tool invoked by MSBuild | - -The AppHost consumes the two libraries, so an AppHost must target **`net10.0`**. The analyzer package is -`netstandard2.0` so it loads into any compiler, and it adds no assembly to your output; the model dump is -a `net10.0` tool run by the build, so the build machine needs a .NET 10 runtime. - -## Prerequisites - -- **.NET 10 SDK or later.** Every shipping package targets `net10.0`, and an Aspire 13.5 AppHost - targets `net10.0` too. -- **Aspire 13.5.3 or later**. Aspire AppHost projects must reference both the AppHost SDK and +- **The .NET 11 SDK** to build. The AppHost itself can target `net10.0` or `net11.0`: the libraries + ship both assets. The SDK requirement comes from the bundled source generators, which run inside the + compiler and are built against Roslyn 5.9. An older SDK reports `CS9057` and skips them, which + drops the `ASPIREC4xxx` validation and the `[LikeC4Registry]` types. +- **Aspire 13.6.5 or later**. Aspire AppHost projects must reference both the AppHost SDK and `Aspire.Hosting.AppHost`. - **Docker** is used by default to run the LikeC4 sidecar container. - Optional: a local Node.js CLI runtime (`npx`, `pnpm`, `yarn`, `bun`, or `deno`) if you call `.WithLocalCLI()`. +**.NET 8 and .NET 9 are not supported.** [Requirements](docs/wiki/Requirements.md) owns the detail: the +target frameworks each package ships, what each optional package needs, and what happens when a +requirement is missing. How this repository is built is in +[Toolchain and support matrix](docs/wiki/Toolchain.md). + ## Installation Add AspireC4 to the AppHost project: @@ -64,10 +58,10 @@ dotnet add package AspireC4.Hosting dotnet add package Aspire.Hosting.AppHost ``` -An Aspire 13.5 AppHost project should contain the equivalent of: +An AppHost project should contain the equivalent of: ```xml - + @@ -214,33 +208,38 @@ Nothing is rendered — and **no container is started** — unless the output wo is fire-and-forget, so on publish it would be killed part-way through — and publish is expected to be hermetic, which reaching for a container runtime a build agent may not have would break. -#### Committing the images +#### Source control -The images are the one piece of generated output worth tracking: a diagram that shows up in a pull -request is documentation, and because export only runs when the diagram changes they do not churn. -Ignore the generated directory but carve the images out of it: +You need no `.gitignore` entry. AspireC4 writes one beside each thing it generates, naming exactly the +files it writes, and leaves a `.gitignore` it did not write alone: -```gitignore -# Note the trailing /* rather than /: git will not descend into an excluded directory, so the -# children have to be excluded individually for the re-include to be reachable. -**/.aspirec4/* -!**/.aspirec4/images/ -# The stamp recording what the images were rendered from is a local cache, not documentation. -**/.aspirec4/images/.aspirec4-* -``` +- `likec4/` and `erd/` — the generated models and viewer are ignored. The architecture model carries + this run's ports, links and run states, so tracking it would dirty every working tree. +- `images/` — only the `.aspirec4-*` stamps are ignored. **The images stay visible to git**: a diagram + in a pull request is documentation, and an image is replaced only when its bytes change. + +Every file is also written only when its content changes — compared against the file on disk, and with +no timestamp in the header — so restarting an unchanged application touches nothing. +`WithoutGitIgnoreFileGeneration()` turns the ignore files off for a repository that commits the model +deliberately. See [Source control](docs/wiki/Generated-Output.md#source-control). + +#### Your own LikeC4 files -The two diagrams differ in how well they cache, though both are stable in source control: +Put hand-written `.c4` files — people, external systems, `extend` blocks, extra views — in a +`likec4-extensions/` folder beside the AppHost and they are included automatically. Generated elements +are named after their Aspire resources, so those files can extend and reference them. This replaces a +separate LikeC4 project reading a committed model. See +[Extending the generated model](docs/wiki/Generated-Output.md#extending-the-generated-model). + +The two diagrams differ in how well their image export caches: - **ER diagram** — fully cached. Derived from your EF model alone, so restarting the AppHost over an unchanged schema renders nothing at all. This is the one that matters most, since it is also the one that can pull a browser image under `WithViewerFidelityExport()`. -- **Architecture diagram** — re-rendered on each run, but to identical bytes. Its model is a picture of - a *running* system: the endpoint ports and the run-state tags settle during startup, so the model the - first export sees is not quite the one the last export sees. The stamp already ignores the ephemeral - ports; the remaining difference is the run states, and those genuinely change the picture, so they - are not normalised away. Measured across restarts the exported PNGs are byte-for-byte the same, so - git sees no change — the cost is a background render, not a dirty tree. - +- **Architecture diagram** — usually re-rendered on each run, to the same pixels. Its model is a picture + of a *running* system, and the run-state tags settle during startup, so the stamp rarely matches; a + re-render that comes out identical leaves the existing image file untouched. The cost is a + background render, not a changed file. Set `ContainerRuntimeCommand` to `podman` where that is the runtime Aspire itself is using. ## Upgrading from before 13.6 @@ -248,7 +247,7 @@ Set `ContainerRuntimeCommand` to `podman` where that is the runtime Aspire itsel 13.6 changed two things, both breaking: - Generated output moved under one root: `./.aspirec4/likec4/` (was `./likec4/gen/`) and - `./.aspirec4/erd/` (was `./erd/gen/`). Replace your `.gitignore` entries with `**/.aspirec4/`. + `./.aspirec4/erd/` (was `./erd/gen/`). Remove your old `.gitignore` entries: AspireC4 now writes its own. - When more than one diagram is registered, they nest under a shared dashboard parent and the architecture diagram's resource is renamed from `aspirec4` to `aspirec4-likec4`. With only `AddAspireC4()`, nothing changes. @@ -357,14 +356,14 @@ dotnet add package AspireC4.EntityFrameworkCore dotnet add package AspireC4.EntityFrameworkCore.ModelDump ``` -This writes `./.aspirec4/erd/schema.gen.d2`, starts a `d2lang/d2` sidecar running `d2 --watch`, and +This writes `./.aspirec4/erd/schema.gen.json` and its viewer (or `schema.gen.d2` with the d2 renderer), starts a viewer sidecar, and adds an **ER Diagram** link to each contributing project in the dashboard and on its element in the architecture diagram. ### Dashboard layout -Both diagrams write beneath a single `./.aspirec4/` root in the AppHost directory — one directory to -ignore in source control regardless of how many are enabled. +Both diagrams write beneath a single `./.aspirec4/` root in the AppHost directory, and each writes the +`.gitignore` that keeps its generated files out of git. > [!NOTE] > The ER diagram's `./.aspirec4/erd/` default follows the architecture diagram's legacy switch: @@ -397,7 +396,7 @@ aspirec4 # parent row, added only when there is more than one di | `ShowNullability` | `true` | Render nullable columns with a trailing `?` | | `GroupBySchema` | `true` | Group tables into d2 containers by database schema | | `GroupByContext` | `true` | Group by `DbContext` when more than one is discovered | -| `DomainTypeDisplay` | `DomainTypeFirst` | How a column shows its domain type — a value object or an enum — beside the database type: `DomainTypeFirst` (`AssetId (guid)`, `ActorType (int, enum)`), `DatabaseFirst`, `DomainTypeOnly`, `DatabaseOnly` | +| `DomainTypeDisplay` | `DomainTypeFirst` | How a column shows its domain type — a value object, an enum or a union — beside the database type: `DomainTypeFirst` (`AssetId (guid)`, `ActorType (int, enum)`), `DatabaseFirst`, `DomainTypeOnly`, `DatabaseOnly` | | `ContainerImageTag` | `null` (`v0.9.0`) | Pin the [`d2lang/d2`](https://hub.docker.com/r/d2lang/d2/) image tag | | `UseViewerServer` | `true` | Serve the viewer so the dashboard can link to it; set `false` where there is no container runtime | | `ViewerServerImageTag` | `null` (`1.29-alpine`) | Pin the `nginx` image tag used to serve the viewer | @@ -408,6 +407,7 @@ aspirec4 # parent row, added only when there is more than one di | `ImageExportDirectory` | `null` (`./.aspirec4/images/`) | Where images are written; relative paths resolve against the AppHost directory. Shared with the architecture diagram | | `D2Command` | `null` | A local `d2` binary to export with; `null` runs the container. Set by `WithLocalD2CLI()` | | `ContainerRuntimeCommand` | `"docker"` | Runtime for the one-shot export container; `podman` where that is what Aspire uses | +| `GenerateGitIgnoreFile` | `true` | Write a `.gitignore` naming the generated files. `WithoutGitIgnoreFileGeneration()` | | `ExportTimeoutSeconds` | `120` | How long to wait for one d2 invocation before abandoning it | | `UseViewerFidelityExport` | `false` | Render images through the viewer in a headless browser so they match its own export. Pulls a ~3.7 GB browser image; `Interactive` only | | `ViewerExportImage` | `null` | Override the browser image, including its tag | @@ -585,13 +585,16 @@ match for the migrated schema. `[NotMapped]`, `[Index(IsUnique = true)]`. - EF naming conventions for primary keys (`Id` / `Id`) and foreign keys (` public bool GenerateConfigFile { get; set; } = true; + /// + /// When (default), writes a .gitignore beside the generated files so + /// that git ignores them without any entry in the repository's own .gitignore. + /// + /// + /// + /// The generated model embeds values that legitimately differ between runs — the endpoint ports + /// Aspire allocates, dashboard links, and each resource's run state — so committing it guarantees a + /// dirty working tree. The file names exactly what AspireC4 writes (the model, the generated + /// likec4.config.json, copies of ), anchored to the output + /// directory, so a directory shared with hand-written files does not hide them. Exported images are + /// not ignored, only the stamp files recording what they were rendered from: the images are + /// stable and are the part of the output worth committing. + /// + /// + /// An existing .gitignore that AspireC4 did not write is never touched. Turn this off when the + /// generated model is deliberately committed, for example because a separate LikeC4 project builds + /// from it; files are written only when their content changes, so nothing is rewritten on a restart + /// of an unchanged application. Prefer for that case — it keeps the + /// hand-written files beside the generated model without committing the generated model. + /// + /// + public bool GenerateGitIgnoreFile { get; set; } = true; + + /// The default , relative to the AppHost directory. + public const string DefaultExtensionsDirectory = "./likec4-extensions"; + + /// + /// A folder of hand-written LikeC4 (.c4) files, relative to the AppHost directory, that is + /// included in the diagram automatically when it exists. Defaults to + /// ; or empty turns the convention off. + /// + /// + /// + /// This is the place for anything the Aspire resource graph cannot describe: extend blocks + /// that add descriptions, links or relationships to generated elements (whose identifiers are the + /// Aspire resource names), external systems, people, and extra views. It lives outside the + /// generated output, so it is committed while the generated model is ignored (see + /// ). + /// + /// + /// The folder is added to when the options are resolved, so it is + /// referenced from the generated likec4.config.json and mounted into the LikeC4 container + /// exactly like a folder registered with + /// . A missing folder is not + /// an error. It does not apply with : a hand-authored LikeC4 project + /// already holds its own files, and AspireC4 writes no config into it. + /// + /// + public string? ExtensionsDirectory { get; set; } = DefaultExtensionsDirectory; + /// /// When , adds links from each LikeC4 element back to the Aspire dashboard /// console logs and structured logs pages for that resource. The links are constructed at runtime diff --git a/src/src/AspireC4/Extensions/Aspire/Hosting/AspireC4DiagramOptionsExtensions.cs b/src/src/AspireC4/Extensions/Aspire/Hosting/AspireC4DiagramOptionsExtensions.cs index dc4ecfd1..ca69dab4 100644 --- a/src/src/AspireC4/Extensions/Aspire/Hosting/AspireC4DiagramOptionsExtensions.cs +++ b/src/src/AspireC4/Extensions/Aspire/Hosting/AspireC4DiagramOptionsExtensions.cs @@ -261,6 +261,46 @@ public static AspireC4DiagramOptions WithoutConfigFileGeneration([NotNull] this return options; } + /// + /// Stops AspireC4 writing a .gitignore beside its generated files, for a repository that + /// commits the generated model deliberately. + /// + /// + [AspireExport] + public static AspireC4DiagramOptions WithoutGitIgnoreFileGeneration([NotNull] this AspireC4DiagramOptions options) + { + options.GenerateGitIgnoreFile = false; + return options; + } + + /// + /// Sets the folder of hand-written .c4 files that is included in the diagram automatically + /// when it exists, in place of the default ./likec4-extensions. + /// + /// + /// A path relative to the AppHost directory, or an absolute path. + /// + [AspireExport] + public static AspireC4DiagramOptions WithExtensionsFolder( + [NotNull] this AspireC4DiagramOptions options, + string folderPath + ) + { + ArgumentException.ThrowIfNullOrWhiteSpace(folderPath); + + options.ExtensionsDirectory = folderPath; + return options; + } + + /// Turns off the automatic inclusion of the ./likec4-extensions folder. + /// + [AspireExport] + public static AspireC4DiagramOptions WithoutExtensionsFolder([NotNull] this AspireC4DiagramOptions options) + { + options.ExtensionsDirectory = null; + return options; + } + /// Enables or disables Aspire dashboard links on generated diagram elements. /// [AspireExport] diff --git a/src/src/AspireC4/Extensions/Aspire/Hosting/AspireC4DistributedApplicationBuilderExtensions.cs b/src/src/AspireC4/Extensions/Aspire/Hosting/AspireC4DistributedApplicationBuilderExtensions.cs index 10934daa..a4c01ba0 100644 --- a/src/src/AspireC4/Extensions/Aspire/Hosting/AspireC4DistributedApplicationBuilderExtensions.cs +++ b/src/src/AspireC4/Extensions/Aspire/Hosting/AspireC4DistributedApplicationBuilderExtensions.cs @@ -67,6 +67,13 @@ public static IResourceBuilder AddAspireC4( .Configure(options => configure?.Invoke(options)) .BindConfiguration(AspireC4DiagramOptions.SectionName); + // After binding, so an AspireC4:ExtensionsDirectory setting is honoured; registered here rather than + // in the callback so the convention applies with no configure call at all. + var appHostDirectory = builder.AppHostDirectory; + builder.Services.PostConfigure(resolved => + LikeC4ProjectLayout.IncludeExtensionsFolder(appHostDirectory, resolved) + ); + var (outputDir, serveDir) = ResolveLayout(builder, options); var imageTag = options.ContainerImageTag ?? LikeC4ServerResource.DefaultTag; @@ -200,16 +207,13 @@ LikeC4ServerResource serverResource targetPort: AspireC4Resource.DefaultPort, name: AspireC4Resource.HttpEndpointName ) - .WithUrlForEndpoint( - AspireC4Resource.HttpEndpointName, - opts => - { - opts.DisplayText = "View LikeC4 Diagram"; - //opts.DisplayOrder = 0; - opts.DisplayLocation = UrlDisplayLocation.SummaryAndDetails; - opts.Url = string.IsNullOrWhiteSpace(defaultViewId) ? "/" : $"/view/{defaultViewId}"; - } - ) + .WithUrlForEndpoint(AspireC4Resource.HttpEndpointName, opts => + { + opts.DisplayText = "View LikeC4 Diagram"; + //opts.DisplayOrder = 0; + opts.DisplayLocation = UrlDisplayLocation.SummaryAndDetails; + opts.Url = string.IsNullOrWhiteSpace(defaultViewId) ? "/" : $"/view/{defaultViewId}"; + }) .WithHttpHealthCheck("/", statusCode: 200, endpointName: AspireC4Resource.HttpEndpointName) // Register container args as a callback so they are evaluated at container-start // time (after BeforeStartEvent has set ContainerServePath). DisableHMR is read @@ -273,15 +277,12 @@ IResourceBuilder serverBuilder { serverBuilder .WithHttpEndpoint(port: hmrHostPort, targetPort: resolvedHmrPort, name: AspireC4Resource.HMREndpointName) - .WithUrlForEndpoint( - AspireC4Resource.HMREndpointName, - opts => - { - opts.DisplayText = "LikeC4 HMR Endpoint"; - //opts.DisplayOrder = 1; - opts.DisplayLocation = UrlDisplayLocation.DetailsOnly; - } - ); + .WithUrlForEndpoint(AspireC4Resource.HMREndpointName, opts => + { + opts.DisplayText = "LikeC4 HMR Endpoint"; + //opts.DisplayOrder = 1; + opts.DisplayLocation = UrlDisplayLocation.DetailsOnly; + }); if (OperatingSystem.IsWindows()) { diff --git a/src/src/AspireC4/Extensions/Aspire/Hosting/AspireC4ResourceExtensions.cs b/src/src/AspireC4/Extensions/Aspire/Hosting/AspireC4ResourceExtensions.cs index 33607b69..c91b5494 100644 --- a/src/src/AspireC4/Extensions/Aspire/Hosting/AspireC4ResourceExtensions.cs +++ b/src/src/AspireC4/Extensions/Aspire/Hosting/AspireC4ResourceExtensions.cs @@ -169,8 +169,7 @@ Action> configure { ArgumentNullException.ThrowIfNull(configure); - var innerResource = - builder.Resource.InnerResource + var innerResource = builder.Resource.InnerResource ?? throw new InvalidOperationException( $"The inner server resource has not been initialised yet. Ensure {nameof(AspireC4DistributedApplicationBuilderExtensions.AddAspireC4)}() has completed before calling {nameof(ConfigureServer)}()." ); diff --git a/src/src/AspireC4/GeneratedOutput.cs b/src/src/AspireC4/GeneratedOutput.cs new file mode 100644 index 00000000..6f03d343 --- /dev/null +++ b/src/src/AspireC4/GeneratedOutput.cs @@ -0,0 +1,173 @@ +namespace Aspire.Hosting.AspireC4; + +/// +/// Writes AspireC4's generated files so that an unchanged application leaves the working tree untouched, +/// and records which of them git should ignore. +/// +/// +/// +/// The in-memory "has the diagram changed" checks start empty every time the AppHost does, so on their +/// own they rewrite every file on the first write of each run. Comparing against the bytes already on +/// disk is what keeps a restart of an unchanged application from touching anything — content or +/// timestamp — which is what makes the output safe to commit and quiet for file watchers. +/// +/// +/// The .gitignore files name exactly the files AspireC4 writes, anchored to their directory, +/// rather than ignoring everything with *. An output directory can be one the user shares with +/// their own files (WithOutputDirectory, WithImageExportDirectory), and a blanket rule there +/// would silently hide their work from source control. +/// +/// +static class GeneratedOutput +{ + /// The file name of the ignore file written beside the output. + internal const string GitIgnoreFileName = ".gitignore"; + + /// + /// The first line of every ignore file AspireC4 writes. A .gitignore that does not start with + /// it belongs to the user and is never overwritten. + /// + internal const string GitIgnoreMarker = "# Written by AspireC4."; + + /// Writes unless the file already holds exactly that text. + /// when the file was written. + internal static async Task WriteIfChangedAsync( + string path, + string content, + CancellationToken cancellationToken + ) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + ArgumentNullException.ThrowIfNull(content); + + if (File.Exists(path)) + { + var existing = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false); + if (string.Equals(existing, content, StringComparison.Ordinal)) + { + return false; + } + } + + await File.WriteAllTextAsync(path, content, cancellationToken).ConfigureAwait(false); + + return true; + } + + /// Copies unless the destination already has the same bytes. + /// when the file was copied. + internal static bool CopyIfChanged(string source, string destination) + { + ArgumentException.ThrowIfNullOrWhiteSpace(source); + ArgumentException.ThrowIfNullOrWhiteSpace(destination); + + if (File.Exists(destination)) + { + FileInfo sourceInfo = new(source); + FileInfo destinationInfo = new(destination); + + if ( + sourceInfo.Length == destinationInfo.Length + && File.ReadAllBytes(source).AsSpan().SequenceEqual(File.ReadAllBytes(destination)) + ) + { + return false; + } + } + + File.Copy(source, destination, overwrite: true); + + return true; + } + + /// + /// Writes, or refreshes, the .gitignore that tells git to ignore in + /// . + /// + /// The output directory the entries live in. + /// One line saying what the ignored files are, written as a comment. + /// + /// File names or patterns relative to . A plain name is anchored with a + /// leading / so it never matches a same-named file further down; a trailing / marks a + /// directory. A pattern containing * is written as given. + /// + /// Cancelled on shutdown. + internal static async Task WriteGitIgnoreAsync( + string directory, + string purpose, + IEnumerable entries, + CancellationToken cancellationToken + ) + { + ArgumentException.ThrowIfNullOrWhiteSpace(directory); + ArgumentException.ThrowIfNullOrWhiteSpace(purpose); + ArgumentNullException.ThrowIfNull(entries); + + var path = Path.Combine(directory, GitIgnoreFileName); + + if (File.Exists(path)) + { + var existing = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false); + if (!existing.StartsWith(GitIgnoreMarker, StringComparison.Ordinal)) + { + return GitIgnoreWrite.KeptUserFile; + } + } + + Directory.CreateDirectory(directory); + + var written = await WriteIfChangedAsync(path, BuildGitIgnore(purpose, entries), cancellationToken) + .ConfigureAwait(false); + + return written ? GitIgnoreWrite.Written : GitIgnoreWrite.Unchanged; + } + + /// Builds the ignore file's content. Exposed for tests. + /// + /// Always \n, and the entries are de-duplicated and sorted ordinally, so the file is identical + /// on every machine and every run. The file ignores itself: it is regenerated on each run, so + /// committing it would only add a file nobody edits. + /// + internal static string BuildGitIgnore(string purpose, IEnumerable entries) + { + var lines = entries + .Where(e => !string.IsNullOrWhiteSpace(e)) + .Select(Anchor) + .Append("/" + GitIgnoreFileName) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal); + + return string.Join( + '\n', + [ + GitIgnoreMarker, + "# " + purpose, + "# Regenerated on every run; to stop AspireC4 writing it, turn off GenerateGitIgnoreFile.", + .. lines, + string.Empty, + ] + ); + } + + static string Anchor(string entry) + { + var normalised = entry.Trim().Replace('\\', '/'); + + return normalised.Contains('*', StringComparison.Ordinal) || normalised.StartsWith('/') + ? normalised + : "/" + normalised; + } +} + +/// What did. +enum GitIgnoreWrite +{ + /// The file was created or its content changed. + Written, + + /// The file already held exactly this content. + Unchanged, + + /// A .gitignore AspireC4 did not write was already there, and was left alone. + KeptUserFile, +} diff --git a/src/src/AspireC4/ImageExportStamp.cs b/src/src/AspireC4/ImageExportStamp.cs index d3d718e5..449ee253 100644 --- a/src/src/AspireC4/ImageExportStamp.cs +++ b/src/src/AspireC4/ImageExportStamp.cs @@ -110,10 +110,27 @@ CancellationToken cancellationToken /// Written last for the same reason the vendor stamp is: a run interrupted part-way through leaves /// no stamp, so the next one re-exports rather than trusting a half-written directory. /// - internal static Task WriteAsync( + internal static Task WriteAsync( string directory, string diagram, string stamp, CancellationToken cancellationToken - ) => File.WriteAllTextAsync(Path.Combine(directory, FileName(diagram)), stamp, cancellationToken); + ) => GeneratedOutput.WriteIfChangedAsync(Path.Combine(directory, FileName(diagram)), stamp, cancellationToken); + + /// + /// Makes git ignore the stamps in , and nothing else. + /// + /// + /// The images are the stable, reviewable part of the output and stay visible to git. The stamps are + /// a local cache of what they were rendered from, and one pattern covers both diagrams' stamps, so + /// either diagram can write it. Called before the "already current" check: a directory whose images + /// are up to date never re-exports, and would otherwise never get the file. + /// + internal static Task WriteGitIgnoreAsync(string directory, CancellationToken cancellationToken) => + GeneratedOutput.WriteGitIgnoreAsync( + directory, + "Stamps recording what AspireC4's exported images were rendered from. The images themselves are not ignored.", + [".aspirec4-*"], + cancellationToken + ); } diff --git a/src/src/AspireC4/Lifecycle/AspireC4LifecycleHook.BackgroundWatcher.cs b/src/src/AspireC4/Lifecycle/AspireC4LifecycleHook.BackgroundWatcher.cs index 1db8d8b5..251cbb39 100644 --- a/src/src/AspireC4/Lifecycle/AspireC4LifecycleHook.BackgroundWatcher.cs +++ b/src/src/AspireC4/Lifecycle/AspireC4LifecycleHook.BackgroundWatcher.cs @@ -61,8 +61,7 @@ async Task WatchDashboardUrlAsync(DistributedApplicationModel appModel, Cancella // Primary strategy: endpoint name "https"/"http" identifies the browser frontend. // This matches how Aspire itself selects the public frontend URL in PopulateDashboardUrls. - var rawUrl = - list.FirstOrDefault(u => u.Name == "https")?.Url + var rawUrl = list.FirstOrDefault(u => u.Name == "https")?.Url ?? list.FirstOrDefault(u => u.Name == "http")?.Url // Fallbacks for future-proofing in case endpoint names change. ?? list.FirstOrDefault(u => u.Url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))?.Url diff --git a/src/src/AspireC4/Lifecycle/AspireC4LifecycleHook.DashboardIntegration.cs b/src/src/AspireC4/Lifecycle/AspireC4LifecycleHook.DashboardIntegration.cs index 7378b12e..8c4afbeb 100644 --- a/src/src/AspireC4/Lifecycle/AspireC4LifecycleHook.DashboardIntegration.cs +++ b/src/src/AspireC4/Lifecycle/AspireC4LifecycleHook.DashboardIntegration.cs @@ -14,8 +14,7 @@ CancellationToken cancellationToken ) { var aspirec4Resource = appModel.Resources.OfType().FirstOrDefault(); - var serverResource = - aspirec4Resource?.InnerResource + var serverResource = aspirec4Resource?.InnerResource ?? appModel.Resources.FirstOrDefault(r => r is LikeC4ServerResource or LikeC4LocalServerResource); if (serverResource is null) @@ -105,28 +104,25 @@ CancellationToken cancellationToken var capturedUrl = url; foreach (var resource in appModel.Resources.OfType()) { - await resourceNotificationService.PublishUpdateAsync( - resource, - s => - // Avoid duplicates on repeated Running notifications. - s.Urls.Any(u => u.Name == "architecture-diagram") - ? s - : ( - s with - { - Urls = s.Urls.Add( - new UrlSnapshot( - Name: "architecture-diagram", - Url: capturedUrl, - IsInternal: false - ) - { - DisplayProperties = new UrlDisplayPropertiesSnapshot(displayName), - } - ), - } - ) - ); + await resourceNotificationService.PublishUpdateAsync(resource, s => + // Avoid duplicates on repeated Running notifications. + s.Urls.Any(u => u.Name == "architecture-diagram") + ? s + : ( + s with + { + Urls = s.Urls.Add( + new UrlSnapshot( + Name: "architecture-diagram", + Url: capturedUrl, + IsInternal: false + ) + { + DisplayProperties = new UrlDisplayPropertiesSnapshot(displayName), + } + ), + } + )); } break; // Only inject once — the URL persists in the snapshot. @@ -272,15 +268,13 @@ async Task ForwardInnerResourceStateAsync(AspireC4Resource outerResource, Cancel if (notification.Resource.Name != innerResource.Name) continue; - await resourceNotificationService.PublishUpdateAsync( - outerResource, - s => - s with - { - State = notification.Snapshot.State, - Urls = notification.Snapshot.Urls, - Properties = WithLikeC4Version(s.Properties), - } + await resourceNotificationService.PublishUpdateAsync(outerResource, s => + s with + { + State = notification.Snapshot.State, + Urls = notification.Snapshot.Urls, + Properties = WithLikeC4Version(s.Properties), + } ); } } diff --git a/src/src/AspireC4/Lifecycle/AspireC4LifecycleHook.FileGeneration.cs b/src/src/AspireC4/Lifecycle/AspireC4LifecycleHook.FileGeneration.cs index 73f278dd..aa5029c6 100644 --- a/src/src/AspireC4/Lifecycle/AspireC4LifecycleHook.FileGeneration.cs +++ b/src/src/AspireC4/Lifecycle/AspireC4LifecycleHook.FileGeneration.cs @@ -51,18 +51,22 @@ async Task WriteC4FileAsync(DistributedApplicationModel appModel, CancellationTo var outputPath = Path.Combine(outputDir, filename); - // Skip writing (and formatting) when only the timestamp changed — i.e. the stable body - // is identical to what was last written. This prevents needless git noise when state - // changes bounce resources through transitional states that end up back where they started. + // Two checks, for two different reasons. The in-memory one decides whether this is a new model at + // all — it gates formatting and image export, and absorbs state changes that bounce a resource + // through a transitional state and back. The on-disk one decides whether anything is written: the + // in-memory value starts empty on every run, so without it each restart of an unchanged + // application would rewrite the file and touch the working tree. var newRawBody = StripHeader(dsl); var changed = !string.Equals(_lastRawBody, newRawBody, StringComparison.Ordinal); if (changed) { _lastRawBody = newRawBody; - await File.WriteAllTextAsync(outputPath, dsl, cancellationToken); // Format the generated file in-place (best-effort) so the on-disk file is human-readable. - if (opts.FormatGeneratedFile) + if ( + await GeneratedOutput.WriteIfChangedAsync(outputPath, dsl, cancellationToken) + && opts.FormatGeneratedFile + ) { await RunFormatAsync(outputPath, outputDir, cancellationToken); } @@ -70,7 +74,7 @@ async Task WriteC4FileAsync(DistributedApplicationModel appModel, CancellationTo // Copy additional user-provided DSL files into the output directory so LikeC4 // picks them up as part of the workspace. - List additionalDestPaths = []; + List generatedFileNames = [filename]; foreach (var sourcePath in opts.AdditionalDSLFiles) { var absoluteSource = Path.GetFullPath(sourcePath); @@ -80,17 +84,24 @@ async Task WriteC4FileAsync(DistributedApplicationModel appModel, CancellationTo } var destFileName = Path.GetFileName(absoluteSource); - var destPath = Path.Combine(outputDir, destFileName); - File.Copy(absoluteSource, destPath, overwrite: true); - additionalDestPaths.Add(destPath); + generatedFileNames.Add(destFileName); - telemetry.AdditionalDSLFileSynced(Path.GetFileNameWithoutExtension(absoluteSource)); + if (GeneratedOutput.CopyIfChanged(absoluteSource, Path.Combine(outputDir, destFileName))) + { + telemetry.AdditionalDSLFileSynced(Path.GetFileNameWithoutExtension(absoluteSource)); + } } // Generate likec4.config.json when opted in (default) and AspireC4 owns the served project. if (opts.GenerateConfigFile && !LikeC4ProjectLayout.IsHandAuthoredProject(serveDir, outputDir)) { await WriteConfigFileAsync(opts, outputDir, cancellationToken); + generatedFileNames.Add(LikeC4ProjectLayout.ConfigFileName); + } + + if (opts.GenerateGitIgnoreFile) + { + await WriteGitIgnoreAsync(outputDir, generatedFileNames, cancellationToken); } telemetry.LikeC4ModelWritten(outputPath); @@ -237,7 +248,26 @@ CancellationToken cancellationToken aliases, opts.ConfigFileMetadata ); - var configPath = Path.Combine(outputDir, "likec4.config.json"); - await File.WriteAllTextAsync(configPath, config, cancellationToken); + var configPath = Path.Combine(outputDir, LikeC4ProjectLayout.ConfigFileName); + await GeneratedOutput.WriteIfChangedAsync(configPath, config, cancellationToken); + } + + async Task WriteGitIgnoreAsync( + string outputDir, + IEnumerable generatedFileNames, + CancellationToken cancellationToken + ) + { + var result = await GeneratedOutput.WriteGitIgnoreAsync( + outputDir, + "The architecture diagram's generated LikeC4 model. Hand-written .c4 files are not listed.", + generatedFileNames, + cancellationToken + ); + + if (result == GitIgnoreWrite.KeptUserFile) + { + telemetry.GitIgnoreFileKept(Path.Combine(outputDir, GeneratedOutput.GitIgnoreFileName)); + } } } diff --git a/src/src/AspireC4/Lifecycle/AspireC4LifecycleHook.cs b/src/src/AspireC4/Lifecycle/AspireC4LifecycleHook.cs index 18a9ca43..7768039f 100644 --- a/src/src/AspireC4/Lifecycle/AspireC4LifecycleHook.cs +++ b/src/src/AspireC4/Lifecycle/AspireC4LifecycleHook.cs @@ -61,11 +61,7 @@ IConfiguration configuration // When non-null, it is injected as a "Version" metadata property on the AspireC4Resource. volatile string? _resolvedLikeC4Version; -#if NET9_0_OR_GREATER readonly Lock _debounceLock = new(); -#else - readonly object _debounceLock = new(); -#endif public Task SubscribeAsync( IDistributedApplicationEventing eventing, @@ -147,9 +143,11 @@ CancellationToken cancellationToken // dashboard shows it before the inner container emits its first state notification. if (_resolvedLikeC4Version is not null) { - await resourceNotificationService.PublishUpdateAsync( - aspirec4Resource, - s => s with { Properties = WithLikeC4Version(s.Properties) } + await resourceNotificationService.PublishUpdateAsync(aspirec4Resource, s => + s with + { + Properties = WithLikeC4Version(s.Properties), + } ); } diff --git a/src/src/AspireC4/Lifecycle/IAspireC4LifecycleHookTelemetry.cs b/src/src/AspireC4/Lifecycle/IAspireC4LifecycleHookTelemetry.cs index 14fa1784..34031503 100644 --- a/src/src/AspireC4/Lifecycle/IAspireC4LifecycleHookTelemetry.cs +++ b/src/src/AspireC4/Lifecycle/IAspireC4LifecycleHookTelemetry.cs @@ -32,6 +32,9 @@ interface IAspireC4LifecycleHookTelemetry [Debug] void AdditionalDSLFileSynced(string fileName); + [Info] + void GitIgnoreFileKept(string path); + [Warning] void FailedToRunFormatter(Exception ex); diff --git a/src/src/AspireC4/LikeC4/Generators/LikeC4DSLGenerator.cs b/src/src/AspireC4/LikeC4/Generators/LikeC4DSLGenerator.cs index 6665a39b..1c4882a0 100644 --- a/src/src/AspireC4/LikeC4/Generators/LikeC4DSLGenerator.cs +++ b/src/src/AspireC4/LikeC4/Generators/LikeC4DSLGenerator.cs @@ -16,10 +16,10 @@ public static string Generate(LikeC4Model model, AspireC4DiagramOptions options) StringBuilder sb = new(512); - var now = DateTimeOffset.UtcNow; + // No timestamp: the file is rewritten only when its content changes, so an unchanged application + // leaves it byte-for-byte identical across runs rather than dirtying every working tree. sb.AppendLine("// ============================================================================="); - sb.Append("// AUTO-GENERATED by AspireC4 — ") - .AppendLine(now.ToString("yyyy-MM-dd HH:mm:ss 'UTC'", System.Globalization.CultureInfo.InvariantCulture)); + sb.AppendLine("// AUTO-GENERATED by AspireC4"); sb.AppendLine("// Do not edit this file manually. Any changes will be overwritten on the"); sb.AppendLine("// next diagram regeneration (on Aspire startup or resource state change)."); sb.AppendLine("// See https://github.com/purview-dev/aspirec4 for more information."); @@ -40,7 +40,9 @@ static void WriteSpecification(StringBuilder sb, LikeC4Model model, AspireC4Diag // Elements extending a hand-authored project declare no kind of their own — the project already // declares it, and re-declaring it here would collide with that declaration. var elementKindsInModel = model.Elements.Where(e => !e.ExtendsExistingElement).Select(e => e.Kind).ToHashSet(); - var allElementKinds = elementKindsInModel.Union(options.ElementKindSpecs.Select(s => s.Name)).OrderBy(k => k); + var allElementKinds = elementKindsInModel + .Union(options.ElementKindSpecs.Select(s => s.Name)) + .OrderBy(k => k, StringComparer.Ordinal); var relationshipKindsInModel = model .Relationships.Where(r => !string.IsNullOrWhiteSpace(r.Kind)) @@ -48,7 +50,7 @@ static void WriteSpecification(StringBuilder sb, LikeC4Model model, AspireC4Diag .ToHashSet(StringComparer.OrdinalIgnoreCase); var allRelationshipKinds = relationshipKindsInModel .Union(options.RelationshipKindSpecs.Select(s => s.Name), StringComparer.OrdinalIgnoreCase) - .OrderBy(k => k); + .OrderBy(k => k, StringComparer.Ordinal); var relationshipKindSpecsByName = options.RelationshipKindSpecs.ToDictionary( s => s.Name, @@ -63,7 +65,7 @@ static void WriteSpecification(StringBuilder sb, LikeC4Model model, AspireC4Diag .Concat(model.Relationships.SelectMany(r => r.Tags)) .Concat(knownStateTags) .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(t => t); + .OrderBy(t => t, StringComparer.Ordinal); var specsByName = options.ElementKindSpecs.ToDictionary(s => s.Name, StringComparer.OrdinalIgnoreCase); @@ -494,7 +496,10 @@ static void WriteViews(StringBuilder sb, LikeC4Model model, AspireC4DiagramOptio else { // Groups must be declared before `include *` or they will not render correctly. - var groups = model.Elements.Where(e => e.Group is not null).GroupBy(e => e.Group!).OrderBy(g => g.Key); + var groups = model + .Elements.Where(e => e.Group is not null) + .GroupBy(e => e.Group!) + .OrderBy(g => g.Key, StringComparer.Ordinal); foreach (var group in groups) { @@ -542,18 +547,13 @@ static void WriteViews(StringBuilder sb, LikeC4Model model, AspireC4DiagramOptio static string SanitizeReference(string name, bool isFullyQualified) => isFullyQualified ? string.Join('.', name.Split('.').Select(Sanitize)) : Sanitize(name); - static string Sanitize(string name) => - string.Create( - name.Length, - name, - static (span, src) => + static string Sanitize(string name) => string.Create(name.Length, name, static (span, src) => + { + for (var i = 0; i < src.Length; i++) { - for (var i = 0; i < src.Length; i++) - { - span[i] = char.IsLetterOrDigit(src[i]) || src[i] == '_' ? src[i] : '_'; - } + span[i] = char.IsLetterOrDigit(src[i]) || src[i] == '_' ? src[i] : '_'; } - ); + }); /// Escapes single quotes in string values used inside LikeC4 quoted strings. static string EscapeQuote(string value) => value.Replace("'", "\\'", StringComparison.Ordinal); diff --git a/src/src/AspireC4/LikeC4/Icons/LikeC4IconManifest.cs b/src/src/AspireC4/LikeC4/Icons/LikeC4IconManifest.cs index 4fcd3c2d..24a267fc 100644 --- a/src/src/AspireC4/LikeC4/Icons/LikeC4IconManifest.cs +++ b/src/src/AspireC4/LikeC4/Icons/LikeC4IconManifest.cs @@ -5,11 +5,13 @@ namespace Aspire.Hosting.AspireC4.LikeC4.Icons; [JsonSerializable(typeof(LikeC4IconManifest))] sealed partial class IconMatcherJsonContext : JsonSerializerContext { } +// Settable rather than init-only: for an init-only property System.Text.Json emits a `safe extern` +// [UnsafeAccessor] under the updated memory-safety rules, and `safe` is still preview in C# 15.0. sealed class LikeC4IconManifest { [JsonPropertyName("generatedAt")] - public string GeneratedAt { get; init; } = ""; + public string GeneratedAt { get; set; } = ""; [JsonPropertyName("icons")] - public Dictionary Icons { get; init; } = []; + public Dictionary Icons { get; set; } = []; } diff --git a/src/src/AspireC4/LikeC4/ModelBuilder.cs b/src/src/AspireC4/LikeC4/ModelBuilder.cs index e9506925..1406924e 100644 --- a/src/src/AspireC4/LikeC4/ModelBuilder.cs +++ b/src/src/AspireC4/LikeC4/ModelBuilder.cs @@ -472,17 +472,13 @@ static string NormaliseMetadataKey(string key, NormaliseMetadataBehaviour behavi : key; } - var normalised = string.Create( - key.Length, - key, - static (span, src) => + var normalised = string.Create(key.Length, key, static (span, src) => + { + for (var i = 0; i < src.Length; i++) { - for (var i = 0; i < src.Length; i++) - { - span[i] = IsValidMetadataKeyChar(src[i]) ? src[i] : '_'; - } + span[i] = IsValidMetadataKeyChar(src[i]) ? src[i] : '_'; } - ); + }); return behaviour == NormaliseMetadataBehaviour.NormaliseLowercase ? normalised.ToLowerInvariantSafe() diff --git a/src/src/AspireC4/LikeC4/Runtime/LikeC4ImageExporter.cs b/src/src/AspireC4/LikeC4/Runtime/LikeC4ImageExporter.cs index 1e1771a4..852697fb 100644 --- a/src/src/AspireC4/LikeC4/Runtime/LikeC4ImageExporter.cs +++ b/src/src/AspireC4/LikeC4/Runtime/LikeC4ImageExporter.cs @@ -196,6 +196,11 @@ CancellationToken cancellationToken var imageDirectory = ResolveImageDirectory(options, appHostDirectory); + if (options.GenerateGitIgnoreFile) + { + await ImageExportStamp.WriteGitIgnoreAsync(imageDirectory, cancellationToken).ConfigureAwait(false); + } + // Checked before anything is run. The AppHost's own change detection resets every time it // restarts, so without this a restart re-renders views that are already correct on disk — and // an export starts a LikeC4 server and a headless browser to do it. @@ -305,6 +310,12 @@ static string[] ExistingImages(string imageDirectory) /// Overwrites, so a view that has been re-exported replaces its previous image rather than /// accumulating. A view removed from the model leaves its last image behind, which is the lesser /// evil: emptying the folder would also delete the ER diagram's images sitting beside them. + /// + /// An image that came out byte-identical is left where it is. The run-state tags settle during + /// startup, so the stamp misses and the views are re-rendered on most runs — into the same pixels. + /// Replacing them anyway changed nothing git could see but still touched every file, which is the + /// churn a watcher, a sync client or an IDE reacts to. + /// /// static void MoveWithPrefix(string staging, string imageDirectory) { @@ -315,7 +326,7 @@ static void MoveWithPrefix(string staging, string imageDirectory) AspireC4ImageExport.ArchitecturePrefix + Path.GetFileName(file) ); - File.Move(file, destination, overwrite: true); + GeneratedOutput.CopyIfChanged(file, destination); } } diff --git a/src/src/AspireC4/LikeC4/Runtime/LikeC4ProjectLayout.cs b/src/src/AspireC4/LikeC4/Runtime/LikeC4ProjectLayout.cs index 58bb8bf7..8723845b 100644 --- a/src/src/AspireC4/LikeC4/Runtime/LikeC4ProjectLayout.cs +++ b/src/src/AspireC4/LikeC4/Runtime/LikeC4ProjectLayout.cs @@ -62,6 +62,49 @@ AspireC4DiagramOptions options internal static bool IsHandAuthoredProject(string serveDirectory, string outputDirectory) => !PathsEqual(serveDirectory, outputDirectory); + /// + /// Adds the conventional extensions folder to + /// when it exists, so hand-written .c4 files beside the AppHost join the diagram with no call. + /// + /// + /// Applied when the options are resolved, after configuration binding, so an + /// AspireC4:ExtensionsDirectory setting is honoured. Skipped for a hand-authored project, which + /// already serves its own files and gets no generated config to list the folder in, and when the + /// folder is the output directory itself or already registered. + /// + /// The AppHost directory the folder is resolved against. + /// The resolved options to add the folder to. + /// The absolute folder that was added, or when nothing was. + internal static string? IncludeExtensionsFolder(string baseDirectory, AspireC4DiagramOptions options) + { + ArgumentException.ThrowIfNullOrWhiteSpace(baseDirectory); + ArgumentNullException.ThrowIfNull(options); + + if ( + string.IsNullOrWhiteSpace(options.ExtensionsDirectory) + || !string.IsNullOrWhiteSpace(options.ProjectDirectory) + ) + { + return null; + } + + var folder = MakeAbsolute(baseDirectory, options.ExtensionsDirectory); + if (!Directory.Exists(folder)) + { + return null; + } + + var (outputDirectory, _) = Resolve(baseDirectory, options); + if (PathsEqual(folder, outputDirectory) || options.AdditionalDSLFolders.Any(f => PathsEqual(f, folder))) + { + return null; + } + + options.AdditionalDSLFolders.Add(folder); + + return folder; + } + /// /// Resolves a configured path to an absolute one without a trailing separator, so the two /// directories compare and combine consistently however they were written in configuration. diff --git a/src/src/AspireC4/README.md b/src/src/AspireC4/README.md index 769f513b..530ab457 100644 --- a/src/src/AspireC4/README.md +++ b/src/src/AspireC4/README.md @@ -8,13 +8,21 @@ the Aspire dashboard. ## Prerequisites -- **.NET 10 or later.** This package targets `net10.0`, so the AppHost that references it must too. -- **Aspire 13.5.3 or later.** The AppHost must reference both the AppHost SDK and +- **The .NET 11 SDK** to build the AppHost. The package's source generator runs inside the compiler + and is built against Roslyn 5.9; an older SDK reports `CS9057` and skips it, which drops the + `ASPIREC4xxx` validation and the `[LikeC4Registry]` types. +- **A `net10.0` or `net11.0` AppHost.** This package ships both assets; an AppHost targeting + `net10.0` (the Aspire AppHost framework) resolves the `net10.0` one. .NET 8 and .NET 9 are not + supported. +- **Aspire 13.6.5 or later.** The AppHost must reference both the AppHost SDK and `Aspire.Hosting.AppHost`. - **Docker** (or Podman) for the default LikeC4 sidecar container. - Optional: a local Node.js CLI runtime (`npx`, `pnpm`, `yarn`, `bun` or `deno`) if you call `.WithLocalCLI()` instead. +The [requirements page](https://github.com/purview-dev/aspirec4/blob/main/docs/wiki/Requirements.md) +has the detail behind each of these. + Container image exports verify bind sharing through `Purview.Containers.Environment`, using `alpine:3.19` by default. Offline environments must preload the probe image or set `CONTAINER_ENVIRONMENT_PROBE_IMAGE`. For a daemon inside WSL or a shared remote filesystem, @@ -32,7 +40,7 @@ dotnet add package AspireC4.Hosting ``` ```xml - + @@ -92,6 +100,8 @@ builder.AddAspireC4(configure: options => options | `ExportImages` | `true` | Export every view as an image to `./.aspirec4/images/` when the model changes | | `ImageFormats` | `{Png}` | `Png` and/or `Jpg` | | `UseLegacyLayout` | `false` | Restore the pre-13.6 output paths and dashboard names | +| `ExtensionsDirectory` | `"./likec4-extensions"` | Hand-written `.c4` files beside the AppHost, included automatically when the folder exists | +| `GenerateGitIgnoreFile` | `true` | Write `.gitignore` files that keep the generated model out of git | ### Run LikeC4 locally instead of in a container @@ -99,6 +109,19 @@ builder.AddAspireC4(configure: options => options builder.AddAspireC4().WithLocalCLI(); ``` +### Source control and your own LikeC4 files + +Add nothing to `.gitignore`: AspireC4 writes its own beside the generated files, naming only what it +writes, and never replaces one it did not write. The exported images stay visible to git so they can be +committed. Files are written only when their content changes, so restarting an unchanged application +touches nothing. + +Hand-written `.c4` files — people, external systems, `extend` blocks for generated elements, extra +views — go in a `likec4-extensions/` folder beside the AppHost, which is included automatically. See +[Source control](https://github.com/purview-dev/aspirec4/blob/main/docs/wiki/Generated-Output.md#source-control) +and +[Extending the generated model](https://github.com/purview-dev/aspirec4/blob/main/docs/wiki/Generated-Output.md#extending-the-generated-model). + ### Use your own LikeC4 project Serve a LikeC4 project you already maintain. The generated model is added to it as one more file, and diff --git a/src/src/EntityFrameworkCore.Analyzers/Analysis/EfModelAnalyser.cs b/src/src/EntityFrameworkCore.Analyzers/Analysis/EfModelAnalyser.cs index e0c0bb0f..e6aea09c 100644 --- a/src/src/EntityFrameworkCore.Analyzers/Analysis/EfModelAnalyser.cs +++ b/src/src/EntityFrameworkCore.Analyzers/Analysis/EfModelAnalyser.cs @@ -50,11 +50,15 @@ CancellationToken cancellationToken AddReachableEntities(entities, cancellationToken); List tables = []; + Dictionary tableByEntity = [with(SymbolEqualityComparer.Default)]; foreach (var pair in entities) { cancellationToken.ThrowIfCancellationRequested(); - tables.Add(BuildTable(pair.Key, pair.Value, fluent, entities, diagnostics)); + var table = BuildTable(pair.Key, pair.Value, fluent, entities, diagnostics); + + tables.Add(table); + tableByEntity[pair.Key] = table; } if (tables.Count == 0) @@ -62,6 +66,7 @@ CancellationToken cancellationToken diagnostics.Add((DiagnosticLibrary.NoEntityTypesDiscovered, context.Name)); } + ApplyPrincipalSideRelationships(tableByEntity); ResolveForeignKeyTargets(tables); return new Result(context.Name, context.ToDisplayString(), fluent.DefaultSchema, tables, diagnostics); @@ -194,9 +199,46 @@ static TableShape BuildTable( continue; } - if (StoreTypeMap.IsComplexValueObject(property.Type)) + switch (StoreTypeMap.ComplexValueObjectMapping(property.Type)) { - AddComplexColumns(table, property.Name, property.Type, StoreTypeMap.IsNullable(property.Type), 0); + case ComplexMapping.Flattened: + AddComplexColumns( + table, + property.Name, + property.Type, + StoreTypeMap.IsNullable(property.Type), + 0, + property.Name, + StoreTypeMap.Unwrap(property.Type).Name + ); + + continue; + + case ComplexMapping.Json: + table.Columns.Add( + BuildColumn( + property, + config, + uniqueFromIndexes, + StoreTypeMap.Json, + (StoreTypeMap.Unwrap(property.Type).Name, EfTypes.DomainTypeKinds.ComplexValueObject) + ) + ); + + continue; + + case ComplexMapping.None: + default: + break; + } + + // A collection of primitives or value objects is a column, not a navigation: EF Core stores a + // primitive collection as JSON, and a complex collection can only be JSON-mapped. + if (ScalarCollectionElement(property.Type) is { } element) + { + table.Columns.Add( + BuildColumn(property, config, uniqueFromIndexes, StoreTypeMap.Json, CollectionDomainType(element)) + ); continue; } @@ -233,10 +275,20 @@ static TableShape BuildTable( return table; } + /// The mapped property. + /// Its entity's fluent configuration, when there is any. + /// Properties a unique index covers. + /// + /// The store type to show when nothing is configured, for a column whose CLR type does not say — a + /// JSON-mapped value object or a collection. Configuration still wins. + /// + /// The domain type to record, in place of the one the property type implies. static ColumnShape BuildColumn( IPropertySymbol property, EntityConfiguration? config, - HashSet uniqueFromIndexes + HashSet uniqueFromIndexes, + string? inferredStoreType = null, + (string? Name, string? Kind)? domain = null ) { PropertyConfiguration? fluentProperty = null; @@ -246,8 +298,7 @@ HashSet uniqueFromIndexes fluentProperty = found; } - var maxLength = - fluentProperty?.MaxLength + var maxLength = fluentProperty?.MaxLength ?? GetAttributeInt(property, EfTypes.Attributes.MaxLength, 0) ?? GetAttributeInt(property, EfTypes.Attributes.StringLength, 0); @@ -256,18 +307,17 @@ HashSet uniqueFromIndexes || HasAttribute(property, EfTypes.Attributes.Required) || !StoreTypeMap.IsNullable(property.Type); - var (name, kind) = DomainType(property.Type, complexType: null); + var (name, kind) = domain ?? DomainType(property.Type, complexType: null); return new ColumnShape { PropertyName = property.Name, - Name = - fluentProperty?.ColumnName + Name = fluentProperty?.ColumnName ?? GetAttributeString(property, EfTypes.Attributes.Column, 0) ?? property.Name, - StoreType = - fluentProperty?.ColumnType + StoreType = fluentProperty?.ColumnType ?? GetAttributeNamedString(property, EfTypes.Attributes.Column, "TypeName") + ?? inferredStoreType ?? StoreTypeMap.ColumnStoreType(property.Type), ClrTypeName = property.Type.ToDisplayString(), DomainTypeName = name, @@ -289,10 +339,24 @@ HashSet uniqueFromIndexes /// /// Column names are approximated as {Property}_{Member}. EF's own flattening is only visible /// with a built model, which the build-time dump has and this analyser does not. + /// + /// Every member records the outermost property it came from in + /// , whatever its own type: a member that is itself a + /// scalar value object or an enum carries that type, so without the owner a renderer could not tell it + /// belongs to the group. + /// /// - static void AddComplexColumns(TableShape table, string prefix, ITypeSymbol complexType, bool nullable, int depth) + static void AddComplexColumns( + TableShape table, + string prefix, + ITypeSymbol complexType, + bool nullable, + int depth, + string ownerProperty, + string ownerType + ) { - if (depth > MaxComplexDepth || complexType is not INamedTypeSymbol named) + if (depth > MaxComplexDepth || StoreTypeMap.Unwrap(complexType) is not INamedTypeSymbol named) { return; } @@ -309,38 +373,53 @@ static void AddComplexColumns(TableShape table, string prefix, ITypeSymbol compl continue; } - if (StoreTypeMap.IsComplexValueObject(member.Type)) + var path = $"{prefix}_{member.Name}"; + var memberNullable = nullable || StoreTypeMap.IsNullable(member.Type); + var mapping = StoreTypeMap.ComplexValueObjectMapping(member.Type); + + if (mapping == ComplexMapping.Flattened) { - AddComplexColumns( - table, - $"{prefix}_{member.Name}", - member.Type, - nullable || StoreTypeMap.IsNullable(member.Type), - depth + 1 - ); + AddComplexColumns(table, path, member.Type, memberNullable, depth + 1, ownerProperty, ownerType); continue; } - if (!StoreTypeMap.IsScalar(member.Type)) + string storeType; + (string? Name, string? Kind) domain; + + if (mapping == ComplexMapping.Json) + { + storeType = StoreTypeMap.Json; + domain = (StoreTypeMap.Unwrap(member.Type).Name, EfTypes.DomainTypeKinds.ComplexValueObject); + } + else if (ScalarCollectionElement(member.Type) is { } element) + { + storeType = StoreTypeMap.Json; + domain = CollectionDomainType(element); + } + else if (StoreTypeMap.IsScalar(member.Type)) + { + storeType = StoreTypeMap.ColumnStoreType(member.Type); + domain = DomainType(member.Type, named.Name); + } + else { continue; } - var (name, kind) = DomainType(member.Type, named.Name); - table.Columns.Add( new ColumnShape { - PropertyName = $"{prefix}_{member.Name}", - Name = GetAttributeString(member, EfTypes.Attributes.Column, 0) ?? $"{prefix}_{member.Name}", - StoreType = StoreTypeMap.ColumnStoreType(member.Type), + PropertyName = path, + Name = GetAttributeString(member, EfTypes.Attributes.Column, 0) ?? path, + StoreType = GetAttributeNamedString(member, EfTypes.Attributes.Column, "TypeName") ?? storeType, ClrTypeName = member.Type.ToDisplayString(), - DomainTypeName = name, - DomainTypeKind = kind, - IsNullable = nullable || StoreTypeMap.IsNullable(member.Type), - MaxLength = - GetAttributeInt(member, EfTypes.Attributes.MaxLength, 0) + DomainTypeName = domain.Name, + DomainTypeKind = domain.Kind, + ComplexPropertyName = ownerProperty, + ComplexTypeName = ownerType, + IsNullable = memberNullable, + MaxLength = GetAttributeInt(member, EfTypes.Attributes.MaxLength, 0) ?? GetAttributeInt(member, EfTypes.Attributes.StringLength, 0), Comment = GetAttributeString(member, EfTypes.Attributes.Comment, 0), } @@ -348,6 +427,52 @@ static void AddComplexColumns(TableShape table, string prefix, ITypeSymbol compl } } + /// + /// The element type of a collection EF Core stores in a single JSON column, or + /// when the collection is a navigation. + /// + /// + /// A primitive collection (List<string>, Guid[]), a collection of scalar value + /// objects or enums, and a collection of complex value objects are all one JSON column. A collection of + /// entities — or of [Owned] types, which EF gives a table of their own — is a navigation. Before + /// this distinction every collection was taken for a navigation, so List<string> Tags + /// produced a phantom String table with Length and Chars columns. + /// + static ITypeSymbol? ScalarCollectionElement(ITypeSymbol type) + { + var element = type switch + { + IArrayTypeSymbol { ElementType.SpecialType: not SpecialType.System_Byte } array => array.ElementType, + INamedTypeSymbol { IsGenericType: true } generic when IsCollection(generic) => generic.TypeArguments[0], + _ => null, + }; + + if (element is null || HasAttribute(StoreTypeMap.Unwrap(element), "Owned")) + { + return null; + } + + return StoreTypeMap.IsScalar(element) || StoreTypeMap.ComplexValueObjectMapping(element) != ComplexMapping.None + ? element + : null; + } + + /// + /// The domain type shown for a JSON collection column: the element's own domain type, as an array — + /// AssetId[], Colour[] — or none for a collection of primitives. + /// + static (string? Name, string? Kind) CollectionDomainType(ITypeSymbol element) + { + if (StoreTypeMap.ComplexValueObjectMapping(element) != ComplexMapping.None) + { + return ($"{StoreTypeMap.Unwrap(element).Name}[]", EfTypes.DomainTypeKinds.ComplexValueObject); + } + + var (name, kind) = DomainType(element, complexType: null); + + return name is null ? (null, null) : ($"{name}[]", kind); + } + /// /// The domain type a column is backed by, when its declared type is not a primitive. /// @@ -359,6 +484,12 @@ static void AddComplexColumns(TableShape table, string prefix, ITypeSymbol compl /// static (string? Name, string? Kind) DomainType(ITypeSymbol type, string? complexType) { + // First, because a union's case accessor gives it the shape of a scalar value object. + if (StoreTypeMap.IsUnion(type)) + { + return (StoreTypeMap.Unwrap(type).Name, EfTypes.DomainTypeKinds.Union); + } + var valueObject = StoreTypeMap.ValueObjectType(type); if (valueObject is not null) @@ -440,8 +571,9 @@ Dictionary entities var explicitName = GetAttributeString(navigation, EfTypes.Attributes.ForeignKey, 0); - var column = - (explicitName is null ? null : table.Columns.FirstOrDefault(c => c.PropertyName == explicitName)) + var column = ( + explicitName is null ? null : table.Columns.FirstOrDefault(c => c.PropertyName == explicitName) + ) ?? table.Columns.FirstOrDefault(c => c.PropertyName == navigation.Name + "Id") ?? table.Columns.FirstOrDefault(c => c.PropertyName == target.Name + "Id") ?? SharedPrimaryKeyColumn(table, target); @@ -592,11 +724,166 @@ static void ResolveForeignKeyTargets(List tables) var principal = byClrType[foreignKey.PrincipalEntity]; foreignKey.PrincipalEntity = principal.Name; - foreignKey.PrincipalColumns.AddRange(principal.Columns.Where(c => c.IsPrimaryKey).Select(c => c.Name)); + foreignKey.PrincipalColumns.AddRange( + PrincipalColumns(foreignKey, table, [.. principal.Columns.Where(c => c.IsPrimaryKey)]) + ); + } + } + } + + /// The principal key columns a foreign key's columns point at, one for one. + /// + /// The foreign key found from a navigation always has one column, but the principal's key can have + /// several. Attaching every key column produced a reference whose two sides differ in length — an + /// edge d2 paired with whichever key column came first, and DBML that does not parse. Each column is + /// paired by type, then by name, so a typed id such as OrderId lands on the key column of that + /// type. + /// + static List PrincipalColumns(ForeignKeyShape foreignKey, TableShape dependent, List key) + { + if (key.Count == 0 || key.Count == foreignKey.Columns.Count) + { + return [.. key.Select(c => c.Name)]; + } + + List paired = []; + + foreach (var name in foreignKey.Columns) + { + var column = dependent.Columns.FirstOrDefault(c => c.Name == name); + var sameType = key.Where(k => column is not null && k.ClrTypeName == column.ClrTypeName).ToList(); + + var match = + sameType.Count == 1 + ? sameType[0] + : key.FirstOrDefault(k => string.Equals(k.Name, name, StringComparison.OrdinalIgnoreCase)) + ?? sameType.FirstOrDefault() + ?? key[0]; + + paired.Add(match.Name); + } + + return paired; + } + + /// + /// Adds the relationships EF Core discovers from the principal's side alone: a collection navigation + /// on the principal, and no reference navigation — or no matching foreign-key property — on the + /// dependent. + /// + /// + /// works from the dependent's reference navigations, so a relationship + /// declared only as Asset.Tags used to vanish. EF matches the foreign key by name — + /// {Principal}{Key}, then the key's own name when it already starts with the principal's, + /// then {Principal}Id — and, when no property matches, adds a shadow foreign key under that + /// first name. Both are reproduced, so the column the database really has is shown. A collection + /// answered by a collection on the other side is a many-to-many, which EF maps through a join table + /// of its own, and is left alone. + /// + static void ApplyPrincipalSideRelationships(Dictionary tables) + { + foreach (var pair in tables) + { + var principal = pair.Key; + var key = pair.Value.Columns.Where(c => c.IsPrimaryKey).ToList(); + + if (key.Count != 1) + { + continue; + } + + var principalName = principal.ToDisplayString(); + + foreach (var navigation in Properties(principal)) + { + if ( + !IsCollection(navigation.Type) + || GetNavigationTarget(navigation.Type) is not { } dependent + || !tables.TryGetValue(dependent, out var dependentTable) + || dependentTable.ForeignKeys.Any(fk => fk.PrincipalEntity == principalName) + ) + { + continue; + } + + // On a self-referencing entity the navigation is its own dependent's property, and is not an inverse. + var inverse = Properties(dependent) + .Where(p => + SymbolEqualityComparer.Default.Equals(GetNavigationTarget(p.Type), principal) + && !SymbolEqualityComparer.Default.Equals(p, navigation) + ) + .ToList(); + + if (inverse.Any(p => IsCollection(p.Type))) + { + continue; + } + + var column = ForeignKeyByConvention(dependentTable, principal.Name, key[0]) + ?? AddShadowForeignKey(dependentTable, principal.Name, key[0]); + + column.IsForeignKey = true; + + ForeignKeyShape foreignKey = new() + { + PrincipalEntity = principalName, + NavigationName = inverse.Count == 1 ? inverse[0].Name : null, + PrincipalNavigationName = navigation.Name, + IsRequired = !column.IsNullable, + }; + + foreignKey.Columns.Add(column.Name); + dependentTable.ForeignKeys.Add(foreignKey); } } } + static ColumnShape? ForeignKeyByConvention(TableShape dependent, string principal, ColumnShape key) + { + string[] candidates = [ShadowForeignKeyName(principal, key), $"{principal}Id"]; + + return candidates + .Select(name => + dependent.Columns.FirstOrDefault(c => + !c.IsForeignKey && string.Equals(c.PropertyName, name, StringComparison.OrdinalIgnoreCase) + ) + ) + .FirstOrDefault(c => c is not null); + } + + /// + /// The name EF Core gives a foreign key it has to invent: the principal's name and its key's, unless + /// the key's name already starts with the principal's. + /// + static string ShadowForeignKeyName(string principal, ColumnShape key) => + key.PropertyName.StartsWith(principal, StringComparison.OrdinalIgnoreCase) + ? key.PropertyName + : principal + key.PropertyName; + + /// + /// Adds the shadow foreign-key column EF Core creates when the dependent declares none — optional, + /// because nothing on the dependent requires it, and typed like the key it references. + /// + static ColumnShape AddShadowForeignKey(TableShape dependent, string principal, ColumnShape key) + { + var name = ShadowForeignKeyName(principal, key); + + ColumnShape column = new() + { + PropertyName = name, + Name = name, + StoreType = key.StoreType, + ClrTypeName = key.ClrTypeName, + DomainTypeName = key.DomainTypeName, + DomainTypeKind = key.DomainTypeKind, + IsNullable = true, + }; + + dependent.Columns.Add(column); + + return column; + } + /// Reads [Index(nameof(X), IsUnique = true)] declarations from the entity type. static HashSet ReadUniqueIndexProperties(INamedTypeSymbol entity) { @@ -646,7 +933,9 @@ static HashSet ReadUniqueIndexProperties(INamedTypeSymbol entity) { if (type is INamedTypeSymbol { IsGenericType: true } generic && IsCollection(type)) { - return Normalise(generic.TypeArguments[0] as INamedTypeSymbol); + return ScalarCollectionElement(type) is null + ? Normalise(generic.TypeArguments[0] as INamedTypeSymbol) + : null; } if (type is not INamedTypeSymbol named || named.TypeKind != TypeKind.Class) @@ -654,8 +943,9 @@ static HashSet ReadUniqueIndexProperties(INamedTypeSymbol entity) return null; } - // A complex value object is flattened into the owner's table, not modelled as an entity. - if (StoreTypeMap.IsComplexValueObject(named)) + // A complex value object is flattened into the owner's table, or held in a JSON column on it; it is + // never an entity of its own. + if (StoreTypeMap.ComplexValueObjectMapping(named) != ComplexMapping.None) { return null; } diff --git a/src/src/EntityFrameworkCore.Analyzers/Analysis/EfTypes.cs b/src/src/EntityFrameworkCore.Analyzers/Analysis/EfTypes.cs index b2428699..f288bb23 100644 --- a/src/src/EntityFrameworkCore.Analyzers/Analysis/EfTypes.cs +++ b/src/src/EntityFrameworkCore.Analyzers/Analysis/EfTypes.cs @@ -46,6 +46,7 @@ public static class DomainTypeKinds public const string ValueObject = "ValueObject"; public const string ComplexValueObject = "ComplexValueObject"; public const string Enum = "Enum"; + public const string Union = "Union"; } /// Fluent builder members the analyser interprets. diff --git a/src/src/EntityFrameworkCore.Analyzers/Analysis/ModelShapes.cs b/src/src/EntityFrameworkCore.Analyzers/Analysis/ModelShapes.cs index d3e5d3db..16a4ee38 100644 --- a/src/src/EntityFrameworkCore.Analyzers/Analysis/ModelShapes.cs +++ b/src/src/EntityFrameworkCore.Analyzers/Analysis/ModelShapes.cs @@ -32,10 +32,17 @@ sealed class ColumnShape public string? DomainTypeName { get; set; } /// - /// Which kind of domain type it is: ValueObject, ComplexValueObject or Enum. + /// Which kind of domain type it is: ValueObject, ComplexValueObject, Enum or + /// Union. /// public string? DomainTypeKind { get; set; } + /// For a member flattened out of a complex value object, the owning entity property. + public string? ComplexPropertyName { get; set; } + + /// The complex value object type holds. + public string? ComplexTypeName { get; set; } + public bool IsNullable { get; set; } public bool IsPrimaryKey { get; set; } diff --git a/src/src/EntityFrameworkCore.Analyzers/AspireC4EfModelGenerator.cs b/src/src/EntityFrameworkCore.Analyzers/AspireC4EfModelGenerator.cs index 4cde79f0..7494530d 100644 --- a/src/src/EntityFrameworkCore.Analyzers/AspireC4EfModelGenerator.cs +++ b/src/src/EntityFrameworkCore.Analyzers/AspireC4EfModelGenerator.cs @@ -198,56 +198,47 @@ static string WriteContextJson(EfModelAnalyser.Result result) .Text("typeName", result.TypeName) .Text("defaultSchema", result.DefaultSchema); - json.Array( - "tables", - result.Tables, - static (writer, table) => + json.Array("tables", result.Tables, static (writer, table) => + { + writer + .StartObject() + .Text("name", table.Name) + .Text("clrTypeName", table.ClrTypeName) + .Text("schema", table.Schema) + .Bool("isJoinEntity", table.IsJoinEntity); + + writer.Array("columns", table.Columns, static (w, column) => + w.StartObject() + .Text("name", column.Name) + .Text("storeType", column.StoreType) + .Text("clrTypeName", column.ClrTypeName) + .Text("domainTypeName", column.DomainTypeName) + .Text("domainTypeKind", column.DomainTypeKind) + .Text("complexPropertyName", column.ComplexPropertyName) + .Text("complexTypeName", column.ComplexTypeName) + .Bool("isNullable", column.IsNullable) + .Bool("isPrimaryKey", column.IsPrimaryKey) + .Bool("isUnique", column.IsUnique) + .Bool("isForeignKey", column.IsForeignKey) + .Number("maxLength", column.MaxLength) + .Text("comment", column.Comment) + .EndObject() + ); + + writer.Array("foreignKeys", table.ForeignKeys, static (w, foreignKey) => { - writer - .StartObject() - .Text("name", table.Name) - .Text("clrTypeName", table.ClrTypeName) - .Text("schema", table.Schema) - .Bool("isJoinEntity", table.IsJoinEntity); - - writer.Array( - "columns", - table.Columns, - static (w, column) => - w.StartObject() - .Text("name", column.Name) - .Text("storeType", column.StoreType) - .Text("clrTypeName", column.ClrTypeName) - .Text("domainTypeName", column.DomainTypeName) - .Text("domainTypeKind", column.DomainTypeKind) - .Bool("isNullable", column.IsNullable) - .Bool("isPrimaryKey", column.IsPrimaryKey) - .Bool("isUnique", column.IsUnique) - .Bool("isForeignKey", column.IsForeignKey) - .Number("maxLength", column.MaxLength) - .Text("comment", column.Comment) - .EndObject() - ); - - writer.Array( - "foreignKeys", - table.ForeignKeys, - static (w, foreignKey) => - { - w.StartObject().Text("principalTable", foreignKey.PrincipalEntity); - w.Array("columns", foreignKey.Columns, static (a, c) => a.Value(c)); - w.Array("principalColumns", foreignKey.PrincipalColumns, static (a, c) => a.Value(c)); - w.Text("navigationName", foreignKey.NavigationName) - .Text("principalNavigationName", foreignKey.PrincipalNavigationName) - .Bool("isUnique", foreignKey.IsUnique) - .Bool("isRequired", foreignKey.IsRequired) - .EndObject(); - } - ); - - writer.EndObject(); - } - ); + w.StartObject().Text("principalTable", foreignKey.PrincipalEntity); + w.Array("columns", foreignKey.Columns, static (a, c) => a.Value(c)); + w.Array("principalColumns", foreignKey.PrincipalColumns, static (a, c) => a.Value(c)); + w.Text("navigationName", foreignKey.NavigationName) + .Text("principalNavigationName", foreignKey.PrincipalNavigationName) + .Bool("isUnique", foreignKey.IsUnique) + .Bool("isRequired", foreignKey.IsRequired) + .EndObject(); + }); + + writer.EndObject(); + }); return json.EndObject().ToString(); } diff --git a/src/src/EntityFrameworkCore.Analyzers/EntityFrameworkCore.Analyzers.csproj b/src/src/EntityFrameworkCore.Analyzers/EntityFrameworkCore.Analyzers.csproj index d01df5d5..6c14bab2 100644 --- a/src/src/EntityFrameworkCore.Analyzers/EntityFrameworkCore.Analyzers.csproj +++ b/src/src/EntityFrameworkCore.Analyzers/EntityFrameworkCore.Analyzers.csproj @@ -1,7 +1,6 @@ true - netstandard2.0 true diff --git a/src/src/EntityFrameworkCore.Analyzers/Helpers/StoreTypeMap.cs b/src/src/EntityFrameworkCore.Analyzers/Helpers/StoreTypeMap.cs index 9be5d748..a0496675 100644 --- a/src/src/EntityFrameworkCore.Analyzers/Helpers/StoreTypeMap.cs +++ b/src/src/EntityFrameworkCore.Analyzers/Helpers/StoreTypeMap.cs @@ -2,28 +2,54 @@ namespace Aspire.Hosting.AspireC4.EntityFrameworkCore.Analyzers.Helpers; +/// How a complex value object reaches the database. +enum ComplexMapping +{ + /// Not a complex value object. + None, + + /// Flattened into the owner's table, one column per member. + Flattened, + + /// Serialised into a single JSON column on the owner's table. + Json, +} + /// -/// Maps CLR types to the store type shown in the ER diagram. +/// Maps CLR types to the store type shown in the ER diagram, and recognises the domain types — value +/// objects, enums and unions — that a column can be declared with. /// /// /// These names are deliberately generic rather than provider-specific: the generator cannot know which /// provider the AppHost will configure, and the diagram is documentation rather than DDL. An explicit /// [Column(TypeName = "...")] or HasColumnType("...") always wins over this map. +/// +/// Value objects are recognised by attribute first and by shape second. The attributes are matched by +/// name, so no dependency on the value-object package is taken. Attributes matter because a generator +/// that writes the Value member — Purview's [Scalar<T>], or the forwarding +/// Value of [Scalar("Amount")] — writes it in a generated file this analyzer cannot see: +/// one generator never sees another's output. Shape alone would miss every such type. +/// /// static class StoreTypeMap { /// The store type used when the CLR type is not recognised. public const string Unknown = "unknown"; + /// The store type of a column holding a JSON document. + public const string Json = "json"; + + const string CompilerServicesNamespace = "System.Runtime.CompilerServices"; + /// Resolves the display store type for a property's CLR type. /// The property type, which may be Nullable<T>. public static string Resolve(ITypeSymbol type) { var unwrapped = Unwrap(type); - if (unwrapped.TypeKind == TypeKind.Enum) + if (unwrapped is INamedTypeSymbol { TypeKind: TypeKind.Enum } enumType) { - return "int"; + return enumType.EnumUnderlyingType is { } underlying ? Resolve(underlying) : "int"; } if (unwrapped is IArrayTypeSymbol array) @@ -65,27 +91,27 @@ public static string Resolve(ITypeSymbol type) or "System.Text.Json.Nodes.JsonNode" or "System.Text.Json.Nodes.JsonObject" or "System.Text.Json.Nodes.JsonArray" - or "System.Text.Json.Nodes.JsonValue" => "json", + or "System.Text.Json.Nodes.JsonValue" => Json, _ => Unknown, }; } /// - /// Determines whether a type is a scalar value object: a struct that wraps a single value under a - /// Value member, as a strongly-typed identifier does. + /// Determines whether a type is a scalar value object: a type that wraps a single value, as a + /// strongly-typed identifier does. /// /// - /// A struct is never an EF entity, so recognising one as a scalar is safe. The Value member - /// is what the value-object generators emit — including a forwarding Value when the author - /// names the underlying member something else — so this matches a strongly-typed id without - /// depending on the library that produced it. + /// + /// A Purview [Scalar], [Scalar<T>] or [Scalar(typeof(T))] attribute — + /// on a struct or a class. + /// IScalarValueObject<TSelf, TValue>, declared by hand. + /// A non-generic struct with a public instance Value member, for value objects from any + /// other library or written by hand. A Value typed object is excluded: that is the + /// shape of a union's case accessor, not of a wrapped value. + /// + /// A union is never a scalar value object, whatever its shape. /// - public static bool IsValueObject(ITypeSymbol type) => - type is INamedTypeSymbol { TypeKind: TypeKind.Struct, IsGenericType: false } named - && named - .GetMembers("Value") - .OfType() - .Any(p => !p.IsStatic && p.DeclaredAccessibility == Accessibility.Public); + public static bool IsValueObject(ITypeSymbol type) => ScalarValueObject(type).IsValueObject; /// The scalar value-object type a property is backed by, or . public static ITypeSymbol? ValueObjectType(ITypeSymbol type) @@ -98,70 +124,244 @@ public static bool IsValueObject(ITypeSymbol type) => /// The real database type for a property, resolving a scalar value object to its primitive. public static string ColumnStoreType(ITypeSymbol type) { - var valueObject = ValueObjectType(type); + var (isValueObject, underlying) = ScalarValueObject(Unwrap(type)); - return valueObject is not null && ValueObjectUnderlying(valueObject) is { } underlying - ? Resolve(underlying) - : Resolve(type); - } + if (isValueObject) + { + return underlying is null ? Unknown : Resolve(underlying); + } - static ITypeSymbol? ValueObjectUnderlying(ITypeSymbol valueObject) => - valueObject - .GetMembers("Value") - .OfType() - .FirstOrDefault(p => !p.IsStatic && p.DeclaredAccessibility == Accessibility.Public) - ?.Type; + return IsUnion(type) ? Unknown : Resolve(type); + } /// - /// Whether a type is mapped as an EF Core complex type — a Purview complex value object. + /// Whether a scalar value object wraps an enum, so a renderer can mark it as one. /// - /// Matched by attribute name, so no dependency on the value-object package is taken. - public static bool IsComplexValueObject(ITypeSymbol type) + public static bool WrapsEnum(ITypeSymbol type) => + ScalarValueObject(Unwrap(type)).Underlying is { } underlying && Unwrap(underlying).TypeKind == TypeKind.Enum; + + static (bool IsValueObject, ITypeSymbol? Underlying) ScalarValueObject(ITypeSymbol type) { - foreach (var attribute in Unwrap(type).GetAttributes()) + if (type is not INamedTypeSymbol { TypeKind: TypeKind.Struct or TypeKind.Class } named || IsUnion(named)) + { + return (false, null); + } + + foreach (var attribute in named.GetAttributes()) { - if (attribute.AttributeClass?.Name is not ("ValueObjectAttribute" or "ValueObject")) + if (attribute.AttributeClass is not { Name: "ScalarAttribute", Arity: <= 1 } attributeClass) { continue; } + if (attributeClass.IsGenericType && attributeClass.TypeArguments.Length == 1) + { + return (true, attributeClass.TypeArguments[0]); + } + + string? propertyName = null; + + foreach (var argument in attribute.ConstructorArguments) + { + if (argument.Kind == TypedConstantKind.Type && argument.Value is ITypeSymbol valueType) + { + return (true, valueType); + } + + propertyName ??= argument.Value as string; + } + + // The manual form: the author declares the value under the property the attribute names, and + // the generator adds a forwarding Value this analyzer cannot see. + return ( + true, + (PublicInstanceProperty(named, propertyName ?? "Value") ?? PublicInstanceProperty(named, "Value"))?.Type + ); + } + + foreach (var contract in named.AllInterfaces) + { + if (contract is { Name: "IScalarValueObject", TypeArguments.Length: 2 }) + { + return (true, contract.TypeArguments[1]); + } + } + + if (named.TypeKind == TypeKind.Struct && !named.IsGenericType) + { + var value = PublicInstanceProperty(named, "Value"); + + if (value is not null && value.Type.SpecialType != SpecialType.System_Object) + { + return (true, value.Type); + } + } + + return (false, null); + } + + static IPropertySymbol? PublicInstanceProperty(INamedTypeSymbol type, string name) => + type.GetMembers(name) + .OfType() + .FirstOrDefault(p => !p.IsStatic && p.DeclaredAccessibility == Accessibility.Public); + + /// + /// Whether a type is a C# union: declared with union, or a hand-written struct marked + /// [Union] or implementing IUnion. + /// + /// + /// Checked before anything else, because a union's generated Value accessor gives it exactly + /// the shape of a scalar value object. EF Core cannot store one without a value converter, and the + /// converter is not visible to this analyzer, so the column's database type is . + /// + public static bool IsUnion(ITypeSymbol type) + { + var unwrapped = Unwrap(type); + + if (IsUnionDeclaration(unwrapped)) + { + return true; + } + + foreach (var attribute in unwrapped.GetAttributes()) + { if ( - IsNamedEnumMember( - attribute.NamedArguments.FirstOrDefault(a => a.Key == "EFMapping").Value, - "ComplexType" - ) + attribute.AttributeClass is { Name: "UnionAttribute" } attributeClass + && attributeClass.ContainingNamespace.ToDisplayString() == CompilerServicesNamespace ) { return true; } } - return false; + return unwrapped.AllInterfaces.Any(i => + i.Name == "IUnion" && i.ContainingNamespace.ToDisplayString() == CompilerServicesNamespace + ); + } + + /// Roslyn's own answer, for a type declared with the union keyword. + /// + /// ITypeSymbol.IsUnion is marked experimental, so it may be renamed or removed in a later + /// compiler — and this analyzer runs inside whatever compiler the consumer has. The call is kept in a + /// method of its own so a missing member fails when this method is compiled, where it is caught, rather + /// than taking the whole analyzer down; the attribute and interface checks still recognise a union. + /// + static bool IsUnionDeclaration(ITypeSymbol type) + { + try + { + return ReadIsUnion(type); + } + catch (MissingMemberException) + { + return false; + } } +#pragma warning disable RSEXPERIMENTAL006 // Evaluation-only API; isolated and guarded above. + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + static bool ReadIsUnion(ITypeSymbol type) => type.IsUnion; +#pragma warning restore RSEXPERIMENTAL006 + /// - /// Whether a typed constant is the named member of its enum type. + /// How a type is mapped as a complex value object, or when it is not one. /// /// - /// The compiler reports either the member or its underlying value depending on how the constant was - /// written, so both are accepted. + /// + /// Purview's [ValueObject]: its EFMapping, else the assembly's + /// [ValueObjectDefaults(EFMapping = ...)], else ComplexType — the library's own default, + /// so a bare [ValueObject] is flattened. Json is one JSON column; None leaves the + /// mapping to the application, which this analyzer cannot see. + /// EF Core's [ComplexType], which is always flattened. + /// EF Core's [Owned]: a single owned reference shares its owner's table, with columns + /// named {Navigation}_{Property} — the same shape as a flattened complex type. + /// + /// A scalar value object is never a complex one. /// - static bool IsNamedEnumMember(TypedConstant constant, string member) + public static ComplexMapping ComplexValueObjectMapping(ITypeSymbol type) { - if (constant.Value is null) + if (Unwrap(type) is not INamedTypeSymbol named || named.SpecialType != SpecialType.None || IsValueObject(named)) { - return false; + return ComplexMapping.None; } - if (string.Equals(constant.Value.ToString(), member, StringComparison.Ordinal)) - return true; + foreach (var attribute in named.GetAttributes()) + { + switch (attribute.AttributeClass?.Name) + { + case "ValueObjectAttribute" or "ValueObject": + return MappingOf( + EnumMemberName(attribute, "EFMapping") ?? AssemblyDefaultMapping(named) ?? "ComplexType" + ); + + case "ComplexTypeAttribute" + or "ComplexType" + or "OwnedAttribute" + or "Owned": + return ComplexMapping.Flattened; + + default: + break; + } + } + + return ComplexMapping.None; + } + + static ComplexMapping MappingOf(string member) => + member switch + { + "ComplexType" => ComplexMapping.Flattened, + "Json" => ComplexMapping.Json, + _ => ComplexMapping.None, + }; + + static string? AssemblyDefaultMapping(INamedTypeSymbol type) + { + foreach (var attribute in type.ContainingAssembly?.GetAttributes() ?? []) + { + if (attribute.AttributeClass?.Name is "ValueObjectDefaultsAttribute" or "ValueObjectDefaults") + { + return EnumMemberName(attribute, "EFMapping"); + } + } + + return null; + } + + /// + /// The member name of an enum-typed named argument, or when it was not set. + /// + /// + /// The compiler reports an enum constant as its underlying value, so the value is mapped back to the + /// member that declares it; a constant that is already the member name is accepted as is. + /// + static string? EnumMemberName(AttributeData attribute, string argumentName) + { + foreach (var argument in attribute.NamedArguments) + { + if (argument.Key != argumentName || argument.Value.Value is not { } value) + { + continue; + } + + if (argument.Value.Type is INamedTypeSymbol { TypeKind: TypeKind.Enum } enumType) + { + var member = enumType + .GetMembers() + .OfType() + .FirstOrDefault(f => f.HasConstantValue && Equals(f.ConstantValue, value)); + + if (member is not null) + { + return member.Name; + } + } + + return value.ToString(); + } - // The constant is not the member name, so check whether it is the underlying value of the member. - return constant.Type is INamedTypeSymbol { TypeKind: TypeKind.Enum } enumType - && enumType - .GetMembers() - .OfType() - .Any(f => f.Name == member && f.HasConstantValue && Equals(f.ConstantValue, constant.Value)); + return null; } /// Determines whether a property type maps to a column rather than a navigation. @@ -169,7 +369,7 @@ public static bool IsScalar(ITypeSymbol type) { var unwrapped = Unwrap(type); - if (unwrapped.TypeKind == TypeKind.Enum || IsValueObject(unwrapped)) + if (unwrapped.TypeKind == TypeKind.Enum || IsValueObject(unwrapped) || IsUnion(unwrapped)) { return true; } diff --git a/src/src/EntityFrameworkCore.Analyzers/README.md b/src/src/EntityFrameworkCore.Analyzers/README.md index e1880387..9fd0e60e 100644 --- a/src/src/EntityFrameworkCore.Analyzers/README.md +++ b/src/src/EntityFrameworkCore.Analyzers/README.md @@ -19,6 +19,13 @@ configuration. | Read from metadata only | With this package | | --- | --- | | `DbSet`, navigations, `[Key]`, `[Table]`, `[Column]`, `[Required]`, `[MaxLength]`, `[Index]`, … | All of that, **plus** `HasDefaultSchema`, `ToTable`, `HasKey`, `Ignore`, `HasIndex(...).IsUnique()`, `HasColumnName`, `HasColumnType`, `HasMaxLength`, `IsRequired`, `HasComment` | + +Both read value objects the same way — Purview's `[Scalar]`, `[Scalar(typeof(T))]` and `[Scalar("Name")]`, +`IScalarValueObject<,>`, a bare `[ValueObject]`, `[ValueObject(EFMapping = Json)]`, EF's `[ComplexType]` and +`[Owned]` — along with value-object collections and C# unions. The attributes matter here: a Purview +generator writes the `Value` member into a generated file this analyzer cannot see, so a strongly-typed id +is recognised by its `[Scalar]` attribute. See +[Value objects, enums and unions](https://github.com/purview-dev/aspirec4/blob/main/docs/wiki/Entity-Framework-Diagrams.md#value-objects-enums-and-unions). | Fidelity: `Approximate` | Fidelity: `Full` | ## Requirements @@ -26,7 +33,12 @@ configuration. - **`netstandard2.0` analyzer, no runtime surface.** The package ships under `analyzers/dotnet/cs/`, so it loads into the compiler for whatever framework your project targets and adds nothing to your build output. The generated `[assembly: AspireC4EfModel]` attribute is compiled into your own assembly, which - the `net10.0` AppHost then reads. + the AppHost then reads. +- **The .NET 11 SDK** to build the project. A source generator runs inside the compiler, and this one + is built against Roslyn 5.9 — the project's target framework has no bearing on it. An older SDK + reports `CS9057` and skips the generator, so the ER diagram falls back to the approximate metadata + read. See the + [requirements page](https://github.com/purview-dev/aspirec4/blob/main/docs/wiki/Requirements.md#the-compiler-floor-net-11-sdk). - The project still has to be registered in the AppHost with `AddProject(...)`, and the AppHost still has to call `WithEntityFrameworkDiagram()`. diff --git a/src/src/EntityFrameworkCore.ModelDump/EfModelDumpProjector.cs b/src/src/EntityFrameworkCore.ModelDump/EfModelDumpProjector.cs index 9441719b..664175de 100644 --- a/src/src/EntityFrameworkCore.ModelDump/EfModelDumpProjector.cs +++ b/src/src/EntityFrameworkCore.ModelDump/EfModelDumpProjector.cs @@ -16,6 +16,7 @@ namespace Aspire.Hosting.AspireC4.EntityFrameworkCore.ModelDump; static class EfModelDumpProjector { const string UnknownStoreType = "unknown"; + const string JsonStoreType = "json"; static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) { WriteIndented = false }; @@ -51,7 +52,9 @@ static IEnumerable Tables(IModel model) foreach (var entityType in model.GetEntityTypes()) { - if (entityType.IsOwned()) + // An owned type that shares its owner's table, or lives in a JSON column on it, is part of the + // owner's table; one with a table of its own — OwnsMany, or OwnsOne with ToTable — is a table. + if (entityType.IsOwned() && (entityType.IsMappedToJson() || SharesOwnerTable(entityType))) { continue; } @@ -75,27 +78,48 @@ static IEnumerable Tables(IModel model) } } + static bool SharesOwnerTable(IEntityType owned) => + owned.FindOwnership() is { } ownership + && owned.GetTableName() == ownership.PrincipalEntityType.GetTableName() + && owned.GetSchema() == ownership.PrincipalEntityType.GetSchema(); + static DumpTable Table(IEntityType entityType) { var foreignKeys = entityType.GetForeignKeys().ToList(); var uniqueProperties = UniqueProperties(entityType); var foreignKeyProperties = foreignKeys.SelectMany(fk => fk.Properties).ToHashSet(); + var store = StoreObjectIdentifier.Table(entityType.GetTableName()!, entityType.GetSchema()); List columns = []; - foreach (var (property, complexType) in Columns(entityType)) + foreach (var column in Columns(entityType)) { - var (name, kind) = DomainType(property, complexType); + if (column.Json is { } json) + { + columns.Add(json); + + continue; + } + + var property = column.Property!; + var (name, kind) = DomainType(property, column.ComplexType); columns.Add( new DumpColumn { - Name = property.GetColumnName(), - StoreType = ResolveStoreType(ProviderType(property)), + Name = property.GetColumnName(store) ?? property.GetColumnName(), + StoreType = ExplicitColumnType(property) + // EF stores a primitive collection as a JSON document in a string column; the other two + // sources call it json, and so does this one. + ?? (property.IsPrimitiveCollection ? JsonStoreType : ResolveStoreType(ProviderType(property))), ClrTypeName = property.ClrType.FullName, DomainTypeName = name, DomainTypeKind = kind, - IsNullable = property.IsNullable, + ComplexPropertyName = column.Owner?.Property, + ComplexTypeName = column.Owner?.Type, + // The column's nullability, not the property's: a member of an optional complex type, or a + // property of an owned type sharing the table, is a required property in a nullable column. + IsNullable = property.IsColumnNullable(store), IsPrimaryKey = property.IsPrimaryKey(), IsUnique = uniqueProperties.Contains(property), IsForeignKey = foreignKeyProperties.Contains(property), @@ -142,49 +166,142 @@ .. entityType }; } - /// Every column of a table: the entity's own properties, then each complex type's members. + /// One column of a table, as it reaches the projection. + /// The mapped property, for an ordinary column. + /// The immediately enclosing complex or owned type, for a member of one. + /// The outermost property and type a flattened member came from. + /// A whole JSON-mapped complex or owned value, already projected. + sealed record ColumnSource( + IProperty? Property, + string? ComplexType, + (string Property, string Type)? Owner, + DumpColumn? Json = null + ); + + /// + /// Every column of a table: the entity's own properties, then each complex type's members, then the + /// properties of owned types that share the table. + /// /// /// The complex type name travels with each member so the column can show it beside the database - /// type, which is what makes a flattened complex value object legible. + /// type, and the outermost owner travels too, so a renderer can fold the members back under the + /// property they came from. A complex or owned value mapped to JSON — and a complex collection, which + /// can only be — is the one column that holds it. /// - static IEnumerable<(IProperty Property, string? ComplexType)> Columns(IEntityType entityType) + static IEnumerable Columns(IEntityType entityType) { foreach (var property in entityType.GetProperties()) { - yield return (property, null); + yield return new(property, null, null); } foreach (var complex in entityType.GetComplexProperties()) { - foreach (var column in ComplexColumns(complex)) + var owner = (complex.Name, complex.ComplexType.ClrType.Name); + + foreach (var column in ComplexColumns(complex, owner)) { yield return column; } } + + foreach (var navigation in entityType.GetNavigations()) + { + if ( + navigation.IsCollection + || navigation.ForeignKey.IsOwnership is false + || navigation.TargetEntityType == entityType + || !navigation.TargetEntityType.IsOwned() + ) + { + continue; + } + + var owned = navigation.TargetEntityType; + var owner = (navigation.Name, owned.ClrType.Name); + + if (owned.IsMappedToJson()) + { + yield return JsonColumn(owned.GetContainerColumnName() ?? navigation.Name, owned.ClrType.Name, owner); + + continue; + } + + if (!SharesOwnerTable(owned)) + { + continue; + } + + // The owned type's key is the owner's, carried through the shared table, so it is not a column + // of its own. + foreach (var property in owned.GetProperties().Where(p => !p.IsPrimaryKey())) + { + yield return new(property, owned.ClrType.Name, owner); + } + } } - static IEnumerable<(IProperty Property, string? ComplexType)> ComplexColumns(IComplexProperty complex) + static IEnumerable ComplexColumns(IComplexProperty complex, (string Property, string Type) owner) { var complexName = complex.ComplexType.ClrType.Name; + if (complex.IsCollection || complex.ComplexType.IsMappedToJson()) + { + var name = complex.ComplexType.GetContainerColumnName() ?? complex.Name; + var type = complex.IsCollection ? $"{complexName}[]" : complexName; + + yield return JsonColumn(name, type, owner == (complex.Name, complexName) ? null : owner); + + yield break; + } + foreach (var property in complex.ComplexType.GetProperties()) { - yield return (property, complexName); + yield return new(property, complexName, owner); } foreach (var nested in complex.ComplexType.GetComplexProperties()) { - foreach (var column in ComplexColumns(nested)) + foreach (var column in ComplexColumns(nested, owner)) { yield return column; } } } + static ColumnSource JsonColumn(string name, string domainTypeName, (string Property, string Type)? owner) => + new( + null, + null, + owner, + new DumpColumn + { + Name = name, + StoreType = JsonStoreType, + DomainTypeName = domainTypeName, + DomainTypeKind = "ComplexValueObject", + ComplexPropertyName = owner?.Property, + ComplexTypeName = owner?.Type, + IsNullable = true, + } + ); + /// The CLR type EF actually stores — the converted type for a value object. static Type ProviderType(IProperty property) => property.GetTypeMapping().Converter?.ProviderClrType ?? property.ClrType; + /// + /// A column type set explicitly with HasColumnType or [Column(TypeName = ...)]. + /// + /// + /// Read from the annotation rather than GetColumnType(), which also answers with the provider's + /// default mapping. That default is provider-specific (INTEGER, nvarchar(max)), and the + /// diagram's generic names are deliberate; only an explicit choice — decimal(18,4) — is real + /// information the generic name would lose. + /// + static string? ExplicitColumnType(IProperty property) => + property.FindAnnotation(RelationalAnnotationNames.ColumnType)?.Value as string; + /// /// The domain type a column is backed by, when its declared type is not a primitive. /// @@ -192,23 +309,52 @@ static Type ProviderType(IProperty property) => /// The enclosing complex type's name, for a plain member of one. /// /// The kind strings match the AppHost's EfDomainTypeKind member names, which is how the - /// emitted JSON round-trips. + /// emitted JSON round-trips. Nullable<T> is unwrapped first: an optional value object or + /// enum is still that value object or enum. A primitive collection shows its element's domain type as + /// an array. /// static (string? Name, string? Kind) DomainType(IProperty property, string? complexType) { - if (IsValueObject(property.ClrType)) + if (property.IsPrimitiveCollection && property.GetElementType() is { } element) { - return (property.ClrType.Name, "ValueObject"); + var (elementName, elementKind) = DomainTypeOf(element.ClrType); + + return elementName is null ? (null, null) : ($"{elementName}[]", elementKind); } - if (property.ClrType.IsEnum) + var (name, kind) = DomainTypeOf(property.ClrType); + + if (name is not null) { - return (property.ClrType.Name, "Enum"); + return (name, kind); } return complexType is not null ? (complexType, "ComplexValueObject") : (null, null); } + static (string? Name, string? Kind) DomainTypeOf(Type type) + { + var unwrapped = Nullable.GetUnderlyingType(type) ?? type; + + // First, because a union's case accessor gives it the shape of a scalar value object. + if (IsUnion(unwrapped)) + { + return (unwrapped.Name, "Union"); + } + + if (IsValueObject(unwrapped)) + { + return (unwrapped.Name, "ValueObject"); + } + + if (unwrapped.IsEnum) + { + return (unwrapped.Name, "Enum"); + } + + return (null, null); + } + static HashSet UniqueProperties(IEntityType entityType) { HashSet unique = []; @@ -243,9 +389,11 @@ static string ResolveStoreType(Type type) { var unwrapped = Nullable.GetUnderlyingType(type) ?? type; + // An enum stored as itself is its underlying integral type; one converted to a string arrives here + // as string, the provider type. if (unwrapped.IsEnum) { - return "int"; + return ResolveStoreType(Enum.GetUnderlyingType(unwrapped)); } if (unwrapped == typeof(byte[])) @@ -275,11 +423,49 @@ static string ResolveStoreType(Type type) }; } - static bool IsValueObject(Type type) => + /// + /// Whether a type is a scalar value object, by the same rules the analyzer and the metadata reader + /// use: Purview's [Scalar] attributes or IScalarValueObject<,> on a struct or a + /// class, else a non-generic struct with a public Value that is not typed object. + /// + /// + /// Only reached for a property EF has mapped, so a class here has a value converter — which is what + /// makes a class-based value object a column rather than a navigation. + /// + static bool IsValueObject(Type type) + { + if (type.IsEnum || type.IsPrimitive || type == typeof(string) || IsUnion(type)) + { + return false; + } + + if ( + type.GetCustomAttributes(inherit: false) + .Any(a => a.GetType().Name is "ScalarAttribute" or "ScalarAttribute`1") + ) + { + return true; + } + + if (type.GetInterfaces().Any(i => i.IsGenericType && i.Name == "IScalarValueObject`2")) + { + return true; + } + + return type.IsValueType + && !type.IsGenericType + && type.GetProperty("Value", BindingFlags.Public | BindingFlags.Instance) is { } value + && value.PropertyType != typeof(object); + } + + /// Whether a type is a C# union: marked [Union] or implementing IUnion. + static bool IsUnion(Type type) => type.IsValueType - && !type.IsEnum - && !type.IsGenericType - && type.GetProperty("Value", BindingFlags.Public | BindingFlags.Instance) is not null; + && ( + type.GetCustomAttributes(inherit: false) + .Any(a => a.GetType() is { Name: "UnionAttribute", Namespace: "System.Runtime.CompilerServices" }) + || type.GetInterfaces().Any(i => i is { Name: "IUnion", Namespace: "System.Runtime.CompilerServices" }) + ); // The dump contract, mirroring EfModel's JSON shape. Fidelity is omitted: a dump is authoritative, // which is the deserialisation default. @@ -331,6 +517,10 @@ sealed class DumpColumn public string? DomainTypeKind { get; init; } + public string? ComplexPropertyName { get; init; } + + public string? ComplexTypeName { get; init; } + public bool IsNullable { get; init; } public bool IsPrimaryKey { get; init; } diff --git a/src/src/EntityFrameworkCore.ModelDump/EntityFrameworkCore.ModelDump.csproj b/src/src/EntityFrameworkCore.ModelDump/EntityFrameworkCore.ModelDump.csproj index 1b48e0a6..6a2dee8f 100644 --- a/src/src/EntityFrameworkCore.ModelDump/EntityFrameworkCore.ModelDump.csproj +++ b/src/src/EntityFrameworkCore.ModelDump/EntityFrameworkCore.ModelDump.csproj @@ -1,6 +1,18 @@ Exe + + net10.0 + LatestMajor true AspireC4.EntityFrameworkCore.ModelDump AspireC4 Entity Framework Core Model Dump diff --git a/src/src/EntityFrameworkCore.ModelDump/README.md b/src/src/EntityFrameworkCore.ModelDump/README.md index ab0e4d7c..d10c8735 100644 --- a/src/src/EntityFrameworkCore.ModelDump/README.md +++ b/src/src/EntityFrameworkCore.ModelDump/README.md @@ -3,8 +3,10 @@ [![NuGet version](https://img.shields.io/nuget/v/AspireC4.EntityFrameworkCore.ModelDump.svg)](https://www.nuget.org/packages/AspireC4.EntityFrameworkCore.ModelDump) Writes your **real** Entity Framework Core model to `obj/` on every build, so the AspireC4 ER diagram -shows exactly what EF builds: conventions, value converters, `IEntityTypeConfiguration` classes and -composite foreign keys included. +shows what EF builds: conventions, value converters, `IEntityTypeConfiguration` classes, composite +foreign keys, fluently configured complex and owned types (`ComplexProperty`, `OwnsOne`, `OwnsMany`, +`ToJson`), explicit column types and string-converted enums included. Inheritance mapping, delete +behaviour, sequences and check constraints are not shown. This package is **optional**. It is a companion to [`AspireC4.Hosting.EntityFrameworkCore`](https://www.nuget.org/packages/AspireC4.Hosting.EntityFrameworkCore), @@ -35,9 +37,11 @@ The reference is the opt-in. The package is a development dependency, so it adds build output or to the projects that reference yours. The dump tool itself targets **`net10.0`** and is invoked as `dotnet .dll` in an MSBuild `Exec` -after `Build`, so the machine doing the build needs a .NET 10 runtime. A build that cannot run the tool +after `Build`. It rolls forward to the **latest installed major runtime**, so the .NET 11 SDK's runtime +is enough, and a project that builds `net11.0` can be loaded. A build that cannot run the tool simply produces no dump (the target is `ContinueOnError`); the AppHost then falls back to the other -sources. +sources. See the +[requirements page](https://github.com/purview-dev/aspirec4/blob/main/docs/wiki/Requirements.md#the-model-dump-tool). ### 2. Make sure the project has a design-time factory diff --git a/src/src/EntityFrameworkCore/AspireC4EfDiagramOptions.cs b/src/src/EntityFrameworkCore/AspireC4EfDiagramOptions.cs index 0a12cd73..29149047 100644 --- a/src/src/EntityFrameworkCore/AspireC4EfDiagramOptions.cs +++ b/src/src/EntityFrameworkCore/AspireC4EfDiagramOptions.cs @@ -147,6 +147,21 @@ internal static bool IsDefaultOutputDirectory(string? outputDirectory) => /// public bool ExportImages { get; set; } = true; + /// + /// When (default), writes a .gitignore into + /// listing the files this diagram generates there, and one into the image directory ignoring only the + /// export stamps. + /// + /// + /// The diagram itself is regenerated on every run from whatever the projects were last built with, + /// so it is build output rather than source; the exported images are the stable, reviewable part + /// and stay visible to git. Only the files AspireC4 writes are listed, anchored to the directory, and + /// a .gitignore AspireC4 did not write is never touched. Files are rewritten only when their + /// content changes, so turning this off to commit the diagram produces a diff only when the model + /// really changed. + /// + public bool GenerateGitIgnoreFile { get; set; } = true; + /// /// The formats written when is set. Defaults to both SVG and PNG. /// @@ -336,6 +351,19 @@ public AspireC4EfDiagramOptions WithoutImageExport() return this; } + /// + /// Stops AspireC4 writing a .gitignore beside the generated ER diagram, for a repository that + /// commits it deliberately. + /// + /// This options instance, for chaining. + /// + public AspireC4EfDiagramOptions WithoutGitIgnoreFileGeneration() + { + GenerateGitIgnoreFile = false; + + return this; + } + /// Sets how a column shows its domain type next to the database type. /// The display mode. /// This options instance, for chaining. diff --git a/src/src/EntityFrameworkCore/Assets/viewer/app.js b/src/src/EntityFrameworkCore/Assets/viewer/app.js index 99171fdf..8f8754d2 100644 --- a/src/src/EntityFrameworkCore/Assets/viewer/app.js +++ b/src/src/EntityFrameworkCore/Assets/viewer/app.js @@ -168,10 +168,20 @@ const columnBadges = (col) => { const isEnumColumn = (col) => col.domainTypeKind === "Enum"; +/** The word shown beside a type that does not say what it is: an enum's, or a union's. */ +const markerOf = (col) => (isEnumColumn(col) ? "enum" : col.domainTypeKind === "Union" ? "union" : null); + const COMPLEX_KIND = "ComplexValueObject"; -/** The property a flattened complex member belongs to: the segment before the first underscore. */ -const complexOwner = (col) => col.name.split("_")[0]; +/** + * The property a flattened complex member belongs to. Recorded in the model as complexPropertyName; + * a model written before that field existed falls back to the segment before the first underscore, + * which a renamed member or an underscored property name defeats. + */ +const complexOwner = (col) => col.complexPropertyName ?? col.name.split("_")[0]; + +/** Whether a column was flattened out of a complex value object. */ +const isComplexMember = (col) => !!col.complexPropertyName || col.domainTypeKind === COMPLEX_KIND; /** * Folds a table's flattened complex value-object columns under the property that owns them. @@ -186,14 +196,14 @@ const complexOwner = (col) => col.name.split("_")[0]; */ function rowPlan(table) { const columns = table.columns ?? []; - const owners = new Set(columns.filter((c) => c.domainTypeKind === COMPLEX_KIND).map(complexOwner)); + const owners = new Set(columns.filter(isComplexMember).map(complexOwner)); const rows = []; const groups = new Map(); for (const col of columns) { const owner = complexOwner(col); - if (!col.name.includes("_") || !owners.has(owner)) { + if (!owners.has(owner) || (!col.complexPropertyName && !col.name.includes("_"))) { rows.push({ kind: "column", key: col.name, col }); continue; @@ -212,7 +222,9 @@ function rowPlan(table) { // so the shallowest member is the one that reports the property's own type. const shallow = !col.name.slice(owner.length + 1).includes("_"); - if (group.typeName === null && col.domainTypeKind === COMPLEX_KIND && shallow) { + if (group.typeName === null && col.complexTypeName) { + group.typeName = col.complexTypeName; + } else if (group.typeName === null && col.domainTypeKind === COMPLEX_KIND && shallow) { group.typeName = col.domainTypeName; } @@ -227,11 +239,14 @@ function rowPlan(table) { } /** Whether any table flattens a complex value object, and so has anything to expand. */ -const hasComplexColumns = (tables) => - tables.some((t) => (t.columns ?? []).some((c) => c.domainTypeKind === COMPLEX_KIND)); +const hasComplexColumns = (tables) => tables.some((t) => (t.columns ?? []).some(isComplexMember)); -/** The member name shown inside its group, with the owning property's prefix removed. */ -const memberName = (group, col) => col.name.slice(group.name.length + 1); +/** + * The member name shown inside its group, with the owning property's prefix removed. A member renamed + * with HasColumnName does not carry the prefix, and is shown as it is. + */ +const memberName = (group, col) => + col.name.startsWith(`${group.name}_`) ? col.name.slice(group.name.length + 1) : col.name; /** A group row's type cell: the value object, and when it is closed what it is holding. */ const groupType = (group, open) => (open ? group.typeName : `${group.typeName} (${group.columns.length})`); @@ -262,12 +277,12 @@ const bothShown = () => const columnAnnotation = (col) => { if (!col.domainTypeName) return null; - const marker = isEnumColumn(col); + const marker = markerOf(col); let db = col.storeType ?? ""; if (col.maxLength > 0 && !db.includes("(")) db += `(${col.maxLength})`; - if (DOMAIN_TYPE_DISPLAY === "DomainTypeFirst") return marker ? `${db}, enum` : db; - if (DOMAIN_TYPE_DISPLAY === "DatabaseFirst") return marker ? `${col.domainTypeName}, enum` : col.domainTypeName; + if (DOMAIN_TYPE_DISPLAY === "DomainTypeFirst") return marker ? `${db}, ${marker}` : db; + if (DOMAIN_TYPE_DISPLAY === "DatabaseFirst") return marker ? `${col.domainTypeName}, ${marker}` : col.domainTypeName; if (DOMAIN_TYPE_DISPLAY === "DomainTypeOnly") return db; return null; }; @@ -284,7 +299,8 @@ const columnType = (col) => { if (bothShown() && col.domainTypeName) { parts.push(DOMAIN_TYPE_DISPLAY === "DatabaseFirst" ? col.domainTypeName : db); } - if (isEnumColumn(col)) parts.push("enum"); + const marker = markerOf(col); + if (marker) parts.push(marker); let t = parts.length === 0 ? primary : `${primary} (${parts.join(", ")})`; if (col.isNullable && !col.isPrimaryKey) t += "?"; diff --git a/src/src/EntityFrameworkCore/D2/Generators/D2DSLGenerator.cs b/src/src/EntityFrameworkCore/D2/Generators/D2DSLGenerator.cs index e8d630a6..ddd49312 100644 --- a/src/src/EntityFrameworkCore/D2/Generators/D2DSLGenerator.cs +++ b/src/src/EntityFrameworkCore/D2/Generators/D2DSLGenerator.cs @@ -1,4 +1,3 @@ -using System.Globalization; using System.Text; using Aspire.Hosting.AspireC4.EntityFrameworkCore.D2.Models; @@ -61,10 +60,8 @@ public static string Generate(D2Model model, AspireC4EfDiagramOptions options) static void WriteHeader(StringBuilder sb) { - var now = DateTimeOffset.UtcNow; sb.AppendLine("# ============================================================================="); - sb.Append("# AUTO-GENERATED by AspireC4 — ") - .AppendLine(now.ToString("yyyy-MM-dd HH:mm:ss 'UTC'", CultureInfo.InvariantCulture)); + sb.AppendLine("# AUTO-GENERATED by AspireC4"); sb.AppendLine("# Do not edit this file manually. Any changes will be overwritten on the"); sb.AppendLine("# next diagram regeneration (on Aspire startup or resource state change)."); sb.AppendLine("# See https://github.com/purview-dev/aspirec4 for more information."); diff --git a/src/src/EntityFrameworkCore/Dbml/DbmlGenerator.cs b/src/src/EntityFrameworkCore/Dbml/DbmlGenerator.cs index e37c71f7..9a8fab98 100644 --- a/src/src/EntityFrameworkCore/Dbml/DbmlGenerator.cs +++ b/src/src/EntityFrameworkCore/Dbml/DbmlGenerator.cs @@ -1,4 +1,3 @@ -using System.Globalization; using System.Text; using Aspire.Hosting.AspireC4.EntityFrameworkCore.Models; @@ -39,11 +38,8 @@ public static string Generate(EfModel model, AspireC4EfDiagramOptions options) static void WriteHeader(StringBuilder sb, AspireC4EfDiagramOptions options) { - var now = DateTimeOffset.UtcNow; - sb.AppendLine("// ============================================================================="); - sb.Append("// AUTO-GENERATED by AspireC4 — ") - .AppendLine(now.ToString("yyyy-MM-dd HH:mm:ss 'UTC'", CultureInfo.InvariantCulture)); + sb.AppendLine("// AUTO-GENERATED by AspireC4"); sb.AppendLine("// Do not edit this file manually. Any changes will be overwritten on the"); sb.AppendLine("// next diagram regeneration (on Aspire startup or resource state change)."); sb.AppendLine("// See https://github.com/purview-dev/aspirec4 for more information."); diff --git a/src/src/EntityFrameworkCore/DomainTypeDisplay.cs b/src/src/EntityFrameworkCore/DomainTypeDisplay.cs index 5103c8cb..350583a7 100644 --- a/src/src/EntityFrameworkCore/DomainTypeDisplay.cs +++ b/src/src/EntityFrameworkCore/DomainTypeDisplay.cs @@ -1,11 +1,13 @@ namespace Aspire.Hosting.AspireC4.EntityFrameworkCore; /// -/// How a column shows its domain type — a value object or an enum — alongside the real database type. +/// How a column shows its domain type — a value object, an enum or a union — alongside the real database type. /// /// -/// The database type is never discarded: every mode keeps it in the model, and each renderer exposes it -/// in its annotation slot when it is not the headline. +/// The database type is never discarded: every mode keeps it in the model, and each renderer that has an +/// annotation slot — Mermaid's comment, DBML's note, the interactive viewer's tooltip — shows it there when +/// it is not the headline. d2's sql_table rows have no such slot, so under +/// a d2 diagram shows the domain type alone. /// public enum DomainTypeDisplay { @@ -16,8 +18,8 @@ public enum DomainTypeDisplay DatabaseFirst, /// - /// AssetId. The database type is kept in the model and shown in each renderer's annotation - /// slot, but is not the headline. + /// AssetId. The database type is kept in the model and shown in the annotation slot of each + /// renderer that has one, but is not the headline. /// DomainTypeOnly, diff --git a/src/src/EntityFrameworkCore/EfColumnTypeDisplay.cs b/src/src/EntityFrameworkCore/EfColumnTypeDisplay.cs index 6fbe5b4e..97400e1a 100644 --- a/src/src/EntityFrameworkCore/EfColumnTypeDisplay.cs +++ b/src/src/EntityFrameworkCore/EfColumnTypeDisplay.cs @@ -40,8 +40,8 @@ public static string Primary(EfColumn column, AspireC4EfDiagramOptions options) } /// - /// The label kept alongside the headline — the other type, plus the enum marker when the - /// column's declared type is an enum. + /// The label kept alongside the headline — the other type, plus the when the + /// column's declared type is an enum or a union. /// /// when there is nothing to keep. public static string? Annotation(EfColumn column, AspireC4EfDiagramOptions options) @@ -54,13 +54,17 @@ public static string Primary(EfColumn column, AspireC4EfDiagramOptions options) return null; } - var marker = column.DomainTypeKind == EfDomainTypeKind.Enum; + var marker = Marker(column); #pragma warning disable IDE0072 // Add missing cases return options.DomainTypeDisplay switch { - DomainTypeDisplay.DomainTypeFirst => marker ? $"{DatabaseType(column)}, enum" : DatabaseType(column), - DomainTypeDisplay.DatabaseFirst => marker ? $"{column.DomainTypeName}, enum" : column.DomainTypeName, + DomainTypeDisplay.DomainTypeFirst => marker is null + ? DatabaseType(column) + : $"{DatabaseType(column)}, {marker}", + DomainTypeDisplay.DatabaseFirst => marker is null + ? column.DomainTypeName + : $"{column.DomainTypeName}, {marker}", // The database type is not the headline, so it is kept here; the enum marker is inline. DomainTypeDisplay.DomainTypeOnly => DatabaseType(column), _ => null, @@ -89,11 +93,28 @@ options.DomainTypeDisplay is DomainTypeDisplay.DomainTypeFirst or DomainTypeDisp ); } - if (column.DomainTypeKind == EfDomainTypeKind.Enum) + if (Marker(column) is { } marker) { - parts.Add("enum"); + parts.Add(marker); } return parts.Count == 0 ? primary : $"{primary} ({string.Join(", ", parts)})"; } + + /// + /// The word that says what kind of type a column holds when the type name alone does not: enum + /// for an enum, union for a C# union. + /// + /// + /// A value object needs no marker — its name is the point — but an enum and a union both look like + /// any other type name, and both change what the column can hold: a fixed set of values, or one of a + /// fixed set of types. + /// + internal static string? Marker(EfColumn column) => + column.DomainTypeKind switch + { + EfDomainTypeKind.Enum => "enum", + EfDomainTypeKind.Union => "union", + _ => null, + }; } diff --git a/src/src/EntityFrameworkCore/EfModelDumpRead.cs b/src/src/EntityFrameworkCore/EfModelDumpRead.cs new file mode 100644 index 00000000..7a49f1ac --- /dev/null +++ b/src/src/EntityFrameworkCore/EfModelDumpRead.cs @@ -0,0 +1,23 @@ +using Aspire.Hosting.AspireC4.EntityFrameworkCore.Models; + +namespace Aspire.Hosting.AspireC4.EntityFrameworkCore; + +/// +/// The outcome of looking for a build-time EF Core model dump. +/// +/// +/// A closed hierarchy rather than a out parameter: the set of outcomes is +/// fixed here, so a switch over it is exhaustive and adding an outcome fails the build in every +/// consumer until it is handled. The reason matters because the dump is the only discovery source +/// that sees conventions and fluent configuration — a caller that only knows "not found" cannot +/// tell a project that needs rebuilding from one that never referenced the ModelDump package. +/// +closed record class EfModelDumpRead; + +/// A usable dump was found and merged into a model. +/// The model the dump described. +sealed record EfModelDumpLoaded(EfModel Model) : EfModelDumpRead; + +/// No usable dump was found. +/// Why the dump could not be used. +sealed record EfModelDumpUnavailable(EfModelDumpStatus Status) : EfModelDumpRead; diff --git a/src/src/EntityFrameworkCore/EfModelDumpReader.cs b/src/src/EntityFrameworkCore/EfModelDumpReader.cs index ba63b3ec..24e843d6 100644 --- a/src/src/EntityFrameworkCore/EfModelDumpReader.cs +++ b/src/src/EntityFrameworkCore/EfModelDumpReader.cs @@ -23,35 +23,31 @@ static class EfModelDumpReader /// Directory holding the dump, relative to a project's directory. internal const string RelativeDirectory = "obj/aspirec4/ef"; - /// Attempts to read a fresh dump for a project. + /// Reads a fresh dump for a project. /// The project's .csproj path. /// /// The project's build output directory, used only for the freshness check. May be /// , in which case the dump is trusted as-is. /// - /// The dumped model, or . - /// Whether a usable dump was found. - public static bool TryRead(string projectPath, string? outputDirectory, out EfModel model) + public static EfModelDumpRead Read(string projectPath, string? outputDirectory) { - model = EfModel.Empty; - if (string.IsNullOrWhiteSpace(projectPath)) { - return false; + return new EfModelDumpUnavailable(EfModelDumpStatus.ProjectUnresolved); } var projectDirectory = Path.GetDirectoryName(projectPath); if (projectDirectory is null) { - return false; + return new EfModelDumpUnavailable(EfModelDumpStatus.ProjectUnresolved); } var directory = Path.Combine(projectDirectory, "obj", "aspirec4", "ef"); if (!Directory.Exists(directory)) { - return false; + return new EfModelDumpUnavailable(EfModelDumpStatus.Missing); } try @@ -60,7 +56,7 @@ public static bool TryRead(string projectPath, string? outputDirectory, out EfMo if (files.Count == 0) { - return false; + return new EfModelDumpUnavailable(EfModelDumpStatus.Missing); } // A dump that predates the built assembly describes a model that no longer exists. @@ -70,7 +66,7 @@ public static bool TryRead(string projectPath, string? outputDirectory, out EfMo { if (File.GetLastWriteTimeUtc(file) < built) { - return false; + return new EfModelDumpUnavailable(EfModelDumpStatus.Stale); } } } @@ -89,20 +85,18 @@ public static bool TryRead(string projectPath, string? outputDirectory, out EfMo if (models.Count == 0) { - return false; + return new EfModelDumpUnavailable(EfModelDumpStatus.Empty); } - model = EfModelLoader.Merge(models); - - return true; + return new EfModelDumpLoaded(EfModelLoader.Merge(models)); } catch (IOException) { - return false; + return new EfModelDumpUnavailable(EfModelDumpStatus.Unreadable); } catch (UnauthorizedAccessException) { - return false; + return new EfModelDumpUnavailable(EfModelDumpStatus.Unreadable); } } @@ -114,33 +108,33 @@ public static bool TryRead(string projectPath, string? outputDirectory, out EfMo /// persistence project rather than the service the AppHost starts. The restored asset graph carries /// the transitive project closure, so that is what is walked. /// - public static bool TryReadGraph(string projectPath, string? outputDirectory, out EfModel model) + public static EfModelDumpRead ReadGraph(string projectPath, string? outputDirectory) { - model = EfModel.Empty; - List models = []; - if (TryRead(projectPath, outputDirectory, out var own) && own.Contexts.Count > 0) + // The project's own dump decides the reported status: a referenced project is expected to + // have no dump at all when it does not own the EF Core provider. + var own = Read(projectPath, outputDirectory); + + if (own is EfModelDumpLoaded loaded) { - models.Add(own); + models.Add(loaded.Model); } foreach (var referenced in ReferencedProjects(projectPath)) { - if (TryRead(referenced, null, out var dump) && dump.Contexts.Count > 0) + if (Read(referenced, null) is EfModelDumpLoaded referencedDump) { - models.Add(dump); + models.Add(referencedDump.Model); } } if (models.Count == 0) { - return false; + return own; } - model = EfModelLoader.Merge(models); - - return true; + return new EfModelDumpLoaded(EfModelLoader.Merge(models)); } /// The projects a project references, taken from its restored asset graph. diff --git a/src/src/EntityFrameworkCore/EfModelLoader.cs b/src/src/EntityFrameworkCore/EfModelLoader.cs index e27148e3..ecb4b96a 100644 --- a/src/src/EntityFrameworkCore/EfModelLoader.cs +++ b/src/src/EntityFrameworkCore/EfModelLoader.cs @@ -64,16 +64,34 @@ public static EfModel LoadFromProject(string projectPath, out EfModelLoadDiagnos // The build-time dump is the real EF Core model — conventions, converters and composite foreign // keys included — so it wins over the descriptor and the metadata reader. It is read from this // project and every project it references, because it runs where the EF Core provider lives. - if (EfModelDumpReader.TryReadGraph(projectPath, outputDirectory, out var dumped)) + var dump = EfModelDumpReader.ReadGraph(projectPath, outputDirectory); + + // Switching over the closed hierarchy makes a new dump outcome a build failure here rather + // than a diagnostic that silently stops being reported. + var dumpStatus = dump switch { - diagnostics = new EfModelLoadDiagnostics(projectPath, outputDirectory, 0, 0, 0, dumped.Contexts.Count); + EfModelDumpLoaded => EfModelDumpStatus.Loaded, + EfModelDumpUnavailable unavailable => unavailable.Status, + }; - return dumped; + if (dump is EfModelDumpLoaded loaded) + { + diagnostics = new EfModelLoadDiagnostics( + projectPath, + outputDirectory, + 0, + 0, + 0, + loaded.Model.Contexts.Count, + dumpStatus + ); + + return loaded.Model; } if (outputDirectory is null) { - diagnostics = new EfModelLoadDiagnostics(projectPath, null, 0, 0); + diagnostics = new EfModelLoadDiagnostics(projectPath, null, 0, 0, DumpStatus: dumpStatus); return EfModel.Empty; } @@ -100,13 +118,25 @@ public static EfModel LoadFromProject(string projectPath, out EfModelLoadDiagnos } catch (IOException) { - diagnostics = new EfModelLoadDiagnostics(projectPath, outputDirectory, scanned, models.Count); + diagnostics = new EfModelLoadDiagnostics( + projectPath, + outputDirectory, + scanned, + models.Count, + DumpStatus: dumpStatus + ); return EfModel.Empty; } catch (UnauthorizedAccessException) { - diagnostics = new EfModelLoadDiagnostics(projectPath, outputDirectory, scanned, models.Count); + diagnostics = new EfModelLoadDiagnostics( + projectPath, + outputDirectory, + scanned, + models.Count, + DumpStatus: dumpStatus + ); return EfModel.Empty; } @@ -126,7 +156,8 @@ public static EfModel LoadFromProject(string projectPath, out EfModelLoadDiagnos outputDirectory, scanned, withDescriptors.Count, - approximate.Count + approximate.Count, + DumpStatus: dumpStatus ); return Merge(models); @@ -367,11 +398,16 @@ static bool ContainsDescriptor(string directory) /// /// How many contexts came from a build-time dump. Non-zero means the authoritative EF model was used. /// +/// +/// Why the build-time dump did or did not supply the model, so an approximate diagram can name the +/// reason it is approximate. +/// public readonly record struct EfModelLoadDiagnostics( string ProjectPath, string? OutputDirectory, int AssembliesScanned, int DescriptorsFound, int MetadataContextsFound = 0, - int DumpContextsFound = 0 + int DumpContextsFound = 0, + EfModelDumpStatus DumpStatus = EfModelDumpStatus.NotChecked ); diff --git a/src/src/EntityFrameworkCore/EfModelMetadataReader.cs b/src/src/EntityFrameworkCore/EfModelMetadataReader.cs index 9c70165c..a5560d1a 100644 --- a/src/src/EntityFrameworkCore/EfModelMetadataReader.cs +++ b/src/src/EntityFrameworkCore/EfModelMetadataReader.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Reflection.Metadata; using System.Reflection.PortableExecutable; @@ -29,6 +30,8 @@ static class EfModelMetadataReader const string DbSetFullName = "Microsoft.EntityFrameworkCore.DbSet`1"; const string NullableFullName = "System.Nullable`1"; const string UnknownStoreType = "unknown"; + const string JsonStoreType = "json"; + const string CompilerServicesNamespace = "System.Runtime.CompilerServices"; /// How deep nested complex value objects are flattened before the walk stops. const int MaxComplexDepth = 4; @@ -298,12 +301,17 @@ static EfContext ReadContext(Type context) AddReachableEntities(entities); List tables = []; + Dictionary tableByEntity = []; foreach (var pair in entities) { - tables.Add(BuildTable(pair.Key, pair.Value, entities)); + var table = BuildTable(pair.Key, pair.Value, entities); + + tables.Add(table); + tableByEntity[pair.Key] = table; } + ApplyPrincipalSideRelationships(tableByEntity); ResolveForeignKeyTargets(tables); return new EfContext @@ -356,10 +364,46 @@ static TableShape BuildTable(Type entity, string defaultName, Dictionary uniqueFromIndexes) + /// The mapped property. + /// Properties a unique index covers. + /// + /// The store type for a column whose CLR type does not say — a JSON-mapped value object or a + /// collection. [Column(TypeName = ...)] still wins. + /// + /// The domain type to record, in place of the one the property type implies. + static ColumnShape BuildColumn( + PropertyInfo property, + HashSet uniqueFromIndexes, + string? inferredStoreType = null, + (string? Name, EfDomainTypeKind? Kind)? domain = null + ) { - var (name, kind) = DomainType(property.PropertyType, complexType: null); + var (name, kind) = domain ?? DomainType(property.PropertyType, complexType: null); return new ColumnShape { PropertyName = property.Name, Name = GetAttributeString(property, "Column", 0) ?? property.Name, - StoreType = - GetAttributeNamedString(property, "Column", "TypeName") ?? ColumnStoreType(property.PropertyType), + StoreType = GetAttributeNamedString(property, "Column", "TypeName") + ?? inferredStoreType + ?? ColumnStoreType(property.PropertyType), ClrTypeName = property.PropertyType.FullName, DomainTypeName = name, DomainTypeKind = kind, @@ -415,8 +472,20 @@ static ColumnShape BuildColumn(PropertyInfo property, HashSet uniqueFrom /// /// Column names are approximated as {Property}_{Member}. EF's own flattening is only visible /// with a built model, which the build-time dump has and this reader does not. + /// + /// Every member records the outermost property it came from, whatever its own type, so a member that + /// is itself a value object or an enum is still recognised as part of the group. + /// /// - static void AddComplexColumns(TableShape table, string prefix, Type complexType, bool nullable, int depth) + static void AddComplexColumns( + TableShape table, + string prefix, + Type complexType, + bool nullable, + int depth, + string ownerProperty, + string ownerType + ) { if (depth > MaxComplexDepth) { @@ -430,36 +499,62 @@ static void AddComplexColumns(TableShape table, string prefix, Type complexType, continue; } - if (IsComplexValueObject(member.PropertyType)) + var path = $"{prefix}_{member.Name}"; + var memberNullable = nullable || IsNullable(member); + var mapping = ComplexValueObjectMapping(member.PropertyType); + + if (mapping == ComplexMapping.Flattened) { AddComplexColumns( table, - $"{prefix}_{member.Name}", + path, member.PropertyType, - nullable || IsNullable(member), - depth + 1 + memberNullable, + depth + 1, + ownerProperty, + ownerType ); continue; } - if (!IsScalar(member.PropertyType)) + string storeType; + (string? Name, EfDomainTypeKind? Kind) domain; + + if (mapping == ComplexMapping.Json) + { + storeType = JsonStoreType; + domain = (Unwrap(member.PropertyType).Name, EfDomainTypeKind.ComplexValueObject); + } + else if (ScalarCollectionElement(member.PropertyType) is { } element) + { + storeType = JsonStoreType; + domain = CollectionDomainType(element); + } + else if (IsScalar(member.PropertyType)) + { + storeType = ColumnStoreType(member.PropertyType); + domain = DomainType(member.PropertyType, Unwrap(complexType).Name); + } + else { continue; } - var (name, kind) = DomainType(member.PropertyType, Unwrap(complexType).Name); + var (name, kind) = domain; table.Columns.Add( new ColumnShape { - PropertyName = $"{prefix}_{member.Name}", - Name = GetAttributeString(member, "Column", 0) ?? $"{prefix}_{member.Name}", - StoreType = ColumnStoreType(member.PropertyType), + PropertyName = path, + Name = GetAttributeString(member, "Column", 0) ?? path, + StoreType = GetAttributeNamedString(member, "Column", "TypeName") ?? storeType, ClrTypeName = member.PropertyType.FullName, DomainTypeName = name, DomainTypeKind = kind, - IsNullable = nullable || IsNullable(member), + ComplexPropertyName = ownerProperty, + ComplexTypeName = ownerType, + IsNullable = memberNullable, MaxLength = GetAttributeInt(member, "MaxLength", 0) ?? GetAttributeInt(member, "StringLength", 0), Comment = GetAttributeString(member, "Comment", 0), } @@ -524,8 +619,9 @@ Dictionary entities var explicitName = GetAttributeString(navigation, "ForeignKey", 0); - var column = - (explicitName is null ? null : table.Columns.FirstOrDefault(c => c.PropertyName == explicitName)) + var column = ( + explicitName is null ? null : table.Columns.FirstOrDefault(c => c.PropertyName == explicitName) + ) ?? table.Columns.FirstOrDefault(c => c.PropertyName == navigation.Name + "Id") ?? table.Columns.FirstOrDefault(c => c.PropertyName == target.Name + "Id") ?? SharedPrimaryKeyColumn(table, target); @@ -661,11 +757,153 @@ static void ResolveForeignKeyTargets(List tables) var principal = byClrType[foreignKey.PrincipalEntity]; foreignKey.PrincipalEntity = principal.Name; - foreignKey.PrincipalColumns = [.. principal.Columns.Where(c => c.IsPrimaryKey).Select(c => c.Name)]; + foreignKey.PrincipalColumns = PrincipalColumns( + foreignKey, + table, + [.. principal.Columns.Where(c => c.IsPrimaryKey)] + ); } } } + /// The principal key columns a foreign key's columns point at, one for one. + /// + /// A foreign key found from a navigation has one column; a composite principal key has several. + /// Attaching them all produced a reference whose sides differ in length — a d2 edge to whichever key + /// column came first, and DBML that does not parse — so each column is paired by type, then by name. + /// + static List PrincipalColumns(ForeignKeyShape foreignKey, TableShape dependent, List key) + { + if (key.Count == 0 || key.Count == foreignKey.Columns.Count) + { + return [.. key.Select(c => c.Name)]; + } + + List paired = []; + + foreach (var name in foreignKey.Columns) + { + var column = dependent.Columns.FirstOrDefault(c => c.Name == name); + var sameType = key.Where(k => column is not null && k.ClrTypeName == column.ClrTypeName).ToList(); + + var match = + sameType.Count == 1 + ? sameType[0] + : key.FirstOrDefault(k => string.Equals(k.Name, name, StringComparison.OrdinalIgnoreCase)) + ?? sameType.FirstOrDefault() + ?? key[0]; + + paired.Add(match.Name); + } + + return paired; + } + + /// + /// Adds the relationships EF Core discovers from the principal's side alone — a collection + /// navigation on the principal, and no matching reference navigation or foreign-key property on the + /// dependent. + /// + /// + /// The analyzer's rule: the foreign key is matched by EF's names — {Principal}{Key}, the key's + /// own name when it already starts with the principal's, then {Principal}Id — and when nothing + /// matches, EF's shadow foreign key is shown under that first name. A collection answered by a + /// collection is a many-to-many through a join table EF owns, and is left alone. + /// + static void ApplyPrincipalSideRelationships(Dictionary tables) + { + foreach (var (principal, principalTable) in tables) + { + var key = principalTable.Columns.Where(c => c.IsPrimaryKey).ToList(); + + if (key.Count != 1) + { + continue; + } + + var principalName = principal.FullName ?? principal.Name; + + foreach (var navigation in principal.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if ( + !IsCollection(navigation.PropertyType) + || GetNavigationTarget(navigation.PropertyType) is not { } dependent + || !tables.TryGetValue(dependent, out var dependentTable) + || dependentTable.ForeignKeys.Any(fk => fk.PrincipalEntity == principalName) + ) + { + continue; + } + + // On a self-referencing entity the navigation is its own dependent's property, and is not an inverse. + var inverse = dependent + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => GetNavigationTarget(p.PropertyType) == principal && p != navigation) + .ToList(); + + if (inverse.Any(p => IsCollection(p.PropertyType))) + { + continue; + } + + var column = ForeignKeyByConvention(dependentTable, principal.Name, key[0]) + ?? AddShadowForeignKey(dependentTable, principal.Name, key[0]); + + column.IsForeignKey = true; + + dependentTable.ForeignKeys.Add( + new ForeignKeyShape + { + PrincipalEntity = principalName, + NavigationName = inverse.Count == 1 ? inverse[0].Name : null, + PrincipalNavigationName = navigation.Name, + IsRequired = !column.IsNullable, + Columns = [column.Name], + } + ); + } + } + } + + static ColumnShape? ForeignKeyByConvention(TableShape dependent, string principal, ColumnShape key) => + new[] { ShadowForeignKeyName(principal, key), $"{principal}Id" } + .Select(name => + dependent.Columns.FirstOrDefault(c => + !c.IsForeignKey && string.Equals(c.PropertyName, name, StringComparison.OrdinalIgnoreCase) + ) + ) + .FirstOrDefault(c => c is not null); + + /// + /// The name EF Core gives a foreign key it has to invent: the principal's name and its key's, unless + /// the key's name already starts with the principal's. + /// + static string ShadowForeignKeyName(string principal, ColumnShape key) => + key.PropertyName.StartsWith(principal, StringComparison.OrdinalIgnoreCase) + ? key.PropertyName + : principal + key.PropertyName; + + /// The shadow foreign-key column EF Core creates when the dependent declares none. + static ColumnShape AddShadowForeignKey(TableShape dependent, string principal, ColumnShape key) + { + var name = ShadowForeignKeyName(principal, key); + + ColumnShape column = new() + { + PropertyName = name, + Name = name, + StoreType = key.StoreType, + ClrTypeName = key.ClrTypeName, + DomainTypeName = key.DomainTypeName, + DomainTypeKind = key.DomainTypeKind, + IsNullable = true, + }; + + dependent.Columns.Add(column); + + return column; + } + /// Reads [Index(nameof(X), IsUnique = true)] declarations from the entity type. static HashSet ReadUniqueIndexProperties(Type entity) { @@ -715,10 +953,10 @@ static HashSet ReadUniqueIndexProperties(Type entity) { if (type.IsGenericType && IsCollection(type)) { - return type.GetGenericArguments()[0]; + return ScalarCollectionElement(type) is null ? type.GetGenericArguments()[0] : null; } - if (!type.IsClass || IsComplexValueObject(type)) + if (!type.IsClass || ComplexValueObjectMapping(type) != ComplexMapping.None) { return null; } @@ -769,7 +1007,7 @@ static bool IsScalar(Type type) { var unwrapped = Unwrap(type); - if (unwrapped.IsEnum || IsValueObject(unwrapped)) + if (unwrapped.IsEnum || IsValueObject(unwrapped) || IsUnion(unwrapped)) { return true; } @@ -781,20 +1019,79 @@ static bool IsScalar(Type type) } /// - /// Determines whether a type is a scalar value object: a struct that wraps a single value under a - /// Value member, as a strongly-typed identifier does. + /// Determines whether a type is a scalar value object: a type that wraps a single value, as a + /// strongly-typed identifier does. /// /// - /// A struct is never an EF entity, so recognising one as a scalar is safe. The Value member - /// is what the value-object generators emit — including a forwarding Value when the author - /// names the underlying member something else — so this matches a strongly-typed id without - /// depending on the library that produced it. + /// Recognised the same way the analyzer does, so the two sources agree: a Purview [Scalar], + /// [Scalar<T>] or [Scalar(typeof(T))] attribute on a struct or a class; + /// IScalarValueObject<TSelf, TValue>; or a non-generic struct with a public instance + /// Value that is not typed object. A union is never one. Compiled metadata already holds + /// a generated Value, so the shape alone finds more here than in source — the attributes are + /// what make a class-based value object a column rather than a phantom table. /// - static bool IsValueObject(Type type) => - type.IsValueType - && !type.IsEnum - && !type.IsGenericType - && type.GetProperty("Value", BindingFlags.Public | BindingFlags.Instance) is not null; + static bool IsValueObject(Type type) => ScalarValueObject(type).IsValueObject; + + static (bool IsValueObject, Type? Underlying) ScalarValueObject(Type type) + { + if (type.IsEnum || type.IsPrimitive || !(type.IsValueType || type.IsClass) || IsUnion(type)) + { + return (false, null); + } + + foreach (var attribute in Attributes(type)) + { + if ( + !TryAttributeType(attribute, out var attributeType) + || attributeType.Name is not ("ScalarAttribute" or "ScalarAttribute`1") + ) + { + continue; + } + + if (attributeType.IsGenericType) + { + return (true, attributeType.GetGenericArguments()[0]); + } + + string? propertyName = null; + + foreach (var argument in attribute.ConstructorArguments) + { + if (argument.Value is Type valueType) + { + return (true, valueType); + } + + propertyName ??= argument.Value as string; + } + + var named = type.GetProperty(propertyName ?? "Value", BindingFlags.Public | BindingFlags.Instance) + ?? type.GetProperty("Value", BindingFlags.Public | BindingFlags.Instance); + + return (true, named?.PropertyType); + } + + foreach (var contract in Interfaces(type)) + { + if (contract.IsGenericType && contract.Name == "IScalarValueObject`2") + { + return (true, contract.GetGenericArguments()[1]); + } + } + + if (type.IsValueType && !type.IsGenericType) + { + var value = type.GetProperty("Value", BindingFlags.Public | BindingFlags.Instance); + + if (value is not null && value.PropertyType.FullName != "System.Object") + { + return (true, value.PropertyType); + } + } + + return (false, null); + } /// The scalar value-object type a property is backed by, or . static Type? ValueObjectType(Type type) @@ -804,18 +1101,49 @@ static bool IsValueObject(Type type) => return IsValueObject(unwrapped) ? unwrapped : null; } - /// The primitive a scalar value object wraps. - static Type? ValueObjectUnderlying(Type valueObject) => - valueObject.GetProperty("Value", BindingFlags.Public | BindingFlags.Instance)?.PropertyType; - /// The real database type for a property, resolving a scalar value object to its primitive. static string ColumnStoreType(Type propertyType) { - var valueObject = ValueObjectType(propertyType); + var (isValueObject, underlying) = ScalarValueObject(Unwrap(propertyType)); + + if (isValueObject) + { + return underlying is null ? UnknownStoreType : ResolveStoreType(underlying); + } + + return IsUnion(propertyType) ? UnknownStoreType : ResolveStoreType(propertyType); + } + + /// + /// Whether a type is a C# union — marked [Union] or implementing IUnion, as the + /// compiler makes a union declaration. + /// + /// + /// Checked before the value-object shape, because a union's generated Value accessor matches + /// it. Both types live in the .NET 11 runtime, so a host on .NET 10 cannot resolve them; that is read + /// as "not a union" for that one type rather than failing the whole assembly. + /// + static bool IsUnion(Type type) + { + var unwrapped = Unwrap(type); - return valueObject is not null && ValueObjectUnderlying(valueObject) is { } underlying - ? ResolveStoreType(underlying) - : ResolveStoreType(propertyType); + if (!unwrapped.IsValueType || unwrapped.IsEnum || unwrapped.IsPrimitive) + { + return false; + } + + foreach (var attribute in Attributes(unwrapped)) + { + if ( + TryAttributeType(attribute, out var attributeType) + && attributeType is { Name: "UnionAttribute", Namespace: CompilerServicesNamespace } + ) + { + return true; + } + } + + return Interfaces(unwrapped).Any(i => i is { Name: "IUnion", Namespace: CompilerServicesNamespace }); } /// @@ -829,6 +1157,13 @@ static string ColumnStoreType(Type propertyType) /// static (string? Name, EfDomainTypeKind? Kind) DomainType(Type type, string? complexType) { + var unwrapped = Unwrap(type); + + if (IsUnion(unwrapped)) + { + return (unwrapped.Name, EfDomainTypeKind.Union); + } + var valueObject = ValueObjectType(type); if (valueObject is not null) @@ -836,8 +1171,6 @@ static string ColumnStoreType(Type propertyType) return (valueObject.Name, EfDomainTypeKind.ValueObject); } - var unwrapped = Unwrap(type); - if (unwrapped.IsEnum) { return (unwrapped.Name, EfDomainTypeKind.Enum); @@ -846,50 +1179,208 @@ static string ColumnStoreType(Type propertyType) return complexType is not null ? (complexType, EfDomainTypeKind.ComplexValueObject) : (null, null); } + /// How a type is mapped as a complex value object, or . + /// + /// The analyzer's rules: Purview's [ValueObject] by its EFMapping, else the assembly's + /// [ValueObjectDefaults], else ComplexType — the library default, so a bare + /// [ValueObject] is flattened; EF Core's [ComplexType]; and [Owned], whose single + /// reference shares its owner's table under the same {Navigation}_{Property} names. + /// + static ComplexMapping ComplexValueObjectMapping(Type type) + { + var unwrapped = Unwrap(type); + + if (unwrapped.IsPrimitive || unwrapped.IsEnum || IsFrameworkType(unwrapped) || IsValueObject(unwrapped)) + { + return ComplexMapping.None; + } + + foreach (var attribute in Attributes(unwrapped)) + { + if (!TryAttributeType(attribute, out var attributeType)) + { + continue; + } + + switch (attributeType.Name) + { + case "ValueObjectAttribute": + return MappingOf( + EnumMemberName(attribute, "EFMapping") ?? AssemblyDefaultMapping(unwrapped) ?? "ComplexType" + ); + + case "ComplexTypeAttribute" + or "OwnedAttribute": + return ComplexMapping.Flattened; + + default: + break; + } + } + + return ComplexMapping.None; + } + + static ComplexMapping MappingOf(string member) => + member switch + { + "ComplexType" => ComplexMapping.Flattened, + "Json" => ComplexMapping.Json, + _ => ComplexMapping.None, + }; + + static string? AssemblyDefaultMapping(Type type) + { + IList attributes; + + try + { + attributes = type.Assembly.GetCustomAttributesData(); + } + catch (Exception ex) when (IsLoadFailure(ex)) + { + return null; + } + + foreach (var attribute in attributes) + { + if ( + TryAttributeType(attribute, out var attributeType) + && attributeType.Name == "ValueObjectDefaultsAttribute" + ) + { + return EnumMemberName(attribute, "EFMapping"); + } + } + + return null; + } + /// - /// Whether a type is mapped as an EF Core complex type — a Purview complex value object. + /// The member name of an enum-typed named argument, or when it was not set. /// - /// Matched by attribute name, so no dependency on the value-object package is taken. - static bool IsComplexValueObject(Type type) + /// Metadata reports the underlying value, so it is mapped back to the member that declares it. + static string? EnumMemberName(CustomAttributeData attribute, string argumentName) { - foreach (var attribute in Unwrap(type).GetCustomAttributesData()) + foreach (var argument in attribute.NamedArguments) { - if (attribute.AttributeType.Name is not ("ValueObjectAttribute" or "ValueObject")) + if (argument.MemberName != argumentName || argument.TypedValue.Value is not { } value) { continue; } - var named = attribute.NamedArguments.FirstOrDefault(a => a.MemberName == "EFMapping"); + var type = argument.TypedValue.ArgumentType; - if (IsNamedEnumMember(named.TypedValue.ArgumentType, named.TypedValue.Value, "ComplexType")) + try { - return true; + if (type.IsEnum && Enum.GetName(type, value) is { } name) + { + return name; + } + } + catch (Exception ex) when (IsLoadFailure(ex) || ex is InvalidOperationException) + { + // Fall through to the raw value. } + + return value.ToString(); } - return false; + return null; + } + + /// A member's attributes, or none when their types cannot be resolved from metadata. + static IList Attributes(MemberInfo member) + { + try + { + return member.GetCustomAttributesData(); + } + catch (Exception ex) when (IsLoadFailure(ex)) + { + return []; + } + } + + static bool TryAttributeType(CustomAttributeData attribute, [NotNullWhen(true)] out Type? attributeType) + { + try + { + attributeType = attribute.AttributeType; + + return true; + } + catch (Exception ex) when (IsLoadFailure(ex)) + { + attributeType = null; + + return false; + } + } + + static Type[] Interfaces(Type type) + { + try + { + return type.GetInterfaces(); + } + catch (Exception ex) when (IsLoadFailure(ex)) + { + return []; + } } /// - /// Whether a constant is the named member of its enum type. + /// The element type of a collection EF Core stores in one JSON column, or when + /// the collection is a navigation. /// /// - /// The compiler reports either the member or its underlying value, so both are accepted. + /// Primitive, value-object, enum and complex collections are a JSON column; a collection of entities — + /// or of [Owned] types, which get a table of their own — is a navigation. Without this a + /// List<string> became a String table with Length and Chars columns. /// - static bool IsNamedEnumMember(Type type, object? value, string member) + static Type? ScalarCollectionElement(Type type) { - if (value is null) + var element = type switch { - return false; + { IsArray: true } when type.GetElementType() is { FullName: not "System.Byte" } arrayElement => + arrayElement, + { IsGenericType: true } when IsCollection(type) => type.GetGenericArguments()[0], + _ => null, + }; + + if (element is null || HasAttribute(Unwrap(element), "Owned")) + { + return null; } - if (string.Equals(value.ToString(), member, StringComparison.Ordinal)) + return IsScalar(element) || ComplexValueObjectMapping(element) != ComplexMapping.None ? element : null; + } + + /// The domain type of a JSON collection column: the element's own, as an array. + static (string? Name, EfDomainTypeKind? Kind) CollectionDomainType(Type element) + { + if (ComplexValueObjectMapping(element) != ComplexMapping.None) { - return true; + return ($"{Unwrap(element).Name}[]", EfDomainTypeKind.ComplexValueObject); } - // EF's attribute reader reports the underlying value, so check that too. - return type.IsEnum && string.Equals(Enum.GetName(type, value), member, StringComparison.Ordinal); + var (name, kind) = DomainType(element, complexType: null); + + return name is null ? (null, null) : ($"{name}[]", kind); + } + + /// An enum is stored as its underlying integral type, which need not be int. + static string EnumUnderlyingStoreType(Type enumType) + { + try + { + return ResolveStoreType(enumType.GetEnumUnderlyingType()); + } + catch (Exception ex) when (IsLoadFailure(ex) || ex is InvalidOperationException) + { + return "int"; + } } static string ResolveStoreType(Type type) @@ -898,7 +1389,7 @@ static string ResolveStoreType(Type type) if (unwrapped.IsEnum) { - return "int"; + return EnumUnderlyingStoreType(unwrapped); } if (unwrapped.IsArray) @@ -971,10 +1462,10 @@ static bool IsNullable(PropertyInfo property) } static bool HasAttribute(MemberInfo member, string name) => - member.GetCustomAttributesData().Any(attribute => IsAttribute(attribute, name)); + Attributes(member).Any(attribute => IsAttribute(attribute, name)); static bool IsAttribute(CustomAttributeData attribute, string name) => - attribute.AttributeType.Name == name || attribute.AttributeType.Name == name + "Attribute"; + TryAttributeType(attribute, out var type) && (type.Name == name || type.Name == name + "Attribute"); static string? GetAttributeString(MemberInfo member, string name, int index) => GetConstructorArgument(member, name, index)?.Value as string; @@ -1024,6 +1515,8 @@ static EfTable ToTable(TableShape shape) => ClrTypeName = c.ClrTypeName, DomainTypeName = c.DomainTypeName, DomainTypeKind = c.DomainTypeKind, + ComplexPropertyName = c.ComplexPropertyName, + ComplexTypeName = c.ComplexTypeName, IsNullable = c.IsNullable, IsPrimaryKey = c.IsPrimaryKey, IsUnique = c.IsUnique, @@ -1047,6 +1540,19 @@ static EfTable ToTable(TableShape shape) => ], }; + /// How a complex value object reaches the database. + enum ComplexMapping + { + /// Not a complex value object. + None, + + /// Flattened into the owner's table, one column per member. + Flattened, + + /// Serialised into one JSON column on the owner's table. + Json, + } + sealed class TableShape { public required string Name { get; set; } @@ -1076,6 +1582,10 @@ sealed class ColumnShape public EfDomainTypeKind? DomainTypeKind { get; init; } + public string? ComplexPropertyName { get; init; } + + public string? ComplexTypeName { get; init; } + public bool IsNullable { get; set; } public bool IsPrimaryKey { get; set; } diff --git a/src/src/EntityFrameworkCore/ErdImageExporter.cs b/src/src/EntityFrameworkCore/ErdImageExporter.cs index 8663e182..847084e3 100644 --- a/src/src/EntityFrameworkCore/ErdImageExporter.cs +++ b/src/src/EntityFrameworkCore/ErdImageExporter.cs @@ -171,6 +171,11 @@ CancellationToken cancellationToken .Select(f => Path.Combine(imageDirectory, $"{ImageBaseName(options.FileName)}.{Extension(f)}")) .ToArray(); + if (options.GenerateGitIgnoreFile) + { + await ImageExportStamp.WriteGitIgnoreAsync(imageDirectory, cancellationToken).ConfigureAwait(false); + } + // Checked before anything is written or run. The AppHost's own change detection resets every // time it restarts, so without this a restart re-renders images that are already correct — and // on the viewer path that means pulling and starting a multi-gigabyte browser for nothing. @@ -212,7 +217,8 @@ await ImageExportStamp // what the d2 renderer writes, so enabling export never changes what it already produced. if (options.Renderer != ErdRenderer.D2) { - await File.WriteAllTextAsync( + await GeneratedOutput + .WriteIfChangedAsync( Path.Combine(outputDirectory, $"{options.FileName}.d2"), D2DSLGenerator.Generate(D2ModelBuilder.Build(model, options), options), cancellationToken diff --git a/src/src/EntityFrameworkCore/Extensions/Aspire/Hosting/AspireC4EfResourceBuilderExtensions.cs b/src/src/EntityFrameworkCore/Extensions/Aspire/Hosting/AspireC4EfResourceBuilderExtensions.cs index 1edc0bf1..7dadd8d8 100644 --- a/src/src/EntityFrameworkCore/Extensions/Aspire/Hosting/AspireC4EfResourceBuilderExtensions.cs +++ b/src/src/EntityFrameworkCore/Extensions/Aspire/Hosting/AspireC4EfResourceBuilderExtensions.cs @@ -223,8 +223,7 @@ public static IResourceBuilder WithLocalD2CLI( { ArgumentNullException.ThrowIfNull(builder); - var diagram = - builder.ApplicationBuilder.Resources.OfType().FirstOrDefault() + var diagram = builder.ApplicationBuilder.Resources.OfType().FirstOrDefault() ?? throw new InvalidOperationException( $"No ER diagram resource was found. Call {nameof(WithEntityFrameworkDiagram)}() before {nameof(WithLocalD2CLI)}()." ); @@ -283,14 +282,11 @@ public static IResourceBuilder WithLocalD2CLI( return Task.CompletedTask; }) .WithHttpEndpoint(targetPort: D2ServerResource.DefaultPort, name: AspireC4EfResource.HttpEndpointName) - .WithUrlForEndpoint( - AspireC4EfResource.HttpEndpointName, - url => - { - url.DisplayText = "View ER Diagram"; - url.DisplayLocation = UrlDisplayLocation.SummaryAndDetails; - } - ) + .WithUrlForEndpoint(AspireC4EfResource.HttpEndpointName, url => + { + url.DisplayText = "View ER Diagram"; + url.DisplayLocation = UrlDisplayLocation.SummaryAndDetails; + }) .ExcludeFromLikeC4() .ExcludeFromManifest(); @@ -330,14 +326,11 @@ static IResourceBuilder CreateViewerServerResource( targetPort: ErdViewerServerResource.DefaultPort, name: AspireC4EfResource.HttpEndpointName ) - .WithUrlForEndpoint( - AspireC4EfResource.HttpEndpointName, - url => - { - url.DisplayText = "View ER Diagram"; - url.DisplayLocation = UrlDisplayLocation.SummaryAndDetails; - } - ) + .WithUrlForEndpoint(AspireC4EfResource.HttpEndpointName, url => + { + url.DisplayText = "View ER Diagram"; + url.DisplayLocation = UrlDisplayLocation.SummaryAndDetails; + }) .WithHttpHealthCheck("/", statusCode: 200, endpointName: AspireC4EfResource.HttpEndpointName) .ExcludeFromLikeC4() .ExcludeFromManifest(); @@ -362,14 +355,11 @@ static IResourceBuilder CreateServerResource( targetPort: D2ServerResource.DefaultPort, name: AspireC4EfResource.HttpEndpointName ) - .WithUrlForEndpoint( - AspireC4EfResource.HttpEndpointName, - url => - { - url.DisplayText = "View ER Diagram"; - url.DisplayLocation = UrlDisplayLocation.SummaryAndDetails; - } - ) + .WithUrlForEndpoint(AspireC4EfResource.HttpEndpointName, url => + { + url.DisplayText = "View ER Diagram"; + url.DisplayLocation = UrlDisplayLocation.SummaryAndDetails; + }) .WithArgs( "--watch", // Without an explicit host d2 binds to localhost inside the container and the diff --git a/src/src/EntityFrameworkCore/InteractiveViewer.cs b/src/src/EntityFrameworkCore/InteractiveViewer.cs index cbe79d90..b097438c 100644 --- a/src/src/EntityFrameworkCore/InteractiveViewer.cs +++ b/src/src/EntityFrameworkCore/InteractiveViewer.cs @@ -166,8 +166,7 @@ static Stream OpenVendorAsset(string name) => static string Read(string name) { - using var stream = - typeof(InteractiveViewer).Assembly.GetManifestResourceStream(Prefix + name) + using var stream = typeof(InteractiveViewer).Assembly.GetManifestResourceStream(Prefix + name) ?? throw new InvalidOperationException( $"Embedded viewer asset '{name}' was not found. Expected resource '{Prefix}{name}'." ); diff --git a/src/src/EntityFrameworkCore/Lifecycle/AspireC4EfLifecycleHook.CrossLinking.cs b/src/src/EntityFrameworkCore/Lifecycle/AspireC4EfLifecycleHook.CrossLinking.cs index ce585381..46a1e3e5 100644 --- a/src/src/EntityFrameworkCore/Lifecycle/AspireC4EfLifecycleHook.CrossLinking.cs +++ b/src/src/EntityFrameworkCore/Lifecycle/AspireC4EfLifecycleHook.CrossLinking.cs @@ -247,24 +247,22 @@ async Task CrossLinkAsync(AspireC4EfResource resource, CancellationToken cancell ApplyArchitectureDiagramLink(contributor, url); await resourceNotificationService - .PublishUpdateAsync( - contributor, - snapshot => - snapshot.Urls.Any(u => u.Name == ErDiagramUrlName) - ? snapshot - : snapshot with - { - Urls = - [ - .. snapshot.Urls, - new UrlSnapshot(ErDiagramUrlName, url, IsInternal: false) - { - DisplayProperties = new UrlDisplayPropertiesSnapshot( - _options.DashboardLinkDisplayName - ), - }, - ], - } + .PublishUpdateAsync(contributor, snapshot => + snapshot.Urls.Any(u => u.Name == ErDiagramUrlName) + ? snapshot + : snapshot with + { + Urls = + [ + .. snapshot.Urls, + new UrlSnapshot(ErDiagramUrlName, url, IsInternal: false) + { + DisplayProperties = new UrlDisplayPropertiesSnapshot( + _options.DashboardLinkDisplayName + ), + }, + ], + } ) .ConfigureAwait(false); } diff --git a/src/src/EntityFrameworkCore/Lifecycle/AspireC4EfLifecycleHook.cs b/src/src/EntityFrameworkCore/Lifecycle/AspireC4EfLifecycleHook.cs index b650941b..639f26ab 100644 --- a/src/src/EntityFrameworkCore/Lifecycle/AspireC4EfLifecycleHook.cs +++ b/src/src/EntityFrameworkCore/Lifecycle/AspireC4EfLifecycleHook.cs @@ -143,8 +143,10 @@ CancellationToken cancellationToken var files = ErdOutput.Create(efModel, _options); - // The first file is the diagram itself; its header carries a timestamp, so comparing the - // stripped body keeps the file off the git diff when nothing has actually changed. + // The first file is the diagram itself. This in-memory check decides whether the diagram changed + // within this run; it starts empty on every run, so the writes below also compare against the + // bytes already on disk, which is what keeps a restart of an unchanged application from touching + // the working tree. var body = StripHeader(files[0].Content); if (string.Equals(body, _lastRawBody, StringComparison.Ordinal)) @@ -162,15 +164,21 @@ CancellationToken cancellationToken { var path = Path.Combine(directory, file.FileName); - await File.WriteAllTextAsync(path, file.Content, cancellationToken).ConfigureAwait(false); - // Remembered so the dashboard command knows what to open. if (file.FileName.Equals("index.html", StringComparison.OrdinalIgnoreCase)) { _viewerPath = path; } - ErdLog.Wrote(logger, path); + if (await GeneratedOutput.WriteIfChangedAsync(path, file.Content, cancellationToken).ConfigureAwait(false)) + { + ErdLog.Wrote(logger, path); + } + } + + if (_options.GenerateGitIgnoreFile) + { + await WriteGitIgnoreAsync(directory, files, logger, cancellationToken).ConfigureAwait(false); } var tables = efModel.Contexts.Sum(c => c.Tables.Count); @@ -226,19 +234,12 @@ CancellationToken cancellationToken // This resource runs no process of its own, so it has to publish its own state or the // dashboard leaves it Unknown with no logs. await resourceNotificationService - .PublishUpdateAsync( - resource, - snapshot => - snapshot with - { - State = KnownResourceStates.Running, - Properties = WithDiagramProperties( - snapshot.Properties, - directory, - efModel.Contexts.Count, - tables - ), - } + .PublishUpdateAsync(resource, snapshot => + snapshot with + { + State = KnownResourceStates.Running, + Properties = WithDiagramProperties(snapshot.Properties, directory, efModel.Contexts.Count, tables), + } ) .ConfigureAwait(false); @@ -363,7 +364,8 @@ internal static EfModelLoad LoadModel( diagnostics.ProjectPath, diagnostics.OutputDirectory ?? "(none)", diagnostics.AssembliesScanned, - diagnostics.DescriptorsFound + diagnostics.DescriptorsFound, + diagnostics.DumpStatus ); } } @@ -464,6 +466,38 @@ CancellationToken cancellationToken } } + /// + /// Lists everything this diagram writes into its output directory in a .gitignore there. + /// + /// + /// vendor/ holds the viewer's bundled JavaScript, and the d2 source is written beside the + /// diagram whenever images are exported with a different renderer; both are listed whether or not + /// they exist yet, so the file does not change as they appear. + /// + async Task WriteGitIgnoreAsync( + string directory, + IReadOnlyList files, + ILogger logger, + CancellationToken cancellationToken + ) + { + var result = await GeneratedOutput + .WriteGitIgnoreAsync( + directory, + "The Entity Framework Core ER diagram generated by AspireC4.", + [.. files.Select(f => f.FileName), $"{_options.FileName}.d2", "vendor/"], + cancellationToken + ) + .ConfigureAwait(false); + + if (result == GitIgnoreWrite.KeptUserFile) + { + var path = Path.Combine(directory, GeneratedOutput.GitIgnoreFileName); + + ErdLog.GitIgnoreFileKept(logger, path); + } + } + /// Removes the leading comment header so generated files compare by body alone. internal static string StripHeader(string dsl) { diff --git a/src/src/EntityFrameworkCore/Lifecycle/ErdLog.cs b/src/src/EntityFrameworkCore/Lifecycle/ErdLog.cs index 0a721907..444893b2 100644 --- a/src/src/EntityFrameworkCore/Lifecycle/ErdLog.cs +++ b/src/src/EntityFrameworkCore/Lifecycle/ErdLog.cs @@ -1,3 +1,4 @@ +using Aspire.Hosting.AspireC4.EntityFrameworkCore.Models; using Microsoft.Extensions.Logging; namespace Aspire.Hosting.AspireC4.EntityFrameworkCore.Lifecycle; @@ -34,6 +35,13 @@ static partial class ErdLog [LoggerMessage(Level = LogLevel.Information, Message = "ER diagram unchanged; nothing written.")] public static partial void Unchanged(ILogger logger); + [LoggerMessage( + Level = LogLevel.Information, + Message = "{Path} was not written by AspireC4, so it was left alone; the generated ER diagram files " + + "may show up in git. Add them to it, or remove it to let AspireC4 manage the file." + )] + public static partial void GitIgnoreFileKept(ILogger logger, string path); + [LoggerMessage( Level = LogLevel.Warning, Message = "No Entity Framework Core models were found. Ensure at least one project resource that " @@ -48,15 +56,18 @@ static partial class ErdLog Level = LogLevel.Warning, Message = "No Entity Framework Core models were found for project {ProjectPath} " + "(output directory: {OutputDirectory}, {AssembliesScanned} assembly(ies) read, " - + "{DescriptorsFound} descriptor(s) found). Ensure the project has been built at least once; " - + "add the AspireC4.EntityFrameworkCore package to a project you own for full fidelity." + + "{DescriptorsFound} descriptor(s) found, model dump: {DumpStatus}). Ensure the project has " + + "been built at least once; add the AspireC4.EntityFrameworkCore package to a project you own " + + "for full fidelity, and AspireC4.EntityFrameworkCore.ModelDump to the project that owns the " + + "provider for the authoritative model." )] public static partial void ProjectProducedNoModel( ILogger logger, string projectPath, string outputDirectory, int assembliesScanned, - int descriptorsFound + int descriptorsFound, + EfModelDumpStatus dumpStatus ); [LoggerMessage( diff --git a/src/src/EntityFrameworkCore/Mermaid/MermaidGenerator.cs b/src/src/EntityFrameworkCore/Mermaid/MermaidGenerator.cs index 5ea41bf9..4e837d25 100644 --- a/src/src/EntityFrameworkCore/Mermaid/MermaidGenerator.cs +++ b/src/src/EntityFrameworkCore/Mermaid/MermaidGenerator.cs @@ -1,4 +1,3 @@ -using System.Globalization; using System.Text; using Aspire.Hosting.AspireC4.EntityFrameworkCore.Models; @@ -73,11 +72,8 @@ public static string Generate(EfModel model, AspireC4EfDiagramOptions options) static void WriteHeader(StringBuilder sb) { - var now = DateTimeOffset.UtcNow; - sb.AppendLine("%% ============================================================================="); - sb.Append("%% AUTO-GENERATED by AspireC4 — ") - .AppendLine(now.ToString("yyyy-MM-dd HH:mm:ss 'UTC'", CultureInfo.InvariantCulture)); + sb.AppendLine("%% AUTO-GENERATED by AspireC4"); sb.AppendLine("%% Do not edit this file manually. Any changes will be overwritten on the"); sb.AppendLine("%% next diagram regeneration (on Aspire startup or resource state change)."); sb.AppendLine("%% See https://github.com/purview-dev/aspirec4 for more information."); diff --git a/src/src/EntityFrameworkCore/Models/EfColumn.cs b/src/src/EntityFrameworkCore/Models/EfColumn.cs index 700f8f81..2399ec5f 100644 --- a/src/src/EntityFrameworkCore/Models/EfColumn.cs +++ b/src/src/EntityFrameworkCore/Models/EfColumn.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace Aspire.Hosting.AspireC4.EntityFrameworkCore.Models; /// Represents a single mapped column. @@ -23,9 +25,28 @@ public sealed record EfColumn /// public string? DomainTypeName { get; init; } - /// Which of the three is, when one is present. + /// Which kind of type is, when one is present. + /// Read leniently, so a kind added by a newer analyzer cannot empty the whole model. + [JsonConverter(typeof(EfDomainTypeKindConverter))] public EfDomainTypeKind? DomainTypeKind { get; init; } + /// + /// For a column flattened out of a complex value object, the owning entity's property it came from — + /// ShippingAddress for ShippingAddress_PostCode. + /// + /// + /// Recorded rather than inferred from the column name: a renamed member or a property name that + /// itself contains an underscore defeats the inference, and a member whose own type is a value object + /// or an enum carries that type rather than the complex one, so nothing else in the column says it + /// belongs to the group. Set on every member, at any depth, to the outermost property. + /// + public string? ComplexPropertyName { get; init; } + + /// + /// The complex value object type holds, e.g. Address. + /// + public string? ComplexTypeName { get; init; } + /// Whether the column is nullable. public bool IsNullable { get; init; } diff --git a/src/src/EntityFrameworkCore/Models/EfDomainTypeKind.cs b/src/src/EntityFrameworkCore/Models/EfDomainTypeKind.cs index fc49834a..9bc4c22d 100644 --- a/src/src/EntityFrameworkCore/Models/EfDomainTypeKind.cs +++ b/src/src/EntityFrameworkCore/Models/EfDomainTypeKind.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Text.Json.Serialization; namespace Aspire.Hosting.AspireC4.EntityFrameworkCore.Models; @@ -6,19 +7,77 @@ namespace Aspire.Hosting.AspireC4.EntityFrameworkCore.Models; /// What kind of domain type a column's declared type is. /// /// -/// The three are distinct: a value type is a struct, a value object is a domain wrapper, and an enum is -/// an enum. Recording which one a column carries lets a renderer treat them differently — the enum -/// marker, for instance. +/// They are distinct: a value object is a domain wrapper, an enum is a fixed set of values, and a union +/// is one of a fixed set of types. Recording which one a column carries lets a renderer treat them +/// differently — the enum and union markers, for instance. /// [JsonConverter(typeof(JsonStringEnumConverter))] public enum EfDomainTypeKind { - /// A scalar value object: a struct wrapping a single value, such as a strongly-typed id. + /// A scalar value object: a type wrapping a single value, such as a strongly-typed id. ValueObject, - /// A complex value object, flattened into its owner's table. + /// + /// A complex value object: flattened into its owner's table, or held in one JSON column when it is + /// mapped to JSON. + /// ComplexValueObject, /// An enum, stored as its underlying primitive. Enum, + + /// + /// A C# union, holding one of a fixed set of case types. EF Core needs a value converter to store + /// one, so its database type is whatever that converter produces. + /// + Union, +} + +/// +/// Reads and writes by name, reading a name it does not know as +/// . +/// +/// +/// Applied to . The model is written by one component and read by another, and they need not be the same version: a +/// service built with a newer analyzer can carry a kind this AppHost has never heard of. A strict +/// converter throws, and a parse failure discards the whole model, so one unfamiliar column would empty +/// the diagram. Read leniently, that column simply shows no domain type. +/// +sealed class EfDomainTypeKindConverter : JsonConverter +{ + public override bool HandleNull => true; + + public override EfDomainTypeKind? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + if (reader.TokenType == JsonTokenType.Number) + { + return reader.TryGetInt32(out var number) && Enum.IsDefined(typeof(EfDomainTypeKind), number) + ? (EfDomainTypeKind)number + : null; + } + + return + Enum.TryParse(reader.GetString(), ignoreCase: true, out var kind) && Enum.IsDefined(kind) + ? kind + : null; + } + + public override void Write(Utf8JsonWriter writer, EfDomainTypeKind? value, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(writer); + + if (value is { } kind) + { + writer.WriteStringValue(kind.ToString()); + } + else + { + writer.WriteNullValue(); + } + } } diff --git a/src/src/EntityFrameworkCore/Models/EfModelDumpStatus.cs b/src/src/EntityFrameworkCore/Models/EfModelDumpStatus.cs new file mode 100644 index 00000000..530c821b --- /dev/null +++ b/src/src/EntityFrameworkCore/Models/EfModelDumpStatus.cs @@ -0,0 +1,43 @@ +namespace Aspire.Hosting.AspireC4.EntityFrameworkCore.Models; + +/// +/// Why a build-time EF Core model dump did, or did not, supply the diagram's model. +/// +/// +/// The dump is the only discovery source that sees conventions, value converters and composite +/// foreign keys, so when it is present but unusable the diagram silently degrades to a less faithful +/// model. Recording which of those happened turns "the diagram is missing configuration" into an +/// actionable message: a stale dump means the project needs rebuilding, a missing one means the +/// ModelDump package is not referenced. +/// +public enum EfModelDumpStatus +{ + /// No dump lookup has run for this project. + NotChecked, + + /// A fresh dump was found and supplied the model. + Loaded, + + /// + /// The project path did not resolve to a directory, so there was nothing to look beneath. + /// + ProjectUnresolved, + + /// + /// The dump directory or its JSON files do not exist, which normally means the project does not + /// reference AspireC4.EntityFrameworkCore.ModelDump or has not been built yet. + /// + Missing, + + /// + /// A dump exists but predates the assembly it describes, so it was ignored rather than risking a + /// diagram of a model that no longer exists. + /// + Stale, + + /// The dump files were readable but described no contexts. + Empty, + + /// The dump could not be read, for example because a build was writing it. + Unreadable, +} diff --git a/src/src/EntityFrameworkCore/Polyfills/CompilerServices.cs b/src/src/EntityFrameworkCore/Polyfills/CompilerServices.cs new file mode 100644 index 00000000..9a926d69 --- /dev/null +++ b/src/src/EntityFrameworkCore/Polyfills/CompilerServices.cs @@ -0,0 +1,26 @@ +// The C# 15 runtime types this package relies on. +// +// The package ships net10.0 and net11.0. `closed` and `union` were introduced in C# 15 alongside +// the .NET 11 runtime that carries their supporting types, so the net10.0 leg - the LTS, and the +// framework an Aspire AppHost targets - needs them declared here; the net11.0 leg uses System.Runtime's. +// Roslyn looks these types up by full name, so an internal declaration is recognised by the compiler +// for this assembly *and* by a consumer compiling against it. +// +// Keep the shapes identical to System.Runtime's: IsClosedTypeAttribute is populated with the fixed +// set of direct descendants, which is what makes a switch over the hierarchy exhaustive from +// another assembly. Delete this file if the net10.0 leg is ever dropped. + +#if !NET11_0_OR_GREATER +#pragma warning disable IDE0130 // The namespace is fixed by the compiler's well-known type lookup. +namespace System.Runtime.CompilerServices; + +#pragma warning restore IDE0130 + +/// Marks a class whose direct descendants are fixed to the declaring assembly. +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +sealed class IsClosedTypeAttribute : Attribute +{ + /// The direct descendants the compiler found when the closed class was compiled. + public Type[] DerivedTypes { get; set; } = []; +} +#endif diff --git a/src/src/EntityFrameworkCore/Properties/AssemblyInfo.cs b/src/src/EntityFrameworkCore/Properties/AssemblyInfo.cs index a98aac7f..e39c644e 100644 --- a/src/src/EntityFrameworkCore/Properties/AssemblyInfo.cs +++ b/src/src/EntityFrameworkCore/Properties/AssemblyInfo.cs @@ -5,3 +5,6 @@ // assembly's internals too. The SDK generates the conventional names per package, which for this // package are prefixed "…EntityFrameworkCore.*Tests" and so do not cover the shared suite. [assembly: InternalsVisibleTo("Aspire.Hosting.AspireC4.IntegrationTests")] +// The analyzer suite compiles one model and reads it through both the analyzer's descriptor and the +// metadata reader, so it can assert the two sources agree; the metadata reader is internal. +[assembly: InternalsVisibleTo("Aspire.Hosting.AspireC4.EntityFrameworkCore.Analyzers.UnitTests")] diff --git a/src/src/EntityFrameworkCore/README.md b/src/src/EntityFrameworkCore/README.md index d342f4d3..89189b76 100644 --- a/src/src/EntityFrameworkCore/README.md +++ b/src/src/EntityFrameworkCore/README.md @@ -15,18 +15,22 @@ You install it in the **AppHost** and call one method. Your service projects nee ## Prerequisites -- **.NET 10 or later.** This package targets `net10.0`; the optional - [`AspireC4.EntityFrameworkCore`](https://www.nuget.org/packages/AspireC4.EntityFrameworkCore) analyzer is - `netstandard2.0` and the +- **A `net10.0` or `net11.0` AppHost, built with the .NET 11 SDK.** This package ships both assets. + The optional + [`AspireC4.EntityFrameworkCore`](https://www.nuget.org/packages/AspireC4.EntityFrameworkCore) analyzer + also needs the .NET 11 SDK in the project it is added to, and the optional [`AspireC4.EntityFrameworkCore.ModelDump`](https://www.nuget.org/packages/AspireC4.EntityFrameworkCore.ModelDump) - tool is `net10.0`. -- An Aspire 13.5+ AppHost already using [`AspireC4.Hosting`](https://www.nuget.org/packages/AspireC4.Hosting) + tool runs on the build machine's latest .NET runtime (10 or later). +- An Aspire 13.6.5+ AppHost already using [`AspireC4.Hosting`](https://www.nuget.org/packages/AspireC4.Hosting) (this package depends on it, so installing this one brings it in). - The projects that own your `DbContext` types are registered in the AppHost with `AddProject(...)`. - Docker (or Podman) for the default viewer sidecar. See [Without a container runtime](#without-a-container-runtime) if you have neither. +The [requirements page](https://github.com/purview-dev/aspirec4/blob/main/docs/wiki/Requirements.md) +has the detail behind each of these. + Container image exports verify bind sharing through `Purview.Containers.Environment`; preload `alpine:3.19` offline or set `CONTAINER_ENVIRONMENT_PROBE_IMAGE`. For WSL or shared remote daemons, install `Purview.Containers.Aspire` and call `builder.AddContainerEnvironment()` @@ -101,6 +105,18 @@ When a context is available from more than one source, the best one is used: **d **descriptor**, then **metadata**. Each context in the diagram records its fidelity, so an approximate model is never mistaken for a complete one. +### Value objects, enums and unions + +All three sources recognise Purview value objects — `[Scalar]`, `[Scalar(typeof(T))]`, +`[Scalar("Name")]` and `IScalarValueObject<,>` as one column of the wrapped type, a bare `[ValueObject]` as +a complex value object flattened into its owner's table, `[ValueObject(EFMapping = Json)]` as one `json` +column — as well as shape-only value objects, EF's `[ComplexType]` and `[Owned]`, collections of +primitives or value objects (one `json` column), enums by their underlying type, and C# unions, marked +`union`. Foreign keys typed with a value object resolve from either end of the relationship, including +EF's shadow foreign key when only the principal declares a collection. The full rules are in the +[Value objects, enums and unions](https://github.com/purview-dev/aspirec4/blob/main/docs/wiki/Entity-Framework-Diagrams.md#value-objects-enums-and-unions) +guide. + ## Screenshots ![AspireC4 interactive ER diagram of the sample AppHost: five DbContext groups of tables with primary and foreign key badges and crow's-foot relationship edges](https://raw.githubusercontent.com/purview-dev/aspirec4/main/docs/images/screenshots/entity-framework-er-diagram.png) @@ -160,7 +176,8 @@ Set options in the `configure` callback or in the `AspireC4:EntityFramework` con | `IncludedContexts` / `ExcludedContexts` | empty | `WithIncludedContexts(...)` / `WithExcludedContexts(...)` | | `OutputDirectory` | `"./.aspirec4/erd/"` | Where generated files are written, relative to the AppHost | | `FileName` | `"schema.gen"` | Generated file name without extension | -| `DomainTypeDisplay` | `DomainTypeFirst` | How value objects and enums appear beside the store type: `AssetId (guid)`, `ActorType (int, enum)` | +| `DomainTypeDisplay` | `DomainTypeFirst` | How value objects, enums and unions appear beside the store type: `AssetId (guid)`, `ActorType (int, enum)` | +| `GenerateGitIgnoreFile` | `true` | Write a `.gitignore` naming the generated files. `WithoutGitIgnoreFileGeneration()` | | `ShowNullability` | `true` | Mark nullable columns with `?` | | `GroupBySchema` / `GroupByContext` | `true` | Group tables by database schema, and by `DbContext` when there are several | | `UseSubgraphs` | `true` | Mermaid only: one `subgraph` per `DbContext` (Mermaid 11.17+) | @@ -204,8 +221,11 @@ d2 is a Go binary. Install it with scoop, Homebrew, `install.sh` or `go install` (`Approximate`). Add `AspireC4.EntityFrameworkCore` to the project that declares it. - **A composite or fluent-only relationship is not drawn.** Only the model dump sees these. Add `AspireC4.EntityFrameworkCore.ModelDump` to the project with the provider and a design-time factory. -- **Not captured from any static source:** owned types, inheritance mapping, delete behaviour, - sequences, check constraints. Relationships across `DbContext` boundaries are never drawn. +- **A complex or owned type configured only fluently is missing.** `ComplexProperty`, `OwnsOne` and + `ToJson` are invisible to the static sources: mark the type `[ValueObject]`, `[ComplexType]` or + `[Owned]`, or add the model dump. +- **Not captured from any source:** inheritance mapping, delete behaviour, sequences, check constraints. + Relationships across `DbContext` boundaries are never drawn. ## More information diff --git a/src/src/SourceGenerators/Helpers/SourceGenHelper.cs b/src/src/SourceGenerators/Helpers/SourceGenHelper.cs index 3c0066ae..fd9a2259 100644 --- a/src/src/SourceGenerators/Helpers/SourceGenHelper.cs +++ b/src/src/SourceGenerators/Helpers/SourceGenHelper.cs @@ -42,29 +42,35 @@ IncrementalGeneratorInitializationContext context var model = generationContext .CollectWith(registryTargets, static (ctx, targets, _) => new StrictValidatorGenerationModel(ctx, targets)) - .CollectWith( - tagCallSites, - static (modelContext, tagSites, _) => modelContext with { TagCallSites = tagSites } + .CollectWith(tagCallSites, static (modelContext, tagSites, _) => + modelContext with + { + TagCallSites = tagSites, + } ) - .CollectWith( - kindCallSites, - static (modelContext, kindSites, _) => modelContext with { KindCallSites = kindSites } + .CollectWith(kindCallSites, static (modelContext, kindSites, _) => + modelContext with + { + KindCallSites = kindSites, + } ) - .CollectWith( - groupCallSites, - static (modelContext, groupSites, _) => modelContext with { GroupCallSites = groupSites } + .CollectWith(groupCallSites, static (modelContext, groupSites, _) => + modelContext with + { + GroupCallSites = groupSites, + } ) - .CollectWith( - metadataCallSites, - static (modelContext, metadataSites, _) => modelContext with { MetadataCallSites = metadataSites } + .CollectWith(metadataCallSites, static (modelContext, metadataSites, _) => + modelContext with + { + MetadataCallSites = metadataSites, + } ) - .CollectWith( - dslDefinition, - static (modelContext, dslDefinitions, ct) => - modelContext with - { - DSLDefinition = LikeC4DSLHelpers.MergeDSLDefinitions(dslDefinitions), - } + .CollectWith(dslDefinition, static (modelContext, dslDefinitions, ct) => + modelContext with + { + DSLDefinition = LikeC4DSLHelpers.MergeDSLDefinitions(dslDefinitions), + } ) .CombineWith(strictMode, static (modelContext, value, _) => modelContext with { StrictMode = value }); diff --git a/src/src/SourceGenerators/LikeC4StrictValidatorGenerator.cs b/src/src/SourceGenerators/LikeC4StrictValidatorGenerator.cs index 1f6c84ce..5c348b3e 100644 --- a/src/src/SourceGenerators/LikeC4StrictValidatorGenerator.cs +++ b/src/src/SourceGenerators/LikeC4StrictValidatorGenerator.cs @@ -47,22 +47,19 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // Combine everything and validate. var pipeline = SourceGenHelper.CreateGenerationPipeline(context); - context.RegisterSourceOutput( - pipeline, - static (ctx, model) => + context.RegisterSourceOutput(pipeline, static (ctx, model) => + { + if (model.Context.Settings.IsSourceGeneratorDisabled) { - if (model.Context.Settings.IsSourceGeneratorDisabled) - { - model.Context.Debug( - $"{nameof(LikeC4StrictValidatorGenerator)} is disabled via the MSBuild '{PropertyLibrary.DisableSourceGenerator}' property." - ); - - return; - } + model.Context.Debug( + $"{nameof(LikeC4StrictValidatorGenerator)} is disabled via the MSBuild '{PropertyLibrary.DisableSourceGenerator}' property." + ); - Validate(ctx, model); + return; } - ); + + Validate(ctx, model); + }); } static DiagnosticDescriptor WithSeverity(DiagnosticDescriptor descriptor, DiagnosticSeverity severity) => diff --git a/src/src/SourceGenerators/SourceGenerators.csproj b/src/src/SourceGenerators/SourceGenerators.csproj index ee88c219..215fd4a7 100644 --- a/src/src/SourceGenerators/SourceGenerators.csproj +++ b/src/src/SourceGenerators/SourceGenerators.csproj @@ -1,7 +1,6 @@ - + true - netstandard2.0 true diff --git a/src/src/TestAppHost.ComplexData/TestAppHost.ComplexData.csproj b/src/src/TestAppHost.ComplexData/TestAppHost.ComplexData.csproj index b704284d..21f98d12 100644 --- a/src/src/TestAppHost.ComplexData/TestAppHost.ComplexData.csproj +++ b/src/src/TestAppHost.ComplexData/TestAppHost.ComplexData.csproj @@ -1,6 +1,8 @@ false + + net10.0 diff --git a/src/src/TestAppHost.DataService/Program.cs b/src/src/TestAppHost.DataService/Program.cs index b9616b87..322f891d 100644 --- a/src/src/TestAppHost.DataService/Program.cs +++ b/src/src/TestAppHost.DataService/Program.cs @@ -9,23 +9,17 @@ // because the integration tests start it on every run. using CancellationTokenSource cts = new(); -using var sigTerm = PosixSignalRegistration.Create( - PosixSignal.SIGTERM, - context => - { - context.Cancel = true; - cts.Cancel(); - } -); +using var sigTerm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context => +{ + context.Cancel = true; + cts.Cancel(); +}); -using var sigInt = PosixSignalRegistration.Create( - PosixSignal.SIGINT, - context => - { - context.Cancel = true; - cts.Cancel(); - } -); +using var sigInt = PosixSignalRegistration.Create(PosixSignal.SIGINT, context => +{ + context.Cancel = true; + cts.Cancel(); +}); Console.WriteLine("Sample data service started. Owns the AspireC4 sample EF Core models."); diff --git a/src/src/TestAppHost.DataService/TestAppHost.DataService.csproj b/src/src/TestAppHost.DataService/TestAppHost.DataService.csproj index 65117a0b..72bf152d 100644 --- a/src/src/TestAppHost.DataService/TestAppHost.DataService.csproj +++ b/src/src/TestAppHost.DataService/TestAppHost.DataService.csproj @@ -2,6 +2,8 @@ Exe false + + net10.0 diff --git a/src/src/TestAppHost.SimpleData/TestAppHost.SimpleData.csproj b/src/src/TestAppHost.SimpleData/TestAppHost.SimpleData.csproj index 61387a03..67a00a40 100644 --- a/src/src/TestAppHost.SimpleData/TestAppHost.SimpleData.csproj +++ b/src/src/TestAppHost.SimpleData/TestAppHost.SimpleData.csproj @@ -1,6 +1,8 @@ false + + net10.0 diff --git a/src/src/TestAppHost/AppHost.cs b/src/src/TestAppHost/AppHost.cs index d6957765..2a8a6899 100644 --- a/src/src/TestAppHost/AppHost.cs +++ b/src/src/TestAppHost/AppHost.cs @@ -135,34 +135,28 @@ // These references will be used to generate the connections in the C4 model, // _and_ ensure that the application has the connection strings for these dependencies by internally // calling `.WithReference(...)`. - .WithLikeC4Reference( - azureManagerRedis, - opts => opts.WithLabel("Caches sessions").WithTechnology("Redis Protocol").WithKind("RESP") + .WithLikeC4Reference(azureManagerRedis, opts => + opts.WithLabel("Caches sessions").WithTechnology("Redis Protocol").WithKind("RESP") ) - .WithLikeC4Reference( - localRedis, - opts => opts.WithLabel("Caches sessions (local)").WithTechnology("Redis Protocol").WithKind("RESP") + .WithLikeC4Reference(localRedis, opts => + opts.WithLabel("Caches sessions (local)").WithTechnology("Redis Protocol").WithKind("RESP") ) - .WithLikeC4Reference( - azurePostgres, - opts => opts.WithLabel("Persists data").WithTechnology("PostgreSQL / JDBC").WithKind("tcp-ip") + .WithLikeC4Reference(azurePostgres, opts => + opts.WithLabel("Persists data").WithTechnology("PostgreSQL / JDBC").WithKind("tcp-ip") ) - .WithLikeC4Reference( - localPostgres, - opts => opts.WithLabel("Persists data (local)").WithTechnology("PostgreSQL / JDBC").WithKind("tcp-ip") + .WithLikeC4Reference(localPostgres, opts => + opts.WithLabel("Persists data (local)").WithTechnology("PostgreSQL / JDBC").WithKind("tcp-ip") ) .WaitFor(azureManagerRedis) .WaitFor(localRedis) .WaitFor(azurePostgres) .WaitFor(localPostgres); -localPostgres.WithLikeC4Reference( - azurePostgres, - opts => opts.WithLabel("syncs with").WithTechnology("PostgreSQL / JDBC").WithKind("tcp-ip") +localPostgres.WithLikeC4Reference(azurePostgres, opts => + opts.WithLabel("syncs with").WithTechnology("PostgreSQL / JDBC").WithKind("tcp-ip") ); -localRedis.WithLikeC4Reference( - azureManagerRedis, - opts => opts.WithLabel("syncs with").WithTechnology("Redis Protocol").WithKind("RESP") +localRedis.WithLikeC4Reference(azureManagerRedis, opts => + opts.WithLabel("syncs with").WithTechnology("Redis Protocol").WithKind("RESP") ); // Test adding some parameters , including one from configuration. diff --git a/src/src/TestAppHost/TestAppHost.csproj b/src/src/TestAppHost/TestAppHost.csproj index f5ee87e4..6c175419 100644 --- a/src/src/TestAppHost/TestAppHost.csproj +++ b/src/src/TestAppHost/TestAppHost.csproj @@ -1,6 +1,12 @@ - + Exe + + net10.0 true $(NoWarn);CA2234; diff --git a/src/tests/AspireC4.IntegrationTests/Mermaid/MermaidValidationTests.cs b/src/tests/AspireC4.IntegrationTests/Mermaid/MermaidValidationTests.cs index 93c5e47a..7e73d5f8 100644 --- a/src/tests/AspireC4.IntegrationTests/Mermaid/MermaidValidationTests.cs +++ b/src/tests/AspireC4.IntegrationTests/Mermaid/MermaidValidationTests.cs @@ -165,8 +165,8 @@ await ContainerEnvironmentResolver .ForRuntime(startInfo.FileName) .ResolveBindMountsAsync(startInfo, cancellationToken); - using var process = - Process.Start(startInfo) ?? throw new InvalidOperationException("Could not start the container runtime."); + using var process = Process.Start(startInfo) + ?? throw new InvalidOperationException("Could not start the container runtime."); var stdout = process.StandardOutput.ReadToEndAsync(cancellationToken); var stderr = process.StandardError.ReadToEndAsync(cancellationToken); diff --git a/src/tests/AspireC4.SourceGenerators.UnitTests/LikeC4RegistryAnalyzerTests.cs b/src/tests/AspireC4.SourceGenerators.UnitTests/LikeC4RegistryAnalyzerTests.cs deleted file mode 100644 index 62a4b9e6..00000000 --- a/src/tests/AspireC4.SourceGenerators.UnitTests/LikeC4RegistryAnalyzerTests.cs +++ /dev/null @@ -1,536 +0,0 @@ -using System.Collections.Immutable; -using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.Diagnostics; - -namespace Aspire.Hosting.AspireC4.SourceGenerators; - -public sealed class LikeC4RegistryAnalyzerTests -{ - const string AttributeSource = """ - namespace Aspire.Hosting.AspireC4 - { - [System.AttributeUsage(System.AttributeTargets.Class, Inherited = false, AllowMultiple = false)] - internal sealed class LikeC4RegistryAttribute : System.Attribute - { - public LikeC4Severity Strict { get; init; } = LikeC4Severity.Inherit; - } - internal enum LikeC4RegistryType { Tag = 0, ElementKind = 1, RelationshipKind = 2, Group = 3, MetadataKey = 4 } - internal enum LikeC4Severity { Inherit = 0, Off = 1, Suggestion = 2, Warning = 3, Error = 4 } - [System.AttributeUsage(System.AttributeTargets.Field, Inherited = false, AllowMultiple = false)] - internal sealed class KnownTypeAttribute : System.Attribute - { - public KnownTypeAttribute(LikeC4RegistryType type) { Type = type; } - public LikeC4RegistryType Type { get; } - public LikeC4Severity Strict { get; init; } = LikeC4Severity.Inherit; - } - } - """; - - // ----------------------------------------------------------------------- - // Multiple [LikeC4Registry] classes (ASPIREC4003) - // ----------------------------------------------------------------------- - - [Test] - public async Task Analyze_WithMultipleDefinitionsClasses_EmitsMultipleDefinitionsDiagnostic( - CancellationToken cancellationToken - ) - { - // Arrange - const string source = """ - using Aspire.Hosting.AspireC4; - namespace TestApp; - - [LikeC4Registry] - class FirstDefinitions { public static class Tags { public const string External = "external"; } } - - [LikeC4Registry] - class SecondDefinitions { public static class Tags { public const string Internal = "internal"; } } - """; - - // Act - var diagnostics = RunAnalyzer(source, cancellationToken: cancellationToken); - - // Assert - var result = GetDiagnostics(diagnostics, "ASPIREC4003"); - await Assert.That(result.Count).IsGreaterThan(0); - } - - [Test] - public async Task Analyze_WithSingleDefinitionsClass_EmitsNoMultipleDefinitionsDiagnostic( - CancellationToken cancellationToken - ) - { - // Arrange - const string source = """ - using Aspire.Hosting.AspireC4; - namespace TestApp; - - [LikeC4Registry] - class OnlyDefinitions { public static class Tags { public const string External = "external"; } } - """; - - // Act - var diagnostics = RunAnalyzer(source, cancellationToken: cancellationToken); - - // Assert - await Assert.That(GetDiagnostics(diagnostics, "ASPIREC4003")).IsEmpty(); - } - - // ----------------------------------------------------------------------- - // Group validation (ASPIREC4004) - // ----------------------------------------------------------------------- - - [Test] - public async Task Analyze_WithDeclaredGroup_EmitsNoDiagnostic(CancellationToken cancellationToken) - { - // Arrange - const string source = """ - using Aspire.Hosting.AspireC4; - namespace TestApp; - - [LikeC4Registry] - class MyRegistry - { - public static class Groups { public const string Frontend = "Frontend"; } - } - - class Setup { static void Configure() { var a = new object(); a.WithLikeC4Group("Frontend"); } } - """; - - // Act - var diagnostics = RunAnalyzer(source, cancellationToken: cancellationToken); - - // Assert - await Assert.That(GetDiagnostics(diagnostics, "ASPIREC4004")).IsEmpty(); - } - - [Test] - public async Task Analyze_WithUndeclaredGroup_EmitsUndeclaredGroupDiagnostic(CancellationToken cancellationToken) - { - // Arrange - const string source = """ - using Aspire.Hosting.AspireC4; - namespace TestApp; - - [LikeC4Registry] - class MyRegistry - { - public static class Groups { public const string Frontend = "Frontend"; } - } - - class Setup { static void Configure() { var a = new object(); a.WithLikeC4Group("Backend"); } } - """; - - // Act - var diagnostics = RunAnalyzer(source, cancellationToken: cancellationToken); - - // Assert - var result = GetDiagnostics(diagnostics, "ASPIREC4004"); - await Assert.That(result.Count).IsGreaterThan(0); - await Assert.That(result[0].GetMessage(CultureInfo.InvariantCulture)).Contains("Backend"); - } - - [Test] - public async Task Analyze_WithGroupCaseVariant_EmitsNoDiagnostic(CancellationToken cancellationToken) - { - // Arrange — group comparison is case-insensitive - const string source = """ - using Aspire.Hosting.AspireC4; - namespace TestApp; - - [LikeC4Registry] - class MyRegistry - { - public static class Groups { public const string Frontend = "Frontend"; } - } - - class Setup { static void Configure() { var a = new object(); a.WithLikeC4Group("frontend"); } } - """; - - // Act - var diagnostics = RunAnalyzer(source, cancellationToken: cancellationToken); - - // Assert - await Assert.That(GetDiagnostics(diagnostics, "ASPIREC4004")).IsEmpty(); - } - - [Test] - public async Task Analyze_WithoutGroupsNestedClass_EmitsNoDiagnosticForGroup(CancellationToken cancellationToken) - { - // Arrange — registry exists but has no Groups nested class → no group validation - const string source = """ - using Aspire.Hosting.AspireC4; - namespace TestApp; - - [LikeC4Registry] - class MyRegistry { public static class Tags { public const string External = "external"; } } - - class Setup { static void Configure() { var a = new object(); a.WithLikeC4Group("anything"); } } - """; - - // Act - var diagnostics = RunAnalyzer(source, cancellationToken: cancellationToken); - - // Assert - await Assert.That(GetDiagnostics(diagnostics, "ASPIREC4004")).IsEmpty(); - } - - [Test] - public async Task Analyze_WithNoDefinitionsClassAndGroupCallSite_EmitsNoDiagnostic( - CancellationToken cancellationToken - ) - { - // Arrange - const string source = """ - namespace TestApp; - class Setup { static void Configure() { var a = new object(); a.WithLikeC4Group("Frontend"); } } - """; - - // Act - var diagnostics = RunAnalyzer(source, cancellationToken: cancellationToken); - - // Assert - await Assert.That(GetDiagnostics(diagnostics, "ASPIREC4004")).IsEmpty(); - } - - [Test] - public async Task Analyze_WithKnownTypeGroupField_ValidatesGroupCallSite(CancellationToken cancellationToken) - { - // Arrange - const string source = """ - using Aspire.Hosting.AspireC4; - namespace TestApp; - - [LikeC4Registry] - static class MyRegistry - { - [KnownType(LikeC4RegistryType.Group)] - public const string Frontend = "Frontend"; - } - - class Setup - { - static void Configure() - { - var a = new object(); - a.WithLikeC4Group("Backend"); - } - } - """; - - // Act - var diagnostics = RunAnalyzer(source, cancellationToken: cancellationToken); - - // Assert - var result = GetDiagnostics(diagnostics, "ASPIREC4004"); - await Assert.That(result.Count).IsGreaterThan(0); - await Assert.That(result[0].GetMessage(CultureInfo.InvariantCulture)).Contains("Backend"); - } - - // ----------------------------------------------------------------------- - // Duplicate type declaration (ASPIREC4005) - // ----------------------------------------------------------------------- - - [Test] - public async Task Analyze_WithDuplicateTypeDeclaration_EmitsDuplicateTypeDeclarationDiagnostic( - CancellationToken cancellationToken - ) - { - // Arrange — Tags declared both as nested class AND via [KnownType] on a field - const string source = """ - using Aspire.Hosting.AspireC4; - namespace TestApp; - - [LikeC4Registry] - static class MyRegistry - { - [KnownType(LikeC4RegistryType.Tag)] - public const string InlineTag = "inline-tag"; - - public static class Tags - { - public const string External = "external"; - } - } - - class Setup { static void Configure() { } } - """; - - // Act - var diagnostics = RunAnalyzer(source, cancellationToken: cancellationToken); - - // Assert - var result = GetDiagnostics(diagnostics, "ASPIREC4005"); - await Assert.That(result.Count).IsGreaterThan(0); - } - - // ----------------------------------------------------------------------- - // MetadataKeys (ASPIREC4006) - // ----------------------------------------------------------------------- - - static string MetadataSource(string keyValue, string callSite) => - $$""" - using Aspire.Hosting.AspireC4; - namespace TestApp; - - [LikeC4Registry] - class MyRegistry - { - public static class MetadataKeys { public const string AzureSku = "{{keyValue}}"; } - } - - class Setup { static void Configure() { var a = new object(); a{{callSite}}; } } - """; - - [Test] - public async Task Analyze_WithMetadataKeysNestedClass_ExtractsWithoutError(CancellationToken cancellationToken) - { - // Arrange — no call sites, just a MetadataKeys nested class - const string source = """ - using Aspire.Hosting.AspireC4; - namespace TestApp; - - [LikeC4Registry] - class MyRegistry { public static class MetadataKeys { public const string AzureSku = "Azure SKU"; } } - - class Setup { static void Configure() { } } - """; - - // Act — AllIncludingMetadata enables metadata key validation - var diagnostics = RunAnalyzer(source, strictMode: "AllIncludingMetadata", cancellationToken: cancellationToken); - - // Assert — no diagnostics from having a MetadataKeys nested class - await Assert.That(GetAspireDiagnostics(diagnostics)).IsEmpty(); - } - - [Test] - public async Task Analyze_WithMetadataCallSite_ExactMatch_EmitsNoDiagnostics(CancellationToken cancellationToken) - { - // Arrange - var source = MetadataSource("Azure_SKU", ".WithMetadata(\"Azure_SKU\", \"Standard_LRS\")"); - - // Act - var diagnostics = RunAnalyzer(source, strictMode: "AllIncludingMetadata", cancellationToken: cancellationToken); - - // Assert - await Assert.That(GetDiagnostics(diagnostics, "ASPIREC4006")).IsEmpty(); - } - - [Test] - public async Task Analyze_WithMetadataCallSite_SpaceVariant_EmitsNoDiagnostics(CancellationToken cancellationToken) - { - // Arrange — call site uses "azure sku" (space instead of underscore) - var source = MetadataSource("Azure_SKU", ".WithMetadata(\"azure sku\", \"Standard_LRS\")"); - - // Act - var diagnostics = RunAnalyzer(source, strictMode: "AllIncludingMetadata", cancellationToken: cancellationToken); - - // Assert — normalises to the same key - await Assert.That(GetDiagnostics(diagnostics, "ASPIREC4006")).IsEmpty(); - } - - [Test] - public async Task Analyze_WithMetadataCallSite_CaseVariant_EmitsNoDiagnostics(CancellationToken cancellationToken) - { - // Arrange - var source = MetadataSource("Azure_SKU", ".WithMetadata(\"AZURE_sku\", \"Standard_LRS\")"); - - // Act - var diagnostics = RunAnalyzer(source, strictMode: "AllIncludingMetadata", cancellationToken: cancellationToken); - - // Assert - await Assert.That(GetDiagnostics(diagnostics, "ASPIREC4006")).IsEmpty(); - } - - [Test] - public async Task Analyze_WithMetadataCallSite_RegistryKeyHasSpaces_CallSiteHasUnderscores_EmitsNoDiagnostics( - CancellationToken cancellationToken - ) - { - // Arrange — registry declares "Azure SKU" (with space); call site uses "Azure_SKU" - var source = MetadataSource("Azure SKU", ".WithMetadata(\"Azure_SKU\", \"Standard_LRS\")"); - - // Act - var diagnostics = RunAnalyzer(source, strictMode: "AllIncludingMetadata", cancellationToken: cancellationToken); - - // Assert - await Assert.That(GetDiagnostics(diagnostics, "ASPIREC4006")).IsEmpty(); - } - - [Test] - public async Task Analyze_WithMetadataCallSite_UndeclaredKey_EmitsDiagnostic(CancellationToken cancellationToken) - { - // Arrange - var source = MetadataSource("Azure_SKU", ".WithMetadata(\"unknown_key\", \"value\")"); - - // Act - var diagnostics = RunAnalyzer(source, strictMode: "AllIncludingMetadata", cancellationToken: cancellationToken); - - // Assert - var aspireDiags = GetDiagnostics(diagnostics, "ASPIREC4006"); - await Assert.That(aspireDiags.Count).IsEqualTo(1); - await Assert.That(aspireDiags[0].GetMessage(CultureInfo.InvariantCulture)).Contains("unknown_key"); - } - - // ----------------------------------------------------------------------- - // Severity escalation - // ----------------------------------------------------------------------- - - [Test] - public async Task Analyze_WithRegistryStrictEnableAndUndeclaredGroup_EmitsErrorSeverity( - CancellationToken cancellationToken - ) - { - // Arrange - const string source = """ - using Aspire.Hosting.AspireC4; - namespace TestApp; - - [LikeC4Registry(Strict = LikeC4Severity.Error)] - static class MyRegistry - { - public static class Groups { public const string Frontend = "Frontend"; } - } - - class Setup - { - static void Configure() - { - var a = new object(); - a.WithLikeC4Group("Backend"); - } - } - """; - - // Act - var diagnostics = RunAnalyzer(source, cancellationToken: cancellationToken); - - // Assert - var result = GetDiagnostics(diagnostics, "ASPIREC4004"); - await Assert.That(result.Count).IsGreaterThan(0); - await Assert.That(result[0].Severity).IsEqualTo(DiagnosticSeverity.Error); - } - - // ----------------------------------------------------------------------- - // DisableAspireC4SourceGenerator MSBuild property - // ----------------------------------------------------------------------- - - [Test] - public async Task Analyze_WithDisabledPropertyTrue_EmitsNoDiagnostics(CancellationToken cancellationToken) - { - // Arrange — undeclared group and metadata, duplicate declaration, two registries - const string source = """ - using Aspire.Hosting.AspireC4; - namespace TestApp; - - [LikeC4Registry] - static class FirstRegistry - { - [KnownType(LikeC4RegistryType.Tag)] - public const string InlineTag = "inline-tag"; - - public static class Tags { public const string External = "external"; } - } - - [LikeC4Registry] - static class SecondRegistry - { - public static class Groups { public const string Frontend = "Frontend"; } - } - - class Setup - { - static void Configure() - { - var a = new object(); - a.WithLikeC4Group("Backend"); - a.WithMetadata("unknown_key", "value"); - } - } - """; - - // Act - var diagnostics = RunAnalyzer(source, disabled: true, cancellationToken: cancellationToken); - - // Assert — analyzer disabled, so no ASPIREC4 diagnostics - await Assert.That(GetAspireDiagnostics(diagnostics)).IsEmpty(); - } - - // ----------------------------------------------------------------------- - - static ImmutableArray RunAnalyzer( - string source, - string? strictMode = null, - bool disabled = false, - CancellationToken cancellationToken = default - ) - { - var compilation = CSharpCompilation.Create( - "TestAssembly", - [ - CSharpSyntaxTree.ParseText(AttributeSource, cancellationToken: cancellationToken), - CSharpSyntaxTree.ParseText(source, cancellationToken: cancellationToken), - ], - GetMetadataReferences(), - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) - ); - - var buildProperties = new Dictionary(StringComparer.OrdinalIgnoreCase); - if (strictMode is not null) - buildProperties["build_property.AspireC4Strict"] = strictMode; - if (disabled) - buildProperties["build_property.DisableAspireC4SourceGenerator"] = "true"; - - AnalyzerOptions? options = - buildProperties.Count > 0 - ? new AnalyzerOptions( - ImmutableArray.Empty, - new TestAnalyzerConfigOptionsProvider(buildProperties) - ) - : null; - - var withAnalyzers = compilation.WithAnalyzers( - ImmutableArray.Create(new LikeC4RegistryAnalyzer()), - options - ); - - return withAnalyzers.GetAnalyzerDiagnosticsAsync(cancellationToken).GetAwaiter().GetResult(); - } - - static IEnumerable GetMetadataReferences() => - [ - MetadataReference.CreateFromFile(typeof(object).Assembly.Location), - MetadataReference.CreateFromFile(typeof(Compilation).Assembly.Location), - MetadataReference.CreateFromFile(typeof(CSharpCompilation).Assembly.Location), - ]; - - static IReadOnlyList GetDiagnostics(ImmutableArray diagnostics, string diagnosticId) => - [.. diagnostics.Where(d => d.Id == diagnosticId)]; - - static IReadOnlyList GetAspireDiagnostics(ImmutableArray diagnostics) => - [.. diagnostics.Where(d => d.Id.StartsWith("ASPIREC4", StringComparison.Ordinal))]; - - // ----------------------------------------------------------------------- - // Test infrastructure — AnalyzerConfigOptionsProvider - // ----------------------------------------------------------------------- - - sealed class TestAnalyzerConfigOptionsProvider(Dictionary options) : AnalyzerConfigOptionsProvider - { - readonly TestAnalyzerConfigOptions _options = new(options); - - public override AnalyzerConfigOptions GlobalOptions => _options; - - public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => _options; - - public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => _options; - } - - sealed class TestAnalyzerConfigOptions(Dictionary options) : AnalyzerConfigOptions - { - public override bool TryGetValue(string requestedKey, [NotNullWhen(true)] out string? optionValue) => - options.TryGetValue(requestedKey, out optionValue); - } -} diff --git a/src/tests/AspireC4.UnitTests/GeneratedOutputTests.cs b/src/tests/AspireC4.UnitTests/GeneratedOutputTests.cs new file mode 100644 index 00000000..631086b4 --- /dev/null +++ b/src/tests/AspireC4.UnitTests/GeneratedOutputTests.cs @@ -0,0 +1,241 @@ +namespace Aspire.Hosting.AspireC4; + +/// +/// Covers how generated files reach disk: written only when their content changes, and listed in an +/// ignore file AspireC4 owns without ever replacing one it does not. +/// +public sealed class GeneratedOutputTests +{ + // Helpers + + static string CreateDirectory() + { + var directory = Path.Combine(Path.GetTempPath(), "aspirec4-output-" + Guid.NewGuid().ToString("N")[..8]); + + Directory.CreateDirectory(directory); + + return directory; + } + + static async Task CreateFileAsync( + string directory, + string name, + string content, + CancellationToken cancellationToken + ) + { + var path = Path.Combine(directory, name); + + await File.WriteAllTextAsync(path, content, cancellationToken); + + // Backdated so a rewrite is visible in the timestamp even on a filesystem with coarse resolution. + File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddHours(-1)); + + return path; + } + + // Tests + + [Test] + public async Task WriteIfChangedAsync_WhenTheFileHoldsTheSameContent_LeavesItUntouched( + CancellationToken cancellationToken + ) + { + // A restart of an unchanged application must not touch the working tree — not even the timestamp + // a file watcher or an IDE would react to. + // Arrange + var directory = CreateDirectory(); + var path = await CreateFileAsync(directory, "model.gen.c4", "model", cancellationToken); + var before = File.GetLastWriteTimeUtc(path); + + // Act + var written = await GeneratedOutput.WriteIfChangedAsync(path, "model", cancellationToken); + + // Assert + await Assert.That(written).IsFalse(); + await Assert.That(File.GetLastWriteTimeUtc(path)).IsEqualTo(before); + } + + [Test] + public async Task WriteIfChangedAsync_WhenTheContentDiffers_WritesIt(CancellationToken cancellationToken) + { + // Arrange + var directory = CreateDirectory(); + var path = await CreateFileAsync(directory, "model.gen.c4", "old", cancellationToken); + + // Act + var written = await GeneratedOutput.WriteIfChangedAsync(path, "new", cancellationToken); + + // Assert + await Assert.That(written).IsTrue(); + await Assert.That(await File.ReadAllTextAsync(path, cancellationToken)).IsEqualTo("new"); + } + + [Test] + public async Task WriteIfChangedAsync_WhenTheFileIsMissing_WritesIt(CancellationToken cancellationToken) + { + // Arrange + var path = Path.Combine(CreateDirectory(), "model.gen.c4"); + + // Act + var written = await GeneratedOutput.WriteIfChangedAsync(path, "model", cancellationToken); + + // Assert + await Assert.That(written).IsTrue(); + await Assert.That(File.Exists(path)).IsTrue(); + } + + [Test] + public async Task CopyIfChanged_WhenTheDestinationHasTheSameBytes_LeavesItUntouched( + CancellationToken cancellationToken + ) + { + // Arrange + var directory = CreateDirectory(); + var source = await CreateFileAsync(directory, "extend.c4", "extend api {}", cancellationToken); + var destination = await CreateFileAsync(CreateDirectory(), "extend.c4", "extend api {}", cancellationToken); + var before = File.GetLastWriteTimeUtc(destination); + + // Act + var copied = GeneratedOutput.CopyIfChanged(source, destination); + + // Assert + await Assert.That(copied).IsFalse(); + await Assert.That(File.GetLastWriteTimeUtc(destination)).IsEqualTo(before); + } + + [Test] + public async Task CopyIfChanged_WhenTheSourceChanged_CopiesIt(CancellationToken cancellationToken) + { + // Arrange + var source = await CreateFileAsync(CreateDirectory(), "extend.c4", "extend api { #new }", cancellationToken); + var destination = await CreateFileAsync(CreateDirectory(), "extend.c4", "extend api {}", cancellationToken); + + // Act + var copied = GeneratedOutput.CopyIfChanged(source, destination); + + // Assert + await Assert.That(copied).IsTrue(); + await Assert.That(await File.ReadAllTextAsync(destination, cancellationToken)).IsEqualTo("extend api { #new }"); + } + + [Test] + public async Task BuildGitIgnore_ForPlainNames_AnchorsThemToTheDirectory() + { + // An unanchored "model.gen.c4" would also ignore a same-named hand-written file further down. + // Act + var content = GeneratedOutput.BuildGitIgnore("purpose", ["model.gen.c4", "vendor/", ".aspirec4-*"]); + + // Assert + await Assert.That(content).Contains("\n/model.gen.c4\n"); + await Assert.That(content).Contains("\n/vendor/\n"); + await Assert.That(content).Contains("\n.aspirec4-*\n"); + } + + [Test] + public async Task BuildGitIgnore_ForTheSameEntriesInAnyOrder_IsIdentical() + { + // Act + var first = GeneratedOutput.BuildGitIgnore("purpose", ["b.c4", "a.c4", "a.c4"]); + var second = GeneratedOutput.BuildGitIgnore("purpose", ["a.c4", "b.c4"]); + + // Assert + await Assert.That(second).IsEqualTo(first); + await Assert.That(first).StartsWith(GeneratedOutput.GitIgnoreMarker); + await Assert.That(first).DoesNotContain("\r"); + } + + [Test] + public async Task BuildGitIgnore_Always_IgnoresItself() + { + // It is regenerated on every run, so committing it would only add a file nobody edits. + // Act + var content = GeneratedOutput.BuildGitIgnore("purpose", ["model.gen.c4"]); + + // Assert + await Assert.That(content).Contains("\n/.gitignore\n"); + } + + [Test] + public async Task WriteGitIgnoreAsync_WhenNoFileExists_WritesOne(CancellationToken cancellationToken) + { + // Arrange + var directory = Path.Combine(CreateDirectory(), "images"); + + // Act + var result = await GeneratedOutput.WriteGitIgnoreAsync( + directory, + "purpose", + [".aspirec4-*"], + cancellationToken + ); + + // Assert + await Assert.That(result).IsEqualTo(GitIgnoreWrite.Written); + await Assert + .That(await File.ReadAllTextAsync(Path.Combine(directory, ".gitignore"), cancellationToken)) + .Contains(".aspirec4-*"); + } + + [Test] + public async Task WriteGitIgnoreAsync_WhenItsOwnFileIsCurrent_ReportsUnchanged(CancellationToken cancellationToken) + { + // Arrange + var directory = CreateDirectory(); + await GeneratedOutput.WriteGitIgnoreAsync(directory, "purpose", ["model.gen.c4"], cancellationToken); + + // Act + var result = await GeneratedOutput.WriteGitIgnoreAsync( + directory, + "purpose", + ["model.gen.c4"], + cancellationToken + ); + + // Assert + await Assert.That(result).IsEqualTo(GitIgnoreWrite.Unchanged); + } + + [Test] + public async Task WriteGitIgnoreAsync_WhenTheEntriesChange_RefreshesItsOwnFile(CancellationToken cancellationToken) + { + // Arrange + var directory = CreateDirectory(); + await GeneratedOutput.WriteGitIgnoreAsync(directory, "purpose", ["model.gen.c4"], cancellationToken); + + // Act + var result = await GeneratedOutput.WriteGitIgnoreAsync( + directory, + "purpose", + ["model.gen.c4", "likec4.config.json"], + cancellationToken + ); + + // Assert + await Assert.That(result).IsEqualTo(GitIgnoreWrite.Written); + await Assert + .That(await File.ReadAllTextAsync(Path.Combine(directory, ".gitignore"), cancellationToken)) + .Contains("/likec4.config.json"); + } + + [Test] + public async Task WriteGitIgnoreAsync_WhenTheUserOwnsTheFile_LeavesItAlone(CancellationToken cancellationToken) + { + // An output directory can be one the user shares with their own files; their ignore rules are theirs. + // Arrange + var directory = CreateDirectory(); + var path = await CreateFileAsync(directory, ".gitignore", "secrets.json\n", cancellationToken); + + // Act + var result = await GeneratedOutput.WriteGitIgnoreAsync( + directory, + "purpose", + ["model.gen.c4"], + cancellationToken + ); + + // Assert + await Assert.That(result).IsEqualTo(GitIgnoreWrite.KeptUserFile); + await Assert.That(await File.ReadAllTextAsync(path, cancellationToken)).IsEqualTo("secrets.json\n"); + } +} diff --git a/src/tests/AspireC4.UnitTests/LikeC4/Generators/LikeC4DSLGeneratorTests.cs b/src/tests/AspireC4.UnitTests/LikeC4/Generators/LikeC4DSLGeneratorTests.cs index 7e4406df..ac033a86 100644 --- a/src/tests/AspireC4.UnitTests/LikeC4/Generators/LikeC4DSLGeneratorTests.cs +++ b/src/tests/AspireC4.UnitTests/LikeC4/Generators/LikeC4DSLGeneratorTests.cs @@ -33,27 +33,74 @@ public async Task Generate_EmptyModel_ProducesMinimalValidDSL() public async Task Generate_AlwaysEmitsAutoGeneratedHeader() { // Arrange - var before = DateTimeOffset.UtcNow; + // Act var dsl = LikeC4DSLGenerator.Generate(LikeC4Model.Empty, DefaultOptions); - var after = DateTimeOffset.UtcNow; // Assert await Assert.That(dsl).StartsWith("//"); await Assert.That(dsl).Contains("AUTO-GENERATED by AspireC4"); await Assert.That(dsl).Contains("Do not edit this file manually"); + } + + [Test] + public async Task Generate_ForTheSameModel_IsByteIdenticalAndCarriesNoTimestamp() + { + // A timestamp in the header made every run of an unchanged application rewrite the model, which + // dirtied every working tree that tracked it. + // Arrange + LikeC4Model model = new() + { + Elements = + [ + new LikeC4Element + { + Name = "api", + Label = "API", + Kind = LikeC4ElementKind.Component, + Tags = ["zeta", "Alpha", "beta"], + }, + ], + Relationships = [], + }; + + // Act + var first = LikeC4DSLGenerator.Generate(model, DefaultOptions); + var second = LikeC4DSLGenerator.Generate(model, DefaultOptions); + + // Assert + await Assert.That(second).IsEqualTo(first); + await Assert.That(TimeStampRegex().IsMatch(first)).IsFalse(); + } - // Timestamp in the header must fall within the test window. - var match = TimeStampRegex().Match(dsl); - await Assert.That(match.Success).IsTrue(); - var timestamp = DateTimeOffset.ParseExact( - match.Groups[1].Value, - "yyyy-MM-dd HH:mm:ss", - System.Globalization.CultureInfo.InvariantCulture, - System.Globalization.DateTimeStyles.AssumeUniversal - ); - await Assert.That(timestamp).IsGreaterThanOrEqualTo(before.AddSeconds(-1)); - await Assert.That(timestamp).IsLessThanOrEqualTo(after.AddSeconds(1)); + [Test] + public async Task Generate_TagsInTheSpecification_AreOrderedOrdinallyWhateverTheCulture() + { + // Culture-sensitive ordering put "Alpha" and "beta" in a different order on machines with a + // different locale, so the same model produced different files on different machines. + // Arrange + LikeC4Model model = new() + { + Elements = + [ + new LikeC4Element + { + Name = "api", + Label = "API", + Kind = LikeC4ElementKind.Component, + Tags = ["beta", "Alpha"], + }, + ], + Relationships = [], + }; + + // Act + var dsl = LikeC4DSLGenerator.Generate(model, DefaultOptions); + + // Assert + await Assert + .That(dsl.IndexOf("tag Alpha", StringComparison.Ordinal)) + .IsLessThan(dsl.IndexOf("tag beta", StringComparison.Ordinal)); } [Test] diff --git a/src/tests/AspireC4.UnitTests/LikeC4/IconInferenceTests.SetupAndState.cs b/src/tests/AspireC4.UnitTests/LikeC4/IconInferenceTests.SetupAndState.cs index eb3f3584..3d64cf2e 100644 --- a/src/tests/AspireC4.UnitTests/LikeC4/IconInferenceTests.SetupAndState.cs +++ b/src/tests/AspireC4.UnitTests/LikeC4/IconInferenceTests.SetupAndState.cs @@ -24,11 +24,10 @@ public override string ToString() { var (visible, hidden) = CreateResources(); - var visibleLabel = - visible - .Annotations.OfType() - .Select(a => a.InitialSnapshot.ResourceType) - .FirstOrDefault(t => t is not null) + var visibleLabel = visible + .Annotations.OfType() + .Select(a => a.InitialSnapshot.ResourceType) + .FirstOrDefault(t => t is not null) ?? visible.Name; var hiddenSuffix = hidden is null ? "" : $" [hidden: {hidden.GetType().Name}]"; diff --git a/src/tests/AspireC4.UnitTests/LikeC4/Runtime/LikeC4ProjectLayoutTests.cs b/src/tests/AspireC4.UnitTests/LikeC4/Runtime/LikeC4ProjectLayoutTests.cs index 2417565a..49c4dc4f 100644 --- a/src/tests/AspireC4.UnitTests/LikeC4/Runtime/LikeC4ProjectLayoutTests.cs +++ b/src/tests/AspireC4.UnitTests/LikeC4/Runtime/LikeC4ProjectLayoutTests.cs @@ -10,13 +10,15 @@ static string CreateBaseDirectory() => static AspireC4DiagramOptions CreateOptions( string? projectDirectory = null, string? outputDirectory = null, - bool useLegacyLayout = false + bool useLegacyLayout = false, + string? extensionsDirectory = AspireC4DiagramOptions.DefaultExtensionsDirectory ) { AspireC4DiagramOptions options = new() { ProjectDirectory = projectDirectory, UseLegacyLayout = useLegacyLayout, + ExtensionsDirectory = extensionsDirectory, }; if (outputDirectory is not null) @@ -27,6 +29,15 @@ static AspireC4DiagramOptions CreateOptions( return options; } + static string CreateExtensionsFolder(string baseDirectory, string name = "likec4-extensions") + { + var folder = Path.Combine(baseDirectory, name); + + Directory.CreateDirectory(folder); + + return folder; + } + // Tests [Test] @@ -162,4 +173,118 @@ public async Task IsHandAuthoredProject_WhenTheProjectContainsTheOutputDirectory // Assert await Assert.That(result).IsTrue(); } + + [Test] + public async Task IncludeExtensionsFolder_WhenTheConventionalFolderExists_AddsIt() + { + // Hand-written .c4 files beside the AppHost join the diagram with no call at all. + // Arrange + var baseDirectory = CreateBaseDirectory(); + var folder = CreateExtensionsFolder(baseDirectory); + var options = CreateOptions(); + + // Act + var added = LikeC4ProjectLayout.IncludeExtensionsFolder(baseDirectory, options); + + // Assert + await Assert.That(added).IsEqualTo(folder); + await Assert.That(options.AdditionalDSLFolders).Contains(folder); + } + + [Test] + public async Task IncludeExtensionsFolder_WhenTheFolderIsMissing_AddsNothing() + { + // Arrange + var baseDirectory = CreateBaseDirectory(); + var options = CreateOptions(); + + // Act + var added = LikeC4ProjectLayout.IncludeExtensionsFolder(baseDirectory, options); + + // Assert + await Assert.That(added).IsNull(); + await Assert.That(options.AdditionalDSLFolders).IsEmpty(); + } + + [Test] + public async Task IncludeExtensionsFolder_WhenTurnedOff_AddsNothing() + { + // Arrange + var baseDirectory = CreateBaseDirectory(); + CreateExtensionsFolder(baseDirectory); + var options = CreateOptions(extensionsDirectory: null); + + // Act + var added = LikeC4ProjectLayout.IncludeExtensionsFolder(baseDirectory, options); + + // Assert + await Assert.That(added).IsNull(); + await Assert.That(options.AdditionalDSLFolders).IsEmpty(); + } + + [Test] + public async Task IncludeExtensionsFolder_WithACustomFolder_AddsThatFolder() + { + // Arrange + var baseDirectory = CreateBaseDirectory(); + var folder = CreateExtensionsFolder(baseDirectory, "architecture"); + var options = CreateOptions(extensionsDirectory: "./architecture"); + + // Act + var added = LikeC4ProjectLayout.IncludeExtensionsFolder(baseDirectory, options); + + // Assert + await Assert.That(added).IsEqualTo(folder); + } + + [Test] + public async Task IncludeExtensionsFolder_WhenAlreadyRegistered_DoesNotAddItTwice() + { + // Resolving the options again, or registering the folder explicitly as well, must not list it twice. + // Arrange + var baseDirectory = CreateBaseDirectory(); + var folder = CreateExtensionsFolder(baseDirectory); + var options = CreateOptions(); + options.AdditionalDSLFolders.Add(folder); + + // Act + var added = LikeC4ProjectLayout.IncludeExtensionsFolder(baseDirectory, options); + + // Assert + await Assert.That(added).IsNull(); + await Assert.That(options.AdditionalDSLFolders.Count).IsEqualTo(1); + } + + [Test] + public async Task IncludeExtensionsFolder_ForAHandAuthoredProject_AddsNothing() + { + // A hand-authored project serves its own files, and AspireC4 writes no config into it to list the + // folder in. + // Arrange + var baseDirectory = CreateBaseDirectory(); + CreateExtensionsFolder(baseDirectory); + var options = CreateOptions(projectDirectory: Path.Combine(baseDirectory, "my-arch")); + + // Act + var added = LikeC4ProjectLayout.IncludeExtensionsFolder(baseDirectory, options); + + // Assert + await Assert.That(added).IsNull(); + } + + [Test] + public async Task IncludeExtensionsFolder_WhenTheFolderIsTheOutputDirectory_AddsNothing() + { + // Including the output directory in itself would load the generated model twice. + // Arrange + var baseDirectory = CreateBaseDirectory(); + CreateExtensionsFolder(baseDirectory, "generated"); + var options = CreateOptions(outputDirectory: "./generated", extensionsDirectory: "./generated"); + + // Act + var added = LikeC4ProjectLayout.IncludeExtensionsFolder(baseDirectory, options); + + // Assert + await Assert.That(added).IsNull(); + } } diff --git a/src/tests/EntityFrameworkCore.Analyzers.UnitTests/EfValueObjectRoundTripTests.cs b/src/tests/EntityFrameworkCore.Analyzers.UnitTests/EfValueObjectRoundTripTests.cs new file mode 100644 index 00000000..19723466 --- /dev/null +++ b/src/tests/EntityFrameworkCore.Analyzers.UnitTests/EfValueObjectRoundTripTests.cs @@ -0,0 +1,566 @@ +using Aspire.Hosting.AspireC4.EntityFrameworkCore.Models; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace Aspire.Hosting.AspireC4.EntityFrameworkCore.Analyzers; + +/// +/// Proves that value objects, unions and the relationships built on them come out the same from both +/// static sources: the analyzer's descriptor and the metadata reader. +/// +/// +/// One model is compiled once, with the EF Core stubs in an assembly of their own so it references +/// Microsoft.EntityFrameworkCore the way a real service does, and then read twice — through the +/// descriptor the generator baked into it, and through . Every test +/// runs against both, so the two sources cannot drift apart. No value-object generator runs here, so a +/// Purview [Scalar<T>] type has no Value member in either: the attribute alone has +/// to be enough, which is exactly the analyzer's situation in a real build. +/// +public sealed class EfValueObjectRoundTripTests + : TUnitSourceGeneratorTestBase +{ + /// Which static source a test reads the model through. + public enum Source + { + Descriptor, + Metadata, + } + + /// The Purview attributes and interface, plus the EF and runtime markers, matched by name. + const string Markers = """ + namespace Purview.ValueObjects.Serialization + { + public enum EntityFrameworkMapping { ComplexType = 0, Json = 1, None = 2 } + + [System.AttributeUsage(System.AttributeTargets.Struct | System.AttributeTargets.Class)] + public sealed class ScalarAttribute : System.Attribute + { + public ScalarAttribute(string propertyName = "Value") { } + + public ScalarAttribute(System.Type valueType, string propertyName = "Value") { } + } + + [System.AttributeUsage(System.AttributeTargets.Struct | System.AttributeTargets.Class)] + public sealed class ScalarAttribute : System.Attribute + { + public ScalarAttribute(string propertyName = "Value") { } + } + + [System.AttributeUsage(System.AttributeTargets.Struct | System.AttributeTargets.Class)] + public sealed class ValueObjectAttribute : System.Attribute + { + public EntityFrameworkMapping EFMapping { get; set; } + } + } + + namespace Purview.ValueObjects + { + public interface IScalarValueObject { TValue Value { get; } } + } + + namespace Microsoft.EntityFrameworkCore + { + [System.AttributeUsage(System.AttributeTargets.Class)] + public sealed class OwnedAttribute : System.Attribute { } + } + + namespace System.Runtime.CompilerServices + { + [System.AttributeUsage(System.AttributeTargets.Struct | System.AttributeTargets.Class)] + public sealed class UnionAttribute : System.Attribute { } + + public interface IUnion { object? Value { get; } } + } + """; + + const string ModelSource = $$""" + using System; + using System.Collections.Generic; + using System.Runtime.CompilerServices; + using Microsoft.EntityFrameworkCore; + using Purview.ValueObjects; + using Purview.ValueObjects.Serialization; + using TestAnnotations; + + {{Markers}} + + namespace Sample + { + // Purview's generic scalar: the generator writes Value, and nothing here can see it. + [Scalar] + public readonly partial struct AssetId { } + + // A class-based scalar, which used to become a phantom table. + [Scalar(typeof(int))] + public sealed partial class TagId { } + + // The manual form: the author names the property that holds the value. + [Scalar("Amount")] + public readonly partial struct Money { public decimal Amount { get; init; } } + + // Shape alone, and the interface alone. + public readonly struct LegacyId { public long Value { get; init; } } + + public readonly struct Code : IScalarValueObject { public string Value { get; init; } } + + public enum Status : byte { Active, Retired } + + // A bare [ValueObject] is ComplexType by Purview's own default, and holds a scalar value object. + [ValueObject] + public sealed record Address + { + public string Street { get; init; } = ""; + + public string? PostCode { get; init; } + + public AssetId Landmark { get; init; } + } + + [ValueObject(EFMapping = EntityFrameworkMapping.Json)] + public sealed record Dimensions { public int Width { get; init; } public int Height { get; init; } } + + [System.ComponentModel.DataAnnotations.Schema.ComplexType] + public sealed class Geo { public double Lat { get; set; } public double Lon { get; set; } } + + [Owned] + public sealed class Audit { public DateTime CreatedAt { get; set; } public string CreatedBy { get; set; } = ""; } + + // A hand-written union: a Value typed object, which is exactly a scalar value object's shape. + [Union] + public readonly struct Reading : IUnion + { + readonly object? _value; + + public Reading(int count) => _value = count; + + public Reading(double measure) => _value = measure; + + public object? Value => _value; + } + + public class Asset + { + public AssetId Id { get; set; } + + public AssetId? ReplacedBy { get; set; } + + public TagId PrimaryTag { get; set; } = null!; + + public Money Price { get; set; } + + public LegacyId Legacy { get; set; } + + public Code Code { get; set; } + + public Status Status { get; set; } + + public Address Location { get; set; } = null!; + + public Dimensions Size { get; set; } = null!; + + public Geo Position { get; set; } = null!; + + public Audit Audit { get; set; } = null!; + + public Reading Reading { get; set; } + + public List Labels { get; set; } = []; + + public List Related { get; set; } = []; + + // Declared only on the principal: a matching foreign key property, and none at all. + public ICollection Parts { get; set; } = []; + + public ICollection Notes { get; set; } = []; + } + + public class Part { public int Id { get; set; } public AssetId AssetId { get; set; } } + + public class Note { public int Id { get; set; } public string Text { get; set; } = ""; } + + // A composite principal key, referenced through a single typed foreign key. + public readonly struct OrderId { public int Value { get; init; } } + + public class Order + { + [Key] + public OrderId Id { get; set; } + + [Key] + public int Revision { get; set; } + } + + public class Line + { + public int Id { get; set; } + + public OrderId OrderId { get; set; } + + public Order Order { get; set; } = null!; + } + + public class AppDbContext : DbContext + { + public DbSet Assets { get; set; } + + public DbSet Orders { get; set; } + + public DbSet Lines { get; set; } + } + } + """; + + // Helpers + + /// Compiles and reads the result through . + async Task> ReadAsync(Source source, CancellationToken cancellationToken) + { + using var result = await GenerateAsync(ModelSource, cancellationToken: cancellationToken); + + var directory = Directory.CreateTempSubdirectory("aspirec4-vo-").FullName; + var modelPath = EmitSeparately(result.CompilationResult.Compilation, directory, cancellationToken); + + if (source == Source.Metadata) + { + return EfModelMetadataReader.ReadFromAssembly(modelPath); + } + + await Assert.That(EfModelReader.TryRead(modelPath, out var descriptor)).IsTrue(); + + return EfModelLoader.Parse(descriptor).Contexts; + } + + /// + /// Emits the EF Core stubs as their own Microsoft.EntityFrameworkCore assembly and the model + /// against it, side by side, so the metadata reader finds the EF reference it gates on. + /// + static string EmitSeparately(Compilation compilation, string directory, CancellationToken cancellationToken) + { + // The stub source is the only tree that declares the annotations namespace; the model only uses it. + var stubTree = compilation.SyntaxTrees.Single(tree => + tree.ToString().Contains("namespace TestAnnotations", StringComparison.Ordinal) + ); + + var stubs = CSharpCompilation.Create( + "Microsoft.EntityFrameworkCore", + [stubTree], + compilation.References, + new CSharpCompilationOptions( + OutputKind.DynamicallyLinkedLibrary, + nullableContextOptions: NullableContextOptions.Enable + ) + ); + + var stubPath = Path.Combine(directory, "Microsoft.EntityFrameworkCore.dll"); + Emit(stubs, stubPath, cancellationToken); + + var model = compilation + .RemoveSyntaxTrees(stubTree) + .AddReferences(MetadataReference.CreateFromFile(stubPath)) + .WithAssemblyName("Sample.Data"); + + var modelPath = Path.Combine(directory, "Sample.Data.dll"); + Emit(model, modelPath, cancellationToken); + + return modelPath; + } + + static void Emit(Compilation compilation, string path, CancellationToken cancellationToken) + { + using FileStream stream = new(path, FileMode.Create, FileAccess.Write); + var emit = compilation.Emit(stream, cancellationToken: cancellationToken); + + if (!emit.Success) + { + throw new InvalidOperationException( + $"Emit of {compilation.AssemblyName} failed: " + + string.Join("; ", emit.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error)) + ); + } + } + + static EfTable Table(IReadOnlyList contexts, string name) => + contexts.SelectMany(c => c.Tables).Single(t => t.Name == name); + + static EfColumn Column(IReadOnlyList contexts, string table, string column) => + Table(contexts, table).Columns.Single(c => c.Name == column); + + // Tests + + [Test] + [Arguments(Source.Descriptor)] + [Arguments(Source.Metadata)] + public async Task Read_PurviewGenericScalarKey_IsAValueObjectOfItsValueType( + Source source, + CancellationToken cancellationToken + ) + { + // The generator writes Value in a file no other generator sees, so the shape rule found nothing + // and the key — and every foreign key to it — disappeared. + // Act + var contexts = await ReadAsync(source, cancellationToken); + + // Assert + var id = Column(contexts, "Assets", "Id"); + await Assert.That(id.StoreType).IsEqualTo("guid"); + await Assert.That(id.DomainTypeName).IsEqualTo("AssetId"); + await Assert.That(id.DomainTypeKind).IsEqualTo(EfDomainTypeKind.ValueObject); + await Assert.That(id.IsPrimaryKey).IsTrue(); + } + + [Test] + [Arguments(Source.Descriptor)] + [Arguments(Source.Metadata)] + public async Task Read_ClassBasedScalar_IsAColumnRatherThanATable( + Source source, + CancellationToken cancellationToken + ) + { + // Act + var contexts = await ReadAsync(source, cancellationToken); + + // Assert + var tag = Column(contexts, "Assets", "PrimaryTag"); + await Assert.That(tag.StoreType).IsEqualTo("int"); + await Assert.That(tag.DomainTypeName).IsEqualTo("TagId"); + await Assert.That(contexts.SelectMany(c => c.Tables).Select(t => t.Name)).DoesNotContain("TagId"); + } + + [Test] + [Arguments(Source.Descriptor)] + [Arguments(Source.Metadata)] + public async Task Read_ScalarNamingItsOwnProperty_UsesThatPropertysType( + Source source, + CancellationToken cancellationToken + ) + { + // Act + var contexts = await ReadAsync(source, cancellationToken); + + // Assert + var price = Column(contexts, "Assets", "Price"); + await Assert.That(price.StoreType).IsEqualTo("decimal"); + await Assert.That(price.DomainTypeName).IsEqualTo("Money"); + } + + [Test] + [Arguments(Source.Descriptor)] + [Arguments(Source.Metadata)] + public async Task Read_ShapeAndInterfaceValueObjects_AreRecognised( + Source source, + CancellationToken cancellationToken + ) + { + // Act + var contexts = await ReadAsync(source, cancellationToken); + + // Assert + await Assert.That(Column(contexts, "Assets", "Legacy").StoreType).IsEqualTo("bigint"); + await Assert.That(Column(contexts, "Assets", "Legacy").DomainTypeName).IsEqualTo("LegacyId"); + await Assert.That(Column(contexts, "Assets", "Code").StoreType).IsEqualTo("varchar"); + await Assert.That(Column(contexts, "Assets", "Code").DomainTypeName).IsEqualTo("Code"); + } + + [Test] + [Arguments(Source.Descriptor)] + [Arguments(Source.Metadata)] + public async Task Read_NullableValueObject_KeepsItsDomainType(Source source, CancellationToken cancellationToken) + { + // Act + var contexts = await ReadAsync(source, cancellationToken); + + // Assert + var replacedBy = Column(contexts, "Assets", "ReplacedBy"); + await Assert.That(replacedBy.DomainTypeName).IsEqualTo("AssetId"); + await Assert.That(replacedBy.StoreType).IsEqualTo("guid"); + await Assert.That(replacedBy.IsNullable).IsTrue(); + } + + [Test] + [Arguments(Source.Descriptor)] + [Arguments(Source.Metadata)] + public async Task Read_EnumWithAByteUnderlyingType_IsStoredAsItsUnderlyingType( + Source source, + CancellationToken cancellationToken + ) + { + // Act + var contexts = await ReadAsync(source, cancellationToken); + + // Assert + var status = Column(contexts, "Assets", "Status"); + await Assert.That(status.StoreType).IsEqualTo("tinyint"); + await Assert.That(status.DomainTypeKind).IsEqualTo(EfDomainTypeKind.Enum); + } + + [Test] + [Arguments(Source.Descriptor)] + [Arguments(Source.Metadata)] + public async Task Read_BareValueObjectAttribute_IsFlattenedAndEveryMemberNamesItsOwner( + Source source, + CancellationToken cancellationToken + ) + { + // Purview defaults EFMapping to ComplexType, so a bare [ValueObject] is flattened — and its scalar + // value-object member, which carries its own type, still says which property it belongs to. + // Act + var contexts = await ReadAsync(source, cancellationToken); + + // Assert + var members = Table(contexts, "Assets") + .Columns.Where(c => c.Name.StartsWith("Location_", StringComparison.Ordinal)) + .ToList(); + await Assert + .That(members.Select(c => c.Name)) + .IsEquivalentTo(["Location_Street", "Location_PostCode", "Location_Landmark"]); + await Assert + .That(members.All(c => c.ComplexPropertyName == "Location" && c.ComplexTypeName == "Address")) + .IsTrue(); + + var landmark = members.Single(c => c.Name == "Location_Landmark"); + await Assert.That(landmark.DomainTypeKind).IsEqualTo(EfDomainTypeKind.ValueObject); + await Assert.That(landmark.DomainTypeName).IsEqualTo("AssetId"); + await Assert.That(members.Single(c => c.Name == "Location_PostCode").IsNullable).IsTrue(); + } + + [Test] + [Arguments(Source.Descriptor)] + [Arguments(Source.Metadata)] + public async Task Read_JsonMappedValueObject_IsOneJsonColumn(Source source, CancellationToken cancellationToken) + { + // Act + var contexts = await ReadAsync(source, cancellationToken); + + // Assert + var size = Column(contexts, "Assets", "Size"); + await Assert.That(size.StoreType).IsEqualTo("json"); + await Assert.That(size.DomainTypeName).IsEqualTo("Dimensions"); + await Assert.That(size.DomainTypeKind).IsEqualTo(EfDomainTypeKind.ComplexValueObject); + await Assert.That(Table(contexts, "Assets").Columns.Select(c => c.Name)).DoesNotContain("Size_Width"); + } + + [Test] + [Arguments(Source.Descriptor)] + [Arguments(Source.Metadata)] + public async Task Read_EfComplexTypeAndOwnedAttributes_AreFlattenedIntoTheOwner( + Source source, + CancellationToken cancellationToken + ) + { + // Act + var contexts = await ReadAsync(source, cancellationToken); + + // Assert + var columns = Table(contexts, "Assets").Columns.Select(c => c.Name).ToList(); + await Assert.That(columns).Contains("Position_Lat"); + await Assert.That(columns).Contains("Audit_CreatedBy"); + await Assert.That(contexts.SelectMany(c => c.Tables).Select(t => t.Name)).DoesNotContain("Audit"); + } + + [Test] + [Arguments(Source.Descriptor)] + [Arguments(Source.Metadata)] + public async Task Read_UnionProperty_IsAUnionColumnNotAValueObject( + Source source, + CancellationToken cancellationToken + ) + { + // A union's Value accessor is a scalar value object's shape, so without a union check first it + // was shown as one, with no database type behind it. + // Act + var contexts = await ReadAsync(source, cancellationToken); + + // Assert + var reading = Column(contexts, "Assets", "Reading"); + await Assert.That(reading.DomainTypeKind).IsEqualTo(EfDomainTypeKind.Union); + await Assert.That(reading.DomainTypeName).IsEqualTo("Reading"); + await Assert.That(reading.StoreType).IsEqualTo("unknown"); + } + + [Test] + [Arguments(Source.Descriptor)] + [Arguments(Source.Metadata)] + public async Task Read_PrimitiveAndValueObjectCollections_AreJsonColumnsNotTables( + Source source, + CancellationToken cancellationToken + ) + { + // Act + var contexts = await ReadAsync(source, cancellationToken); + + // Assert + var labels = Column(contexts, "Assets", "Labels"); + await Assert.That(labels.StoreType).IsEqualTo("json"); + await Assert.That(labels.DomainTypeName).IsNull(); + + var related = Column(contexts, "Assets", "Related"); + await Assert.That(related.StoreType).IsEqualTo("json"); + await Assert.That(related.DomainTypeName).IsEqualTo("AssetId[]"); + + var tables = contexts.SelectMany(c => c.Tables).Select(t => t.Name).ToList(); + await Assert.That(tables).DoesNotContain("String"); + await Assert.That(tables).DoesNotContain("AssetId"); + } + + [Test] + [Arguments(Source.Descriptor)] + [Arguments(Source.Metadata)] + public async Task Read_RelationshipDeclaredOnlyOnThePrincipal_ResolvesTheForeignKey( + Source source, + CancellationToken cancellationToken + ) + { + // Asset.Parts with no Part.Asset navigation: EF finds Part.AssetId by name. It used to vanish, + // because foreign keys were only looked for from a reference navigation on the dependent. + // Act + var contexts = await ReadAsync(source, cancellationToken); + + // Assert + var foreignKey = Table(contexts, "Part").ForeignKeys.Single(); + await Assert.That(foreignKey.PrincipalTable).IsEqualTo("Assets"); + await Assert.That(foreignKey.Columns).IsEquivalentTo(["AssetId"]); + await Assert.That(foreignKey.PrincipalColumns).IsEquivalentTo(["Id"]); + await Assert.That(foreignKey.PrincipalNavigationName).IsEqualTo("Parts"); + await Assert.That(Column(contexts, "Part", "AssetId").IsForeignKey).IsTrue(); + } + + [Test] + [Arguments(Source.Descriptor)] + [Arguments(Source.Metadata)] + public async Task Read_PrincipalOnlyRelationshipWithNoForeignKeyProperty_ShowsTheShadowForeignKey( + Source source, + CancellationToken cancellationToken + ) + { + // EF adds an AssetId column to Note even though the class declares none; the diagram shows the + // column the database really has, typed like the key it references. + // Act + var contexts = await ReadAsync(source, cancellationToken); + + // Assert + var shadow = Column(contexts, "Note", "AssetId"); + await Assert.That(shadow.IsForeignKey).IsTrue(); + await Assert.That(shadow.IsNullable).IsTrue(); + await Assert.That(shadow.DomainTypeName).IsEqualTo("AssetId"); + await Assert.That(Table(contexts, "Note").ForeignKeys.Single().PrincipalTable).IsEqualTo("Assets"); + } + + [Test] + [Arguments(Source.Descriptor)] + [Arguments(Source.Metadata)] + public async Task Read_SingleColumnForeignKeyIntoACompositeKey_PairsItWithTheKeyColumnOfItsType( + Source source, + CancellationToken cancellationToken + ) + { + // Attaching every key column gave a one-column reference two principal columns: d2 paired it with + // whichever came first, and DBML could not parse it. + // Act + var contexts = await ReadAsync(source, cancellationToken); + + // Assert + var foreignKey = Table(contexts, "Lines").ForeignKeys.Single(); + await Assert.That(foreignKey.Columns).IsEquivalentTo(["OrderId"]); + await Assert.That(foreignKey.PrincipalColumns).IsEquivalentTo(["Id"]); + } +} diff --git a/src/tests/EntityFrameworkCore.UnitTests/EfColumnTypeDisplayTests.cs b/src/tests/EntityFrameworkCore.UnitTests/EfColumnTypeDisplayTests.cs index cb19a4da..72066379 100644 --- a/src/tests/EntityFrameworkCore.UnitTests/EfColumnTypeDisplayTests.cs +++ b/src/tests/EntityFrameworkCore.UnitTests/EfColumnTypeDisplayTests.cs @@ -29,6 +29,8 @@ static EfColumn CreateValueObject(string storeType = "guid", int? maxLength = nu static EfColumn CreateEnum() => CreateColumn("ActorType", EfDomainTypeKind.Enum, "int"); + static EfColumn CreateUnion() => CreateColumn("Reading", EfDomainTypeKind.Union, "varchar"); + static AspireC4EfDiagramOptions CreateOptions(DomainTypeDisplay display) => new() { DomainTypeDisplay = display }; // Tests @@ -188,4 +190,35 @@ public async Task DatabaseType_IncludesTheConfiguredLength() // Assert await Assert.That(databaseType).IsEqualTo("varchar(100)"); } + + [Test] + public async Task Compose_ForAUnion_MarksIt() + { + // A union's name says no more than an enum's does, and it changes what the column can hold. + // Act + var composed = EfColumnTypeDisplay.Compose(CreateUnion(), CreateOptions(DomainTypeDisplay.DomainTypeFirst)); + + // Assert + await Assert.That(composed).IsEqualTo("Reading (varchar, union)"); + } + + [Test] + public async Task Annotation_ForAUnionShownDatabaseFirst_KeepsTheMarkerWithTheDomainType() + { + // Act + var annotation = EfColumnTypeDisplay.Annotation(CreateUnion(), CreateOptions(DomainTypeDisplay.DatabaseFirst)); + + // Assert + await Assert.That(annotation).IsEqualTo("Reading, union"); + } + + [Test] + public async Task Marker_ForAValueObject_IsNone() + { + // Act + var marker = EfColumnTypeDisplay.Marker(CreateValueObject()); + + // Assert + await Assert.That(marker).IsNull(); + } } diff --git a/src/tests/EntityFrameworkCore.UnitTests/EfModelDumpProjectorTests.ValueObjects.cs b/src/tests/EntityFrameworkCore.UnitTests/EfModelDumpProjectorTests.ValueObjects.cs new file mode 100644 index 00000000..0bfee6bf --- /dev/null +++ b/src/tests/EntityFrameworkCore.UnitTests/EfModelDumpProjectorTests.ValueObjects.cs @@ -0,0 +1,236 @@ +using Aspire.Hosting.AspireC4.EntityFrameworkCore.ModelDump; +using Aspire.Hosting.AspireC4.EntityFrameworkCore.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace Aspire.Hosting.AspireC4.EntityFrameworkCore; + +/// +/// The dump projector's handling of value objects, owned types, JSON mapping and column types — the +/// cases where the real model knows more than either static source, and so must not show less. +/// +public sealed partial class EfModelDumpProjectorTests +{ + // Helpers + + static EfModel ProjectValueObjects() + { + using DumpValueObjectDbContext context = new( + new DbContextOptionsBuilder().UseSqlite("Data Source=:memory:").Options + ); + + var json = EfModelDumpProjector.Project( + context.GetService().Model, + nameof(DumpValueObjectDbContext), + typeof(DumpValueObjectDbContext).FullName! + ); + + return EfModelLoader.Parse(json); + } + + static EfColumn AssetColumn(string name) => + Table(ProjectValueObjects(), "DumpAssets").Columns.Single(c => c.Name == name); + + // Tests + + [Test] + public async Task Project_NullableValueObject_KeepsItsDomainType() + { + // Nullable is generic, so the value-object rule rejected it and the column showed a bare guid — + // the most complete source losing what the weaker two keep. + // Act + var replacedBy = AssetColumn("ReplacedBy"); + + // Assert + await Assert.That(replacedBy.DomainTypeName).IsEqualTo("DumpOrderId"); + await Assert.That(replacedBy.DomainTypeKind).IsEqualTo(EfDomainTypeKind.ValueObject); + await Assert.That(replacedBy.StoreType).IsEqualTo("guid"); + await Assert.That(replacedBy.IsNullable).IsTrue(); + } + + [Test] + public async Task Project_EnumConvertedToString_IsStoredAsAStringAndStillMarked() + { + // Act + var status = AssetColumn("Status"); + + // Assert + await Assert.That(status.StoreType).IsEqualTo("varchar"); + await Assert.That(status.DomainTypeKind).IsEqualTo(EfDomainTypeKind.Enum); + } + + [Test] + public async Task Project_ExplicitColumnType_IsShownAsConfigured() + { + // Act + var price = AssetColumn("Price"); + + // Assert + await Assert.That(price.StoreType).IsEqualTo("decimal(18,4)"); + } + + [Test] + public async Task Project_ComplexMembers_NameTheirOwningProperty() + { + // Act + var city = Table(Project(), "DumpCustomers") + .Columns.Single(c => c.Name.Contains("City", StringComparison.Ordinal)); + + // Assert + await Assert.That(city.ComplexPropertyName).IsEqualTo("Address"); + await Assert.That(city.ComplexTypeName).IsEqualTo("DumpAddress"); + } + + [Test] + public async Task Project_OwnedTypeSharingTheTable_IsFlattenedIntoItsOwner() + { + // Owned types used to be skipped outright, so their columns vanished from the owner's table. + // Act + var model = ProjectValueObjects(); + var createdBy = Table(model, "DumpAssets").Columns.Single(c => c.Name == "Audit_CreatedBy"); + + // Assert + await Assert.That(createdBy.ComplexPropertyName).IsEqualTo("Audit"); + await Assert.That(createdBy.ComplexTypeName).IsEqualTo("DumpAudit"); + await Assert.That(model.Contexts.SelectMany(c => c.Tables).Select(t => t.Name)).DoesNotContain("DumpAudit"); + } + + [Test] + public async Task Project_OwnedTypeMappedToJson_IsOneJsonColumn() + { + // Act + var dimensions = AssetColumn("Dimensions"); + + // Assert + await Assert.That(dimensions.StoreType).IsEqualTo("json"); + await Assert.That(dimensions.DomainTypeName).IsEqualTo("DumpDimensions"); + await Assert.That(dimensions.DomainTypeKind).IsEqualTo(EfDomainTypeKind.ComplexValueObject); + } + + [Test] + public async Task Project_OwnedCollection_IsATableOfItsOwn() + { + // OwnsMany has a table, and a foreign key to its owner; skipping every owned type hid both. + // Act + var lines = Table(ProjectValueObjects(), "DumpAssetLine"); + + // Assert + await Assert.That(lines.ForeignKeys.Single().PrincipalTable).IsEqualTo("DumpAssets"); + } + + [Test] + public async Task Project_PrimitiveCollection_IsAJsonColumn() + { + // Act + var labels = AssetColumn("Labels"); + + // Assert + await Assert.That(labels.StoreType).IsEqualTo("json"); + } + +#if NET11_0_OR_GREATER + [Test] + public async Task Project_UnionWithAConverter_IsAUnionColumnOfTheConvertedType() + { + // EF needs a converter to store a union, so the database type is whatever it produces; the + // union's Value accessor must not be mistaken for a scalar value object's. + // Act + var reading = AssetColumn("Reading"); + + // Assert + await Assert.That(reading.DomainTypeKind).IsEqualTo(EfDomainTypeKind.Union); + await Assert.That(reading.DomainTypeName).IsEqualTo("DumpReading"); + await Assert.That(reading.StoreType).IsEqualTo("varchar"); + } +#endif +} + +/// An owned type sharing its owner's table. +public sealed class DumpAudit +{ + public DateTimeOffset CreatedAt { get; set; } + + public string CreatedBy { get; set; } = string.Empty; +} + +/// An owned type mapped to a JSON column. +public sealed class DumpDimensions +{ + public int Width { get; set; } + + public int Height { get; set; } +} + +/// An owned collection, which EF gives a table of its own. +public sealed class DumpAssetLine +{ + public string Description { get; set; } = string.Empty; +} + +#if NET11_0_OR_GREATER +/// A hand-written union of a count and a label, using the .NET 11 runtime's union types. +[System.Runtime.CompilerServices.Union] +public readonly struct DumpReading : System.Runtime.CompilerServices.IUnion +{ + readonly object? _value; + + public DumpReading(int count) => _value = count; + + public DumpReading(string label) => _value = label; + + public object? Value => _value; +} +#endif + +public sealed class DumpAsset +{ + public DumpOrderId Id { get; set; } + + public DumpOrderId? ReplacedBy { get; set; } + + public DumpStatus Status { get; set; } + + public decimal Price { get; set; } + + public DumpAudit Audit { get; set; } = new(); + + public DumpDimensions Dimensions { get; set; } = new(); + + public List Lines { get; set; } = []; + + public List Labels { get; set; } = []; + +#if NET11_0_OR_GREATER + public DumpReading Reading { get; set; } +#endif +} + +/// Value objects, owned types, JSON mapping and explicit column types, in one real model. +public sealed class DumpValueObjectDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet DumpAssets => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + ValueConverter orderId = new(value => value.Value, raw => new DumpOrderId { Value = raw }); + + modelBuilder.Entity(builder => + { + builder.HasKey(a => a.Id); + builder.Property(a => a.Id).HasConversion(orderId); + builder.Property(a => a.ReplacedBy).HasConversion(orderId); + builder.Property(a => a.Status).HasConversion(); + builder.Property(a => a.Price).HasColumnType("decimal(18,4)"); + builder.OwnsOne(a => a.Audit); + builder.OwnsOne(a => a.Dimensions, owned => owned.ToJson()); + builder.OwnsMany(a => a.Lines); +#if NET11_0_OR_GREATER + builder + .Property(a => a.Reading) + .HasConversion(value => value.Value!.ToString()!, raw => new DumpReading(raw)); +#endif + }); + } +} diff --git a/src/tests/EntityFrameworkCore.UnitTests/EfModelDumpProjectorTests.cs b/src/tests/EntityFrameworkCore.UnitTests/EfModelDumpProjectorTests.cs index 27db6cef..e41295a6 100644 --- a/src/tests/EntityFrameworkCore.UnitTests/EfModelDumpProjectorTests.cs +++ b/src/tests/EntityFrameworkCore.UnitTests/EfModelDumpProjectorTests.cs @@ -11,7 +11,7 @@ namespace Aspire.Hosting.AspireC4.EntityFrameworkCore; /// Verifies the build-time dump projector against a real EF Core model — the authoritative source that /// carries conventions, value converters and composite foreign keys. /// -public sealed class EfModelDumpProjectorTests +public sealed partial class EfModelDumpProjectorTests { // Helpers diff --git a/src/tests/EntityFrameworkCore.UnitTests/EfModelLoaderTests.cs b/src/tests/EntityFrameworkCore.UnitTests/EfModelLoaderTests.cs index 4ffaef01..7431bed1 100644 --- a/src/tests/EntityFrameworkCore.UnitTests/EfModelLoaderTests.cs +++ b/src/tests/EntityFrameworkCore.UnitTests/EfModelLoaderTests.cs @@ -54,8 +54,65 @@ static void WriteAssembly(string directory, string sourceAssembly, DateTime writ File.SetLastWriteTimeUtc(destination, writtenUtc); } + /// + /// A descriptor from a newer analyzer than this AppHost: one column carries a domain kind this + /// version has never heard of, and one carries the complex-owner fields. + /// + const string NewerDescriptor = /*lang=json,strict*/ + """ + {"schemaVersion":1,"contexts":[{"name":"AppDbContext","typeName":"Sample.AppDbContext","tables":[ + {"name":"Assets","clrTypeName":"Sample.Asset","columns":[ + {"name":"Id","storeType":"guid","domainTypeName":"AssetId","domainTypeKind":"ValueObject","isPrimaryKey":true}, + {"name":"Shape","storeType":"json","domainTypeName":"Shape","domainTypeKind":"SomeFutureKind"}, + {"name":"Location_City","storeType":"varchar","domainTypeName":"Address","domainTypeKind":"ComplexValueObject", + "complexPropertyName":"Location","complexTypeName":"Address"}], + "foreignKeys":[]}]}]} + """; + // Tests + [Test] + public async Task Parse_DescriptorWithAnUnknownDomainKind_KeepsTheModelAndDropsOnlyTheKind() + { + // A strict enum converter threw on the unfamiliar kind, and a parse failure empties the whole model, + // so one column from a newer analyzer would have blanked the diagram. + // Act + var model = EfModelLoader.Parse(NewerDescriptor); + + // Assert + var columns = model.Contexts.Single().Tables.Single().Columns; + await Assert.That(columns.Count).IsEqualTo(3); + await Assert.That(columns.Single(c => c.Name == "Shape").DomainTypeKind).IsNull(); + await Assert.That(columns.Single(c => c.Name == "Shape").DomainTypeName).IsEqualTo("Shape"); + await Assert.That(columns.Single(c => c.Name == "Id").DomainTypeKind).IsEqualTo(EfDomainTypeKind.ValueObject); + } + + [Test] + public async Task Parse_FlattenedComplexMember_ReadsItsOwner() + { + // Act + var model = EfModelLoader.Parse(NewerDescriptor); + + // Assert + var city = model.Contexts.Single().Tables.Single().Columns.Single(c => c.Name == "Location_City"); + await Assert.That(city.ComplexPropertyName).IsEqualTo("Location"); + await Assert.That(city.ComplexTypeName).IsEqualTo("Address"); + } + + [Test] + public async Task Serialise_DomainKind_RoundTripsByName() + { + // Arrange + var model = EfModelLoader.Parse(NewerDescriptor); + + // Act + var json = ErdOutput.Serialise(model); + + // Assert + await Assert.That(json).Contains("\"domainTypeKind\": \"ValueObject\""); + await Assert.That(EfModelLoader.Parse(json).Contexts.Single().Tables.Single().Columns.Count).IsEqualTo(3); + } + [Test] public async Task Parse_GeneratorDescriptor_ReadsContextMetadata() { @@ -300,6 +357,7 @@ public async Task LoadFromProject_ProjectWithoutBinDirectory_ReportsThatNothingW await Assert.That(diagnostics.OutputDirectory).IsNull(); await Assert.That(diagnostics.AssembliesScanned).IsEqualTo(0); await Assert.That(diagnostics.DescriptorsFound).IsEqualTo(0); + await Assert.That(diagnostics.DumpStatus).IsEqualTo(EfModelDumpStatus.Missing); } [Test] @@ -463,6 +521,7 @@ CancellationToken cancellationToken // Assert await Assert.That(model.Contexts.Select(c => c.Name)).Contains("DumpDbContext"); await Assert.That(diagnostics.DumpContextsFound).IsEqualTo(1); + await Assert.That(diagnostics.DumpStatus).IsEqualTo(EfModelDumpStatus.Loaded); } finally { @@ -486,8 +545,9 @@ public async Task LoadFromProject_WithStaleDump_FallsBackAndIgnoresIt(Cancellati // Act var model = EfModelLoader.LoadFromProject(projectPath, out var diagnostics); - // Assert — the descriptor in the assembly is used instead. + // Assert — the descriptor in the assembly is used instead, and the reason is reported. await Assert.That(diagnostics.DumpContextsFound).IsEqualTo(0); + await Assert.That(diagnostics.DumpStatus).IsEqualTo(EfModelDumpStatus.Stale); await Assert.That(model.Contexts.Select(c => c.Name)).DoesNotContain("DumpDbContext"); await Assert.That(model.Contexts.Select(c => c.Name)).Contains("BlogDbContext"); } @@ -526,6 +586,7 @@ await File.WriteAllTextAsync( // Assert await Assert.That(model.Contexts.Select(c => c.Name)).Contains("DumpDbContext"); await Assert.That(diagnostics.DumpContextsFound).IsEqualTo(1); + await Assert.That(diagnostics.DumpStatus).IsEqualTo(EfModelDumpStatus.Loaded); } finally { @@ -533,6 +594,62 @@ await File.WriteAllTextAsync( } } + [Test] + public async Task LoadFromProject_WithoutDumpDirectory_ReportsTheDumpAsMissing(CancellationToken cancellationToken) + { + // Arrange — a build output with no obj/aspirec4/ef directory beside it. + var projectPath = await CreateProjectAsync(cancellationToken); + var root = Path.GetDirectoryName(projectPath)!; + var output = Path.Combine(root, "bin", "Debug", "net10.0"); + WriteAssembly(output, typeof(EfModelLoaderTests).Assembly.Location, DateTime.UtcNow); + + try + { + // Act + _ = EfModelLoader.LoadFromProject(projectPath, out var diagnostics); + + // Assert + await Assert.That(diagnostics.DumpStatus).IsEqualTo(EfModelDumpStatus.Missing); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Test] + public async Task LoadFromProject_WithDumpThatDescribesNoContexts_ReportsTheDumpAsEmpty( + CancellationToken cancellationToken + ) + { + // A dump file that parses but holds nothing is a different problem from a missing dump: the + // ModelDump target ran, so the project needs looking at rather than rebuilding. + // Arrange + var projectPath = await CreateProjectAsync(cancellationToken); + var root = Path.GetDirectoryName(projectPath)!; + var output = Path.Combine(root, "bin", "Debug", "net10.0"); + WriteAssembly(output, typeof(EfModelLoaderTests).Assembly.Location, DateTime.UtcNow.AddMinutes(-5)); + WriteDump( + root, + "Empty", /*lang=json,strict*/ + """{"schemaVersion":1,"contexts":[]}""", + DateTime.UtcNow + ); + + try + { + // Act + _ = EfModelLoader.LoadFromProject(projectPath, out var diagnostics); + + // Assert + await Assert.That(diagnostics.DumpStatus).IsEqualTo(EfModelDumpStatus.Empty); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + /// Writes the minimal restored asset graph naming one referenced project. static async Task WriteAssetsGraphAsync( string projectDirectory, diff --git a/src/tests/Repository.UnitTests/TypeScriptTests.cs b/src/tests/Repository.UnitTests/TypeScriptTests.cs new file mode 100644 index 00000000..f4b63832 --- /dev/null +++ b/src/tests/Repository.UnitTests/TypeScriptTests.cs @@ -0,0 +1,100 @@ +using System.Diagnostics; + +namespace Aspire.Hosting.AspireC4.Repository; + +/// +/// Runs the repository's TypeScript test suite as part of the .NET test run. +/// +/// +/// +/// Purview.Build discovers test projects by *Tests.csproj and runs them with +/// dotnet test; it has no JavaScript test module. Without this bridge bun test would +/// only run when somebody remembered to invoke it, so neither just test nor the shared +/// pipeline would execute the TypeScript tests at all. +/// +/// +/// The suite is dependency-free - it imports only node:* and bun:test - so it runs +/// from a clean checkout without an install step. The shared pipeline installs Bun before it runs; +/// a developer machine needs Bun for the repository's other scripts anyway. +/// +/// +public sealed class TypeScriptTests +{ + const string Bun = "bun"; + + [Test] + public async Task BunTest_RepositorySuite_ShouldPass(CancellationToken cancellationToken) + { + // Arrange + var repositoryRoot = FindRepositoryRoot(); + + // Act + var (exitCode, output) = await RunBunTestsAsync(repositoryRoot, cancellationToken); + + // Assert + await Assert + .That(exitCode) + .IsEqualTo(0) + .Because($"`bun test` failed in '{repositoryRoot}'.{Environment.NewLine}{output}"); + } + + static async Task<(int ExitCode, string Output)> RunBunTestsAsync( + string repositoryRoot, + CancellationToken cancellationToken + ) + { + ProcessStartInfo startInfo = new() + { + FileName = Bun, + WorkingDirectory = repositoryRoot, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + startInfo.ArgumentList.Add("test"); + + using var process = Process.Start(startInfo) + ?? throw new InvalidOperationException($"Could not start '{Bun}'. Ensure Bun is installed."); + + var stdout = process.StandardOutput.ReadToEndAsync(cancellationToken); + var stderr = process.StandardError.ReadToEndAsync(cancellationToken); + + try + { + await process.WaitForExitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + + throw; + } + + return (process.ExitCode, await stdout + await stderr); + } + + static string FindRepositoryRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + + while (directory is not null) + { + if ( + File.Exists(Path.Combine(directory.FullName, "global.json")) + && Directory.Exists(Path.Combine(directory.FullName, "scripts")) + ) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new InvalidOperationException($"Could not find the repository root above '{AppContext.BaseDirectory}'."); + } +} diff --git a/src/tests/SharedTestingInfra/Extensoins/Microsoft/Extensions/DependencyInjection/OptionsNameHelper.cs b/src/tests/SharedTestingInfra/Extensoins/Microsoft/Extensions/DependencyInjection/OptionsNameHelper.cs index 9694699c..3098d400 100644 --- a/src/tests/SharedTestingInfra/Extensoins/Microsoft/Extensions/DependencyInjection/OptionsNameHelper.cs +++ b/src/tests/SharedTestingInfra/Extensoins/Microsoft/Extensions/DependencyInjection/OptionsNameHelper.cs @@ -40,8 +40,8 @@ public static string GetSectionName() foreach (var memberName in DefaultMemberNames) { - var value = - TryGetStaticStringProperty(optionsType, memberName) ?? TryGetStaticStringField(optionsType, memberName); + var value = TryGetStaticStringProperty(optionsType, memberName) + ?? TryGetStaticStringField(optionsType, memberName); if (!string.IsNullOrWhiteSpace(value)) { diff --git a/tests/Docker/Dockerfile.e2e b/tests/Docker/Dockerfile.e2e index be27d53d..3d8de6fb 100644 --- a/tests/Docker/Dockerfile.e2e +++ b/tests/Docker/Dockerfile.e2e @@ -13,7 +13,8 @@ # aspirec4-e2e-docker \ # dotnet test src/tests/AspireC4.IntegrationTests -FROM mcr.microsoft.com/dotnet/sdk:10.0 +# Must match the feature band pinned in global.json: the repo builds with the .NET 11 SDK. +FROM mcr.microsoft.com/dotnet/sdk:11.0 RUN apt-get update -qq && \ apt-get install -y --no-install-recommends \ diff --git a/tests/Docker/Dockerfile.e2e-cli b/tests/Docker/Dockerfile.e2e-cli index d03ba086..0828b279 100644 --- a/tests/Docker/Dockerfile.e2e-cli +++ b/tests/Docker/Dockerfile.e2e-cli @@ -27,7 +27,8 @@ # ── Base ────────────────────────────────────────────────────────────────────── -FROM mcr.microsoft.com/dotnet/sdk:10.0 AS base +# Must match the feature band pinned in global.json: the repo builds with the .NET 11 SDK. +FROM mcr.microsoft.com/dotnet/sdk:11.0 AS base RUN apt-get update -qq && \ apt-get install -y --no-install-recommends \