Skip to main content

add-formatter

Add or fix a reify type formatter — pick the DisplayNode shape, write the exegesis detector, add golden coverage, and debug a detector that does not fire. Use when teaching hansei to render a new known type (a tokio primitive, a std/crate container, a byte-array notation) or when a formatter stopped attaching or renders the wrong member.

Quellinformationen

Repository
oxidecomputer/hansei
Letzte Quellaktivität
4. Oktober 2026 um 18:47
Erkannte Sprache von SKILL.md
Englisch
Sterne
83
Forks
1

Installationsoptionen

Standardmäßig ist der Prompt ausgewählt, der zuerst die Quelle prüft. Sie können zu einem direkten Befehl wechseln oder eine lokale Kopie herunterladen.

Quelldateien prüfen

Lesen Sie SKILL.md und alle von SkillsMP angezeigten Begleitdateien, bevor Sie sich für eine Installation entscheiden.

SKILL.md wird angezeigt

SKILL.md
Quellanweisungen · Schreibgeschützte Vorschau
name
add-formatter
description
Add or fix a reify type formatter — pick the DisplayNode shape, write the exegesis detector, add golden coverage, and debug a detector that does not fire. Use when teaching hansei to render a new known type (a tokio primitive, a std/crate container, a byte-array notation) or when a formatter stopped attaching or renders the wrong member.
# Add (or fix) a type formatter Doctrine (also in CLAUDE.md): a formatter is **not** per-type code. It is a `DisplayNode` (`hansei-bundle/src/schema.rs`) — a display *program* carried in the bundle as data — executed by one generic interpreter (`eval_node` in `reify/src/render/node.rs`). Detection happens in exegesis while structured DWARF is available; the bundle records `Selector` paths; reify reduces them to offsets once (`DisplayNode::resolve` in `reify/src/debug_type.rs`). The normal cost is **one edit site**: a detector under `exegesis/src/detect/`. `utf8_path_node` and `slice_node` are the shape to aim for — a node literal and nothing else. Workflow: discover the layout (§4) → pick the node shape (§1) → write the detector (§2) → add coverage (§3) → verify + the usual gates (`cargo nextest run -p exegesis -p reify -p hansei-bundle`, clippy, `cargo fmt --all` **and** `cargo fmt --manifest-path test-programs/Cargo.toml`). A new node kind (§5) is the exceptional path. ## 1. Pick the node shape Compose the existing vocabulary; do not reach for a new node kind (§5 says what that costs). The kinds, with the formatters that use them: | Node | Renders | Used by | | --- | --- | --- | | `Scalar { at, decode }` | one machine word, decoded (see below) | atomics, `RawMutex`, watch state, semaphore permits | | `Symbol { at }` | a code pointer as `0x… -> name` | function pointers, `RawWakerVTable` slots | | `Struct { fields }` | a **curated** record `T { a, b }` | `Notify`, both semaphores, `Chan` | | `List { head, next, node, node_ty }` | an intrusive singly-linked list | waiter queues | | `Str { pointer, length, capacity }` | a quoted UTF-8 buffer | `&str`, `String`, camino paths | | `Slice { pointer, length, capacity, element }` | a `(ptr, len)` buffer of elements | `Vec`, `&[T]`, `Box<[T]>` | | `Bytes { at, notation }` | an inline byte array in a standard text form | `Ipv4Addr`/`Ipv6Addr`, `Uuid`, `ArtifactHash` | | `Alias { at, follow_pointers }` | one inner member as the whole value | `NonNull`, `UnsafeCell`, loom shims, scalar newtypes | | `SlotCount { bitmap, slots }` | a readiness bitmap as `[<n> slots]` | mpsc `Block` | | `Pointer { at, via, then }` | one pointer hop, re-rooting `then` | mpsc `Receiver` → `Chan` | | `DynPointer { … }` | a trait-object wide pointer + vtable | `Arc<dyn>`, `Box<dyn>` | | `Map { length, key, value, entries }` | `{ k: v, … }` over a storage-specific walk | `BTreeMap` | | `Variant { discriminant, arms, default }` | a sum type chosen by a *computed* discriminant | `watch::Receiver`'s unseen value | | `CustomList { vars, condition, body, element }` | a sequence from a tiny imperative program | mpsc block-chain window | A fixed-size byte array with a canonical text form is a `Notation` on `Bytes`, not a kind of its own: a `Uuid` and an `Ipv6Addr` are both `[u8; 16]`, so what separates them is the spelling. Adding one is a variant plus a detector, with no format bump beyond the notation itself. The last two are the escape hatches, and they are why new node kinds should be rare: `Variant` + `ValueExpr` absorbed `watch::Receiver` (`5f5a7ab`) and `CustomList` absorbed the whole mpsc block chain (`25feec4`), both of which had looked irreducible. Addressing is a `Selector` — a list of `Step::Member(MemberRef)` / `Step::Deref` steps. A cross-pointer reach is not special: it is a selector with a `Deref` in it, so an `Arc`-backed word is reachable from an outer root. A `MemberRef` is `Named(s)` or `Index(i)`; **prefer the name**, and you get it for free by going through `Emitter::walk` or `Emitter::address` rather than writing an index. Extraction rewrites member lists *after* display programs are attached (`drop_members_of_other_states` retains a coroutine state's own members), which shifts positions but not names. A `Struct` shows *only* the `Field`s it lists (a formatter's job is to hide internal detail): `Field::Member { at, node }` renders a real member under its DWARF name — structurally when `node` is `None`, and with a computed value otherwise — and `Field::Synth { label, node }` synthesizes one under an explicit label. What a node requires of the types its selectors land on is stated once, in `hansei-bundle/src/shape.rs`: `DisplayNode::addressed()` names each datum and the `Shape` its type must have. Both universes check it — `shape_matches` against the type table on save and load, `addressing_holds` against DWARF as the program is built — so a detector that navigates to the wrong member declines and renders structurally rather than producing a bundle that fails validation. The table is the *floor*; a detector may screen more tightly (`&str` insists on a byte pointer where `Str` takes any pointer) but never less. Word semantics are data too: `ScalarDecode::Bits(Vec<BitField>)` describes the bit layout (`shift`, `width`, `FieldRender::Enum`/`Uint`), so old reify renders new bundles correctly. Two contracts hold everywhere and you get them free: an enum value missing from its table renders `<unknown: N>`, and any word bit no field covers renders `<unknown bits: 0xNN>` — the rule that actually catches upstream drift. Build tables with `Emitter::enum_field` / the existing `mutex_byte_decode`, `notify_state_decode`, `semaphore_permits_decode`, `watch_state_decode`, `bool_decode`; do not re-derive a bit layout. A record field that is just a decoded word is one call: `emitter.named_scalar(label, at, decode)`. ## 2. Write the detector (`exegesis/src/detect/`) File it by who owns the layout: dispatch tables and shared machinery in `detect/mod.rs`, std/core/alloc (plus the structural chain) in `detect/std.rs`, third-party crates in `detect/crates.rs`, version-invariant tokio in `detect/tokio.rs`, and per-family `detect/tokio_v<floor>.rs` for the tokio types a release moved (doctrine and the family-add procedure: CLAUDE.md *tokio version families* and the `onboard-tokio-release` skill). Detectors are **name-keyed and fail safe**: the dispatch table screens on the name, the body describes the layout and returns `None` on any mismatch — the type then renders structurally, no crash. Whatever a detector returns is checked against the shape table before it is accepted (`debug_format_of`). Every detector has one signature, `fn(&mut Emitter<'_>, TypeId) -> Option<DisplayNode>`; one that only navigates DWARF starts with `let reader = emitter.reader;`, and one that needs `emitter.intern(…)` for a label or decode table or `emitter.reserve(ty)` for an `element`/`key`/`node_ty` uses the emitter directly. So **write the function, add a row, done** — pick the row by how the type is *named*: - **`BY_NAME`** — keyed by the fq name with generic arguments stripped. Nearly every formatter belongs here. Do not re-check the name in the body: the key is that check. A row is `All(detector)` unless a tokio release moved a layout the detector navigates, in which case it is `Versioned(&[(Family, detector), …])`. - **`BY_PREFIX`** — keyed by a prefix of the *full* name, for a family no single base name spans (`&[T]` / `Box<[T]>`, the per-width `NonZero*Inner`, the per-width loom `atomic_*` modules). A prefix is a looser screen, so the body keeps the residual check the key cannot express. - **`STRUCTURAL`** — only for a type no name selects: a `{ pointer, vtable }` pair, a pointer to a subroutine type, a bare scalar newtype. Tried in order, most specific first, with the scalar newtype deliberately last. Keep this list short; a name-keyed detector here is invisible to `--explain-format`. A helper another detector calls on an arbitrary member type (`is_generic_atomic`, `is_raw_mutex`, `is_batch_semaphore`, `unsafe_cell_layout`, `non_null_layout`) *does* check the name, since no table screened that member for it. Where two spellings of one concept share a display program, give them one node-building detector over a small shape struct holding the navigation result (`VecShape`, `ChanShape`) rather than two detectors. **Reach for a datum; do not walk to it.** A detector builds the `DisplayNode` it wants and fills each `Selector` by handing `Emitter::walk` a *reach* — a description of how to get there, which the walk resolves against DWARF, lowers to a name-addressed selector, and declines on with an `--explain-format` line naming the step that failed: ```rust Some(DisplayNode::Str { pointer: emitter.walk(id, &reach![Named("data_ptr")])?.0, length: emitter.walk(id, &reach![Named("length")])?.0, capacity: None, }) ``` A `reach![…]` is a list of `ReachStep`s: - `Named("a")` — the uniquely-named member. Two members of that name decline, as does none. - `Deref` — follow the pointer reached so far. - `PeelTo(shape)` — descend the zero-offset wrapper chain to the one value of that `Shape`. This is how every tokio atomic's word is reached: loom, `UnsafeCell` and atomic shims vary in spelling and depth, and `WORD` (a `usize`) or `Shape::Uint(1)` or `Shape::Pointer` says what is wanted without naming any of them. Unlike naming the atomic type, it has no blind spot between the generic `Atomic<T>` and a concrete `AtomicU8`. - `PeelToParam` — the same descent, to the `T` a `Wrapper<T>` declares. For an atomic, whose stored type is its own parameter rather than a fixed shape. - `FindParam` — the same descent through *any* member, for a `T` a type keeps past its own bookkeeping rather than behind wrappers: the value inside a lock, whose guard word sits at offset zero ahead of it. - `Resolved(sel)` — splice in a path something else resolved, which is how a shape helper's selectors are anchored under the member holding them. The spliced steps are re-addressed, so a helper may return positional ones. The rest of the emitter's vocabulary: - `member_named(root, name)` — a `MemberRef` for a member addressed by name, checking it is there. What a `Field` wants. - `visible_fields(root, overrides)` — every member structural display would show, in declaration order, with the named ones computed instead. Splice synthesized fields around the result; that is how the channel prepends its `queued`. - `landed(root, reach)` — the type a reach lands on, for a screen tighter than the node's own requirement. - `pointee(ty)` / `behind(ty)` — the type a pointer targets, to root something at the far side or to name it. - `address(members, index)` — how a member *found* by shape becomes an address: by name where a name selects it, by position where none can. Every walk that finds rather than declares ends here, which is what keeps a discovered path as durable as a declared one. Never write `Selector::member(index)` in a detector. A test asserts no emitted program addresses a member by position (`assert_addresses_by_name` in `exegesis/tests/golden.rs`), since only an unnamed member or one of several sharing a name may be reached that way. For a **transparent wrapper** — a newtype whose one member *is* the value — the member is identified by a predicate, not a name, so it stays imperative: `zero_offset_member(reader, &st.members, Some("pointer"), accept)` finds the unique member at offset zero of that name (pass `None` for whatever name) whose type `accept`s, `sole_param_target` gives the `T` a `Wrapper<T>` declares so `accept` can require the member to be it, and `transparent(emitter, &st.members, member)` builds the node — addressing it by name through `Emitter::address`. The ten such wrappers (`UnsafeCell`, `NonNull`, `Unique`, `NonZero` and its niche inner, `UsizeNoHighBit`, the loom shims, the bare scalar newtype) are each a screen plus one call; an eleventh should be too. Anything else that finds a member by shape rather than by name — `find_unique` with a `Want`/`Through`, as the two `Vec` spellings and the B-tree use — must turn what it found into an address with `Emitter::address` or `Emitter::readdress` rather than recording a position. The remaining reader-level helpers: `struct_of(reader, id)` (the struct a type is, a detector's usual first line), `aggregate_members`, `unique_member`, `is_unsigned_integer`, `raw_type_size`. Shared shape builders are worth extending rather than copying: `waiter_queue_field` serves both `Notify` and the bounded `Semaphore`, `mpsc_queued_node` builds the block-chain program, and `mutex_byte_path` / `permits_path` are the paths two detectors each reach for. ## 3. Add coverage **Debug formats are not in the `.golden` files at all** — the summary covers tasks, awaits, dyn-futures and infra/statics only. A new formatter changes *no* golden, and re-blessing tells you nothing about it. The coverage is the inline assertion: 1. Add or extend a fixture in `test-programs/src/bin/` that instantiates the type and reaches a steady parked state (the existing programs print a marker line — e.g. `READY` — once parked; see `testkit::cores::Parked`). Mind CLAUDE.md *Before every commit*: fixture line numbers are pinned by the goldens, so reflow means re-blessing and reviewing the shift. 2. Add an `assert_format(program, bundle, "<fq type name>", "<expected>")` to the matching test in `exegesis/tests/golden.rs`. Write the expected string as anything and run once: the panic prints the actual render, so blessing it is a copy-paste. `describe_debug_format` resolves every selector to its field-name chain *and* byte offset, which is what catches a detector that fires but lands on the wrong member. 3. For a formatter whose exact tree is not worth pinning, a presence-only `assert!` will do (grep the existing `debug_formats.values().any(|node| matches!(node, DisplayNode::X { .. }))` checks) — that catches a detector that stops matching at all, but not one that starts matching the wrong thing. 4. Only re-bless if the fixture change also moved a task shape or await line. If the render side needed anything new (a decode, a degradation path), add synthetic rendering tests beside the code (`reify/src/render/*.rs`) over the hand-built bundles in `reify/src/testhelper.rs` — see CLAUDE.md *Testing* for the `fixture_ids!` / `FakeMem` conventions. ## 4. Discovering a layout, and debugging a detector **Verifying against real DWARF.** Do this *before* writing the detector to learn the exact field names/offsets, and *after* to confirm it attached — and for any type no fixture exercises yet. Extraction is portable, so any debug build of a real tokio program will do, on any host. Extract a tokio-info file that includes the type, then dump it: ``` hansei tokio-info extract <debug-binary> -o /tmp/x.tinfo \ --include-type "tokio::sync::mpsc::bounded::Semaphore" --allow-missing-infra \ --allow-unsupported hansei tokio-info dump /tmp/x.tinfo | grep -n "struct <Name> " # read its members ``` `--include-type <fqn>` forces an otherwise-unreached type to be emitted; `--allow-missing-infra` lets extraction proceed on a non-target binary, and `--allow-unsupported` writes the file even when the binary carries a version outside the supported ranges (without it the `--explain-*` reports still print, but nothing is written for `dump` to read). The dump prints each aggregate's `+offset name : [type-id]` members and, for types your detector claimed, a `debug: <DisplayNode>` line showing the emitted node tree. A production-scale binary carries the full tokio/std graph and extracts in seconds; the ones on hand for a particular checkout are listed in the untracked `CLAUDE.local.md`. Extraction runs `validate()` on save and `dump` runs it on load, so a bad path surfaces there rather than silently. **When a detector does not fire**, ask why instead of guessing — `--explain-format <substring>` reports, for every emitted type whose name contains the substring, which name-keyed detector was selected (or that none was), where a named walk stopped and what members the type actually has, whether a shape walk found nothing or found several candidates, and what program was emitted: ``` hansei tokio-info extract <debug-binary> -o /tmp/x.tinfo --allow-missing-infra \ --explain-format "tokio::sync::notify::Notify" tokio::sync::notify::Notify [type 2263] name-keyed detector for `tokio::sync::notify::Notify` selected => Struct ``` A name-spelling mismatch (`&camino::Utf8Path` is not `camino::Utf8Path`) shows up as "no name-keyed detector", a renamed field as "no unique member `x` in T, which has …", and a wrong `Through` mode as "found N+ candidates". Since only the three `STRUCTURAL` detectors are not
Auf GitHub ansehen
Diese SKILL.md ist sehr gross, daher zeigt SkillsMP hier nur den ersten Abschnitt. Auf GitHub ansehen