Skip to content

refactor(wash): consolidate duplicated cross-module contracts into shared seams - #64

Merged
willwashburn merged 24 commits into
mainfrom
refactor/subprocess-tools
Jun 21, 2026
Merged

refactor(wash): consolidate duplicated cross-module contracts into shared seams#64
willwashburn merged 24 commits into
mainfrom
refactor/subprocess-tools

Conversation

@willwashburn

@willwashburnwillwashburn commented Jun 21, 2026

Copy link
Copy Markdown
Member

Summary

A multi-round refactor of the wash crate focused on collapsing duplicated cross-module contracts into single-source-of-truth seams, plus a few correctness and robustness fixes surfaced along the way. Every commit was live-tested against the real MCP server/hooks and independently reviewed; the suite stays green throughout (182 tests).

The throughline: no tool hand-rolls a Command, reaches into a sibling module, or re-implements a cross-module wire shape / parse / lookup.

Shared seams introduced

SeamReplaces
process (spawn/decode/baseline/timeout)per-tool Command + decode + baseline plumbing in Build/TestRun/GitState/GhPR
tools::ok_with_metahand-rolled _meta envelopes (now incl. Read)
tools::{cwd_arg, usize_arg}copy-pasted cwd/usize arg parsing
tools::logscross-module reach-ins into build:: for log dir/write/latest
crate::transcript3 copies of the JSONL read/row-walk loop
hooks::{payload_field, bare_relaywash_name}snake/camel field lookup + relaywash name-strip, each duplicated
profile::ledger_hometwo divergent inlined .relayburn defaults
meta::Meta_meta accessorsscattered _meta field reads
mcp::{format_tool_result, error_tool_result, call_tool}duplicated success/error wire shapes; dispatch arm now unit-testable
fuzzy normalization, ast::{leading_ws, strip_python_comment}two normalization impls; verbatim duplicated helpers

Robustness

  • Per-tool subprocess timeouts (15m build/test, 60s git, 120s gh) enforced in the single process::run seam, so the single-threaded MCP server can't hang. Build/TestRun return partial output on timeout; git/gh surface a clear error. The std-only design uses a bounded reader-grace so an orphaned grandchild holding the pipe can never stall the server (review caught this as a live bug — it had been masking the suite at 30s; now back to ~2.3s).
  • Corrupt MCP frame (bad UTF-8 body) now skips instead of killing the long-lived server.

Correctness fixes

  • Cargo.lock committed (was ignored as build output) → reproducible builds; pinned relayburn-sdk to a version with the sync ingest the Stop hook calls; dropped the dead tokio dep. (This unblocked the build.)
  • Search renders non-UTF-8 lines lossily (U+FFFD, ripgrep behavior) instead of dropping the line body.
  • getFailureLog reads the most recent test log instead of whichever log sorted last by filename.
  • GitState/GhPR now report baselineBytes; Build no longer counts a synthetic newline in its baseline.
  • Fixed a UTF-8 slice panic.

Testing

  • cargo build --release + cargo test --release green — 182 tests (matches CI; CI runs build+test).
  • Each step live-tested by driving the real stdio MCP server / hook handlers and confirming byte-level output.
  • Pure-dedup commits adversarially reviewed for behavior-preservation before landing.

Deliberately not done (flagged for operator)

  • Tool-name canonicalization divergence (categorize also strips mcp__github__): unifying changes categorization output — a product decision, not a mechanical dedup.
  • Repo-wide rustfmt drift (~153 files, pre-existing, not CI-enforced): wants a pinned toolchain + one-shot fmt in a separate PR.

🤖 Generated with Claude Code

Review in cubic


Formatting: pinned toolchain + CI gate

The crate had accumulated rustfmt drift (no code change, just layout) because CI ran dtolnay/rust-toolchain@stable (a floating channel) and there was no toolchain pin — so every machine's rustfmt could format differently. This makes that impossible going forward:

  • rust-toolchain.toml pins the toolchain to 1.95.0 with rustfmt+clippy. This is the single source of truth — every developer's rustup reads it, so local and CI format identically.
  • One-shot cargo fmt --all normalized the repo (30 files, pure layout — style: commit, skippable in review).
  • New CI fmt job runs cargo fmt --all --check and fails the build on any unformatted file; both CI jobs are pinned to 1.95.0 to match the toolchain file. rust-toolchain.toml added to the path filter so a future bump triggers CI.

To bump the toolchain later: change the version in rust-toolchain.tomlandci.yml together, in a commit that also runs cargo fmt.

willwashburnand others added 21 commits June 20, 2026 12:22
The lockfile was gitignored as if it were build output, so the repo
pinned nothing for `relayburn-sdk = "2.3"`. A stale local lock at 2.3.0
(async `ingest`) no longer matched the synchronous `ingest(...)?` call in
the Stop hook, breaking `cargo build`/`cargo test`. Commit the lockfile
(reproducible builds for a binary-distribution crate), pinned at
relayburn-sdk 2.10.2 where `ingest` is synchronous, and drop wash's now
dead-weight direct `tokio` runtime dependency.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Build, TestRun, GitState and GhPR each hand-rolled the same
spawn -> capture -> lossy-decode -> wrap sequence with small accidental
differences. The worst was measurable: Build priced its savings baseline
as stdout+1+stderr while TestRun used stdout+stderr, so baselineBytes --
the product's headline metric -- meant two different things.
Add `process::{Captured, subprocess_baseline, run}` as the single
definition of how wash spawns a child (stdin closed), decodes its output,
and prices vanilla cost, plus `tools::ok_with_meta` as the single place a
result `_meta` is built. Route all four tools through them.
- Drop Build's synthetic `+1` (the "\n" stitched between streams is a
relaywash formatting detail, not vanilla output) -- baseline shifts down
by one byte per call; recorded in the changelog.
- gh now spawns with stdin closed (was inherited); none of the gh read ops
want stdin, and this stops gh from ever consuming MCP protocol bytes.
- Behavior otherwise preserved: identical result JSON, identical Meta
(replaces/collapsedCalls), git/gh non-zero-exit error text unchanged.
169 tests pass (3 new in process.rs).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GitState and GhPR truncate aggressively -- that's their whole point --
but reported no baselineBytes, so their savings were invisible in the
ledger. Thread an explicit `&mut u64` accumulator through `git()`/`gh()`
(each adds the raw bytes from `process::run`) and the op helpers; a single
op fans out into several subprocess calls, so the baseline is their sum.
`run()` now returns `(Value, u64)` and the handler attaches `Some(baseline)`.
Honest accounting: small git ops (e.g. `status` on a near-clean tree) can
report baseline < responseBytes -- the structured form isn't always
smaller. The ledger now sees that truthfully instead of recording nothing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lection
Log-file persistence was split: build.rs owned log_dir()/write_log() and
test_run.rs reached across the module boundary (crate::tools::build::...)
both to write its log and, in fetch_failure_slice, to read the latest one.
Extract `tools::logs` (dir / write / latest) so neither tool reaches into
the other. While moving the reader, fix a latent bug: it sorted ALL `.log`
files by filename and took the last, but names are `<prefix>-<millis>.log`
so `testrun-` always outsorts `build-` regardless of time -- getFailureLog
could read a build (or other) log instead of the latest test log. `latest`
now takes a prefix and scopes to one log family; getFailureLog asks for
"testrun". Also clamp the failure-slice byte window to char boundaries so
multibyte log content near the match can't panic the slice.
170 tests pass (logs has its own prefix-isolation test); live MCP confirms
TestRun writes a testrun log and getFailureLog finds the failure marker.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The `cwd` arg resolution (parse string, else current_dir, else ".") was
copy-pasted identically in build, gh_pr, test_run and search, with a
String variant in git_state. Hoist it to `tools::cwd_arg` and call it from
all five. Pure dedup -- resolution is byte-identical, including the
current_dir-fails fallback. 170 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Six tools repeated `get(key).as_u64().map(|n| n as usize).unwrap_or(D)`
for their fixed-default integer args. Hoist to `tools::usize_arg`. Search's
max_results keeps its own chain -- its default comes from the learned
profile (`.or(prof.max_results)`), a different shape. Pure dedup. 170 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
session_start (`ledger_home_default`) and edit_batching_nudge
(`nudge_dir_default`) each hand-rolled the RELAYBURN_HOME / HOME/.relayburn
fallback, inlining the ".relayburn" literal, while profile::ledger_home is
the canonical version (DEFAULT_HOME const) already used by compaction,
post_tool_observe and accounting. Delete both copies and call the shared
one, so the ledger root has a single definition. Behavior-identical. 170
tests pass; both hooks live-tested to write under the canonical home.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "read JSONL, skip blank + malformed lines" loop was implemented three
times: hooks/compaction.rs::read_jsonl, accounting::parse_turns (inline),
and an accounting test helper -- three subtly different takes on the same
external-format contract (one logged, one was silent, one panicked on a bad
line). When Claude Code's transcript shape shifts, that's three places to fix.
Add `crate::transcript` with `parse_lines(text, on_bad_line)` (caller picks
the malformed-line policy) and `read_file(path)` (logs + skips). Route all
three through it: compaction keeps its log-and-continue behavior, accounting
keeps its silent skip via a no-op closure. Behavior-preserving aside from one
reworded stderr diagnostic.
172 tests pass (2 new in transcript.rs); live pre/post-compact run confirms
the shared reader skips a malformed line and still writes the compaction event.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every hook (and the accounting ingest) hand-rolled
`payload.get("x").or_else(|| payload.get("xCamel"))` to tolerate Claude
Code's two spellings of each field -- 11 copies, each a place to forget the
camelCase fallback when a field is added. Add `hooks::payload_field(payload,
snake, camel)` and route all 11 through it.
Deliberately scoped to the fallback lookup only: each caller keeps its own
tail (defaults "default"/"unknown"/"", the post_tool_observe prefix-strip,
the accounting empty-string filter), so behavior is exactly preserved.
172 tests pass; live MCP confirms builtin-block / tool-redirect /
post-tool-observe all resolve camelCase payload fields through the helper.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The `{"content":[{"type":"text","text":...}],"isError":true}` envelope for a
tool *execution* failure was hand-rolled in two places: the MCP server's
`tools/call` Err branch and the bench harness's `run_task`, which replays tool
calls outside the server. The bench copy could silently drift from what the
live server emits.
Extract `mcp::error_tool_result(message)` as a sibling to the existing
`format_tool_result` (already the shared success-path wire shape) and call it
from both sites. Behavior is byte-identical.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`build_event` built the zero-initialized `ToolSurvival` accumulator via the
same all-zeros struct literal in four places. Adding a fifth field later would
mean updating four sites, any of which is easy to miss.
Derive `Default` (all fields are `u64`, so the default is all-zeros — identical
to the literal) and replace the four `.entry(k).or_insert(ToolSurvival { .. })`
calls with `.or_default()`. Behavior is unchanged; the per-tool survival tests
and a live pre/post-compact run confirm identical output.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The MCP server runs `tools/call` synchronously on its read loop, so a child
that never exits — a build waiting on stdin, a `gh` call stuck on an auth
prompt, a hung test — stalls the entire session with no way to recover.
Add a deadline to the shared `process::run` helper (all four process-backed
tools already route through it, so each inherits the timeout): Build/TestRun
900s, git 60s, gh 120s. Build/TestRun return the partial output captured before
the kill; git/gh surface a clear "timed out" error.
The capture path is rewritten to be hang-proof: `child.kill()` only signals the
direct child, so a grandchild that inherited the stdout/stderr pipe (cargo→rustc,
sh→backgrounded process, gh helpers) would keep it open and a plain
`read_to_end` + `join` would block forever — reintroducing the very stall this
fixes. Instead the readers drain into shared buffers and signal completion on a
channel; the caller waits only up to a bounded grace period, then returns the
output captured so far and lets any orphaned reader exit when the pipe finally
closes. Still std-only, no new dependencies.
Re-scope of plan 002: the plan predates the spawn-helper dedup (plan 005) and
assumed it would create `process.rs`; since that helper already exists, the
deadline lives there rather than in a parallel runner.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`normalize_for_match` (needle path) and `normalize_with_map` (haystack path)
encoded the same whitespace/Unicode normalization contract twice, in two
different styles. They MUST agree byte-for-byte or `fuzzy_find_all`'s
`normalized.find(&norm_needle)` silently fails to match — and they had already
drifted once (the EOF trailing-space trim was hand-patched into the haystack
side to re-sync them).
Make `normalize_for_match` a thin wrapper over `normalize_with_map(s).0` so
there is one source of truth. The needle path discards the back-map but gets
identical normalization for free. Verified byte-identical to the old
implementation across tabs, newline boundaries, EOF trimming, and the
U+00A0/U+200B remap cases; the existing contract tests and a live Edit with a
tab/smart-quote/trailing-space-divergent needle confirm the splice still lands.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The `_meta` wire-field names (`responseBytes`, `baselineBytes`) are defined once
via serde renames on `Meta`, but the read side re-spelled them as string
literals in three places: the bench harness (two extractors) and the
post-tool-observe hook. A `SCHEMA_VERSION` bump that renamed a field would
update the writer in one spot while these readers silently returned `None` —
degrading savings telemetry with no compile error.
Add `Meta::response_bytes_of` / `Meta::baseline_bytes_of` next to the serde
renames so the wire names have one home, and route the three readers through
them. Each caller still passes its own container, since the nesting genuinely
differs (bench reads `structuredContent._meta`, the hook reads root `_meta`) —
the shared accessor makes that distinction explicit rather than accidental.
Behavior-preserving (verified: identical navigation and `as_u64` semantics).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…olicy
The `tools/call` arm of `dispatch` inlined the spec-sensitive error policy —
protocol failures (missing name, unknown tool) become a JSON-RPC error, while a
tool execution failure becomes a normal result with `isError: true` — but that
contract was only reachable through the full method-string match, so the only
tests covering it had to spawn the binary and drive stdio.
Pull the arm into `McpServer::call_tool(&self, params) -> Result<Value>` and have
dispatch delegate. Behavior is identical (verified live across all three paths:
success, execution error, protocol error). Adds four in-process unit tests for
the error policy that previously required end-to-end stdio tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The run loop treated a frame whose body was not valid UTF-8 as fatal —
`String::from_utf8(..).context(..)?` propagated out of `run` and terminated the
process — while the JSON-parse failure one line below merely `continue`d. For a
long-lived stdio server, a single corrupt frame must not take down every
subsequent valid request. Skip the frame instead, matching both the adjacent
JSON-parse recovery and the malformed-header recovery in `take_framed_message`.
Also delete the `drop_header` closure in `take_framed_message`: it captured
nothing and always returned `None`, so the two call sites now `return None`
directly.
Adds an end-to-end regression test that feeds a non-UTF-8 frame followed by a
valid `initialize` and asserts the server still answers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`HitSink::record` decoded match lines with `from_utf8(bytes).unwrap_or("")`, so a
line carrying a stray non-UTF-8 byte (Latin-1 text, or a binary smudge in a file
that cleared the NUL check) collapsed to an empty snippet body — while the match
was still counted. The agent saw a hit at line N with a blank line and no reason
why, silently losing the content the search exists to surface.
Decode with `from_utf8_lossy` so the line renders with U+FFFD replacement chars,
matching ripgrep. Adds a test feeding a Latin-1 byte that asserts the body
survives with the replacement char rather than vanishing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`leading_ws` was defined byte-for-byte identically in both `ast/mod.rs` and
`ast/line_regex.rs` — a verbatim duplicate inside the same module tree, pure
drift risk for no benefit. `line_regex` is a child module, so it can use the
parent's definition via `super::leading_ws`; the local copy is removed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The rule for recognizing a Python `:`-body past a trailing `# comment`
(`s.split('#').next().unwrap_or(s).trim_end()`) was duplicated byte-for-byte in
`find_body_end` and the line-regex extractor. Extract it as
`strip_python_comment` so the detection rule has one definition; both call sites
now route through it. Behavior-preserving (the helper is identical to both
originals; the Python inline-comment tests and a live signatures read confirm).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
read.rs hand-rolled the _meta envelope (a local read_result helper plus
an inline ToolResult::new(...).with_meta(Meta::new([Read], 1)...) block)
instead of using tools::ok_with_meta — the single funnel mod.rs was built
to own so a future Meta change can't silently miss a tool. read_result now
delegates to ok_with_meta(.., None); the baseline branch calls it with
Some(baseline). Byte-identical output (verified live + adversarial review);
the now-unused Meta import is dropped.
Search/Edit deliberately keep their own Meta::new — they carry multi-label
replaces / computed collapsedCalls that ok_with_meta's single-label,
collapsed=1 shape doesn't fit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 2-prefix tool-name canonicalization (strip mcp__relaywash__, else
relaywash__, else keep raw) was duplicated byte-for-byte in accounting's
extract_tools and post_tool_observe. Extracted hooks::bare_relaywash_name;
both call it. accounting already depends on hooks (payload_field,
sanitize_session_id) so the seam has one natural home.
Deliberately NOT merged with categorize's canonical(), which additionally
strips mcp__github__ — folding that would change categorization output (a
github tool would newly canonicalize), a product decision, not a mechanical
rename. The helper's doc comment records why. Behavior-identical (verified
live across both prefixes + adversarial review).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@gemini-code-assist

Copy link
Copy Markdown

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@coderabbitai

coderabbitaiBot commented Jun 21, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@willwashburn, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 29 minutes and 9 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2169bea7-01e3-4288-8d84-979c3b00bcce

📥 Commits

Reviewing files that changed from the base of the PR and between 6b6eeeb and 1b39060.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (41)
  • .github/workflows/ci.yml
  • .gitignore
  • CHANGELOG.md
  • crates/wash/Cargo.toml
  • crates/wash/src/accounting/mod.rs
  • crates/wash/src/accounting/pricing.rs
  • crates/wash/src/ast/line_regex.rs
  • crates/wash/src/ast/mod.rs
  • crates/wash/src/ast/tree_sitter_sig.rs
  • crates/wash/src/bench/compare.rs
  • crates/wash/src/bench/mod.rs
  • crates/wash/src/bench/run.rs
  • crates/wash/src/fuzzy.rs
  • crates/wash/src/hooks/builtin_block.rs
  • crates/wash/src/hooks/compaction.rs
  • crates/wash/src/hooks/edit_batching_nudge.rs
  • crates/wash/src/hooks/mod.rs
  • crates/wash/src/hooks/post_tool_observe.rs
  • crates/wash/src/hooks/session_start.rs
  • crates/wash/src/hooks/tool_redirect.rs
  • crates/wash/src/lib.rs
  • crates/wash/src/main.rs
  • crates/wash/src/mcp/mod.rs
  • crates/wash/src/mcp/server.rs
  • crates/wash/src/meta.rs
  • crates/wash/src/process.rs
  • crates/wash/src/profile.rs
  • crates/wash/src/search.rs
  • crates/wash/src/tools/build.rs
  • crates/wash/src/tools/edit.rs
  • crates/wash/src/tools/gh_pr.rs
  • crates/wash/src/tools/git_state.rs
  • crates/wash/src/tools/logs.rs
  • crates/wash/src/tools/mod.rs
  • crates/wash/src/tools/read.rs
  • crates/wash/src/tools/search.rs
  • crates/wash/src/tools/test_run.rs
  • crates/wash/src/transcript.rs
  • crates/wash/src/walk.rs
  • crates/wash/tests/mcp_stdio.rs
  • rust-toolchain.toml
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/subprocess-tools

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:baac4f8354

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

match child.try_wait() {
Ok(Some(status)) => break status.code(),
Ok(None) if Instant::now() >= deadline => {
let _ = child.kill();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Kill timed-out subprocess trees

When a timed-out tool command has spawned workers (for example the Build/TestRun paths that run npm, npx, cargo, or go), child.kill() only terminates the direct wrapper process. Any grandchildren keep running with inherited stdout/stderr pipes while run returns after the reader grace period, leaving orphaned builds/tests consuming CPU and potentially racing later tool calls. Start the child in its own process group/job and kill the group on timeout so the timeout actually stops the whole command.

Useful? React with 👍 / 👎.

willwashburnand others added 3 commits June 21, 2026 00:08
CI used dtolnay/rust-toolchain@stable (a floating channel) and there was no
rust-toolchain.toml, so every machine's rustfmt could format differently —
which is how ~30 files drifted with no code change. Pin channel 1.95.0 with
rustfmt+clippy so local and CI agree byte-for-byte. Bump deliberately, and
reformat in the same commit, so formatting changes stay intentional.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
One-shot reformat under the newly pinned 1.95.0 toolchain so the repo is
fmt-clean and the CI gate (next commit) starts from green. Pure layout —
rustfmt never changes semantics; build + 182 tests unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a fast fmt job running `cargo fmt --all --check`, and pin both jobs'
toolchain to 1.95.0 (matching rust-toolchain.toml) so CI can't format
differently from local. Add rust-toolchain.toml to the path filter so a
future toolchain bump triggers CI. This is what stops the drift recurring:
unformatted code now fails the build.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@willwashburn
willwashburn merged commit d659c66 into mainJun 21, 2026
5 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@willwashburn