From 7d87d5c7546f8ed17a5336308c914223a86d1a6b Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Mon, 21 Sep 2026 11:32:29 +0000 Subject: [PATCH] Ship the storage-type alias props in build/, not buildTransitive/ [patch] buildTransitive/ is by definition the folder whose contents flow past the project that referenced the package to everything downstream of it. The alias props are 220 project-wide global usings keyed on the bare type name, so two storage types reaching one project define every alias twice and the compile dies with one CS1537 per quantity against GlobalUsings.g.cs. That made the collision reachable from a project that references no alias package at all. An application assembled from one project per storage type -- which is the shape the four packages exist to enable -- satisfies "one alias package per project" in every project it contains, and still does not build. Nothing in the 220 x (n-1) errors names a package, and the file they point at is one the author never wrote. build/ binds the aliases in the project that declares the reference and nowhere else, which is what the documented rule already says. The transitive explosion then cannot happen, rather than being diagnosed after the fact, and the PrivateAssets="all" workaround stops being something a consumer has to know in advance. Verified end to end against the issue's repro -- two wrapper projects, one per storage type, and an app that project-references both. Packed from this branch it builds and each wrapper keeps its own binding; packed with the props back in buildTransitive/ it fails with the CS1537 wall. AliasPropsPackagingTests holds the folder, since it is one word in a PackagePath that no build in this repository exercises -- nothing here consumes the alias packages as packages -- and is only wrong once a consumer composes two of them. Fixes #244 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CGHNqRPzdBvCeegruQ11CG --- .github/workflows/verify-generated.yml | 2 +- CLAUDE.md | 4 +- Semantics.Quantities.Decimal/README.md | 11 +- .../Semantics.Quantities.Decimal.csproj | 6 +- .../ktsu.Semantics.Quantities.Decimal.props | 0 Semantics.Quantities.Double/README.md | 11 +- .../Semantics.Quantities.Double.csproj | 6 +- .../ktsu.Semantics.Quantities.Double.props | 0 Semantics.Quantities.Float/README.md | 11 +- .../Semantics.Quantities.Float.csproj | 6 +- .../ktsu.Semantics.Quantities.Float.props | 0 Semantics.Quantities.Precise/README.md | 11 +- .../Semantics.Quantities.Precise.csproj | 6 +- .../ktsu.Semantics.Quantities.Precise.props | 0 .../Quantities/AliasPropsPackagingTests.cs | 151 ++++++++++++++++++ scripts/Generate-AliasProps.ps1 | 25 ++- 16 files changed, 223 insertions(+), 27 deletions(-) rename Semantics.Quantities.Decimal/{buildTransitive => build}/ktsu.Semantics.Quantities.Decimal.props (100%) rename Semantics.Quantities.Double/{buildTransitive => build}/ktsu.Semantics.Quantities.Double.props (100%) rename Semantics.Quantities.Float/{buildTransitive => build}/ktsu.Semantics.Quantities.Float.props (100%) rename Semantics.Quantities.Precise/{buildTransitive => build}/ktsu.Semantics.Quantities.Precise.props (100%) create mode 100644 Semantics.Test/Quantities/AliasPropsPackagingTests.cs diff --git a/.github/workflows/verify-generated.yml b/.github/workflows/verify-generated.yml index 1aa4ffd5..0aa9e468 100644 --- a/.github/workflows/verify-generated.yml +++ b/.github/workflows/verify-generated.yml @@ -2,7 +2,7 @@ name: Verify Generated Files # Two kinds of generated artifacts are committed to this repo: # 1. Source-generator output under Semantics.Quantities/Generated/ (EmitCompilerGeneratedFiles=true). -# 2. The storage-type alias props under Semantics.Quantities./buildTransitive/, produced by +# 2. The storage-type alias props under Semantics.Quantities./build/, produced by # scripts/Generate-AliasProps.ps1 from the quantity catalogue. # This job rebuilds and regenerates both, then fails if anything drifts from its source — so a # committed generated file or alias-props file can never go stale. diff --git a/CLAUDE.md b/CLAUDE.md index bb82f55b..00df7496 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,7 @@ The opt-in lives in `.sonarlint/sonar-local.props` (analyzer package) and `.sona | `Semantics.Color` | Physically-grounded color types. Canonical linear-RGB `Color` hub plus color-space satellites (`Srgb`, `Hsl`, `Hsv`, `Oklab`, `Oklch`); every type converts to and from every other, routed through the nearest shared hub (`Srgb` within the sRGB family, `Oklab` within the perceptual family, linear `Color` across families) so no conversion takes a redundant gamma round-trip. Also WCAG accessibility tooling, HSL/perceptual adjustment operations (lighten/saturate/hue/invert), and `NamedColors`. Targets `net8.0`–`net10.0` + `netstandard2.0`/`netstandard2.1`. | | `Semantics.Quantities` | Hand-written runtime types (`IPhysicalQuantity`, `PhysicalQuantityCore`, `IVector0`..`IVector4`, `UnitSystem`) plus generator output under `Generated/`. Every generated quantity is a `readonly record struct`. | | `Semantics.SourceGenerators` | Roslyn incremental generators that emit quantity types, units, conversions, magnitudes, physical constants, and storage-type helpers from metadata. Only the physics-specific half lives here — `Models/`, `Metadata/`, `Generators/`, and the bindings in `SemanticsGenerator`/`SemanticsDiagnostics`/`Emit`. The C# syntax templates come from `ktsu.CodeBlocker.Templates`; the metadata-driven generator base, metadata loading and the diagnostic catalogue come from `ktsu.SourceGeneratorToolkit` (#181, #192). | -| `Semantics.Quantities.{Double,Float,Decimal,Precise}` | Props-only satellite packages. Each ships a `buildTransitive` props file (generated by `scripts/Generate-AliasProps.ps1`) that injects global-using aliases binding every quantity to one storage type, so consumers write `Mass` instead of `Mass`. `Precise` binds to `ktsu.PreciseNumber.PreciseNumber` and is the one whose storage type comes from a package rather than being a C# keyword, so it carries a `PackageReference` the others do not; the core `Semantics.Quantities` still has no PreciseNumber dependency. | +| `Semantics.Quantities.{Double,Float,Decimal,Precise}` | Props-only satellite packages. Each ships a `build` props file (generated by `scripts/Generate-AliasProps.ps1`) that injects global-using aliases binding every quantity to one storage type, so consumers write `Mass` instead of `Mass`. `build` rather than `buildTransitive` on purpose: the aliases are project-wide global usings keyed on the bare type name, so two of these packages reaching one project define every alias twice and the compile fails with one `CS1537` per quantity. `build` binds the aliases in the project that declares the reference and nowhere else, which is what "one alias package per project" means; a project downstream of that one references the package it wants for itself. `Precise` binds to `ktsu.PreciseNumber.PreciseNumber` and is the one whose storage type comes from a package rather than being a C# keyword, so it carries a `PackageReference` the others do not; the core `Semantics.Quantities` still has no PreciseNumber dependency. | | `Semantics.Benchmarks` | BenchmarkDotNet suite covering quantities, strings and paths. Not shipped and not covered by tests, so it carries `SonarQubeExclude`. Feeds the per-release charts in `docs/benchmarks/`. | | `Semantics.Vocabulary` | **Shared source, not a project.** Resolves `dimensions.json` into the quantities and operators it describes and separates out what cannot be honoured. Compiled into both `Semantics.SourceGenerators` and `Semantics.Cpp` via `Compile Include`; see its README for why source rather than an assembly, and what that costs. | | `Semantics.Cpp` | The C++ projection of the quantity vocabulary, in its own project because `ktsu.Coder` ships no `net8.0`. Reads `dimensions.json` and emits one C++ class per dimension, per vector form and per named overload, plus the declared relationships as operators. | @@ -431,7 +431,7 @@ var converted = sourceString.As(); - Edit `Semantics.SourceGenerators/Metadata/dimensions.json` to add a dimension, vector form, semantic overload, or relationship. - Rebuild `Semantics.SourceGenerators` and the consuming `Semantics.Quantities` project; emitted files appear in `Semantics.Quantities/Generated/Semantics.SourceGenerators//`. - Treat generator output as committed source. Diff it before commit so accidental regressions are visible. -- After adding or renaming a quantity, regenerate the storage-type alias props with `pwsh scripts/Generate-AliasProps.ps1` (it reads the generated catalogue and rewrites `Semantics.Quantities.{Double,Float,Decimal,Precise}/buildTransitive/*.props`) and commit them. The `verify-generated` workflow rebuilds, regenerates, and fails the PR if either the generated sources or the alias props drift. +- After adding or renaming a quantity, regenerate the storage-type alias props with `pwsh scripts/Generate-AliasProps.ps1` (it reads the generated catalogue and rewrites `Semantics.Quantities.{Double,Float,Decimal,Precise}/build/*.props`) and commit them. The `verify-generated` workflow rebuilds, regenerates, and fails the PR if either the generated sources or the alias props drift. - Factory names are the **singular lemma** (#49). The generator emits `From{name}` using each unit's `name` from `units.json` verbatim (e.g. `Length.FromMeter`, `Mass.FromKilogram`, `Speed.FromMeterPerSecond`, `Length.FromFoot`, `Frequency.FromHertz`). The rule is purely mechanical, so `name` must itself be the singular lemma — including compounds, whose leading noun is singular too (`MeterPerSecond`, `RevolutionPerMinute`, `PartPerMillion`, not `MeterPerSecond`/`RevolutionPerMinute`/`PartPerMillion`). There is no `factoryName` field and no pluralisation step; the generator never has to know English pluralisation. - Generator diagnostics: - **SEM001** — a relationship in `dimensions.json` references a dimension that does not exist (typo or rename). The operator is silently dropped. diff --git a/Semantics.Quantities.Decimal/README.md b/Semantics.Quantities.Decimal/README.md index 3a7ca32f..2f8746ac 100644 --- a/Semantics.Quantities.Decimal/README.md +++ b/Semantics.Quantities.Decimal/README.md @@ -16,7 +16,7 @@ Every quantity in `ktsu.Semantics.Quantities` is generic over its numeric storage type, so you normally write `Mass`, `Speed`, and so on. If a project uses one storage type throughout, that generic argument is noise. -This package is props-only. It ships no assembly, just a `buildTransitive` props file that injects one C# global-using alias per quantity, binding each open generic type to `decimal`. Reference it and you can write `Mass`, `Speed`, `Force3D` with no generic argument, and every quantity resolves to its `decimal` form. The aliases are real `Mass` (and so on), so they interoperate with the entire API with no conversion. +This package is props-only. It ships no assembly, just a `build` props file that injects one C# global-using alias per quantity, binding each open generic type to `decimal`. Reference it and you can write `Mass`, `Speed`, `Force3D` with no generic argument, and every quantity resolves to its `decimal` form. The aliases are real `Mass` (and so on), so they interoperate with the entire API with no conversion. Installing this package also pulls in the matching version of `ktsu.Semantics.Quantities` as a dependency, so it is the only reference you need. @@ -70,6 +70,15 @@ The aliases are project-wide global usings keyed on the bare type name (`Mass`, A project that genuinely needs mixed storage types should skip the alias packages and reference `ktsu.Semantics.Quantities` directly, writing the closed generic (`Mass`) explicitly. +The binding applies to the project that declares the `PackageReference`, and to no other. The props +ship in the package's `build/` folder rather than `buildTransitive/`, so they are not inherited by +projects that reference *this* project. An application assembled from several projects, each wrapping +a different storage type, therefore compiles: each project that wants aliases references its own alias +package, and no project collects a binding it never asked for. Earlier versions shipped the props in +`buildTransitive/`, where the binding did flow downstream and two of them in one dependency graph +produced a wall of `CS1537` against a generated file the author never wrote; `PrivateAssets="all"` on +the alias reference was the workaround for that, and is no longer needed. + The alias lists are generated from the quantity catalogue by `scripts/Generate-AliasProps.ps1` and validated in CI, so they stay in lockstep with the quantities the core package emits. ## Contributing diff --git a/Semantics.Quantities.Decimal/Semantics.Quantities.Decimal.csproj b/Semantics.Quantities.Decimal/Semantics.Quantities.Decimal.csproj index caafb062..056976c0 100644 --- a/Semantics.Quantities.Decimal/Semantics.Quantities.Decimal.csproj +++ b/Semantics.Quantities.Decimal/Semantics.Quantities.Decimal.csproj @@ -5,20 +5,20 @@ net10.0;net9.0;net8.0 - + false false false - Storage-type aliases for ktsu.Semantics.Quantities. Reference this package to write `Mass` instead of `Mass<decimal>` (and every other quantity) bound to decimal, project-wide. Use one storage-type alias package per project. + Storage-type aliases for ktsu.Semantics.Quantities. Reference this package to write `Mass` instead of `Mass<decimal>` (and every other quantity) bound to decimal, project-wide. Use one storage-type alias package per project. The aliases bind only in the project that references this package, not in projects downstream of it, so each project that wants them references the package itself. $(NoWarn);NU5128 - + diff --git a/Semantics.Quantities.Decimal/buildTransitive/ktsu.Semantics.Quantities.Decimal.props b/Semantics.Quantities.Decimal/build/ktsu.Semantics.Quantities.Decimal.props similarity index 100% rename from Semantics.Quantities.Decimal/buildTransitive/ktsu.Semantics.Quantities.Decimal.props rename to Semantics.Quantities.Decimal/build/ktsu.Semantics.Quantities.Decimal.props diff --git a/Semantics.Quantities.Double/README.md b/Semantics.Quantities.Double/README.md index 2ea123cb..bad8a263 100644 --- a/Semantics.Quantities.Double/README.md +++ b/Semantics.Quantities.Double/README.md @@ -16,7 +16,7 @@ Every quantity in `ktsu.Semantics.Quantities` is generic over its numeric storage type, so you normally write `Mass`, `Speed`, and so on. If a project uses one storage type throughout, that generic argument is noise. -This package is props-only. It ships no assembly, just a `buildTransitive` props file that injects one C# global-using alias per quantity, binding each open generic type to `double`. Reference it and you can write `Mass`, `Speed`, `Force3D` with no generic argument, and every quantity resolves to its `double` form. The aliases are real `Mass` (and so on), so they interoperate with the entire API with no conversion. +This package is props-only. It ships no assembly, just a `build` props file that injects one C# global-using alias per quantity, binding each open generic type to `double`. Reference it and you can write `Mass`, `Speed`, `Force3D` with no generic argument, and every quantity resolves to its `double` form. The aliases are real `Mass` (and so on), so they interoperate with the entire API with no conversion. Installing this package also pulls in the matching version of `ktsu.Semantics.Quantities` as a dependency, so it is the only reference you need. @@ -70,6 +70,15 @@ The aliases are project-wide global usings keyed on the bare type name (`Mass`, A project that genuinely needs mixed storage types should skip the alias packages and reference `ktsu.Semantics.Quantities` directly, writing the closed generic (`Mass`) explicitly. +The binding applies to the project that declares the `PackageReference`, and to no other. The props +ship in the package's `build/` folder rather than `buildTransitive/`, so they are not inherited by +projects that reference *this* project. An application assembled from several projects, each wrapping +a different storage type, therefore compiles: each project that wants aliases references its own alias +package, and no project collects a binding it never asked for. Earlier versions shipped the props in +`buildTransitive/`, where the binding did flow downstream and two of them in one dependency graph +produced a wall of `CS1537` against a generated file the author never wrote; `PrivateAssets="all"` on +the alias reference was the workaround for that, and is no longer needed. + The alias lists are generated from the quantity catalogue by `scripts/Generate-AliasProps.ps1` and validated in CI, so they stay in lockstep with the quantities the core package emits. ## Contributing diff --git a/Semantics.Quantities.Double/Semantics.Quantities.Double.csproj b/Semantics.Quantities.Double/Semantics.Quantities.Double.csproj index f3d4a8cb..1adb285b 100644 --- a/Semantics.Quantities.Double/Semantics.Quantities.Double.csproj +++ b/Semantics.Quantities.Double/Semantics.Quantities.Double.csproj @@ -5,20 +5,20 @@ net10.0;net9.0;net8.0 - + false false false - Storage-type aliases for ktsu.Semantics.Quantities. Reference this package to write `Mass` instead of `Mass<double>` (and every other quantity) bound to double, project-wide. Use one storage-type alias package per project. + Storage-type aliases for ktsu.Semantics.Quantities. Reference this package to write `Mass` instead of `Mass<double>` (and every other quantity) bound to double, project-wide. Use one storage-type alias package per project. The aliases bind only in the project that references this package, not in projects downstream of it, so each project that wants them references the package itself. $(NoWarn);NU5128 - + diff --git a/Semantics.Quantities.Double/buildTransitive/ktsu.Semantics.Quantities.Double.props b/Semantics.Quantities.Double/build/ktsu.Semantics.Quantities.Double.props similarity index 100% rename from Semantics.Quantities.Double/buildTransitive/ktsu.Semantics.Quantities.Double.props rename to Semantics.Quantities.Double/build/ktsu.Semantics.Quantities.Double.props diff --git a/Semantics.Quantities.Float/README.md b/Semantics.Quantities.Float/README.md index 007714ad..1e908c23 100644 --- a/Semantics.Quantities.Float/README.md +++ b/Semantics.Quantities.Float/README.md @@ -16,7 +16,7 @@ Every quantity in `ktsu.Semantics.Quantities` is generic over its numeric storage type, so you normally write `Mass`, `Speed`, and so on. If a project uses one storage type throughout, that generic argument is noise. -This package is props-only. It ships no assembly, just a `buildTransitive` props file that injects one C# global-using alias per quantity, binding each open generic type to `float`. Reference it and you can write `Mass`, `Speed`, `Force3D` with no generic argument, and every quantity resolves to its `float` form. The aliases are real `Mass` (and so on), so they interoperate with the entire API with no conversion. +This package is props-only. It ships no assembly, just a `build` props file that injects one C# global-using alias per quantity, binding each open generic type to `float`. Reference it and you can write `Mass`, `Speed`, `Force3D` with no generic argument, and every quantity resolves to its `float` form. The aliases are real `Mass` (and so on), so they interoperate with the entire API with no conversion. Installing this package also pulls in the matching version of `ktsu.Semantics.Quantities` as a dependency, so it is the only reference you need. @@ -70,6 +70,15 @@ The aliases are project-wide global usings keyed on the bare type name (`Mass`, A project that genuinely needs mixed storage types should skip the alias packages and reference `ktsu.Semantics.Quantities` directly, writing the closed generic (`Mass`) explicitly. +The binding applies to the project that declares the `PackageReference`, and to no other. The props +ship in the package's `build/` folder rather than `buildTransitive/`, so they are not inherited by +projects that reference *this* project. An application assembled from several projects, each wrapping +a different storage type, therefore compiles: each project that wants aliases references its own alias +package, and no project collects a binding it never asked for. Earlier versions shipped the props in +`buildTransitive/`, where the binding did flow downstream and two of them in one dependency graph +produced a wall of `CS1537` against a generated file the author never wrote; `PrivateAssets="all"` on +the alias reference was the workaround for that, and is no longer needed. + The alias lists are generated from the quantity catalogue by `scripts/Generate-AliasProps.ps1` and validated in CI, so they stay in lockstep with the quantities the core package emits. ## Contributing diff --git a/Semantics.Quantities.Float/Semantics.Quantities.Float.csproj b/Semantics.Quantities.Float/Semantics.Quantities.Float.csproj index c1a687c4..f931bc82 100644 --- a/Semantics.Quantities.Float/Semantics.Quantities.Float.csproj +++ b/Semantics.Quantities.Float/Semantics.Quantities.Float.csproj @@ -5,20 +5,20 @@ net10.0;net9.0;net8.0 - + false false false - Storage-type aliases for ktsu.Semantics.Quantities. Reference this package to write `Mass` instead of `Mass<float>` (and every other quantity) bound to float, project-wide. Use one storage-type alias package per project. + Storage-type aliases for ktsu.Semantics.Quantities. Reference this package to write `Mass` instead of `Mass<float>` (and every other quantity) bound to float, project-wide. Use one storage-type alias package per project. The aliases bind only in the project that references this package, not in projects downstream of it, so each project that wants them references the package itself. $(NoWarn);NU5128 - + diff --git a/Semantics.Quantities.Float/buildTransitive/ktsu.Semantics.Quantities.Float.props b/Semantics.Quantities.Float/build/ktsu.Semantics.Quantities.Float.props similarity index 100% rename from Semantics.Quantities.Float/buildTransitive/ktsu.Semantics.Quantities.Float.props rename to Semantics.Quantities.Float/build/ktsu.Semantics.Quantities.Float.props diff --git a/Semantics.Quantities.Precise/README.md b/Semantics.Quantities.Precise/README.md index dcf61a96..50c3889c 100644 --- a/Semantics.Quantities.Precise/README.md +++ b/Semantics.Quantities.Precise/README.md @@ -16,7 +16,7 @@ Every quantity in `ktsu.Semantics.Quantities` is generic over its numeric storage type, so you normally write `Mass`, `Speed`, and so on. If a project uses one storage type throughout, that generic argument is noise. -This package is props-only. It ships no assembly, just a `buildTransitive` props file that injects one C# global-using alias per quantity, binding each open generic type to [`ktsu.PreciseNumber.PreciseNumber`](https://nuget.org/packages/ktsu.PreciseNumber). Reference it and you can write `Mass`, `Speed`, `Force3D` with no generic argument, and every quantity resolves to its `PreciseNumber` form. The aliases are real `Mass` (and so on), so they interoperate with the entire API with no conversion. +This package is props-only. It ships no assembly, just a `build` props file that injects one C# global-using alias per quantity, binding each open generic type to [`ktsu.PreciseNumber.PreciseNumber`](https://nuget.org/packages/ktsu.PreciseNumber). Reference it and you can write `Mass`, `Speed`, `Force3D` with no generic argument, and every quantity resolves to its `PreciseNumber` form. The aliases are real `Mass` (and so on), so they interoperate with the entire API with no conversion. Installing this package also pulls in the matching version of `ktsu.Semantics.Quantities` and `ktsu.PreciseNumber` as dependencies, so it is the only reference you need. The core quantities package does not depend on `ktsu.PreciseNumber`; that dependency arrives only with this package. @@ -90,6 +90,15 @@ The aliases are project-wide global usings keyed on the bare type name (`Mass`, A project that genuinely needs mixed storage types should skip the alias packages and reference `ktsu.Semantics.Quantities` directly, writing the closed generic (`Mass`) explicitly. +The binding applies to the project that declares the `PackageReference`, and to no other. The props +ship in the package's `build/` folder rather than `buildTransitive/`, so they are not inherited by +projects that reference *this* project. An application assembled from several projects, each wrapping +a different storage type, therefore compiles: each project that wants aliases references its own alias +package, and no project collects a binding it never asked for. Earlier versions shipped the props in +`buildTransitive/`, where the binding did flow downstream and two of them in one dependency graph +produced a wall of `CS1537` against a generated file the author never wrote; `PrivateAssets="all"` on +the alias reference was the workaround for that, and is no longer needed. + The alias lists are generated from the quantity catalogue by `scripts/Generate-AliasProps.ps1` and validated in CI, so they stay in lockstep with the quantities the core package emits. ## Contributing diff --git a/Semantics.Quantities.Precise/Semantics.Quantities.Precise.csproj b/Semantics.Quantities.Precise/Semantics.Quantities.Precise.csproj index 78ad5c60..1d8fca4c 100644 --- a/Semantics.Quantities.Precise/Semantics.Quantities.Precise.csproj +++ b/Semantics.Quantities.Precise/Semantics.Quantities.Precise.csproj @@ -5,20 +5,20 @@ net10.0;net9.0;net8.0 - + false false false - Storage-type aliases for ktsu.Semantics.Quantities. Reference this package to write `Mass` instead of `Mass<PreciseNumber>` (and every other quantity) bound to ktsu.PreciseNumber, project-wide. Unit factors and vector lengths are then exact to the precision the value carries rather than to a double. Use one storage-type alias package per project. + Storage-type aliases for ktsu.Semantics.Quantities. Reference this package to write `Mass` instead of `Mass<PreciseNumber>` (and every other quantity) bound to ktsu.PreciseNumber, project-wide. Unit factors and vector lengths are then exact to the precision the value carries rather than to a double. Use one storage-type alias package per project. The aliases bind only in the project that references this package, not in projects downstream of it, so each project that wants them references the package itself. $(NoWarn);NU5128 - + diff --git a/Semantics.Quantities.Precise/buildTransitive/ktsu.Semantics.Quantities.Precise.props b/Semantics.Quantities.Precise/build/ktsu.Semantics.Quantities.Precise.props similarity index 100% rename from Semantics.Quantities.Precise/buildTransitive/ktsu.Semantics.Quantities.Precise.props rename to Semantics.Quantities.Precise/build/ktsu.Semantics.Quantities.Precise.props diff --git a/Semantics.Test/Quantities/AliasPropsPackagingTests.cs b/Semantics.Test/Quantities/AliasPropsPackagingTests.cs new file mode 100644 index 00000000..272b546a --- /dev/null +++ b/Semantics.Test/Quantities/AliasPropsPackagingTests.cs @@ -0,0 +1,151 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Test.Quantities; + +using System.Collections.Generic; +using System.Linq; +using System.Xml.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Checks that the storage-type alias packages ship their props where the binding stops at the +/// project that asked for it. +/// +/// +/// +/// Each Semantics.Quantities.{Double,Float,Decimal,Precise} package is props only: its +/// payload is one file of roughly 220 <Using> items, which the SDK turns into +/// project-wide global using Mass = …<double>; aliases keyed on the bare type name. Two +/// storage types reaching one project therefore define every alias twice, and the compile fails with +/// one CS1537 per quantity against GlobalUsings.g.cs — a generated file the author +/// never wrote, in an error that names no package. +/// +/// +/// buildTransitive/ is by definition the folder whose contents flow past the referencing +/// project to everything downstream of it, so packing there made that collision reachable from a +/// project that references no alias package at all: the documented rule, "one alias package per +/// project", was satisfied by every project in the graph and the build still died. build/ +/// binds where the reference is and nowhere else, which is the rule exactly, and removes the failure +/// mode rather than diagnosing it. +/// +/// +/// The folder is one word in a PackagePath, invisible in every build in this repository — +/// nothing here consumes the alias packages as packages — and only wrong once a consumer composes +/// two of them. So it is asserted here, where a regression costs a test rather than a release. +/// +/// +[TestClass] +public class AliasPropsPackagingTests +{ + /// The storage-type suffixes that name the four satellite packages. + private static readonly string[] StorageSuffixes = ["Double", "Float", "Decimal", "Precise"]; + + [TestMethod] + public void EveryAliasPackagePacksItsPropsIntoBuildRatherThanBuildTransitive() + { + List offenders = []; + + foreach ((string suffix, XElement packed) in PackedPropsItems()) + { + string packagePath = packed.Attribute("PackagePath")?.Value ?? ""; + if (Normalize(packagePath) is not "build/") + { + offenders.Add($"Semantics.Quantities.{suffix}: PackagePath=\"{packagePath}\""); + } + } + + Assert.IsEmpty( + offenders, + "An alias package packs its props outside build/, so the storage-type binding flows past " + + "the project that referenced it. Two alias packages anywhere in one dependency graph then " + + "collide on every alias name. Offenders: " + string.Join("; ", offenders)); + } + + [TestMethod] + public void EveryAliasPackagePacksThePropsFileThatExistsOnDisk() + { + List offenders = []; + + foreach ((string suffix, XElement packed) in PackedPropsItems()) + { + string include = packed.Attribute("Include")?.Value ?? ""; + string expected = $"build/ktsu.Semantics.Quantities.{suffix}.props"; + + if (Normalize(include) != expected) + { + offenders.Add($"Semantics.Quantities.{suffix}: Include=\"{include}\", expected \"{expected}\""); + continue; + } + + string onDisk = Path.Combine( + RepositoryRoot(), + $"Semantics.Quantities.{suffix}", + "build", + $"ktsu.Semantics.Quantities.{suffix}.props"); + + if (!File.Exists(onDisk)) + { + offenders.Add($"Semantics.Quantities.{suffix}: {expected} is packed but missing on disk"); + } + } + + Assert.IsEmpty( + offenders, + "An alias package packs a props path that does not name the generated file under build/. " + + "scripts/Generate-AliasProps.ps1 writes them there, and the verify-generated workflow " + + "regenerates and diffs them. Offenders: " + string.Join("; ", offenders)); + } + + /// + /// Yields the single packed props item from each alias project, failing if a project does not + /// have exactly one — the whole package is that file, so none or several is itself the defect. + /// + private static IEnumerable<(string Suffix, XElement Packed)> PackedPropsItems() + { + foreach (string suffix in StorageSuffixes) + { + string project = Path.Combine( + RepositoryRoot(), + $"Semantics.Quantities.{suffix}", + $"Semantics.Quantities.{suffix}.csproj"); + + Assert.IsTrue(File.Exists(project), $"Could not find {project}."); + + List packed = + [ + .. XDocument.Load(project) + .Descendants() + .Where(element => element.Name.LocalName == "None") + .Where(element => (element.Attribute("Pack")?.Value ?? "").Equals("true", StringComparison.OrdinalIgnoreCase)) + .Where(element => Normalize(element.Attribute("Include")?.Value ?? "").EndsWith(".props", StringComparison.Ordinal)) + ]; + + Assert.HasCount( + 1, + packed, + $"Semantics.Quantities.{suffix} packs {packed.Count} props files; the package is exactly one."); + + yield return (suffix, packed[0]); + } + } + + /// + /// Rewrites an MSBuild path to forward slashes so a Windows-spelled attribute compares equal on + /// every platform, keeping the trailing separator that distinguishes a folder from a file. + /// + /// The attribute value as written in the project file. + /// The same path spelled with forward slashes. + private static string Normalize(string path) => path.Replace('\\', '/'); + + private static string RepositoryRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null && !Directory.Exists(Path.Combine(directory.FullName, "Semantics.SourceGenerators"))) + { + directory = directory.Parent; + } + + Assert.IsNotNull(directory, "Could not locate the repository root from the test output directory."); + return directory!.FullName; + } +} diff --git a/scripts/Generate-AliasProps.ps1 b/scripts/Generate-AliasProps.ps1 index 6a21f5c2..7cdc132d 100644 --- a/scripts/Generate-AliasProps.ps1 +++ b/scripts/Generate-AliasProps.ps1 @@ -4,11 +4,20 @@ ktsu.Semantics.Quantities. satellite packages. .DESCRIPTION - Each satellite package ships a buildTransitive/.props that NuGet - auto-imports into consumers. The props inject MSBuild items, which the - SDK turns into `global using Mass = ktsu.Semantics.Quantities.Mass;` - (and so on for every quantity type), so a project that references the package - can write `Mass` instead of `Mass`. + Each satellite package ships a build/.props that NuGet auto-imports + into the project that references the package. The props inject MSBuild + items, which the SDK turns into + `global using Mass = ktsu.Semantics.Quantities.Mass;` (and so on for + every quantity type), so a project that references the package can write `Mass` + instead of `Mass`. + + build/ rather than buildTransitive/ on purpose. The alias names are project-wide + global usings keyed on the bare type name, so two storage types reaching one + project define every alias twice and the compile fails with one CS1537 per + quantity. buildTransitive/ flows the binding to every project downstream of the + referencing one, so a project that references no alias package at all - and + therefore cannot satisfy the "one alias package per project" rule - would collect + two. build/ binds exactly where the reference is, which is what the rule says. The catalog of quantity types is the set of committed source-generator outputs (every quantity is emitted as a `.g.cs`), so this script stays in sync @@ -58,8 +67,8 @@ foreach ($entry in $storageTypes.GetEnumerator()) { $keyword = $entry.Value $packageId = "ktsu.Semantics.Quantities.$suffix" $projectDir = Join-Path $repoRoot "Semantics.Quantities.$suffix" - $buildTransitive = Join-Path $projectDir 'buildTransitive' - New-Item -ItemType Directory -Path $buildTransitive -Force | Out-Null + $buildFolder = Join-Path $projectDir 'build' + New-Item -ItemType Directory -Path $buildFolder -Force | Out-Null $sb = [System.Text.StringBuilder]::new() [void]$sb.AppendLine('') @@ -72,7 +81,7 @@ foreach ($entry in $storageTypes.GetEnumerator()) { [void]$sb.AppendLine("`t") [void]$sb.AppendLine('') - $propsPath = Join-Path $buildTransitive "$packageId.props" + $propsPath = Join-Path $buildFolder "$packageId.props" # CRLF to match the repo's line-ending convention. $content = ($sb.ToString() -replace "`r?`n", "`r`n") [System.IO.File]::WriteAllText($propsPath, $content, [System.Text.UTF8Encoding]::new($false))