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
106 changes: 106 additions & 0 deletions .agents/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# AI-agent integration

Reusable AI-agent instructions have one canonical location:

```text
AGENTS.md Repository guidance
.agents/skills/*/SKILL.md Workflow procedures
.agents/references/*.md Reference material AGENTS.md links to
.agents/references/reviews/*.md Review checklists
scripts/*.ps1 Executable checks and workflows
```

Tool-specific directories contain only discovery metadata or protocol adapters:

```text
.claude/skills/*/SKILL.md Claude skill metadata plus a shared-procedure reference
.claude/agents/*.md Claude agent metadata plus a shared-checklist reference
.claude/settings.json Claude hook wiring
.claude/hooks/*.ps1 Claude hook protocol adapters
.codex/agents/*.toml Codex agent metadata plus a shared-checklist reference
.codex/hooks.json Codex hook wiring
.codex/hooks/*.ps1 Codex hook protocol adapters
```

Codex discovers the canonical skills directly from `.agents/skills/`. Their `agents/openai.yaml` files
contain Codex-only interface metadata and the explicit-invocation policy. Claude needs a thin skill wrapper
because its `disable-model-invocation` policy lives in `SKILL.md` frontmatter.

The hook adapters differ because Claude and Codex use different payload and response contracts. Both delegate
all substantive behavior to the same scripts: `scripts/tidy-code.ps1` and `scripts/public-api-guard.ps1`.

## The reviewers

| Agent | Invoke it when | Checklist |
| --- | --- | --- |
| `aot_package_compat_reviewer` | resource lookup, culture fallback, satellite discovery, the generator's output, or the analyzer and `buildTransitive` wiring changes | [`reviews/aot-package-compat.md`](references/reviews/aot-package-compat.md) |
| `ui_parity_reviewer` | one UI package changes, or one of their samples or test suites | [`reviews/ui-parity.md`](references/reviews/ui-parity.md) |

Invoke a reviewer only when its scope applies; there is nothing to be gained from a parity review of a
change to the source generator.

Both are **read-only by construction**. The Claude definitions grant `Read`, `Grep` and `Glob` and nothing
else; the Codex definitions set `sandbox_mode = "read-only"`. Neither has a shell, and that is deliberate: a
reviewer that can run a shell can also write a file, and "please do not edit" is not a sandbox. Where a
checklist needs a build, a pack or the Native AOT gate, the reviewer says so and the caller runs it.

Neither definition pins a model or a reasoning effort. Which model reviews is the operator's choice, and a
pinned one goes stale in a file nobody re-reads.

## What the hooks do, and what they will not do

Both `PostToolUse` hooks are **scoped to the file the triggering edit touched**. Claude reports it as
`tool_input.file_path`; Codex reports an `apply_patch` whose patch text is in `tool_input.command`, so the
adapter reads the `*** Add File:`, `*** Update File:` and `*** Move to:` headers out of it and skips
`*** Delete File:`. A rename arrives as an Update of the old name and a Move to the new one; the old name no
longer exists, so it drops out.

These rules follow, and they are the point of the design:

- **No fallback.** If the payload cannot be parsed, the hook formats nothing and says so in one line. It does
not fall back to "every file git reports as changed" — that would rewrite work in progress that this edit
never touched.
- **No path outside the repository.** Every path is resolved and then checked to be under the repository
root, so `../` and a symbolic link that points out of the tree are both refused. `bin/` and `obj/` are
refused too; generated output is not ours to format.
- **They never fail an edit.** A `PostToolUse` failure cannot undo an edit that already happened, so both
adapters exit 0 whatever went wrong and report it as text. Neither one stages a file, changes an API
snapshot, installs a tool, or runs a style, ordering, build or test pass.

The child scripts run in a **child `pwsh` process**. They end with `exit`, which run in-process would end the
adapter before it could emit its protocol output — and a Codex hook that writes nothing is a hook that
failed. Concurrent edits serialize on a named mutex derived from the repository path, so two formatters
cannot run over one file, and the two agents serialize against each other as well.

### Codex needs the hooks trusted, once per clone

Codex does not run a repository's hooks until the project is trusted. Until then **nothing fires**. Run
`/hooks` in Codex to see the hook definitions this repository declares, review them, and trust the project.
Review them again whenever `.codex/hooks.json` or anything under `.codex/hooks/` changes in a pull request: a
hook is code that runs on your machine after every edit, and "it was already trusted" is not a review.

Never bypass project trust to make a hook fire.

### When no hook covers the edit

The hooks only see edits made through a tool that reports one. An edit made another way — a shell
redirection, an editor outside the agent, a `git apply`, a Codex session whose hooks are not trusted yet — is
not formatted by anything. So:

```bash
pwsh -File scripts/tidy-code.ps1 # format what git reports as changed
pwsh -File scripts/pre-commit-gate.ps1 # and before committing, check the whole tree
```

`pre-commit-gate.ps1` is the backstop for all of it, and the build is the backstop for it: formatting, style
and member ordering are build errors, so an unformatted file cannot reach a green pull request whether a hook
fired or not.

The tidy hook runs the **default scope only**, on the file the edit touched — CSharpier for C#, XamlStyler
for XAML and AXAML, under a second either way. Code style and member ordering are not run on every edit:
`dotnet format style` needs MSBuild and ReSharper loads the whole solution, and neither belongs on the
critical path of a single edit. All of them are build errors, and `scripts/pre-commit-gate.ps1` checks
`-Scope all` before a commit (`-Fix` applies it), so nothing reaches a pull request untidied.

When changing behavior, edit the canonical file. Keep only required names, descriptions, policies, tool
settings and reference instructions in tool-specific files.
113 changes: 113 additions & 0 deletions .agents/references/code-style.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Code style details

Background for the rules in [AGENTS.md](../../AGENTS.md#code-style-formatting-and-ordering). Read this when a
tool does something you did not expect, or when you are about to write a type name in a place the build does
not check.

## The concerns, and the tool that owns each

| Concern | Tool | Configured in |
| --- | --- | --- |
| C# formatting — whitespace, line breaks, wrapping | **CSharpier** | `.editorconfig` (`max_line_length`, `indent_size`) |
| C# style — `var`, `=>`, `this.`, null checks, usings | **Roslyn analyzers** | `.editorconfig` |
| C# ordering — types and their members | **ReSharper** applies it, **NewStyleCop** checks *part* of it | `ResXLocalization.slnx.DotSettings` and `stylecop.json` |
| XAML and AXAML layout | **XamlStyler** | `Settings.XamlStyler` |

The word *part* is load-bearing. StyleCop checks kind, access, constant, static and readonly — `SA1201`,
`SA1202`, `SA1203`, `SA1204`, `SA1214`. It has no notion of alphabetical order **within** one of those
groups, which the ReSharper file layout applies and nothing checks. A member that is in the right group but
the wrong place inside it compiles, passes the analyzers, and is only visible by running the pipeline and
looking at what it moves.

Each tool owns its concern completely, and the C# ones are build errors rather than warnings, in `samples/`
and `tests/` as much as in `src/`. Two different mechanisms, both in the repository-root
`Directory.Build.props`: `EnforceCodeStyleInBuild=true` with `TreatWarningsAsErrors=true` covers style and
ordering, and the `CSharpier.MsBuild` package covers formatting. It runs in check mode, so a build never
rewrites your files — an unformatted file fails the build and names itself. `-p:CSharpier_Bypass=true` skips
it.

Run them all with one command:

```powershell
pwsh -File scripts/tidy-code.ps1 -Scope all
```

## Where the build misses a BCL type name

The rule is: write `string`, `object?`, `int`, `bool`, `nint`, `nuint` — never `String`, `Object?`, `Int32`,
`Boolean`, `IntPtr`, `UIntPtr`. `dotnet_style_predefined_type_for_*` is `true:error`, and `IDE0049` is the
diagnostic.

**`IDE0049` is not reported by an ordinary build.** Measured on this repository: with
`EnforceCodeStyleInBuild=true` the build is green while
`dotnet format style --diagnostics IDE0049 --severity error` reports every occurrence. So the style pipeline
is what enforces this rule, not the compiler — which is why CI runs `tidy-code.ps1 -Check` rather than
relying on the build alone, and why an editor that only builds will not tell you.

Two further blind spots, both in the analyzer itself:

- **`nint`/`nuint` are not on its list.** They arrived in C# 9 as their own feature and only became aliases
for `IntPtr`/`UIntPtr` in C# 11; the analyzer was never extended. `IntPtr` is invisible to it.
- **It never looks inside `nameof(...)`.** With good reason: `nameof(int)` does not compile at all, so a
blanket skip is the safe choice. Where `nameof` names a CLR type on purpose, leave it and say why in a
comment.

A third gap is not the analyzer's fault: **`tests/package-consumption/` is outside the solution**, and its
deliberately empty `Directory.Build.props` gives it no style gate at all. CSharpier and XamlStyler still
format those files — neither needs a project — but the style rules there are on the author.

## `var`, in all three cases

```ini
csharp_style_var_for_built_in_types = true:error
csharp_style_var_when_type_is_apparent = true:error
csharp_style_var_elsewhere = true:silent
```

The third one is silent on purpose. Every local declaration under `src/` already uses `var` — 69 of them,
none with an explicit type — so the preference matches what the code does; it stays silent because a
declaration whose type nothing on the line reveals is a readability judgement rather than something a build
should reject.

## Primary constructor parameters

A primary constructor parameter is assigned to a `private readonly` backing field, and members read
`this.field` rather than the parameter. Nothing enforces it — a parameter is not an instance member, so
`dotnet_style_qualification_for_field` cannot see it — but a captured parameter compiles to a field with no
`readonly`, and using one directly silently drops the guarantee that it cannot be reassigned.

## Member order, in one place

```text
constants, fields, constructors, finalizers, delegates, events, enums, interfaces, properties, indexers,
conversion operators, operators, methods, nested structs and classes
```

Within a group: public, internal, protected internal, protected, private protected, private; static before
instance; readonly before mutable; then alphabetical by name.

Explicit interface implementations are the exception worth knowing. ReSharper ranks one below private,
because in C# it carries no access modifier. StyleCop counts an explicit property, indexer or method as
public and wants it first in its group — but counts an explicit **event** as private. That is why
`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`.

## XAML

XamlStyler owns `.xaml` and `.axaml`, and CSharpier is kept away from them by `.csharpierignore`.

Three of its settings are switched **off**, and they are not formatting: `ReorderGridChildren` and
`ReorderCanvasChildren` change which child is drawn on top when two overlap, and `ReorderSetters` changes
which setter wins when a style sets the same property twice. Movement that changes what the user sees is not
formatting.

XamlStyler writes the host operating system's newline and has no setting for it, so on Windows every file it
touches comes back CRLF. `scripts/tidy-code.ps1` normalizes exactly the files it processed, byte by byte, and
stages nothing.

## XML and configuration

Two-space indentation for `.csproj`, `.props`, `.targets`, `.slnx`, `.config`, `.xml` and `.json`. Nothing in
the tidy pipeline applies it — CSharpier is kept away by `.csharpierignore`, `dotnet format` does not touch
XML whitespace, and the ReSharper profile only reorders C# members — so it is an editor rule, and
`.editorconfig` is where it is stated.
91 changes: 91 additions & 0 deletions .agents/references/reviews/aot-package-compat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Native AOT, trimming and packaging compatibility review

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.

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

This is a **review**: report findings, cite file and line, do not edit files.

## Why this checklist exists at all

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
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
exists: the gate runs it and asserts exact strings for exactly this reason.

## Blocking findings

- **A new suppressed `IL2xxx` or `IL3xxx` anywhere in `src/`.** These diagnostics are the only
build-time proof that a reflection path survives trimming; suppressing one voids it. Restructure
instead. There are no sanctioned suppressions in this repository today — a first one is a finding
that needs the maintainer, not a reviewer.

`[RequiresUnreferencedCode]` and `[RequiresDynamicCode]` are **not** suppressions: they propagate
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.

- **A new reflection call** — `Type.GetType`, `Assembly.Load`, `Activator.CreateInstance`,
`GetMethod`, `GetProperty`, `MakeGenericType` — reachable from a public entry point without a
`[DynamicallyAccessedMembers]` annotation covering exactly what it asks for. Grep for these; the
library's own lookups go through `ResourceManager`, which is annotated by the framework, and a new
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.

## The generator's output

The typed key classes are the other half of the contract, and they are generated at build time rather
than published, so trimming cannot reach them — but packaging can drop them.

- **Exactly one generator DLL per UI package**, at `analyzers/dotnet/cs`. Two copies are NU5118 at
pack time; zero means the consumer gets no typed keys and a build failure that names a missing type
rather than a missing generator.
- **`buildTransitive/<PackageId>.targets`** must be present in each UI package, and must be the same
`build/ResXLocalization.Resx.targets` the repository imports itself. That file is what hands each
`.resx` and `.Designer.cs` to the generator; without it the generator runs and emits nothing.
- **The generator's Roslyn floor stays at 4.8.0**, set for that project in
`Directory.Packages.props`. A generator built against a newer Roslyn does not load in an older
compiler: `CS9057`, no generated keys, and a consumer build that fails on missing types. Raising
the floor is a breaking change for every consumer on the .NET 8 or 9 SDK.
- **No generator or compiler dependency in a runtime nuspec.** The `ProjectReference` to the
generator carries `PrivateAssets="all"` and `ReferenceOutputAssembly="false"` for that reason;
losing either leaks `Microsoft.CodeAnalysis.CSharp` into the package's dependency list.

## What the tooling cannot tell you

- **`TrimmerSingleWarn`.** Left at its default, ILC collapses every diagnostic from an assembly into
one `IL2104` line and the individual codes never appear. A change that stops
`verify-package-aot.ps1` passing `-p:TrimmerSingleWarn=false` turns the gate into a check that
counts zero because it cannot see.
- **`SatelliteResourceLanguages`.** A consumer that does not declare it ships no satellites at all
and silently renders the neutral language. The README and the AOT guide both say so; a change that
removes that instruction is a documentation finding with a runtime consequence.
- **The package source mapping** in `tests/package-consumption/nuget.config`. It binds
`ResXLocalization.*` to the local feed. Without it, a missing local package resolves the published
package of the same version from nuget.org and the whole gate passes against the last release.

## What to report

For each finding: the file and line, what the diagnostic or the packaging consequence is, and which
check would have caught it. Where the answer needs a build, a pack or the AOT gate run, say so and
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
```

Do not run them yourself, and do not fix what you found.
Loading
Loading