Skip to content

Meta: Spinel Date runtime, Campfire gate, Writebook emit ledger - #11

Closed
thomasklemm wants to merge 59 commits into
mainfrom
cursor/spinel-meta-41f1
Closed

thomasklemm wants to merge 59 commits into
mainfrom
cursor/spinel-meta-41f1

Conversation

@thomasklemm

Copy link
Copy Markdown
Owner

Umbrella Spinel PR. Supersedes and continues #423 (cannot push to eddygarcas:spinel-date-runtime from this agent; please close rubys#423 in favor of this branch, or cherry-pick).

Refs #303. Coordinates with #451 (IR/YJIT meta) — this PR does not fight Relation/alloc work unless needed for Spinel compile.

Landed

Date / Spinel (from rubys#423 + follow-ups)

  • Bounded program-defined Date for Spinel (runtime/spinel/date.rb), ISO SQL seams, JSON serialization, emit-and-run suite date_columns_spinel.
  • Campfire fix: only emit/load Date when app_uses_date (schema date columns or date values in emitted roots). Unconditional Date#strftime breaks poly Time|Date dispatch (matz/spinel#7334); Campfire has no t.date and must not load it.
  • format_db_date("") → nil; SqliteAdapter.escape_value(Date) → YYYY-MM-DD; inspect / xmlschema on Date.
  • CI: framework-tests-spinel downloads real-blog-fixture for date_columns_spinel; ci-plan owns serialization file.

Campfire

Writebook

  • Survey emit to Spinel succeeds (898 files) under --survey --allow-unsupported.
  • Honest ledger of remaining blockers (has_markdown, delegated_type, keyword rest, ingest gaps) — see agent notes. Native AOT compile status tracked in follow-up commits on this PR.

Still open / ledger

Verification

  • cargo test --locked --test date_columns — 11 passed (includes omit-Date-when-unused).
  • Writebook/Campfire emit surveys recorded under agent artifacts.
  • Please run advisory Spinel CI (ci:full) including campfire-compare-spinel and date_columns_spinel.

Commits (stacked)

  1. Add bounded Date runtime for Spinel (from Add bounded Date runtime for Spinel rubys/roundhouse#423, rebased)
  2. Gate Spinel Date load; harden date seams for Since #294 a single date column stops the spinel emit, and --allow-unsupported does not write the tree as the error says rubys/roundhouse#303
Open in Web Open in Cursor 

dchuk and others added 30 commits October 4, 2026 16:48
Filter intermediate polymorphic associations by their owner type in both lazy and batch SQL. Add generic emitted CRuby and native regressions, including scope and ordering controls.

Co-Authored-By: Codex <noreply@openai.com>
On the ruby and JRuby targets, only an `ActiveJob::Base` subclass
loads `runtime/active_job`. This causes two problems.

An app without `app/jobs/application_job.rb` never loads the file. The
test helper calls `ActiveJob.enqueue_without_running` at load time,
so the first test of that app fails with a `NoMethodError`.

An app with jobs loads the file with its models, after
`thread_state`. `thread_state` replaces the job queue methods with
locked, per-thread versions, and the later load puts three of the
unlocked versions back: `enqueue`, `drain` and `performed`.

The Spinel `boot.rb` already loads the file before `thread_state`.
This commit adds the same line to the ruby overlay `boot.rb`.
AOT pins a local from its first write. Rails helpers that take a record
or its fixture label (`user = users(user) unless user.is_a? User`), an
id or a record (`user = User.find(user)`), or that coerce in place
(`id = id.to_i`) reuse the binder, so Spinel refuses the later write
(`sp_sym` then `User`). campfire's spliced `sign_in` took 27 compiled
test files with it.

Rewrite the later writes onto `__rh_<name>` and follow subsequent reads.
A one-statement `if`/`unless` around the assign becomes a join so the
fresh local is written once. Non-parameters, `||=`, and multi-statement
branches stay as they are.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a108c5-8f79-731a-87ba-dce013d4be5f
A record in a non-id query slot (`before: @message`) is projected to
`.id.to_s` at the call site, but the helper's RBS still said Integer.
Spinel refused the String against that seed, so campfire's
messages_controller_test never linked. Type those keys as String; id /
*_id stay Integer because those sites pass `record.id`.

`f(**h)` into `def f(**rest)` survived ingest as a positional Hash.
Ruby 3 will not auto-convert it (`given 1, expected 0`) — campfire's
embeds_from(**details) against attachments_for(**details). Restore the
KeywordSplat after the legacy projection that strips it for
explicit-keyword expansion.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a108c5-8f79-731a-87ba-dce013d4be5f
Campfire's config.ru is `use Rack::Deflater`. The CRuby overlay dropped
that line, so every signed-in page went out uncompressed (~420 KB room
page vs ~20 KB behind Thruster) and campfire-http-shape recorded 65
Content-Encoding misses.

Wrap only Main.run_rack: the /cable hijack tuple is [-1, {}, []] and
Deflater has no skip for it. Spinel has no Rack; tep gzips inline
bodies when Accept-Encoding includes gzip, using packages/zlib (the
CRC-32 port has no codec). Measured on this box: CRuby 423 KB → 20 KB;
Spinel 423 KB → 24 KB. Gzip itself costs Spinel room-page throughput
(1984 → 694 req/s); identity remains available without Accept-Encoding.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a108ac-5b18-764e-8d41-d4cddd8b07b4
scripts/bench-campfire --dhh-http --gzip times the same five workloads
the Basecamp ports publish (room, messages?before=, sidebar, search,
POST into a second room) with wrk, Accept-Encoding: gzip, and a
restaged seed after writes. Non-2xx responses are not throughput.

The seed fills message_search_index (insert_all skipped the FTS
callback) and adds index_messages_on_room_id_and_created_at so a
room page does not sort the whole history. rust/go/elixir lanes boot
native once-campfire-* binaries on the same sqlite file.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a108ac-5b18-764e-8d41-d4cddd8b07b4
Thermos: rust/go/elixir sqlite is overwritten only when that lane
runs, CSRF/login/parity curls force Accept-Encoding: identity so
gzip bytes cannot empty a token, and a POST cell without a token
is skipped instead of timed as 422s. Overlay/help comments now
say HTML-only gzip. FTS backfill indexes plain text, not HTML.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a108ac-5b18-764e-8d41-d4cddd8b07b4
ActiveSupport's compact_blank on a Hash rejects blank VALUES, and
campfire's default_url_options is `{ host:, protocol: }.compact_blank`.
The pass only rewrote a typed Array, so a Hash of nilable Strings filed
residue and kept a send Spinel cannot AOT (`unsupported call:
compact_blank`). Rewrite it as `reject { |_k, v| v.blank? }` through
the same emptiness rule a String blank? already takes.

An Array whose element is still an inference var — a helper's
`[ author.name, author.bio ].compact_blank` — used to keep the send
too. The reject body now calls ActiveSupport.blank?, the same runtime
predicate an untyped blank? already takes. A class with its own
predicate still files residue.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a108c5-8f79-731a-87ba-dce013d4be5f
`turbo_stream.replace target, partial: "…", collection: xs, as: :x`
is Rails' once-per-element render. Declining it left the builder in
source shape, where turbo_stream resolves to nothing — campfire's
accounts/users/index.turbo_stream.erb. Treating collection: as a
single render would keep only the first element.

Walk the collection into the same capture accumulator the block form
already uses, render the named partial once per element through
named_partial_call, and hand the concatenated String to
Broadcasts.turbo_stream_fragment. An unknown option key still stays
source-shaped.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a108c5-8f79-731a-87ba-dce013d4be5f
Campfire pages a room with ordered.last(PAGE_SIZE). last_n used to
materialize every row and slice in memory. Unloaded, with no existing
LIMIT/OFFSET, reverse each ORDER BY, LIMIT n, load, and reverse the
rows back — Rails' SQL tail. A loaded relation or one that already
has a window still takes the in-memory tail of that page.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a108ac-5b18-764e-8d41-d4cddd8b07b4
Tep.maybe_gzip! now leaves 1xx/204/304 and HEAD uncompressed, matching
Rack::Deflater. The POST wrk seed strips the 0x prefix before parsing
the table address so LuaJIT does not collapse every thread onto seed 0.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a108ac-5b18-764e-8d41-d4cddd8b07b4
--http-suite replaces --dhh-http. Comments name the workloads, not a
tweet. The corpus OpenGraph fixtures are unchanged.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a108ac-5b18-764e-8d41-d4cddd8b07b4
A signed-in room page is the same identity bytes for every wrk GET.
Zlib.gzip on each request was the measured cliff (Spinel 1984 → 694
req/s). Cache the compressed copy, keyed by the identity body, on
both CRuby (GzipCache around run_rack) and Spinel tep. /cable, HEAD,
1xx/204/304, and Static stay uncompressed.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a108ac-5b18-764e-8d41-d4cddd8b07b4
CI unit shards failed the measured untyped ceilings after last_n
(+16 full-context, +64 in the historical probe). reverse_order_term
now flips each comma-separated fragment, so order(a: :asc, b: :desc)
is a DESC LIMIT tail of both columns. Tep and GzipCache skip gzip
when Accept-Encoding names gzip;q=0.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a108ac-5b18-764e-8d41-d4cddd8b07b4
…h-http-bench

Gzip Campfire HTML, time the five HTTP routes, cache identical bodies
CodeRabbit caught several rewrites that would change Ruby semantics:
a union element with its own blank? must not go through ActiveSupport,
*items beside **opts is not an erased keyword splat, and a parameter
write that does not dominate later reads cannot become a branch-local.
Invalid as:/locals: stay source-shaped, collection locals bind once,
and a nil collection iterates as empty.

The writebook inventory was failing because the turbo_stream residue
message changed identity. Restore the previous wording. Ignore the
unused parenthesized field in Array.wrap grounding.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a108c5-8f79-731a-87ba-dce013d4be5f
Ingest flattens a consuming `def f(**details)` to `details = {}`.
Restoring `**h` against that is unexpected keywords in Ruby 3. Only a
kept keyword-rest (`last.keyword && last.rest`) needs the splat back.

A modifier-if join nested in a loop or outer `if` does not dominate
later reads, so that parameter stays on the dynamic path. Tests now
pin both halves: flattened vs forwarding **rest, nested join-if, and
the exact `__rh_id_2` / ActiveSupport.blank? names.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a108c5-8f79-731a-87ba-dce013d4be5f
…inel-compile-walls

Meta PR: Split rebound parameters, keep query keys and **rest on the wire
CRuby GzipCache and Spinel tep both held Mutex across Zlib.gzip, so
every miss (and, on Spinel, every 420 KB Hash lookup) serialized onto
one core. Gzip now runs outside the lock. CRuby still keys by the
identity body — SHA-256 of the same bytes was slower on MRI (~1725 →
~1140 req/s on /rooms/1). Spinel keys by SHA-256 so the lock only
covers a 64-char lookup.

CRuby Rails.cache.read_str no longer dups the stored fragment: a
<% cache %> hit only appends it, and DupCoder's write-side copy already
isolates the store. read still dups. write_str freezes its stored copy.

On this orb, CRuby /rooms/1 went 1725 → 1886 req/s, messages 3303 →
3601, search 2851 → 3271. Spinel /rooms/1 869 → 1210 with the digest
key.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a108ac-5b18-764e-8d41-d4cddd8b07b4
write and increment_str put Strings in the same @DaTa hash as
write_str. After read_str stopped duping, a caller that mutated a
typed read of a write-path entry would change later hits. Freeze
those stored copies too; read still dups.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a108ac-5b18-764e-8d41-d4cddd8b07b4
…digest

Gzip Campfire HTML outside the lock; skip fragment dups on CRuby
…`'s writer (rubys#289)

* `super` in a model's own password writer reaches `has_secure_password`'s writer

In Rails, `has_secure_password` defines `password=` in a module the
model includes, so a model can wrap it:

    def password=(value)
      @password_supplied = true
      super
    end

Here the model's writer wins outright and the macro's is never
synthesized, so that `super` went to `ActiveRecord::Base`, which has no
such writer. `check` was clean, and the emitted program raised
`super: no superclass method 'password='`.

A post-analyze pass, `apply_secure_password_super`, now adds the
macro's writer to such a model as `_secure_password_writer` and points
each `super` in the override at `self._secure_password_writer(...)` (a
bare `super` passes the writer's own parameter), as
`lower::as_json_super` does for `as_json`. The override is marked as
mutating self. The pass runs before `create_block`, which inlines
blocks, so it sees the writer as written, and after the analyzer, so
it types the call and the helper it adds: the transpile's diagnostics
gain nothing. (`roundhouse check` does not run this pass.)

It leaves a writer alone, rather than risk skipping another writer or
passing the wrong value, when a module is mixed into the model or an
ancestor model (in a class body or by an initializer), when the app
already has a method of the helper's name (in a model, a column of
any table, or a library class), when the writer's parameters are not one plain
positional, and when a `super` sits inside a block (there the writer's
parameter name can mean the block's own variable).

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* `super` in each secure-password writer reaches its macro writer

A model can declare has_secure_password more than once. The super pass
handled only the first declaration, so recovery_password= still reached
ActiveRecord::Base and raised NoMethodError.

Consider each attribute independently, retaining the existing eligibility
checks for its helper. The regression covers custom writers with bare and
explicit super in both declaration orders. The emitted Ruby test also
checks that the two writers update their own plaintext fields.

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Bunsy <268838774+bunnykong@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The invite link stays private; contributors ask on an issue or PR.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…bys#427)

Since 7816924 (rubys#279), unresolved source constants only fall back to
exactly modeled classes. These standard-library names were missing
from the registry, so Struct.new constant reads and JSON rescue
classes became unsupported.

Register the existing Ruby-family values at that fallback's registry
seam. Keep capability errors for targets that do not provide them,
without inventing member or synchronization return types.

The execution regression passes at fc49a94, fails at 7816924 and
current main, and passes with this fix. Library and focused suites
pass, as do Ruby, JRuby and native Spinel execution. All 98 strict
fixture/target comparisons match main; rejected cases also produce
identical output with --allow-unsupported.

Co-authored-by: Bunsy <268838774+bunnykong@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Preserve relation receivers for find_by and find_by!

Keep terminal finder receivers on the runtime Relation path when the
whole call cannot be folded. Cover inline, assigned, and helper-returned
relations with emitted execution and nullable association/missing rows.

Co-Authored-By: Codex <noreply@openai.com>

* Document relation finder regression fixtures

Explain the receiver and optional-association paths exercised by the fixture,
and the distinct missing-record contracts checked by the assertion helper.

Co-Authored-By: Codex <noreply@openai.com>

---------

Co-authored-by: Codex <noreply@openai.com>
`rails new --api` writes `class ApplicationController <
ActionController::API`, and the runtime defined only
`ActionController::Base`. `check` was clean, but the ruby tree raised
NameError loading the controller, and the spinel binary built and then
answered every request with

    500 GET /widgets/1 -- NoMethodError: undefined method 'params=' for an instance of WidgetsController

`ActionController::API` is now Base under the API name, in
`runtime/ruby/action_controller/api.rb`, which the aggregator requires
beside the other ruby-family controller files. Rails' API is Base
without the browser modules; here those stay within reach. Like the
other files the aggregator requires, it stays off the strict-target
tables.

Fixes rubys#163

Co-authored-by: Bunsy <268838774+bunnykong@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…d` (rubys#385)

* `owner.<has_many>.new(attrs)` builds with the foreign key, like `build`

CollectionProxy aliases `new` to `build`. Only `build`, `create` and
`create!` were seeded with the owner's foreign key, so `assoc.new`
reached the reader's folded Array and raised `undefined method 'new'
for an instance of Array` — in a model method and in a view's blank
nested form alike. `new` now takes the `build` path everywhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Normalize `owner.<has_many>.new` to `build` once, at ingest

`new` on an association is CollectionProxy's alias for `build`. The
previous commit taught `scope_chain` the alias, which covered a model
method or view's no-arg / literal-hash form, but the motivating shape,
`@article.comments.new(comment_params)` in a nested create, goes
through the controller lowering (and seeds, and per-target test
emitters), which know `build` only: the emitted action still called
`Array#new`.

Rewrite the spelling once, at the end of ingest, so every consumer
reads `build`: a new `ingest::association_new` pass renames `new` when
its receiver is a bare read of a name some model declares `has_many`
(the by-name rule `AssocRegistry::is_has_many_name` uses), across
every hook body, views, and test modules. `Model.new` (a Const
receiver) and a local variable (not a Send) never match. The
`scope_chain` additions become redundant and are reverted.

tests/association_new_params.rs overlays real-blog's
comments_controller with `@article.comments.new(comment_params)` and
runs the emitted controller test, asserting the POSTed comment saved
under the article. It fails on main and on the previous commit with
`undefined method 'new' for an instance of Array`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Normalize association new only for its owning model

Rails treats CollectionProxy#new as build, but an unrelated comments reader can return a class whose new must remain new. Move the alias normalization from ingest to the early post-analysis lower pass and require a typed model owner or the declaring model context. Preserve model method overrides and prove the emitted behavior with real-blog overlays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Require typed collection reads for association new

A model class method or constant can share a has_many reader name while returning an unrelated class. Check the analyzed read type and declaring model, and keep bare reads scoped to model instance methods. Cover the real-model class receiver cases with emitted Ruby regressions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ing the callback (rubys#393)

* A model callback's `if:`/`unless:` guards its body instead of dropping it

`parse_callback` returned None for any callback carrying `if:` or
`unless:`, so the declaration fell through to Unknown and was dropped
entirely: a `before_validation :m, on: :create, if: -> { color.blank? }`
ran in no circumstance at all, leaving the column blank and the record
failing validation.

Read the value into `Callback.condition`: a zero-arity lambda/proc
contributes its body (Rails instance-execs it with self the record), a
Symbol is the receiverless predicate call. `unless:` is negated, and both
together become `if && !unless`, matching Rails' "run when if holds and
unless does not". `push_symbol_callback` wraps the callback body in that
guard, composing with the existing `on: :create` new_record? guard. An
unmodeled value (a lambda with parameters, an array of conditions) still
declines as before.

Pinned by tests/callback_conditions.rs and two emit_and_run tests that run
the emitted program; tests/model_lowerer.rs's old drop expectation is
updated to the predicate guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Splice only a zero-parameter, single-expression lambda/proc condition

A callback condition's lambda/proc body is spliced inline into the hook,
with `self` the record and no frame of its own. A parameter
(`->(r) { r.title.present? }`, `proc { |p| … }`, numbered params, `it`)
would read unbound; a `return`/`next`/`break` would exit the whole
callback chain; a local write would leak into the hook. Those now
decline back to the unsupported-DSL warning, as before conditions were
modelled, instead of emitting a broken guard with no diagnostic.

The `unless:` test now asserts the negated guard itself (`if !(loud?)`)
rather than names that also appear as method definitions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* A local bound by a target node also declines the condition

A lambda/proc `if:`/`unless:` body is spliced inline as the callback's
guard, so a local write in it would leak into the hook's scope and has
to decline. The Escapes visitor caught local-variable write,
operator-write, or-write and and-write nodes, but a local bound by a
target node — a multiple assignment `(a, b = title, 1)`, a match-write
`title in String => t`, a `rescue => e` or a hash-pattern binding — is
bound through `LocalVariableTargetNode` and slipped past, splicing the
body with the local leaked. Visiting that node too declines them, as
they did before conditions were modelled: the callback falls back to
the unsupported-DSL warning rather than running with a broken guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Any multi-write also declines a spliced condition

The `Escapes` visitor already caught a local bound through a target
node, which covers a multi-write whose targets are locals. A
multi-write with non-local targets — `(@A, @b = name, 1) && @a` — binds
through instance-variable targets instead and slipped past, splicing a
guard that does not parse (`if @A, @b = [...] && @a`). Visiting the
multi-write node declines it, and any multi-write, since the guard is a
single inline expression and a multi-write binds regardless of target
kind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
bunnykong and others added 27 commits October 5, 2026 11:47
Search from the most recent end and promote hits within the typed
statement array. Keep eviction at lease boundaries and route compiled
cache regressions through the native CI planner.

Co-authored-by: Bunsy <268838774+bunnykong@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Codex <noreply@openai.com>
* Drain the blocking server's request body by bytes

* Say what spinel's String#length does on recv'd bytes

The drain fix in 07de472 said spinel's `length` counts characters on
bytes received through sp_net. Probed on spinel 775ba5f68, it does not
in the shape sphttp_drain_body used: bytes from
`sp_net_recv_some(:binstr)` keep `length == bytesize` through `+`,
character slicing and `byteslice`. Only `<<` onto `+""` gives a String
whose `length` counts characters, which is how the threaded and
scheduled header readers build their blob (and where 5cb6d05's
`raw_body.length` stall came from).

So the old `out.length < n` measured correctly by accident of
`out + chunk` staying binary, and the over-read the test reproduces
was latent, not live. The comments in net.rb, the drain driver and
the shared harness now say that, and call the harness's `utf8:` mode
a stress model rather than spinel's behavior. No code change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep the body cap from saturating to the length it refuses

Tep.decimal_byte_count saturates any run past 18 digits at 10^18,
and TEP_MAX_BODY_BYTES went through the same parser. So an override
of more than 18 digits, including a zero-padded small value like
"0000000000000000001024", became a cap of 10^18. A Content-Length
past 18 digits saturates to that same 10^18, compared equal to the
cap, and passed `body_refusal`: the override switched the cap off.

The parser now skips leading zeros before counting digits, so a
zero-padded value reads as its value (Puma's `.to_i` does the same).
An override must be below the ceiling or it leaves the 100 MiB
default, like any other value that is not a positive byte count. And
`body_refusal` refuses a saturated length whatever the cap, so the
ceiling is never compared as a size.

tests/spinel_request_body_cap.rs gains two override runs: the
zero-padded value must produce a 1024-byte cap, and a 25-digit one
must leave the default; both keep a 25-digit Content-Length a 413.
Before the fix, both runs let the 25-digit length reach the app.
Verified on a spin build of real-blog (spinel 775ba5f68): with
TEP_MAX_BODY_BYTES=0000000000000000001024, 1024 bytes is served and
1025 bytes or a 25-digit length is a 413.

Reported in review.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
libvips 8.15+ skips BLOCKED classes in vips_foreign_map, so
vips_foreign_find_load answers nil for a loader block_untrusted /
Vips.block would refuse to run. 8.14 (Debian bookworm / Ubuntu jammy)
still names MagickFile for a BMP and SvgFile for an SVG after the
operation itself raises "operation is blocked". A policy probe that
asks find_load then sees a loader the process cannot run.

The image processor wraps find_load through VipsExt so a blocked
loader is not selected — the 8.15 contract, on both the spinel package
and the gem. Wrapping the Ruby method in place re-enters the wrapper
on the package. Any Rails app that stores user uploads and sets that
initializer is entitled to the same answer; this is not a Campfire
rewrite.

Pinned with ingest lifts (parens, comments, quotes, false arms) and
an emit-and-run that checks PNG still loads while BMP/PSD/SVG do not.
Campfire's compiled vips_loader_policy_test: 15 passed, 2 skipped
(host has no niftiload/dcrawload) on libvips 8.14.1.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a108c5-8f79-731a-87ba-dce013d4be5f
The emit-and-run that asks Vips.vips_foreign_find_load after the
app's loader policy needs the gem and a loadable libvips. Unit CI
had neither, so shard 2 died on `cannot load such file -- vips`
while a machine with the gem already installed hid the gap.

Same grain as sqlite3 and bcrypt on that job: libvips42 plus
ruby-vips, pinned by ci_policy_workflow.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a108c5-8f79-731a-87ba-dce013d4be5f
Honor Vips.block_untrusted in find_load on libvips 8.14
Campfire's message paging (basecamp/once-campfire#292) loads a page with
`skip_preloading!` and hands only its fragment-cache misses to
`preload_associations`; rubys#304 and rubys#312 `reorder` a page's relation. None
of the three existed in the runtime, so the emitted app raised
NoMethodError on every message page and Spinel refused `reorder`.

`skip_preloading!` keeps the recorded includes on the relation and has
`load_records` leave them alone; `preload_associations(records)` runs
them against whatever records it is given. `reorder` drops the ordering
gathered so far, then orders by its arguments.

tests/emit_and_run.rs pins all three on both lanes: deleting the parts
after loading shows that only the widget handed to preload_associations
kept its parts. The untyped-site ceiling rises by one for reorder's
splat, the read order and order! already pay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rails runs a scope body with its relation as the current scope, and a
class method the body calls at implicit self runs there too. Campfire's
`scope :last_page, -> { last_page_of(PAGE_SIZE) }`
(basecamp/once-campfire#292) emitted `last_page_of(PAGE_SIZE)` with no
relation, so a room's page would have been the last forty messages of
every room. `check` was clean throughout.

- Scope bodies count as demand for the class methods they call at
  implicit self, closed over the bodies of class methods that take the
  relation themselves. A callee whose body neither queries nor creates
  (campfire's `match_terms`) keeps its signature.
- The emit seam hands those calls the body's `__rel`. The scope-body
  rewrite cannot: it runs before the registry exists.
- A receiver the analyzer typed as a model's relation, or as an Array
  of the model (the catalog does not yet tell them apart), is demand
  too, and every relation-taking class method gets a delegate on
  Relation that passes itself as `__rel`. That answers relations no
  syntactic channel recognizes, such as the through-association in
  `Current.user.reachable_messages.search(q).last_page_of_matches(n)`
  (rubys#304).
- `reorder` is a relation chain method, so a body that starts with it
  roots on `__rel`.

tests/emit_and_run.rs pins both shapes on both lanes, with data where
the unscoped answer differs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Campfire's `Page.load(relation, direction, size)`
(basecamp/once-campfire#292) reads `relation.public_send(direction,
size)` and is called with `:first` and `:last`. Spinel refuses a runtime
method name, and on the Ruby lane the send reached Relation's zero-arg
`last` and raised ArgumentError.

send_dispatch gains a fourth proof of the name set: the selector is a
parameter, and every call site passes a Symbol literal there. Call sites
are constant receivers resolving to the class, and receiver-less or self
calls from its own methods; a call of the same name on an untyped
receiver, or one typed as the class, leaves the parameter unproven. The
rewrite also accepts an effectful receiver when the selector is a plain
variable: one arm runs, so the receiver is still evaluated once, and a
variable scrutinee has no effect to reorder against.

A new post-analyze pass, relation_counted_terminal, renames `first(n)`
and `last(n)` on an analyzer-typed Relation to the runtime's `first_n`
and `last_n`, which the synthesized arms need. Array receivers are left
alone.

Pinned on both lanes in tests/emit_and_run.rs. Campfire's suite on the
preview fork goes from 378 to 399 of 430 on the Ruby lane; the pinned
upstream stays at 406 of 406.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bf844be and c8646ce failed main's CI in two places the local default
suite never reached, because cargo test stops at the first failing
binary.

- inference_on_spinel_blog_runtime_with_rbs: Relation#reorder,
  #skip_preloading! and #preload_associations add seven untyped
  sub-expressions under this probe (relation.rb 802 -> 809, measured with
  and without them); the full-context gate counts one. Ceiling 1273 ->
  1280.
- writebook inventory: positionable.rb:24's two dynamic sends were each
  ledgered once as "receiver is not an effect-free reader" and once as
  "not statically enumerable". A variable selector now admits an
  effectful receiver, so all four carry the second reason. Same sites,
  same count. f21f1e5's Relation delegates add one honest residue line
  per Ruby-family emit: `Session.start!` takes keywords, so it gets no
  delegate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Extend ingestion for keyword-hash enum mappings, source-location literals,
non-evaluating defined? queries, anonymous keyword forwarding, supported
class-variable storage and literal-array post-rest assignments.

Unify the shared IR contracts with current main, preserve owning scopes and
user-defined enum predicates, and retain explicit unsupported boundaries.
Pin the accepted syntax and review corrections with regression coverage.

Originally contributed by Tim Tischler. Follow-up integration and fixes were
validated locally and by complete CI on the exact final PR head.

Co-Authored-By: Tim Tischler <tim.tischler@procore.com>
Co-Authored-By: Amp <amp@ampcode.com>
Since Ruby 3.4, `csv` is a bundled gem, not a default gem. Under
bundler, a bundled gem is not on the load path unless the Gemfile
lists it. The emit writes `require "csv"` into each file that uses
`CSV`, but the emitted Gemfile does not list `csv`. So an app that
uses `CSV` stops at boot under `bundle exec`, with "cannot load such
file -- csv". A test file that uses `CSV` fails the same way.

This commit adds `gem "csv"` to the emitted Gemfile when a file in
the tree requires it. An app that does not use `CSV` gets no new
line. The Ruby, JRuby and Spinel trees all get the line, because the
Spinel tree also runs its tests under `bundle exec` with this Gemfile.

PR rubys#104 added `bigdecimal`, `base64` and `resolv` to the template
Gemfile for the same reason. Every app needs those three, because the
runtime requires them. Only some apps use `CSV`, so this commit does
not add `csv` to the template.
Name a bundled gem in the Gemfile when the app requires it
The roundhouse-rb/once-campfire preview branch (upstream main plus the
open PRs, on Rails main) failed 31 of its tests on the Ruby lane. These
close the ones that are runtime or test-harness gaps:

- Channel subscriptions answer `stream_names` (Rails 8.2 made `streams`
  private in channel tests), on both cable runtimes.
- assert_response takes Rails' second argument, a failure message
  (`assert_response :success, platform`), instead of a response.
- `ActiveSupport::JSON.encode` for a flat Hash, and
  `ActionCable.server.broadcast(..., coder: nil)` sending already-encoded
  text as given (basecamp/once-campfire#292 encodes the unread notice
  once). The CRuby overlay reads the text back into the Hash it carries.
- Under the test job adapter, `perform_later` holds its work, the same
  zero-argument Proc a drain takes, so a blockless `perform_enqueued_jobs
  only:` runs it later, and a blockless `assert_enqueued_with` counts
  from the test's start (rubys#296's tests post first and perform after).
  Each test starts with nothing held.
- assert_select's string matcher reads `:not([attr])`, the `*=`, `^=`
  and `$=` operators, and attribute values written with `>` where the
  tag helper escaped it (rubys#301's `img[src*='install-edge']`, rubys#303's
  `input[type=checkbox]:not([checked])` and its `data-action`).

The preview branch's Ruby lane goes from 399 to 409 of 430; the pinned
upstream stays at 406 of 406. tests/emit_and_run.rs pins the selector
and response shapes, and the held job with its pre-encoded broadcast.
The untyped-site ceiling rises by five, measured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`where`/`having` with named placeholders (`:size`, `IN (:user_ids)`)
only bound positional `?`, so campfire's direct-room lookup
(basecamp/once-campfire#310) sent its placeholders to SQLite unbound.
They read as NULL, every lookup missed, and every Ping created a new
direct room. On the preview fork that surfaced as "Creator must exist"
in three Rooms::Direct tests and a second room in the directs
controller's.

- Relation#substitute_binds hands a single Hash argument to
  substitute_named_binds: each `:name` the Hash answers is replaced by its
  escaped value, an Array by a comma-separated list; a `::` cast and an
  unknown name are left alone.
- Relation#to_set (campfire compares direct-room members as sets).
- Spinel's record equality gains `eql?` and `hash`, matching `==`, as the
  CRuby overlay's already does, so a Set of records loaded twice holds.

Pinned on both lanes in tests/emit_and_run.rs. The active-record probe's
ceiling rises by 56 (a character scan, which the probe cannot type); the
full-context gate counts no new sites.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lambda IR has no slot for parameters after a rest, and ingest dropped
them silently: `->(*, payload) { payload[:sql] }` emitted as
`-> { payload[:sql] }`, a body reading a name nothing bound, and the
expression IR diverged across a round trip. An expression-position
lambda also dropped a NAMED rest (`->(*args) { args }`).

Ruby's rule is that the rest takes everything but the trailing
parameters, so ingest now names the rest (`__rest` when it is
anonymous) and pops them off it, last first, ahead of the body, at all
three lambda and block sites. A named rest keeps exactly what Ruby
gives it. The one difference: too few arguments bind nil where a lambda
would raise ArgumentError.

Found in campfire's query counters (`->(*, payload)`, the shape Rails'
own query assertions document). Optional and keyword parameters on an
expression-position lambda are still dropped; that gap is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rails 7.2 puts `ActiveRecord::Assertions::QueryAssertions` on every
`ActiveSupport::TestCase`, and campfire's preview-fork tests count
queries, assert none match a pattern, and read query plans through
`ActiveSupport::Notifications.subscribed(callback, "sql.active_record")`.
None of it existed, so five test files failed to load or raised.

- The test helper defines `ActiveSupport::Notifications.subscribed` for
  "sql.active_record" over `Db.capture_sql`, which records every
  statement and skips a query-cache replay as Rails' counter skips CACHE
  events; the callback gets Rails' five arguments and a payload with
  `:sql` and `:name`, and the block's value is returned.
- `QueryAssertions` (`assert_queries_count`, `assert_no_queries`,
  `assert_queries_match`, `assert_no_queries_match`) on the same capture,
  leaving schema introspection out unless asked, included in TestBase.
- `Connection#select_rows` answers Rails' Arrays of values.

Pinned in tests/emit_and_run.rs. The active-record probe's ceiling
rises by five, measured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Relation#exists? with an offset asks SQLite for one row past it
  (`SELECT 1 AS one … LIMIT 1 OFFSET n`), as Rails does. A COUNT ignores
  the offset, so campfire's `paged?` (basecamp/once-campfire#297)
  answered whether a room had any messages at all and showed the
  welcome box everywhere. Without an offset the count path, which can
  answer from preloaded records, is unchanged.
- The test helper carries the four knobs campfire's messages caching
  test turns around one block: `Rails.cache =`,
  `ActiveSupport::Cache::MemoryStore.new` (the store class Rails.cache
  already answers with), `ActionView::PartialRenderer.collection_cache`,
  and a controller's `cache_store` and `perform_caching`. The test's own
  assertions then exercise the runtime's fragment cache: a cached page
  runs no rich-text, attachment or boost queries, and an edited message
  refreshes.

Pinned in tests/emit_and_run.rs. The active-record probe's ceiling rises
by nine, measured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The projection every model query reads (`_columns_sql`) now writes
`"messages"."id" AS id`, the table and column always quoted, as Rails'
SQLite adapter writes them. Apps' tests pick statements out by that
text: campfire's query-plan test (basecamp/once-campfire#312) collects
`payload[:sql]` where it `start_with?(%(SELECT "messages"))`, and with
unquoted identifiers it found none. The alias keeps sql_ident's quoting
only where the name needs it, so `ORDER BY created_at` still resolves
against the output column.

The preview fork's Ruby lane goes to 424 of 429; the pinned upstream
stays at 406 of 406. Pinned by the query-assertions test in
tests/emit_and_run.rs; sql_keyword_columns follows the new spelling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`app/views/product-item/_default.html.erb` was emitted as
`module Views::Product-item` with a `product-item` parameter, neither of
which is Ruby, so the whole view file failed to parse. `camelize` now
treats `-` as a word separator (`ProductItem`) and the inferred view
argument turns `-` into `_`.

Found by parsing the emitted views of a real app (21 of 793 views, all in
`product-item/`-style directories). `a_hyphenated_view_directory_renders`
renders an ERB partial from such a directory through the blog's controller
test; it fails with a SyntaxError without the change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
`.slim` templates were recorded as "view template not ingested". A new
`src/slim.rs` compiles Slim to the `_buf`-append shape on top of the HAML
compiler's emitter: tags with `.class`/`#id`, attributes (quoted, Ruby,
wrapped, boolean, `==` raw, multi-line), `tag: child` nesting, `=`/`==`/`-`
with blocks closed by indentation, `|`/`'` text, `/` and `/!` comments,
`doctype html`, and the `ruby:`, `javascript:` and `css:` embedded
engines. Other embedded engines, splats and non-HTML5 doctypes are survey
gaps.

Per invariant 6, `a_slim_view_renders` (tests/emit_and_run.rs) swaps the
blog's index for a Slim twin and asserts the rendered markup. It found
that `render_attrs`, which HAML and Slim both emit, was never routed to
`ViewHelpers`, so a dynamic attribute raised NoMethodError in the emitted
Ruby; it is now a `ViewHelperKind::RenderAttrs`.

Checking a real Slim app (745 templates, 0 errors in them) and parsing
the emitted views also exposed three shared bugs, each with a unit test:
`ruby_string_literal` escaped the code inside `#{}`, the Ruby emitter
dropped the parentheses of `(x rescue nil) == true`, and emitted
`j if c ... end` for a command call whose first argument is an `if`.

Tests that used `.slim` as the unsupported-engine witness use `.rabl`.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
`ruby_string_literal` copies a balanced `#{...}` verbatim but ended it at
the first `}`, including one inside a percent literal such as `%q(})`,
after which the rest of the code was escaped as text. The scan now skips
percent literals (paired and same-character delimiters, backslash
escapes), treating `%` after an operand as modulo.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lid Ruby

Resolves the conflict with rubys#440 in tests/emit_and_run.rs by keeping
both new tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…opts

* Push Relation#last_n into SQL with a reversed LIMIT

Campfire's room page is ordered.last(PAGE_SIZE) over a room that can
hold hundreds of messages. last_n used to hydrate the whole relation
and then Array#last, so with_creator / with_attachment_details /
with_boosts preloaded every row, not the page.

Match Rails: reverse each ORDER BY, LIMIT n, reverse the rows so the
original direction is restored. Unparseable order terms and an OFFSET
keep the materialize fallback.

Co-Authored-By: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Thomas Klemm <github@tklemm.eu>

* Skip html_escape and url_encode copies on already-safe strings

gsub always allocates, even when the pattern misses. Campfire's room
page escapes author names, CSS classes, and numeric cache-busters on
every message row; almost none contain special characters.

Return the input after a match? scan, the same shortcut ERB::Util uses
for an already-safe string. Dirty strings still go through the table
gsub. Same fast path on builder_text, builder_attr, and the URI
encoders.

Co-Authored-By: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Thomas Klemm <github@tklemm.eu>

* Stop allocating a fresh Hash on every no-opts tag helper

def f(opts = {}) evaluates a new Hash on every call that omits
options. Campfire's message partials call link_to, content_tag, and
button_to dozens of times per row.

Share one frozen EMPTY_HTML_OPTS. Methods that delete keys already
dup first, so the constant is not mutated.

Co-Authored-By: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Thomas Klemm <github@tklemm.eu>

* Yield Relation#each from the loaded cache without duping

Collection partials lower to relation.each. to_a.each allocated a
shallow copy of the records array on every pass, including the
messages/_message loop on /rooms/1.

Walk the memoized array in place, the same array a later each reuses.
to_a still dups, so callers that mutate its result cannot corrupt the
cache.

Co-Authored-By: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Thomas Klemm <github@tklemm.eu>

* Memoize ActionText::Content#to_plain_text

blank?/empty?/present? all call to_plain_text. On a fragment-cache
miss campfire's message presentation asks more than once per body,
and the HTML scan is the expensive half.

Cache the result on the Content instance. @html is set only in
initialize, so the memo stays valid for the object's lifetime.

Co-Authored-By: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Thomas Klemm <github@tklemm.eu>

* Route Relation#find_each through the zero-copy each path

find_each was still to_a.each. After each walks the loaded cache
without duping, find_each should share that path so a second pass
over the same page does not allocate another Array.

Co-Authored-By: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Thomas Klemm <github@tklemm.eu>

* Optimize Rails::Cache read_str/write_str for Campfire fragment keys

Skip key.to_s when the key is already a String, and split read_str
expiry so ttl-0 entries return without touching Time.now. Add focused
unit tests for the string store read/write path.

Co-Authored-By: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Thomas Klemm <github@tklemm.eu>

* Fast-path link_to and content_tag when html options are empty

Campfire message rows call both helpers many times per render with no
extra attributes. Skip opts.to_h on Ruby Hash, bypass render_attrs when
the options hash is empty, and build the minimal anchor/tag string
directly.

Co-Authored-By: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Thomas Klemm <github@tklemm.eu>

* Generalize new runtime-opt comments and pin ActionText blank memo

Reword the last_n / html_escape / to_plain_text / cache comments as
general Rails patterns rather than Campfire-specific call sites, and
add a blank?/to_plain_text reuse assertion for the memo path.

Co-Authored-By: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Thomas Klemm <github@tklemm.eu>

* Probe Relation existence with SELECT 1 LIMIT n, not COUNT

Rails' exists?/empty?/any? ask for one matching row; one?/many?
only need to know whether more than one exists. Route those through
exists_sql / probe_existence so cardinality checks never hydrate.
Relation#size uses COUNT when unloaded (Rails contract). last_page?
already short-circuits a short loaded page; keep offset_row_exists?
as the named OFFSET-aware probe.

Generic Item tests pin: no hydrates on exists?/empty?/any?/one?/many?,
exists_sql shape, unloaded size, and short-page last_page?.

Co-Authored-By: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Thomas Klemm <github@tklemm.eu>

* Probe Relation existence with SELECT 1 LIMIT instead of COUNT

Wire exists?/empty?/any?/one?/many? through exists_sql so cardinality
questions do not hydrate rows or scan a full COUNT. size answers from
COUNT when unloaded; last_page? short-circuits on a short loaded page.
Add focused hydrate-count and SQL-shape tests.

Co-Authored-By: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Thomas Klemm <github@tklemm.eu>

* Short-circuit find_by(id: nil) without a SQL round trip

A sole primary-key => nil lookup can never match (PKs are NOT NULL).
Return nil before WHERE id IS NULL LIMIT 1 — the room-show
find_messages path when message_id is absent.

Co-Authored-By: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Thomas Klemm <github@tklemm.eu>

* Address Thermos review: each return, drop empty-tag forks, pin last_n

Relation#each still walks the loaded cache without duping, but returns
self so callers cannot mutate @records. Remove link_to/content_tag empty
fast-paths that duplicated render_attrs' empty short-circuit. Keep
convert_html_to_plain_text private and widen Cache read_str/write_str
key types in RBS. Add last_n tests for loaded / prior LIMIT / OFFSET.

Co-Authored-By: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Thomas Klemm <github@tklemm.eu>

* Keep Relation/ActionText typing gates green after SQL opts

Raise the measured Bar B and spinel-blog RBS ceilings for exists_sql /
probe_existence / nil_primary_key_lookup? and find_each's zero-copy
loop. Declare Content#convert_html_to_plain_text, return Relation from
each/find_each, and duplicate find_each's loop so Bar A stays at zero
TyVars.

Co-Authored-By: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Thomas Klemm <github@tklemm.eu>

* Ask unloaded Relation#include? via exists? instead of ids

Campfire membership checks (room.users.include?) were projecting every
id (and sorting when the scope was ordered). Cast the record id, answer
from the loaded cache when present, otherwise SELECT 1 LIMIT 1.

Pin Campfire room-show shapes in unit tests: last_page hydrates PAGE_SIZE
rows, find_by(id: nil) skips SQL, any?/exists_sql avoid ORDER BY and
hydration.

Co-Authored-By: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Thomas Klemm <github@tklemm.eu>

* Emit String#match?(re) as re.test(s) under TypeScript

The TS emitter assumed Regexp receivers for match?, so
`s.match?(HTML_ESCAPE_PATTERN)` became `s.test(...)` and threw.
Swap when the receiver is String (or the argument is Regexp).

Co-Authored-By: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Thomas Klemm <github@tklemm.eu>

* Keep ActionText::Content#blank? as OwnDispatch in blank lowering

Framework tests load Content outside the app class registry, so the
pass folded content.blank? to false. Empty markup is blank; leave the
real method in place.

Co-Authored-By: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Thomas Klemm <github@tklemm.eu>

* Fix Relation size/exists DISTINCT; drop find_by nil special case

size on a limited unloaded relation counts a SELECT-1 subquery so
limit(2).size is at most 2. exists_sql projects DISTINCT primary keys
so distinct.many?/one? see separate rows. Remove the Campfire-only
find_by(id: nil) short-circuit. Revert Cache key is_a?(String) no-op
and cross-target assert_same on escape identity.

Co-Authored-By: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Thomas Klemm <github@tklemm.eu>

* Portable escape fast-paths: include? instead of String#match?

String#match?(re) has no portable emit on Rust/Python (match_pred /
match_p on str), which broke compare(rust) and the Python overlay.
Probe with include? character chains instead — same skip-gsub win,
YJIT-friendly, and every target already emits include?. Revert
opts.is_a?(Hash) gates that mapped to HashMap#is_object under Rust.
Bump the RBS untyped ceiling +6 for limited size / DISTINCT exists.

Co-Authored-By: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Thomas Klemm <github@tklemm.eu>

* Fix distinct.limit.size and recognize Ty::Str for match? emit

Limited size projected DISTINCT 1, collapsing distinct rows to one;
project the primary key when @distinct, matching exists_sql. TypeScript
String#match? → re.test also treats Ty::Str receivers as strings.

Co-Authored-By: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Thomas Klemm <github@tklemm.eu>

* Rewrite rel.count > n to more_than? (SELECT 1 OFFSET n)

Campfire's Message.paged? is count > PAGE_SIZE. COUNT(*) answers the
total; more_than?(n) asks the same FROM/JOIN/WHERE as count_sql then
LIMIT 1 OFFSET n, without mutating the relation (offset would) and
without hydrating. Model.any?/none? take the same exists? probe.
Drop unused offset_row_exists?; share loaded_records between each
and find_each; qualify exists?(id) with the primary key.

Co-Authored-By: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Thomas Klemm <github@tklemm.eu>

* Force more_than? parens; pin emit_and_run and typing ceilings

Operator `count > n` is unparenthesized, so the rewrite emitted
`more_than? PAGE_SIZE`. Always parenthesize. Overlay more_than? onto
real-blog so treeshake keeps the method. RBS untyped 1386 -> 1395;
runtime_src gradual 571 -> 560.

Co-Authored-By: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Thomas Klemm <github@tklemm.eu>

* Keep Base.any?/none? free of Relation for strict targets

Strict targets transpile Base without ActiveRecord::Relation. Move
the SELECT-1 class emptiness probe to the ruby-family connection.rb
reopen (same pattern as where/all). Also: last_page? short-circuit
only for a non-empty short page, so an empty out-of-range page is
not reported as last.

Co-Authored-By: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Thomas Klemm <github@tklemm.eu>

* Fix CI: connection any? RBS, C#/Kotlin empty opts maps

connection.rb's Base.any?/none? reopen lacked RBS (unit fully-typed
gate). EMPTY_HTML_OPTS as a module constant emitted as object? on C#
and untyped-key empty `{}` args became MutableMap<Any?, Any?> on
Kotlin — pin Dictionary/String-keyed maps and align the RBS with
helper opts params.

Co-Authored-By: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Thomas Klemm <github@tklemm.eu>

* Trim chatty comments on Relation, helpers, and emit pins

Keep the traps (strict COUNT, DISTINCT collapse, offset mutates,
include? vs match?). Drop duplicate link_to notes and Campfire
asides that restated the code.

Co-Authored-By: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Thomas Klemm <github@tklemm.eu>

* Correct gradual typing ceiling: 521 was a mis-measure (562)

Base.any?/none? COUNT + connection.rb exists? reopen still pays
Relation.new sites this probe counts. Earlier -39 claim was wrong;
measured residual is 562.

Co-Authored-By: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Thomas Klemm <github@tklemm.eu>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Enable date-only schema columns in the Spinel target with a program-defined Date, ISO SQL date seams, and JSON serialization. Add native emit-and-run coverage and preserve the date boundary on other targets.

Refs rubys#303

Co-Authored-By: Codex <noreply@openai.com>
Only emit the program-defined Date class, its RBS, date JSON reopen,
and boot requires when the app uses date values. Loading Date#strftime
into every Spinel tree currently breaks poly Time|Date receivers
(matz/spinel#7334), which 500'd Campfire on /session/new.

Also: format_db_date treats "" as nil; escape_value formats Date as
YYYY-MM-DD when Date is loaded; analyzer-surface inspect/xmlschema;
CI downloads the real-blog fixture for date_columns_spinel; ci-plan
owns the serialization file.

Refs rubys#303

Co-Authored-By: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Thomas Klemm <github@tklemm.eu>
@coderabbitai

coderabbitai Bot commented Oct 5, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@thomasklemm thomasklemm closed this Oct 5, 2026
@thomasklemm

Copy link
Copy Markdown
Owner Author

Closed: opened against the fork by mistake. Spinel meta work continues on upstream rubys#451.

Sign up for free to 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.