CLAUDE.md · git:20261025.f7ac88e · 2026-10-25 · sha256 14d0ecfa06935f80

CLAUDE.md git:20261025.f7ac88eA

Immutable. This exact content never changes and is served at /api/v1/blob/14d0ecfa06935f80.

# CLAUDE.md

Guidance for Claude Code when working in this repository.

## Benchmarks

- During development, run benchmarks in quick mode: `QUICK=1 benchmarks/run.sh`, with `FILTER='*SimpleTest*'`
  (any BenchmarkDotNet filter) to run only the cases you are working on. Quick mode measures each case for 6 to 10
  iterations, stopping once its error is under 2%, so it takes about 30–45 minutes for everything (Nino, MemoryPack,
  MessagePack and protobuf-net).
- Both modes run v3 3.3.13 (from NuGet: `benchmarks/Nino.Benchmark` built with `NINO_BENCH_V3=true`) once, directly
  before v4, on the same machine; MemoryPack runs in both, so its two results show how much the machine moved.
- Quick numbers are for comparing your own changes back to back on one machine. Never publish them: the numbers
  in README.md, docs/v4-benchmarks.md, the chart and the release notes come from a full `benchmarks/run.sh` run
  (or the release workflow), whose job id stays `ShortRun-.NET 10.0`.
- See benchmarks/README.md for the options.

## Machine code

A change to a read or write path (Nino.Core, Nino.Immutable, the code the generator emits) can slow cases it does not
touch: everything a top-level call reaches is inlined into one method, under one inlining budget. Before committing
one, run `python3 benchmarks/asm.py compare origin/main` (about two minutes, see benchmarks/README.md) and explain or fix
every method it lists. What has gone wrong before:
- A branch whose condition is a method call (`RawFormatter.Is`, `NinoBlock.Size`, ...) is compiled and inlined even
  for the types where it is dead, and uses up the budget there. Keep rare or large paths (vector loops, element by
  element loops, string allocation, recycling) out of line (`NoInlining`), with only the common path inline.
- Never pass `ref reader` or `ref writer` to a method that is not inlined, even in a dead branch: the address keeps
  the struct in memory for the whole method. Pass it by value and return it (`reader = ReadEach(values, reader)`).
- Mark every small helper a read or write path calls `AggressiveInlining`, even one whose arguments are constants
  and that looks certain to fold: in a large method the JIT's inliner gives up on it. `NinoLanes.Paired`, called
  with constants in the AVX2 group loop, stayed a call per load and spilled the vectors around it: AVX2 writes took
  twice as long as SSSE3's. Look for such calls in the Tier1 listing of every ISA a path takes (`--asm`, with
  `DOTNET_EnableAVX512=0` and `DOTNET_EnableAVX2=0` too).
- A JIT-time check made of several helper calls is one `static readonly` field instead (`NinoElements.Traits`,
  `NinoBlock<T, TFormatter>.Size`): one load, nothing to inline.
- Vector loops: bump `ref` cursors and count down (`at = ref Unsafe.Add(ref at, step)`, `while (--n != 0)`), never
  `(nuint)i * size` per iteration; take everything that does not change per iteration (masks, indices) out of the
  loop into locals.
- On Arm64 (and anywhere without AVX-512) `Vector512<T>` and `Vector256<T>` are structs of 16-byte vectors: passed
  into an inlined loop body they are copied through the stack every iteration. Take their `Vector128` parts out once,
  before the loop, and pass those.
- Arm64 `tbl` with two to four table registers needs consecutive registers: build the tuples from loads made once
  per iteration, before any lookup (`(w0, w1)`, `(w1, w2)`), never from per-lookup loads, or the JIT adds `mov`s.
  Each table register is a micro-op on Neoverse cores: give each lookup only the registers its bytes come from, and
  store only the bytes the values fill (`NinoBlock.WireTables64`); check each shape, as types that compile to the
  same code can differ by the data (`(byte, int)` took twice as long as `(short, int)`).
- Look at Arm64 code too, not only x64: build for `linux-arm64` and run under `qemu-aarch64-static` with
  `DOTNET_TieredCompilation=0 DOTNET_JitDisasm='<method>' DOTNET_JitStdOutFile=<file>` (the code paths only a type
  outside the benchmark suite takes, such as `(byte, int)` for the 64-byte permutes, need a small program of their
  own). A clean loop is loads (`ldp`), lookups, stores (`stp`) and the cursor adds; `stp`/`ldp` to `[fp, ...]`, `mul`
  or `mov v..` in it are the signs above.
- Every optimization has to hold on each platform Nino runs on: x64 with AVX-512, AVX2 or SSSE3 only, Arm64, Arm32
  and other 32-bit targets, WebAssembly and anything without SIMD (`DOTNET_EnableHWIntrinsic=0`), Mono and Unity
  (IL2CPP included), .NET Framework. Fix a problem everywhere it occurs, not only in the case that showed it, and say
  in the commit body which platforms a change speeds up and which keep the old path. A path through a runtime's
  internals (`CollectionViews`: the fields of `List<T>`, `HashSet<T>`, `Dictionary<TKey, TValue>`, ...) declares a
  layout for each implementation (.NET 64-bit and 32-bit, .NET Framework, Mono), checks it against an instance with
  known contents at startup and uses the public API when none matches; run the tests on .NET, the `net472` build under
  Mono and the `linux-arm` and `linux-arm64` builds under qemu, and keep the fallback (the enumerator) out of line.
- Building a runtime's own objects without its public API (`Nino.Immutable/HashTrees.cs`: an `ImmutableDictionary`
  or `ImmutableHashSet` built as its tree, instead of one builder `Add` at a time; `SortedSetNodes` in Nino.Core: a
  `SortedSet<T>`'s red-black tree; `ReadLinked`: a `LinkedList<T>`'s ring of nodes; `ConcurrentTables.Builder`: a
  `ConcurrentDictionary`'s bucket chains): one way for every runtime where
  one works, and a separate way per runtime (behind `#if` or a startup check) only where none does. What works
  everywhere so far:
  - Mirror classes that declare the fields of the real class in its order, read and written through `Unsafe.As`:
    every runtime lays out two classes with the same field list the same way. Check the field lists of every version
    of the library users may have (System.Collections.Immutable 1.5 to 10.0 have the same fields), and check the
    layout at startup against an instance with known contents, through the public API; use the public API when it
    fails.
  - Never call an instance method on a mirrored object (a `MemberwiseClone` helper in the mirror class): Mono and
    IL2CPP take a shared generic method's context from `this`'s class, and Mono aborts (an assertion, not an
    exception, so no `catch` helps). Static code only.
  - Allocate a mirror instance with `new` and set its type word to the real type's (`ObjectTypes.Retype`): the same
    fields mean the same size and the same references for the GC. Faster than `RuntimeHelpers.GetUninitializedObject`
    (13 ns on .NET 10 but 77 on .NET 8, FormatterServices 59 on Mono) and needs nothing IL2CPP may lack. The type word
    sits where the reference points (CoreCLR: method table, one header word; Mono and IL2CPP: vtable or class, then
    the monitor, two words), found at startup; call `GetType()` on a retyped object out of line (`NoInlining`), or
    the JIT folds it to the type it allocated (Arm32 did).
  - Run it on .NET 8 and 10, the `net472` build under Mono (also `MONO_GC_DEBUG=verify-before-collections`), Arm32
    under qemu, with a GC stress (forced compacting collections between builds, then every object checked).
    Under qemu-arm (Arm32), set `DOTNET_EnableWriteXorExecute=0`: with W^X the runtime writes dynamic methods
    (`Expression.Compile`, reflection invoke stubs) through a second mapping qemu-user does not see, and they crash.
    Set `DOTNET_INTERNAL_ThreadSuspendInjection=0` too: qemu (8.2 and 10.2) aborts in about one run in four
    (`thumb_tr_translate_insn: Assertion (dc->base.pc_next & 1) == 0`) when the GC's suspension signal lands.
    To run tests under qemu-aarch64, pass `-cpu neoverse-n1` (listings can keep the default): qemu 8.2's default CPU
    (`max`) has LRCPC2, whose `ldapur`/`stlur` (what the optimizing JIT emits for `Volatile.Read`/`Write` of a field)
    it emulates wrong. With or without Nino's code, runs crashed (`ToImmutableDictionary`), or a static field written
    by `Volatile.Write` read as never written elsewhere; `DOTNET_TieredCompilation=0` moves the failures, it does
    not avoid them. CI's "Runtime internals" job runs `tests/Nino.RuntimeTests` on .NET (x64, Arm64, Arm32 under
    qemu, Windows x86), Mono (x64, Arm64) and .NET Framework 4.8 (64-bit and 32-bit): give a new path a case there
    with known contents, and its startup flag a row in its `FastPaths`, so a runtime that falls back to the public
    API fails.
- Prefer one path for every platform. A platform-specific fast path (an intrinsic, `UnsafeAccessor` on .NET 8+,
  `allows ref struct` on .NET 9+, a runtime's internal layout) is allowed only behind `#if` or a startup check, with
  the shared path kept as the fallback; say in a comment which platforms take it, and test that both paths write and
  read the same bytes (run the suite once with the fast path off: `DOTNET_EnableHWIntrinsic=0`, the `netstandard`
  builds of Nino.Core, Mono).
- The signs in the listing: `Reader:Skip`, `Reader:CopyTo` or `NinoUnsafe:Read` as calls (the budget ran out);
  `NinoReadContext:Return` or `NinoWriteContext:Return` calls and `[rbp-0x..]` loads and stores of the cursor (the
  reader or writer was not kept in registers).

## Security

Nino reads untrusted data (network messages, files, request bodies): crafted bytes must never crash the process, read
outside the input, allocate out of proportion to it, recurse without a bound or take time out of proportion to it.
- Every check of the data throws `NinoDataException`, through a `NinoThrow` helper (out of line). The caller's
  mistakes and the write side (a graph nested too deep or holding itself, a collection changed while written) keep
  `InvalidOperationException`. Tests assert the exact type (`Assert.ThrowsExactly`).
- Check a length against the bytes left (`TryReadLength`, `NinoLimits.TooLong`) before allocating; grow, never
  presize, what an element larger in memory than on the wire claims. Every recursive path counts depth: class bodies
  through `Depth`, structs and collections that can hold themselves through `EnterStruct`/`ExitStruct` on both the
  reader and the writer, against `NinoSettings.MaxDepth`.
- Reject bytes no writer writes: a `bool` or presence byte other than 0 or 1 (`ReadBool`, `ReadPresent`,
  `NinoChecks.Bools`), keyed members that do not fill their entry (`EndMember`), sections of deferred bodies that do
  not match their region. A raw block copy of a type with a `bool` in it goes through `NinoLayout<T>.CopiesAsIs`,
  never `IsCanonical` alone.
- A hash table Nino fills counts the keys in each bucket against `NinoSettings.MaxHashCollisions` (`HashTables`:
  `TryFill`, `Insert`, or `Added`/`Walk` after the table's own add, as `HashTables.Plan` says; `ConcurrentTables` for
  `ConcurrentDictionary`). A new path that adds keys from the data to a hash table does one of these.
- `NinoSettings.Untrusted` holds the checks that cost time on every read (seeded comparers, value checks of raw
  structs, trailing bytes). It is a constant once frozen (`NinoLimits.Untrusted`): keep each test of it a single
  `if` on the field, with the work out of line, so the default path compiles to what it was; prove it with
  `benchmarks/asm.py compare`.
- A change here runs the fuzzer (`tests/Nino.Fuzz`: `selftest`, then a timed run with and without
  `DOTNET_EnableHWIntrinsic=0`), the hostile-input unit tests (`StrictReadTests`, `HashFloodingTests`,
  `UntrustedModeTests`, `SettingsTests`, the nesting tests) and the conformance corpus in .NET, Go, Rust and TypeScript.
  A rule the other runtimes must follow goes into `docs/spec/wire-format.md` §9.

## Source generator

- A generator bug shows up as code that does not compile for one shape of type nobody tested (#203: every generic
  abstract class or interface). Take a name the model may leave empty (`BaseFormatter`, `DispatchRootFormatter`,
  `Construction.Creator`, ...) through `TypeModel.Need`, never by interpolating it directly. Decide what a type gets
  from the model's rules (`DispatchesSubtypes`, `IsDispatchRoot`, ...), not from a new check next to them.
- A feature that adds a way to declare a type (a kind, an attribute option, a place) adds a dimension to
  `tests/Nino.UnitTests/Corner/ShapeMatrixTests.cs`, so it is compiled and round-tripped with every other one.
- Generated registration code may call back into Nino.Core (`NinoPolymorphic.Register(Type)`), which asks every
  registry again: anything a registry calls must be re-entrant (`NinoRegistry.RegisterType` guards it per thread).

## TypeScript runtime (runtimes/typescript)

`src/nino.ts` is the runtime Nino.Codegen embeds and nino-gen writes next to the generated TypeScript. The
reader takes hostile input as the Go and Rust ones do: check bounds and lengths before allocating, count depth as .NET
does and defer past it, never build objects whose keys come from the data on a plain object (`__proto__`): use `Map`.
Before committing: `npm ci && npm test` (type check and the `node:test` suite) in `runtimes/typescript`, and the
`Nino.Codegen.Integration.Tests` and `Nino.Codegen.Tests` .NET tests.

## Go runtime

`runtimes/go` (package `nino`, see its README.md) reads and writes the wire format for Go; `internal/sample` holds the
corpus's types with codecs in the style a generator would write. Both performance and safety on untrusted input are
requirements:
- Never panic on any input, and never rely on `recover`: check every bound, and keep errors sticky (after a failure
  the reader returns zeros, empty slices and null, so every loop ends). Check a length against the bytes left
  before allocating, with .NET's caps (`SliceCap`, `capacity`); grow, never presize, what the data only claims.
- Count depth exactly as .NET does (`ReadClass`, `WriteClass`, regions and sections): the bytes must match, and
  the stack must stay bounded however deep the data. A polymorphic slot accepts only the ids of the types it allows.
- Hot paths: no reflection, no boxing into `any` outside polymorphic slots; a run of fixed-size members is one
  `Take`/`Extend` and `binary.LittleEndian` at constant offsets; blocks of primitives are one copy (guarded for
  big-endian hosts). Keep rare paths (`Fail`, growth, deferral) in `//go:noinline` functions. Check
  `go build -gcflags=-d=ssa/check_bce/debug=1` and `-gcflags=-m` on what you change, and keep writing a value free
  of allocations (`bench`, `-benchmem`).
- Struct tags (`tagged.go`, `layout.go`, `marshal.go`) are the one place reflection is allowed: a plan built once
  per type, then walked. Anything the wire format cannot express is a `*TagError` naming the field when the plan
  is built, never a silent default; reads go through the same `Reader` calls as generated code, so the guarantees
  above hold there too. The sample types carry the tags and `NinoLayout` a generator emits (README, "Tags on
  generated structs"); keep them and `internal/sample/tagged_test.go` in step with the hand-written codecs.
- Before committing: `go vet ./...`, `go test -race ./...` with the conformance corpus present, a short run of
  `FuzzReader`, `FuzzDecode`, `FuzzTagged` and `FuzzTaggedDecode`, and the benchmarks back to back against the
  previous commit for a change to a read or write path. The runtime's module has no dependencies; competitors live in the `bench` module.

## Rust runtime (runtimes/rust)

- Security: the reader takes hostile input. No input may panic it, read out of bounds, recurse without a bound or
  allocate out of proportion to the data. The crate is `#![deny(unsafe_code)]` and denies `indexing_slicing`,
  `unwrap_used`, `expect_used`, `panic` and `arithmetic_side_effects` (lib.rs): use `get`, `checked_*`/`wrapping_*`
  with a comment saying why it cannot wrap, and errors. The one module allowed `unsafe` is `src/simd.rs`: the
  string writer's transcoding, and the reader's decoding of Latin-1 and UTF-16 into a `String`'s spare capacity
  (`from_utf8_unchecked` only on what the decoder itself writes from code units, so valid UTF-8 by construction;
  unpaired surrogates become U+FFFD). The reader's bounds checks stay safe code: an unsafe decoder takes a `&[u8]`
  already taken from the input. Every block has a `// SAFETY:` comment, its tests compare every block kind and
  decoder with a plain one for every length up to 256 and random input, and the `rust-miri` job in
  `.github/workflows/runtimes.yml` runs them under Miri (x86_64 with SSE2 only, SSSE3 and SSE4.1, Arm64, s390x).
  Add `unsafe` only for a measured win, and only there.
- Allocation: a collection reserves `r.capacity::<T>(count)`, never `count` itself: the per-read budget (16 bytes per
  input byte plus 4 KiB) stops nested collections each pre-allocating against the same bytes (the fuzz found that).
  Check a count against the bytes left (`length(min_size)`) before reading elements.
- Depth: class bodies recurse up to `DEPTH_INLINE` and are deferred past it, as .NET does; structs count toward the
  limit through `enter_struct`/`exit_struct`. Never add a recursive path that skips them.
- Stricter than .NET, never looser: reject what .NET never writes (bools other than 0/1, out-of-range dates, decimal
  flags, non-minimal BigInteger, raw padding, duplicate keys, malformed sections), and read everything it writes.
  A new check goes in README.md's "Stricter than .NET" list.
- Performance: the generated (and derived) code is what the templates write: a run of fixed-size members is one
  bounds check (`r.array::<N>()`, `w.reserve(n)`), `#[inline]` on every small codec, and rare or large paths
  (`latin1_wide`, UTF-16 decoding, building an `Error`) `#[inline(never)]`. Compare with `cargo bench -p nino-bench`
  back to back on one machine; never publish the numbers.
- The derive (`nino-serialization-derive`, in `nino-derive/`) generates the same code as the templates; anything the
  wire format cannot express is a compile error naming the field, with a case in `tests/compile_fail/`. Give a new
  attribute a corpus or derive test.
- Before committing: `cargo fmt --all --check`, `cargo clippy --workspace --all-features --all-targets -- -D warnings`
  and `cargo test --workspace --all-features` with `NINO_CORPUS` set (the conformance, derive and fuzz tests skip
  without a corpus). `tests/conformance/types.rs` is generated by `tests/conformance/gen.py`; edit the generator.
  Builds share one `CARGO_TARGET_DIR`: Rust targets are large.

## Code generation for other languages

`src/Nino.Codegen` (nino-gen: packed as the Nino.Codegen build package and, through `src/Nino.Codegen.Tool`, as the
Nino.Codegen.Tool .NET tool) writes TypeScript, Go and Rust from the published layouts (docs/codegen.md):
- Every option is defined once, in `Options.cs` (`CodegenOptions.All`: its command-line name, the `<NinoCodegen>`
  metadata that sets it, its language and values); `build/Nino.Codegen.targets` passes each metadata as that option,
  and `Nino.Codegen.Integration.Tests` checks the two agree and builds projects with the targets.
- Logic lives in the IR (`Model/`) and each target's C# (`Targets/`), never in the templates (`Templates/*/*.sbn`),
  which only assemble text from what the target hands them. A decision about the bytes goes into the IR once, for
  every language.
- Every template or target change is tested end to end against the corpus: regenerate the committed files
  (`NINO_UPDATE_GENERATED=1 dotnet test tests/Nino.Codegen.Tests`), then run the TypeScript (`npm test`), Go
  (`go test -race ./...`) and Rust (`cargo test --workspace --all-features` with `NINO_CORPUS` set) checks, which read,
  write back and attack the generated code with the corpus's valid, malicious and truncated cases.
- Generated code follows each runtime's performance and safety rules above (bounds checked before allocating, depth
  counted as .NET does, one bounds check per run of fixed-size members, rare paths out of line): what the
  hand-written samples do, the templates write. Anything a language cannot express is a NINOGEN002 warning naming
  the type, never code that reads differently.

## Commit messages and release notes

- Conventional Commits: `type(scope): summary`, plus a body that says why. Types: `feat`, `fix`, `perf`,
  `refactor`, `docs`, `test`, `ci`, `build`, `chore`; the scope is the area (`core`, `generator`, `collections`,
  `benchmarks`, ...). The release notes group commits by type (`.github/scripts/release_notes.py`, `classify`).
- Release notes are generated (`.github/scripts/release_notes.py`): the commit list, and highlights from trailers in
  the message's last paragraph, next to `Co-Authored-By`. Give a user-facing change one:
  - `Wire-Format:` anything that changes the bytes Nino writes, and for which types;
  - `Added:` a new API, attribute or behavior;
  - `Performance:` what got faster, for which types, and by how much (measured);
  - `Fixed:` a user-visible bug.
  Write each as one sentence or two for users, in Markdown, continuing over indented lines if needed. Put
  `Closes #N` in the body above the trailers; the issue is appended to the entry. Leave tests, CI, docs and
  refactors without trailers. The docs site's changelog (nino.docs) is written from the same trailers.