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
5 changes: 5 additions & 0 deletions .agents/references/code-style.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ public and wants it first in its group — but counts an explicit **event** as p
`ResXLocalization.slnx.DotSettings` has one "Explicit interface …" entry per kind and none for events:
giving events one puts an explicit event ahead of a public one and breaks `SA1202`.

Public static methods have an entry of their own, ahead of "Explicit interface methods". StyleCop counts
an explicit method as a public instance method, so a public static method after it breaks `SA1204`; the
extra entry keeps a class that has both - such as `MessageArguments`, with its factory methods and
`IEnumerable.GetEnumerator` - in an order both tools accept.

## XAML

XamlStyler owns `.xaml` and `.axaml`, and CSharpier is kept away from them by `.csharpierignore`.
Expand Down
57 changes: 45 additions & 12 deletions .agents/references/reviews/aot-package-compat.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

Use this whenever a change touches resource lookup, culture fallback, satellite discovery, the source
generator's output, or the analyzer and `buildTransitive` wiring the packages carry. This is a
**standing** checklist: `ResXLocalization.Avalonia` and `ResXLocalization.Core` advertise Native AOT
support, and everything here exists to keep it from regressing.
**standing** checklist: `ResXLocalization.Core`, `ResXLocalization.Avalonia` and
`ResXLocalization.MessageFormat` advertise Native AOT support, and everything here exists to keep it
from regressing.

Read [docs/guides/native-aot.md](../../../docs/guides/native-aot.md) before reviewing.

Expand All @@ -13,11 +14,12 @@ This is a **review**: report findings, cite file and line, do not edit files.

Trimming damage does not announce itself. A resource that is no longer found, a satellite assembly
that is no longer loaded, a typed key whose `ResourceManager` was trimmed away — each produces a
binary that builds, starts, and answers with the `!key!` sentinel or the wrong language. Nothing is
binary that builds, starts, and answers with the `!key!` failure text or the wrong language. Nothing is
trimmed on the just-in-time compiler, so **every test suite in this repository passes throughout**.

The only check that can see it is `scripts/verify-package-aot.ps1`, which publishes a package-only
consumer natively and runs it. A green test run is not evidence here. Neither is a native binary that
The only check that can see it is `scripts/verify-package-aot.ps1`, which publishes two package-only
consumers natively and runs them: `AvaloniaConsumer`, and `MessageFormatConsumer` for the ICU formatter
of `ResXLocalization.MessageFormat`. A green test run is not evidence here. Neither is a native binary that
exists: the gate runs it and asserts exact strings for exactly this reason.

## Blocking findings
Expand All @@ -31,9 +33,16 @@ exists: the gate runs it and asserts exact strings for exactly this reason.
the requirement to the caller. But adding either to a public member is its own blocking finding —
it changes what a consumer sees when they publish, and the packages promise they see nothing.

- **`IsAotCompatible` removed from `ResXLocalization.Core` or `ResXLocalization.Avalonia`**, or added
to `ResXLocalization.WPF`. The first two set it through `src/Directory.Build.props`; WPF is
excluded there deliberately, because WPF has no Native AOT.
- **`IsAotCompatible` removed from `ResXLocalization.Core`, `ResXLocalization.Avalonia` or
`ResXLocalization.MessageFormat`**, or added to `ResXLocalization.WPF`. The first three set it
through `src/Directory.Build.props`; WPF is excluded there deliberately, because WPF has no Native
AOT.

- **A call to an `object args` overload of the `MessageFormat` library** in
`src/ResXLocalization.MessageFormat` - the extension `MessageFormatterExtensions.FormatMessage` or the
static `MessageFormatter.Format(string, object, CultureInfo?)`. Both read properties by reflection,
carry `[RequiresUnreferencedCode]` and lose every argument in a Native AOT binary. The package calls
only the `IReadOnlyDictionary` overload of `FormatMessage`.

- **A new reflection call** — `Type.GetType`, `Assembly.Load`, `Activator.CreateInstance`,
`GetMethod`, `GetProperty`, `MakeGenericType` — reachable from a public entry point without a
Expand All @@ -42,8 +51,9 @@ exists: the gate runs it and asserts exact strings for exactly this reason.
reflection site is a change of kind rather than of degree.

- **Anything that makes a satellite assembly optional.** The German satellite is what the AOT gate
asserts on: a change that stops the consumer producing `de/AvaloniaConsumer.resources.dll`, or
stops the library finding it, is the exact failure this gate exists for.
asserts on: a change that stops a consumer producing `de/AvaloniaConsumer.resources.dll` or
`de/MessageFormatConsumer.resources.dll`, or stops the library finding it, is the exact failure this
gate exists for.

## The generator's output

Expand All @@ -64,6 +74,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 @@ -84,8 +116,9 @@ check would have caught it. Where the answer needs a build, a pack or the AOT ga
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/verify-package-aot.ps1 -Pack # both consumers on net10.0, from a fresh pack
pwsh -File scripts/verify-package-aot.ps1 -Framework net8.0 # both consumers on the LTS floor
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.
17 changes: 11 additions & 6 deletions .agents/references/reviews/ui-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,19 @@ both, so anything that is not framework-specific belongs there rather than in on
| Concern | Avalonia | WPF |
| --- | --- | --- |
| Localize a key | `LocalizeExtension` | `LocalizeExtension` |
| Localize an enum in a template | `LocalizeEnumExtension` | `LocalizeEnumExtension` |
| Localize an enum as a bound value | `LocalizeEnumConverter` | `LocalizeEnumConverter` |
| Composite-format arguments | `LocalizeArgs.Arg0`…`Arg8` | `LocalizeArgs.Arg0`…`Arg8` |
| Localize an enum value | `LocalizeEnumExtension` | `LocalizeEnumExtension` |
| Localize an enum in a hand-written `MultiBinding` | `LocalizeEnumConverter` | `LocalizeEnumConverter` |
| Composite-format arguments | `Argument` children in `LocalizeExtension.Arguments` | `Argument` children in `LocalizeExtension.Arguments` |
| Assigning a binding to `Argument.Value`, `LocalizeEnumExtension.Value` or `LocalizeExtension.Key` instead of applying it | `[AssignBinding]` on the property | the `XamlSetMarkupExtension` handler of `Argument`, of `LocalizeEnumExtension` and of `LocalizeExtension` |
| An argument, enum-value or key binding that is a `MultiBinding` or a `PriorityBinding` | accepted | rejected by `ProvideValue`: WPF's `MultiBinding` accepts only plain `Binding` children |
| Select the previewer's language | `DesignTime.Culture`, an attached property on `AvaloniaObject`; design mode from `Design.IsDesignMode` | `DesignTime.Culture`, an attached property on `DependencyObject`; design mode from the internal `DesignMode.IsInDesignMode` |
| A designer that supplies the target property as a CLR property (Rider's WPF previewer) | no fallback: the Avalonia previewer evaluates bindings | deliberate difference: `LocalizeExtension` and `LocalizeEnumExtension` return the lookup without arguments as a string, before validation, when `DesignMode.ShouldReturnDesignTimeText` is true |

Where the frameworks genuinely differ, the difference is expected to be **local and explained**:
Avalonia binds through `AvaloniaProperty` and an observable, WPF through `DependencyProperty` and a
`MultiBinding`; Avalonia subscribes with weak events, WPF relies on its own weak binding-target
references; WPF has no Native AOT. None of those is a licence for a different public API.
`LocalizeExtension` and `LocalizeEnumExtension` build their `MultiBinding` through
`LocalizedMultiBindingBuilder` in both packages, but from framework-specific bindings (a
`CompiledBinding` in Avalonia, a `Binding` in WPF); WPF has no Native AOT. None of those is a licence for
a different public API.

## What to check

Expand Down
10 changes: 6 additions & 4 deletions .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,22 @@ body:
label: Package
description: >
Which package is affected? ResXLocalization.Core is the shared engine both UI packages depend on;
pick it when the problem shows in a lookup made from code rather than from markup. "Not sure" is a
real answer and is fine.
pick it when the problem shows in a lookup made from code rather than from markup. Pick
ResXLocalization.MessageFormat when the problem shows only after Localizer.Current.UseMessageFormat().
"Not sure" is a real answer and is fine.
options:
- ResXLocalization.Avalonia
- ResXLocalization.WPF
- ResXLocalization.Core
- ResXLocalization.MessageFormat
- More than one, or not sure
validations:
required: true
- type: input
id: version
attributes:
label: Package version
placeholder: "1.1.0"
placeholder: "2.0.0"
validations:
required: true
- type: dropdown
Expand All @@ -34,7 +36,7 @@ body:
- Scoped (Key + ResourceManager)
- Search-all (key only)
- Enum ({l:LocalizeEnum} / LocalizeEnumConverter)
- Dynamic format arguments (LocalizeArgs.Arg0…Arg8)
- Dynamic format arguments (<l:Argument>)
- Culture fallback / satellite discovery
- Source generator / build
- Other / not applicable
Expand Down
29 changes: 21 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ env:
DOTNET_CLI_TELEMETRY_OPTOUT: true
DOTNET_NOLOGO: true
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
MESSAGEFORMAT_CONSUMER_PROJECT: tests/package-consumption/MessageFormatConsumer/MessageFormatConsumer.csproj
NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
NUGET_CONFIG: NuGet.config
PACKAGE_OUTPUT: artifacts/packages
Expand Down Expand Up @@ -414,11 +415,22 @@ jobs:
working-directory: ${{ env.CONSUMER_DIRECTORY }}
run: dotnet run --project AvaloniaConsumer/AvaloniaConsumer.csproj --framework net8.0 -p:TargetFrameworks=net8.0 --configuration ${{ env.CONFIGURATION }} -p:ResXLocalizationVersion=${{ needs.package-and-docs.outputs.version }}

- name: Run MessageFormat package consumer (net8.0 SDK)
working-directory: ${{ env.CONSUMER_DIRECTORY }}
run: dotnet run --project MessageFormatConsumer/MessageFormatConsumer.csproj --framework net8.0 -p:TargetFrameworks=net8.0 --configuration ${{ env.CONFIGURATION }} -p:ResXLocalizationVersion=${{ needs.package-and-docs.outputs.version }}

- name: Run WPF package consumer (net8.0 SDK)
if: runner.os == 'Windows'
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 @@ -431,12 +443,12 @@ jobs:
shell: bash
run: rm -f "${CONSUMER_DIRECTORY}/global.json"

# The packed-package Native AOT gate. It publishes the Avalonia consumer with PublishAot, requires zero
# IL2xxx/IL3xxx diagnostics, and RUNS the native binary - a file that exists proves nothing, because
# 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.
# The packed-package Native AOT gate. It publishes AvaloniaConsumer and MessageFormatConsumer with
# PublishAot, requires zero IL2xxx/IL3xxx diagnostics, and RUNS each native binary - a file that exists
# proves nothing, because 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 +478,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 Expand Up @@ -522,7 +534,8 @@ jobs:
path: ${{ env.PACKAGE_OUTPUT }}

# The script is the gate, not this job: it validates the artifact set from each nuspec, isolates the
# consumer package cache, publishes, requires zero IL diagnostics, and runs the binary.
# consumer package cache, then publishes both consumers, requires zero IL diagnostics, and runs each
# binary.
- name: Native AOT gate
run: pwsh -NoProfile -NonInteractive -File scripts/verify-package-aot.ps1 -Framework ${{ matrix.framework }} -Runtime ${{ matrix.runtime }} -PackageVersion ${{ needs.package-and-docs.outputs.version }} -Configuration ${{ env.CONFIGURATION }}

Expand Down Expand Up @@ -611,7 +624,7 @@ jobs:
run: |
tag_version="${GITHUB_REF_NAME#v}"
status=0
for id in ResXLocalization.Core ResXLocalization.Avalonia ResXLocalization.WPF; do
for id in ResXLocalization.Core ResXLocalization.Avalonia ResXLocalization.MessageFormat ResXLocalization.WPF; do
if [[ ! -f "${PACKAGE_OUTPUT}/${id}.${tag_version}.nupkg" ]]; then
echo "::error title=Tag/version mismatch::Tag ${GITHUB_REF_NAME} expects ${id}.${tag_version}.nupkg, but the build produced:"
ls -1 "${PACKAGE_OUTPUT}"
Expand Down
Loading
Loading