Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .agents/references/reviews/aot-package-compat.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,28 @@ than published, so trimming cannot reach them — but packaging can drop them.
generator carries `PrivateAssets="all"` and `ReferenceOutputAssembly="false"` for that reason;
losing either leaks `Microsoft.CodeAnalysis.CSharp` into the package's dependency list.

## The build wiring on every supported SDK

`build/ResXLocalization.Resx.targets` runs inside every consumer's build, on whichever SDK that consumer
has - the .NET 8 SDK as much as the one this repository pins - and inside WPF's temporary markup-compile
pass, a copy of the project named `<Name>_<random>_wpftmp` that WPF builds before the real one. Neither
is visible to a test suite here, which builds on the pinned SDK with the repository's own
`Directory.Build.props`.

- **Anything the wiring or the generator derives from a project property must hold in the temporary
pass, on every supported SDK.** `MSBuildProjectName`, and every `RootNamespace` computed from it,
differ there; the whole mechanism, the fix and its limits are explained in the comment "WPF's temporary markup-compile pass" in `build/ResXLocalization.Resx.targets`. The fix
changes only `ManifestResourceName`, `LogicalName` and `CustomToolNamespace` of the targets file's own
copies of the `.resx` items. A change that removes it, widens it to `RootNamespace` or to other items
or metadata, or adds a new dependency on a property derived from the project name, is a finding.
- **A claim about what an SDK does is checked, not assumed.** Cite the targets file or the task of each
supported SDK that the claim is about, with file and line - for example
`sdk/<version>/Sdks/Microsoft.NET.Sdk.WindowsDesktop/targets/Microsoft.WinFX.targets`. A claim you
could not check is reported as unchecked, never as a fact.
- **What catches it:** the "Package consumers (.NET 8 SDK)" step of `scripts/pre-release-gate.ps1`,
and CI's "Consume packages on .NET 8 SDK" job, through `WpfConsumer` and `WpfDerivedNamespaceConsumer`
(the second also on the pinned SDK).

## What the tooling cannot tell you

- **`TrimmerSingleWarn`.** Left at its default, ILC collapses every diagnostic from an assembly into
Expand All @@ -86,6 +108,7 @@ name the command:
```text
pwsh -File scripts/verify-package-aot.ps1 -Pack # both frameworks, from a fresh pack
pwsh -File scripts/verify-package-aot.ps1 -Framework net8.0 # the LTS floor alone
pwsh -File scripts/pre-release-gate.ps1 -SkipNativeAot -SkipDocumentation # every consumer, pinned SDK and .NET 8 SDK
```

Do not run them yourself, and do not fix what you found.
11 changes: 9 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,13 @@ jobs:
working-directory: ${{ env.CONSUMER_DIRECTORY }}
run: dotnet run --project WpfConsumer/WpfConsumer.csproj --configuration ${{ env.CONFIGURATION }} -p:ResXLocalizationVersion=${{ needs.package-and-docs.outputs.version }}

# RootNamespace derived from the project name, which WPF's temporary markup-compile pass gets wrong;
# see the comment "WPF's temporary markup-compile pass" in build/ResXLocalization.Resx.targets.
- name: Run WPF package consumer with a derived RootNamespace (net8.0 SDK)
if: runner.os == 'Windows'
working-directory: ${{ env.CONSUMER_DIRECTORY }}
run: dotnet run --project WpfDerivedNamespaceConsumer/WpfDerivedNamespaceConsumer.csproj --configuration ${{ env.CONFIGURATION }} -p:ResXLocalizationVersion=${{ needs.package-and-docs.outputs.version }}

- name: Run combined Avalonia and WPF package consumer (net8.0 SDK)
if: runner.os == 'Windows'
working-directory: ${{ env.CONSUMER_DIRECTORY }}
Expand All @@ -436,7 +443,7 @@ jobs:
# everything trimming can break (a satellite that is no longer loaded, a resource name that no longer
# resolves) produces a binary that starts and answers wrongly.
#
# Three legs. The trimming behaviour under test belongs to the ILC compiler and does not vary by host,
# Three runs. The trimming behaviour under test belongs to the ILC compiler and does not vary by host,
# so net8.0 - the LTS floor the packages promise - is exercised on Linux, and net10.0 on both.
verify-package-aot:
name: Verify packed-package Native AOT (${{ matrix.framework }}, ${{ matrix.runtime }})
Expand Down Expand Up @@ -466,7 +473,7 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
# The 8.0 runtime is what the net8.0 leg's build-time tooling runs on.
# The 8.0 runtime is what the build-time tooling of the net8.0 run runs on.
dotnet-version: |
8.0.x
10.0.x
Expand Down
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,10 @@ change rendering rather than layout, are in
| --- | --- | --- |
| One UI package changed | mirror it into the other, and mirror the tests | `ui_parity_reviewer` |
| Resource lookup, culture fallback, satellite discovery, the generator's output, or the packaging that carries them | `pwsh -File scripts/verify-package-aot.ps1 -Pack` | `aot_package_compat_reviewer` |
| The package contents, the `buildTransitive` wiring, or a dependency version | pack, then run each project under `tests/package-consumption` | — |
| The package contents, the `buildTransitive` wiring, the generator's output, or a dependency version | `pwsh -File scripts/pre-release-gate.ps1 -SkipNativeAot -SkipDocumentation`: packs, then runs every project under `tests/package-consumption` on the pinned SDK and again on the .NET 8 SDK | `aot_package_compat_reviewer` |

The .NET 8 SDK step needs an 8.0 SDK installed next to the pinned one. It exists because the SDKs differ in
more than their compiler - for example in WPF's temporary markup-compile pass, see the comment "WPF's temporary markup-compile pass" in `build/ResXLocalization.Resx.targets`.

Nothing is trimmed on the just-in-time compiler, so **no test suite in this repository can see trimming
damage**. The AOT gate publishes a package-only consumer natively and runs it; that is the only check that
Expand Down
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ this project adheres to [Semantic Versioning](https://semver.org/).
`FromDictionary` or `FromObject` and checked when they are built: positional, named or mixed.
- Enum lookups with format arguments: `Get(sortOrder, MessageArguments.FromValues(count))`.
- `ILocalizer.EnumKeyBuilder`, one replaceable enum key convention for code and XAML.
- Typed keys for every neutral `.resx`, with or without a `.Designer.cs`: files without a designer,
neutral files with a dot in their name and projects using `GenerateResxSource` produce keys classes.
Without a designer, the keys class takes its namespace from the file's `CustomToolNamespace` when it
is set.
- The generated `<Name>Keys.ResourceManager` property: the resource manager of that `.resx`, which every
key of the class uses. Register it for search-all lookups and pass it for scoped lookups.
- Generator warnings `RXLGEN003` (a `.resx` reached the generator without its MSBuild resource name)
and `RXLGEN004` (two neutral `.resx` files would produce the same keys class).

### Changed

Expand Down Expand Up @@ -60,6 +68,20 @@ this project adheres to [Semantic Versioning](https://semver.org/).
- **BREAKING:** A custom `ILocalizer` implementation has exactly three `Get` overloads - string,
`ResourceKey` and enum keys, each with an optional `MessageArguments` - and implements
`EnumKeyBuilder`. See the [migration guide](docs/guides/migration-2.0.md).
- **BREAKING:** Typed keys use the generated `<Name>Keys.ResourceManager`, no longer the designer's
`<Name>.ResourceManager`; `ReferenceEquals(AppStringsKeys.Greeting.Manager, AppStrings.ResourceManager)`
is now `false`. See the [migration guide](docs/guides/migration-2.0.md).
- **BREAKING:** Files without a designer, neutral files with a dot in their name and projects using
`GenerateResxSource` now produce keys classes, which can collide with types of your own. See the
[migration guide](docs/guides/migration-2.0.md).
- **BREAKING:** Two neutral `.resx` files that produce the same keys class report `RXLGEN004` and
generate neither class, instead of merging into one partial class. See the
[migration guide](docs/guides/migration-2.0.md).

### Removed

- The generator warning `RXLGEN002` (a `.resx` without a recognized classic designer): a designer is no
longer required.

## [1.1.0] - 2026-07-20

Expand Down
12 changes: 8 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,18 +88,22 @@ The script names them, with their trigger and their command, in its own summary
| Run it when the change touches | Command |
| --- | --- |
| Resource lookup, culture fallback, satellite discovery, the generated keys, or the packaging that carries them | `pwsh -File scripts/verify-package-aot.ps1 -Pack` |
| What the packages contain, the `buildTransitive` wiring, or a dependency version | `pwsh -File scripts/pre-release-gate.ps1 -SkipNativeAot -SkipDocumentation`, which packs and then runs every consumer, on both of AvaloniaConsumer's target frameworks |
| What the packages contain, the `buildTransitive` wiring, the generated keys, or a dependency version | `pwsh -File scripts/pre-release-gate.ps1 -SkipNativeAot -SkipDocumentation`, which packs and then runs every consumer on the pinned SDK, on both of AvaloniaConsumer's target frameworks, and again on the .NET 8 SDK, on net8.0 |

**`pre-release-gate.ps1`** is everything CI checks that can honestly be checked on your machine, in CI's
order, stopping at the first failure: the ignored-revision check, line endings, tidiness, a Release build,
every test suite, the DocFX metadata and site build with `--warningsAsErrors`, the pack with package
validation, each Native AOT leg this host can run, and the package consumers against the packages just
packed. It ends in one line: `PASSED: All checks passed.` or `FAILED: Check <name> failed. See output.`
validation, the Native AOT gate for each framework and runtime this host can build, and the package
consumers against the packages just packed - on the pinned SDK, and again on the .NET 8 SDK, the way CI's
minimum-SDK job builds them. The SDKs differ in more than their compiler - for example in WPF's
temporary markup-compile pass, see the comment "WPF's temporary markup-compile pass" in `build/ResXLocalization.Resx.targets`. It ends in one line:
`PASSED: All checks passed.` or `FAILED: Check <name> failed. See output.`

Its comment-based help lists every CI job it does **not** reproduce, and why. Read that before treating a
green run as a promise that CI will be green.

`-SkipNativeAot`, `-SkipConsumers` and `-SkipDocumentation` cover the checks with heavy prerequisites.
`-SkipNativeAot`, `-SkipConsumers`, `-SkipMinimumSdk` and `-SkipDocumentation` cover the checks with heavy
prerequisites.

## Checking versus fixing

Expand Down
5 changes: 3 additions & 2 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,9 @@

<ItemGroup Condition="'$(MSBuildProjectName)' == 'ResXLocalization.SourceGenerators'">
<!-- The SHIPPED analyzer is built against the lowest Roslyn that carries the APIs it uses
(IIncrementalGenerator, AdditionalTextsProvider, SymbolDisplay.FormatLiteral - all present
since 4.8). A generator built against a newer Roslyn only loads in compilers of that version
(IIncrementalGenerator, AdditionalTextsProvider, AnalyzerConfigOptionsProvider with
GetOptions(AdditionalText) and AnalyzerConfigOptions.TryGetValue, SymbolDisplay.FormatLiteral -
all present since 4.8). A generator built against a newer Roslyn only loads in compilers of that version
or later, so a consumer on the .NET 8 or 9 SDK would get CS9057 and no generated keys.
4.8.0 == VS 2022 17.8 / the .NET 8 SDK baseline. Every other Roslyn reference in the
repository, including the generator's own test project, uses the version above. -->
Expand Down
79 changes: 74 additions & 5 deletions build/ResXLocalization.Resx.targets
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,17 @@
@(EmbeddedResource) / @(Compile) are populated by the SDK default-item globs, so this covers
each project's resources automatically and is benign for projects with none.

Done in a target (not a bare ItemGroup) so the per-item metadata Conditions batch correctly and run
before the compiler collects @(AdditionalFiles).
The generator also needs MSBuild's name for each .resx: ManifestResourceName (or LogicalName, which
replaces it) is the name the resources are embedded under, and WithCulture says whether the file is a
satellite. CustomToolNamespace, which only the IDE's designer tools read otherwise, names the
namespace of a keys class whose .resx has no designer. A generator cannot read MSBuild items. The only
channel is the analyzer config file that GenerateMSBuildEditorConfigFile writes into obj/, as a
snapshot of every CompilerVisibleItemMetadata value of the @(AdditionalFiles) that exist when it runs.
So the target below:
* depends on PrepareResourceNames, the target that computes ManifestResourceName and WithCulture;
* runs before GenerateMSBuildEditorConfigFileShouldRun, so the items are in the snapshot. An item
added later (for example before CoreCompile) reaches the generator without its metadata.
Done in a target (not a bare ItemGroup) so the per-item metadata Conditions batch correctly.

SINGLE SOURCE OF TRUTH. This file is consumed two ways:
* In-repo: Directory.Build.targets <Import>s it (the sample/tests use ProjectReference and so do
Expand All @@ -16,11 +25,71 @@
file to buildTransitive/<PackageId>.targets (the file name must equal the PackageId so NuGet
auto-imports it for direct and transitive consumers).
-->

<!--
WPF'S TEMPORARY MARKUP-COMPILE PASS. Every other comment, script and document on this topic refers
here.

The problem. WPF compiles XAML that uses the project's own types in a temporary copy of the project
first, named <project name>_<random>_wpftmp (Microsoft.WinFX.targets:374-377 in SDK 8.0.426 and
10.0.401). The generator runs in that pass too. There MSBuildProjectName is the temporary name, so a
RootNamespace computed from it carries the suffix "_<random>_wpftmp":
* the SDK default $(MSBuildProjectName.Replace(" ", "_")), on the .NET 8 SDK, which passes no
RootNamespace into the copy (8.0.426 Microsoft.WinFX.targets:440-460; 10.0.401 passes it, :457);
* an expression such as Company.$(MSBuildProjectName), on every SDK: the project's own assignment
replaces the value the .NET 10 SDK passes in (measured on 10.0.401).
A literal RootNamespace is never affected. Every name built from RootNamespace carries the suffix too:
ManifestResourceName, a LogicalName such as $(RootNamespace).Strings.resources, a CustomToolNamespace
such as $(RootNamespace).Texts. The keys class of a .resx without a designer then lands in a
namespace that exists only in that pass, and the code and XAML that use it fail with CS0234/CS0103.

The fix. Only in that pass - _TargetAssemblyProjectName is set, which WPF's
GenerateTemporaryTargetAssembly task does there with the real project name, and the project name is
that real name plus "_..._wpftmp" - the suffix is removed from ManifestResourceName, LogicalName and
CustomToolNamespace of OUR OWN copies of the .resx items, the ones handed to the generator.
RootNamespace, the EmbeddedResource items and every other tool's input stay as MSBuild computed them;
the temporary assembly is only used to resolve types for the XAML, never run.

Limits. A RootNamespace transform that changes the suffix itself (ToUpper(), replacing "_") is not
covered; a literal RootNamespace, or a CustomToolNamespace on the .resx, is the workaround. With
IncludePackageReferencesDuringMarkupCompilation=false no generator runs in that pass at all. If WPF
ever names or marks the pass differently, the conditions stop matching and nothing is changed.

Tested by tests/package-consumption: WpfConsumer (SDK default) and WpfDerivedNamespaceConsumer
(derived RootNamespace, LogicalName and CustomToolNamespace), which scripts/pre-release-gate.ps1 runs
on the pinned SDK and on the .NET 8 SDK.
-->
<ItemGroup>
<CompilerVisibleItemMetadata Include="AdditionalFiles" MetadataName="ManifestResourceName" />
<CompilerVisibleItemMetadata Include="AdditionalFiles" MetadataName="LogicalName" />
<CompilerVisibleItemMetadata Include="AdditionalFiles" MetadataName="WithCulture" />
<CompilerVisibleItemMetadata Include="AdditionalFiles" MetadataName="CustomToolNamespace" />
</ItemGroup>

<Target Name="AddResxAdditionalFilesForResXLocalization"
BeforeTargets="CoreCompile">
BeforeTargets="GenerateMSBuildEditorConfigFileShouldRun"
DependsOnTargets="PrepareResourceNames">

<!-- WPF's temporary markup-compile pass only: see the comment of that name above. -->
<PropertyGroup Condition="'$(_TargetAssemblyProjectName)' != '' and $(MSBuildProjectName.EndsWith('_wpftmp')) and $(MSBuildProjectName.StartsWith('$(_TargetAssemblyProjectName)_'))">
<_ResXLocalizationWpfTmpSuffix>$(MSBuildProjectName.Substring($(_TargetAssemblyProjectName.Length)))</_ResXLocalizationWpfTmpSuffix>
</PropertyGroup>

<!--
The metadata is qualified with the item name: unqualified, MSBuild rejects the batch (MSB4096) as soon
as one item lacks the metadata, which for LogicalName and CustomToolNamespace is almost every item.
-->
<ItemGroup>
<AdditionalFiles Include="@(EmbeddedResource)"
Condition="'%(Extension)' == '.resx'" />
<_ResXLocalizationResx Include="@(EmbeddedResource)"
Condition="'%(Extension)' == '.resx'" />
<_ResXLocalizationResx Condition="'$(_ResXLocalizationWpfTmpSuffix)' != ''">
<ManifestResourceName>$([System.String]::Copy('%(_ResXLocalizationResx.ManifestResourceName)').Replace('$(_ResXLocalizationWpfTmpSuffix)', ''))</ManifestResourceName>
<LogicalName>$([System.String]::Copy('%(_ResXLocalizationResx.LogicalName)').Replace('$(_ResXLocalizationWpfTmpSuffix)', ''))</LogicalName>
<CustomToolNamespace>$([System.String]::Copy('%(_ResXLocalizationResx.CustomToolNamespace)').Replace('$(_ResXLocalizationWpfTmpSuffix)', ''))</CustomToolNamespace>
</_ResXLocalizationResx>
<AdditionalFiles Include="@(_ResXLocalizationResx)" />
<_ResXLocalizationResx Remove="@(_ResXLocalizationResx)" />

<AdditionalFiles Include="@(Compile)"
Condition="$([System.String]::new('%(Filename)').EndsWith('.Designer'))" />
<UpToDateCheckInput Include="@(EmbeddedResource)"
Expand Down
Loading
Loading