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
8 changes: 7 additions & 1 deletion .agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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
```
Expand All @@ -26,7 +27,12 @@ Codex-only UI metadata and explicit-invocation policy. Claude needs thin skill w
`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.
all substantive behavior to the same scripts: `scripts/tidy-cs.ps1` and `scripts/public-api-guard.ps1`.

The tidy hook runs the **default scope only** — CSharpier, under a second. 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 three are build errors, and `scripts/preflight.ps1` runs
`tidy-cs.ps1 -Scope all` before a commit, so nothing reaches a pull request untidied.

When changing behavior, edit the canonical file. Keep only required names, descriptions, policies, tool/model
settings, and reference instructions in tool-specific files.
116 changes: 116 additions & 0 deletions .agents/references/code-style.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# 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.

## Three concerns, three tools

| Concern | Tool | Configured in |
|---|---|---|
| Formatting — whitespace, line breaks, wrapping | **CSharpier** | `.editorconfig` (`max_line_length`, `indent_size`) |
| Style — `var`, `=>`, `this.`, null checks, usings | **Roslyn analyzers** | `.editorconfig` |
| Ordering — types and their members | **ReSharper** applies it, **NewStyleCop** checks it | `DbConnectionPlus.slnx.DotSettings` and `stylecop.json` |

Each tool owns its concern completely, and all three are build errors rather than warnings, in `tests/` and
`benchmarks/` as much as in `src/`. Two different mechanisms, both in the 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.

## 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` enforces
most of it.

`IDE0049` has two blind spots. Neither produces a diagnostic, so `TreatWarningsAsErrors` has nothing to fail
on. Both were verified against the analyzer:

- **`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
(CS1525 — a keyword is not a name), so a blanket skip is the safe choice. Where `nameof` names a CLR type
on purpose, as in `EnumerableReaderTests`, leave it and say why in a comment.

A third gap is not the analyzer's fault: **`tests/package-consumption/` is not in `DbConnectionPlus.slnx`**,
so `dotnet format` and `dotnet build` on the solution never see it, and its deliberately empty
`Directory.Build.props` means it gets no style gate even when `verify-package-aot.ps1` builds it. If you touch
those files, apply the style by hand.

## Primary constructor parameters

Every primary constructor parameter is assigned to a `private readonly` backing field, and members read
`this.field` rather than the parameter:

```csharp
internal class SqliteEntityManipulator(SqliteDatabaseAdapter databaseAdapter) : IEntityManipulator
{
private readonly SqliteDatabaseAdapter databaseAdapter = databaseAdapter;
```

Using the parameter directly is shorter and looks equivalent. It is not. A parameter that a member body reads
is *captured*, and the field the compiler generates for it carries no `readonly` — so the value becomes
reassignable from inside the type, where the explicit field would have made that a compile error. C# has no way
to mark a primary constructor parameter `readonly`, so the backing field is the only way to keep the guarantee.

There is no diagnostic for this. `IDE0290` asks for the primary constructor and stops there; the `this.`
rules cannot see a parameter at all. It is on the author.

The field costs nothing at runtime: a parameter used only in a field initializer is not captured, so the
explicit field and the compiler's capture field are one field, not two.

## When a tool surprises you

- **`cleanupcode` re-indents the content of raw string literals and CSharpier puts it back.** Neither is
idempotent alone; the pair is. Always let CSharpier run last — `tidy-cs.ps1` does.
- **Explicit interface implementations sort first — except events.** StyleCop counts an explicit property,
indexer or method as public, so each of those has its own "Explicit interface …" entry in the file layout
putting it at the front of its group. An explicit **event** is counted as private, so it stays where
ReSharper puts it by default, at the end; giving events an entry breaks the build. The entries match
`ImplementsInterface` **and** `Access Is="Private"` — without the access test they would also catch implicit
implementations and pull `Equals(T)` away from `Equals(object)`.
- **Overloads with the same name have no defined order between them.** Two methods called `Equals` tie on
every key the file layout sorts by — kind, access, static, readonly and name — and ReSharper's sort is
stable, so it leaves them in whatever order it found them. Reordering the same file starting from two
different states can therefore give two different results, and both are correct: StyleCop accepts either,
and moving a method declaration changes nothing at runtime. CI does not flap over it, because a stable sort
leaves the committed order alone. There is no fix available — ReSharper's `SortBy` offers `<Name />` but
nothing that separates overloads by signature. Put new overloads next to their siblings and it will never
come up.

## XML documentation comments

XML docs go on all public and most internal members of the **shipping** projects — `<param>`, `<returns>`,
`<exception>`, `<remarks>`. Match the density of the file you are editing; `DbConnectionExtensions.QueryFirst.cs`
sets the bar. Invalid XML docs break the docfx workflow.

The benchmarks are the exception: nothing consumes them as an API, so they use plain `//` comments and switch
`RCS1181` off for that reason.

## Member order

StyleCop's order, applied by ReSharper and checked by NewStyleCop. Write a new member straight into the right
place rather than relying on the fixer:

constants → fields → constructors → finalizers → delegates → events → enums → interfaces
→ properties → indexers → conversion operators → operators → methods → nested structs → nested classes

Within each of those groups: public before internal before protected before private, static before instance,
readonly before mutable, and **alphabetical** after that.

Note that fields go at the **top** of a type, and that explicit interface implementations sort ahead of the
ordinary methods — with the exception for events described above.

## Rules the build fully enforces

These need no attention beyond letting the tools run — they are listed here so that the rule set in AGENTS.md
stays short, not because they are optional:

- **Primary constructors** wherever `IDE0290` asks for one.
- **Expression-bodied members** are `error`-severity for methods, constructors, operators, properties, indexers,
accessors, lambdas and local functions. Use `=>` wherever a member is a single expression.
- **File-scoped namespaces**, with usings outside the namespace.
- **Max line length is 120**, set once in `.editorconfig` and read from there by CSharpier. Do not wrap lines by
hand — write it on one line and let CSharpier break it.
- Nullable and `ImplicitUsings` are enabled; common namespaces come from `GlobalUsings.cs`.
31 changes: 0 additions & 31 deletions .claude/hooks/format-cs.ps1

This file was deleted.

42 changes: 42 additions & 0 deletions .claude/hooks/tidy-cs.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# PostToolUse hook: format an edited C# file with CSharpier.
#
# The logic itself lives in scripts/tidy-cs.ps1, so that Codex's hook and a human run exactly the same
# thing. This file is only the hook wiring: read the tool payload off stdin, pull the edited path out of
# it, and delegate.
#
# Formatting only, which is the default scope and takes under a second. Style and member ordering are
# not run here: `dotnet format style` needs MSBuild and ReSharper loads the whole solution, and neither
# belongs on the critical path of every single edit. All three are build errors, and scripts/preflight.ps1
# runs `-Scope all` before a commit, so nothing slips through.
#
# Never fails the edit - a formatter problem is surfaced as text and the hook still exits 0.

$ErrorActionPreference = 'Stop'

try {
$payload = [Console]::In.ReadToEnd() | ConvertFrom-Json
$filePath = $payload.tool_input.file_path

if ([String]::IsNullOrWhiteSpace($filePath)) { exit 0 }
if ([System.IO.Path]::GetExtension($filePath) -ne '.cs') { exit 0 }
if (-not (Test-Path -LiteralPath $filePath)) { exit 0 }

$script = Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) 'scripts/tidy-cs.ps1'
if (-not (Test-Path -LiteralPath $script)) {
Write-Output "tidy-cs hook: scripts/tidy-cs.ps1 not found at $script"
exit 0
}

$output = & $script -Path $filePath 2>&1 | Out-String

# Quiet on success. On failure say so explicitly and say what it means, because the build treats
# formatting as an error - the same contract Codex's adapter reports through additionalContext.
if ($LASTEXITCODE -ne 0) {
Write-Output "CSharpier failed on $filePath. The build treats formatting as an error, so fix this before building:`n$output"
}
}
catch {
Write-Output "tidy-cs hook error: $($_.Exception.Message)"
}

exit 0
2 changes: 1 addition & 1 deletion .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"hooks": [
{
"type": "command",
"command": "pwsh -NoProfile -NonInteractive -File \"$CLAUDE_PROJECT_DIR/.claude/hooks/format-cs.ps1\"",
"command": "pwsh -NoProfile -NonInteractive -File \"$CLAUDE_PROJECT_DIR/.claude/hooks/tidy-cs.ps1\"",
"timeout": 60
},
{
Expand Down
2 changes: 1 addition & 1 deletion .codex/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"hooks": [
{
"type": "command",
"command": "pwsh -NoProfile -NonInteractive -Command \"& (Join-Path (git rev-parse --show-toplevel) '.codex/hooks/format-cs.ps1')\"",
"command": "pwsh -NoProfile -NonInteractive -Command \"& (Join-Path (git rev-parse --show-toplevel) '.codex/hooks/tidy-cs.ps1')\"",
"statusMessage": "Formatting changed C# files",
"timeout": 120
},
Expand Down
20 changes: 12 additions & 8 deletions .codex/hooks/format-cs.ps1 → .codex/hooks/tidy-cs.ps1
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
# Codex PostToolUse hook: format the C# files an edit just touched, against .editorconfig.
# Codex PostToolUse hook: format the C# files an edit just touched, with CSharpier.
#
# The formatting logic itself lives in scripts/format-cs.ps1, which Claude Code's hook runs too.
# This file is only the hook wiring.
# The logic itself lives in scripts/tidy-cs.ps1, which Claude Code's hook runs too. This file is only
# the hook wiring.
#
# Why it does not read a path out of the payload: for a file edit Codex reports tool_name "apply_patch" and
# puts the patch text in tool_input.command, not a file path. The script with no arguments formats
# every .cs file git reports as changed, which covers the edit that just happened and costs nothing when
# there is none.
#
# Formatting only, which is the default scope. Style and member ordering are build errors and
# scripts/preflight.ps1 runs `-Scope all` before a commit; neither belongs on the critical path of
# every edit.
#
# Contract (https://learn.chatgpt.com/docs/hooks): exit 0 and write the response JSON to stdout. Exit code 2
# would block the operation - this hook never does that, because a formatter problem must not stop an edit.

Expand Down Expand Up @@ -41,25 +45,25 @@ try {
$repositoryRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
}

$script = Join-Path $repositoryRoot 'scripts/format-cs.ps1'
$script = Join-Path $repositoryRoot 'scripts/tidy-cs.ps1'
if (-not (Test-Path -LiteralPath $script)) {
Write-HookResult -AdditionalContext "format-cs hook: scripts/format-cs.ps1 not found at $script"
Write-HookResult -AdditionalContext "tidy-cs hook: scripts/tidy-cs.ps1 not found at $script"
exit 0
}

$output = & pwsh -NoProfile -NonInteractive -File $script 2>&1 | Out-String

# Quiet on success: the agent does not need to be told that nothing needed formatting. A failure is
# reported, because it means the next build breaks on a style rule.
# reported, because it means the next build breaks on a formatting rule.
if ($LASTEXITCODE -ne 0) {
Write-HookResult -AdditionalContext "dotnet format failed. The build treats style rules as errors, so fix this before building:`n$output"
Write-HookResult -AdditionalContext "CSharpier failed. The build treats formatting as an error, so fix this before building:`n$output"
}
else {
Write-HookResult
}
}
catch {
Write-HookResult -AdditionalContext "format-cs hook error: $($_.Exception.Message)"
Write-HookResult -AdditionalContext "tidy-cs hook error: $($_.Exception.Message)"
}

exit 0
14 changes: 14 additions & 0 deletions .config/dotnet-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,20 @@
"docfx"
],
"rollForward": false
},
"csharpier": {
"version": "1.3.0",
"commands": [
"csharpier"
],
"rollForward": false
},
"jetbrains.resharper.globaltools": {
"version": "2026.2.1",
"commands": [
"jb"
],
"rollForward": false
}
}
}
18 changes: 18 additions & 0 deletions .csharpierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# CSharpier formats C# in this repository, and nothing else.
#
# Since version 1.0 it can also format XML, which here means the .csproj, .props, .targets, .slnx and
# .config files. Those are hand-maintained: they carry long explanatory comments, deliberate blank lines
# between the property groups, and one item per line. CSharpier's XML formatter reflows all of that -
# it splits short attribute lists across four lines each and re-indents elements without re-indenting
# the comment bodies inside them. .editorconfig already fixes their indentation, which is all they need.
#
# Build output and generated documentation are not listed: CSharpier honours .gitignore, and obj, bin,
# docs/api and docs/_site are all in there already.

*.csproj
*.props
*.targets
*.slnx
*.config
*.xml
*.DotSettings
Loading
Loading