diff --git a/runtime/ruby/action_text.rb b/runtime/ruby/action_text.rb
index cdbed6b65..7eae63867 100644
--- a/runtime/ruby/action_text.rb
+++ b/runtime/ruby/action_text.rb
@@ -1165,8 +1165,37 @@ def as_json
@html
end
+ # Empty / whitespace-only markup is blank without scanning. Non-empty
+ # shells (`
`, `
`) still need to_plain_text —
+ # empty blockquotes become curly quotes and are not blank.
def blank?
- to_plain_text == ""
+ html = @html
+ return true if html.nil?
+ n = html.length
+ return true if n == 0
+ i = 0
+ while i < n
+ c = html[i, 1].to_s
+ unless c == " " || c == "\t" || c == "\n" || c == "\r" || c == "\f"
+ # Entity-decoded plain text (` ` → " ") is blank when
+ # whitespace-only. Scan here: ActionText::Content must not
+ # resolve `ActiveSupport` through this class (emitted tests
+ # do not load the ActiveSupport module in this namespace).
+ text = to_plain_text
+ j = 0
+ m = text.length
+ while j < m
+ d = text[j, 1].to_s
+ unless d == " " || d == "\t" || d == "\n" || d == "\r" || d == "\f"
+ return false
+ end
+ j = j + 1
+ end
+ return true
+ end
+ i = i + 1
+ end
+ true
end
def empty?
@@ -1367,8 +1396,22 @@ def convert_html_to_plain_text
i = i + 1
end
else
- out = out + c if skipping == ""
- i = i + 1
+ # One slice for a run of ordinary text — avoids O(n) 1-char
+ # Strings + concatenations on Writebook Page#plain_text /
+ # Campfire ActionText bodies. Keep [i,1] only to find the
+ # next markup boundary (portable; one-arg index is not).
+ if skipping == ""
+ start = i
+ i = i + 1
+ while i < n
+ nc = @html[i, 1].to_s
+ break if nc == "<" || nc == "&"
+ i = i + 1
+ end
+ out = out + @html[start, i - start].to_s
+ else
+ i = i + 1
+ end
end
end
Content.chomp_newlines(out)
diff --git a/runtime/ruby/action_view/view_helpers.rb b/runtime/ruby/action_view/view_helpers.rb
index 2414823ac..5f4c68805 100644
--- a/runtime/ruby/action_view/view_helpers.rb
+++ b/runtime/ruby/action_view/view_helpers.rb
@@ -1034,7 +1034,9 @@ def self.escape_or_empty(value)
# a `next unless` — the same kotlin gap sits latent there.)
def self.render_attrs(attrs)
return "" if attrs.empty?
- pairs = []
+ # Concat, not `<<`: `out << s` lowers as `.add` / write-into-`&str`
+ # on Kotlin/C#/Rust/Python/Elixir. Same shape as `sanitize_to_id`.
+ out = ""
attrs.each do |k, v|
# The name bindings sit ABOVE the nil guards on purpose: the
# TypeScript emitter declares a local where it is FIRST
@@ -1053,7 +1055,7 @@ def self.render_attrs(attrs)
# `(String) -> String` and the untyped values flowing
# through Hash[String, untyped] need explicit
# stringification.
- pairs << " #{name}-#{inner_name}=\"#{html_escape(inner_v.to_s)}\""
+ out = out + " #{name}-#{inner_name}=\"#{html_escape(inner_v.to_s)}\""
end
end
elsif boolean_attr?(name)
@@ -1071,13 +1073,13 @@ def self.render_attrs(attrs)
# Rails (truthy) and is omitted here — no corpus site
# writes one, and literal sites lower through the
# compile-time loops, not this method.
- pairs << " #{name}=\"#{name}\"" unless v.to_s == "false"
+ out = out + " #{name}=\"#{name}\"" unless v.to_s == "false"
else
- pairs << " #{name}=\"#{html_escape(attr_value_text(name, v))}\""
+ out = out + " #{name}=\"#{html_escape(attr_value_text(name, v))}\""
end
end
end
- pairs.join
+ out
end
# The TEXT of one attribute value, before escaping: `to_s` here,
diff --git a/runtime/ruby/action_view/view_helpers_ext.rb b/runtime/ruby/action_view/view_helpers_ext.rb
index ef62491c4..8c0e11091 100644
--- a/runtime/ruby/action_view/view_helpers_ext.rb
+++ b/runtime/ruby/action_view/view_helpers_ext.rb
@@ -354,8 +354,14 @@ def self.strip_tags(html)
i = i + len
end
else
- out = out + c
+ start = i
i = i + 1
+ while i < n
+ nc = s[i, 1].to_s
+ break if nc == "<" || nc == ">" || nc == "&"
+ i = i + 1
+ end
+ out = out + s[start, i - start].to_s
end
end
out
@@ -448,44 +454,71 @@ def self.sanitize_allowing(html, tags, attributes)
# literal (`ActionText::SafeListSanitizer.allowed_attributes`).
# Rails::HTML5::SafeListSanitizer.allowed_tags, 1.7.1.
+ # Frozen memo: sanitize/auto_link call these every pass; rebuilding
+ # ~40-element Arrays per call was pure alloc on Writebook TOC /
+ # Campfire message sanitize. Callers must not mutate the result.
def self.sanitize_default_tags
- ["a", "abbr", "acronym", "address", "b", "big", "blockquote",
+ cached = @sanitize_default_tags
+ return cached unless cached.nil?
+ cached = ["a", "abbr", "acronym", "address", "b", "big", "blockquote",
"br", "cite", "code", "dd", "del", "dfn", "div", "dl", "dt",
"em", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "img",
"ins", "kbd", "li", "mark", "ol", "p", "pre", "samp", "small",
- "span", "strong", "sub", "sup", "time", "tt", "ul", "var"]
+ "span", "strong", "sub", "sup", "time", "tt", "ul", "var"].freeze
+ @sanitize_default_tags = cached
+ cached
end
# Rails::HTML5::SafeListSanitizer.allowed_attributes, 1.7.1.
def self.sanitize_default_attributes
- ["abbr", "alt", "cite", "class", "datetime", "height", "href",
- "lang", "name", "src", "title", "width", "xml:lang"]
+ cached = @sanitize_default_attributes
+ return cached unless cached.nil?
+ cached = ["abbr", "alt", "cite", "class", "datetime", "height", "href",
+ "lang", "name", "src", "title", "width", "xml:lang"].freeze
+ @sanitize_default_attributes = cached
+ cached
end
# Loofah::HTML5::SafeList::ATTR_VAL_IS_URI — the attributes whose
# value gets the protocol check.
def self.sanitize_uri_attributes
- ["action", "cite", "href", "longdesc", "poster", "preload",
- "src", "xlink:href", "xml:base"]
+ cached = @sanitize_uri_attributes
+ return cached unless cached.nil?
+ cached = ["action", "cite", "href", "longdesc", "poster", "preload",
+ "src", "xlink:href", "xml:base"].freeze
+ @sanitize_uri_attributes = cached
+ cached
end
# Loofah::HTML5::SafeList::ALLOWED_PROTOCOLS.
def self.sanitize_allowed_protocols
- ["afs", "aim", "callto", "data", "ed2k", "fax", "ftp", "gopher",
+ cached = @sanitize_allowed_protocols
+ return cached unless cached.nil?
+ cached = ["afs", "aim", "callto", "data", "ed2k", "fax", "ftp", "gopher",
"http", "https", "irc", "line", "mailto", "modem", "news",
"nntp", "rsync", "rtsp", "sftp", "sms", "ssh", "tag", "tel",
- "telnet", "urn", "webcal", "xmpp"]
+ "telnet", "urn", "webcal", "xmpp"].freeze
+ @sanitize_allowed_protocols = cached
+ cached
end
# Loofah::HTML5::SafeList::ALLOWED_URI_DATA_MEDIATYPES.
def self.sanitize_data_mediatypes
- ["image/gif", "image/jpeg", "image/png", "text/css", "text/plain"]
+ cached = @sanitize_data_mediatypes
+ return cached unless cached.nil?
+ cached = ["image/gif", "image/jpeg", "image/png", "text/css", "text/plain"].freeze
+ @sanitize_data_mediatypes = cached
+ cached
end
# Loofah::HTML5::SafeList::VOID_ELEMENTS — serialized with no close
# tag, never pushed on the open stack.
def self.sanitize_void_elements
- ["area", "br", "hr", "img", "input"]
+ cached = @sanitize_void_elements
+ return cached unless cached.nil?
+ cached = ["area", "br", "hr", "img", "input"].freeze
+ @sanitize_void_elements = cached
+ cached
end
# HTML5 RAWTEXT / escapable-rawtext containers: their content is
@@ -495,13 +528,21 @@ def self.sanitize_void_elements
# `noscript` is deliberately absent — with scripting off, which is
# how the gem's parser runs, its children parse as markup.
def self.sanitize_rawtext_elements
- ["iframe", "noembed", "noframes", "plaintext", "script", "style",
- "textarea", "title", "xmp"]
+ cached = @sanitize_rawtext_elements
+ return cached unless cached.nil?
+ cached = ["iframe", "noembed", "noframes", "plaintext", "script", "style",
+ "textarea", "title", "xmp"].freeze
+ @sanitize_rawtext_elements = cached
+ cached
end
# Allow-list entries this port refuses to serve (see the header).
def self.sanitize_unservable_tags
- sanitize_rawtext_elements + ["svg", "math", "template"]
+ cached = @sanitize_unservable_tags
+ return cached unless cached.nil?
+ cached = (sanitize_rawtext_elements + ["svg", "math", "template"]).freeze
+ @sanitize_unservable_tags = cached
+ cached
end
def self.sanitize_space?(c)
@@ -635,8 +676,15 @@ def self.sanitize_engine(s, tags, attributes)
i = i + len
end
else
- out = out + c
+ # Batch ordinary text until the next markup-sensitive char.
+ start = i
i = i + 1
+ while i < n
+ nc = s[i, 1].to_s
+ break if nc == "<" || nc == ">" || nc == "&"
+ i = i + 1
+ end
+ out = out + s[start, i - start].to_s
end
end
# Close anything the input left open, innermost first — measured:
@@ -805,8 +853,14 @@ def self.sanitize_text(t)
i = i + len
end
else
- out = out + c
+ start = i
i = i + 1
+ while i < n
+ nc = t[i, 1].to_s
+ break if nc == "<" || nc == ">" || nc == "&"
+ i = i + 1
+ end
+ out = out + t[start, i - start].to_s
end
end
out
diff --git a/runtime/ruby/action_view/view_helpers_ext.rbs b/runtime/ruby/action_view/view_helpers_ext.rbs
index e40fdcf1f..6387384d5 100644
--- a/runtime/ruby/action_view/view_helpers_ext.rbs
+++ b/runtime/ruby/action_view/view_helpers_ext.rbs
@@ -34,6 +34,14 @@ module ActionView
def self.sanitize_void_elements: () -> Array[String]
def self.sanitize_rawtext_elements: () -> Array[String]
def self.sanitize_unservable_tags: () -> Array[String]
+ self.@sanitize_default_tags: Array[String]?
+ self.@sanitize_default_attributes: Array[String]?
+ self.@sanitize_uri_attributes: Array[String]?
+ self.@sanitize_allowed_protocols: Array[String]?
+ self.@sanitize_data_mediatypes: Array[String]?
+ self.@sanitize_void_elements: Array[String]?
+ self.@sanitize_rawtext_elements: Array[String]?
+ self.@sanitize_unservable_tags: Array[String]?
def self.sanitize_space?: (String c) -> bool
def self.sanitize_tag_name: (String raw) -> String
def self.sanitize_foreign_end: (String s, Integer from, String name) -> Integer
diff --git a/runtime/ruby/active_record/base.rb b/runtime/ruby/active_record/base.rb
index 8c412c466..060f8b314 100644
--- a/runtime/ruby/active_record/base.rb
+++ b/runtime/ruby/active_record/base.rb
@@ -421,6 +421,13 @@ def self._adapter_count
ActiveRecord.adapter.count(table_name)
end
+ # Unscoped non-emptiness. Level-3 models override with SELECT 1 LIMIT 1;
+ # this default keeps hand-written / Base tests on the universal count
+ # adapter method (strict AdapterInterface has no select_rows).
+ def self._adapter_any?
+ count > 0
+ end
+
def self._adapter_exists_by_id?(id)
ActiveRecord.adapter.exists?(table_name, id)
end
@@ -595,14 +602,15 @@ def self.exists?(id)
_adapter_exists_by_id?(id)
end
- # Unscoped class emptiness via COUNT. Scoped forms go through
- # Relation; ruby-family connection.rb overrides these to exists?.
+ # Unscoped class emptiness via `_adapter_any?` (Level-3: SELECT 1
+ # LIMIT 1; Base default: count > 0). Scoped forms go through Relation;
+ # ruby-family connection.rb still overrides with Relation.exists?.
def self.none?
- count == 0
+ !_adapter_any?
end
def self.any?
- count > 0
+ _adapter_any?
end
# Bulk DELETE without instantiating records or running callbacks —
diff --git a/runtime/ruby/active_record/base.rbs b/runtime/ruby/active_record/base.rbs
index b5a0b1008..c335f61cf 100644
--- a/runtime/ruby/active_record/base.rbs
+++ b/runtime/ruby/active_record/base.rbs
@@ -169,6 +169,11 @@ module ActiveRecord
def _note_unloaded: (Hash[String, untyped] _row) -> void
# Persistence state — backed by `@persisted` / `@destroyed`.
+ # Declared so RBS-seeded typing (and the Spinel AR RBS probe) does
+ # not leave the readers as Ty::Var when a stem other than base.rb
+ # is the only harvest surface for ActiveRecord::Base.
+ @persisted: bool
+ @destroyed: bool
def persisted?: () -> bool
def new_record?: () -> bool
def destroyed?: () -> bool
@@ -199,6 +204,7 @@ module ActiveRecord
def _adapter_update: () -> void
def _adapter_delete: () -> void
def self._adapter_count: () -> Integer
+ def self._adapter_any?: () -> bool
def self._adapter_exists_by_id?: (Integer id) -> bool
def self._adapter_truncate: () -> void
def _adapter_reload: () -> Base?
diff --git a/runtime/ruby/active_record/connection.rb b/runtime/ruby/active_record/connection.rb
index 32bf74bc2..d3606f51f 100644
--- a/runtime/ruby/active_record/connection.rb
+++ b/runtime/ruby/active_record/connection.rb
@@ -161,22 +161,54 @@ def self.connection
# drops a trailing empty field, so `"… where rowid = ?"` splits to
# one part, and appending a bind after each part while binds remain
# reconstructs it exactly. A `?` with no bind left is dropped, which
- # is the same shape `substitute_binds` leaves it in.
+ # is the same shape `substitute_binds` leaves it in. Question marks
+ # inside single- or double-quoted SQL literals are not placeholders.
+ # SQLite does not treat `\` as a string escape — a backslash is
+ # literal, so `'\''` ends the quote at the second apostrophe.
def self.sanitize_sql(statement)
- parts = statement[0].to_s.split("?")
+ sql = statement[0].to_s
out = ""
+ bind = 1
i = 0
- while i < parts.length
- out = out + parts[i].to_s
- bind = i + 1
- if bind < statement.length
- out = out + ActiveRecord.adapter.escape_value(statement[bind])
+ n = sql.length
+ quote = nil
+ while i < n
+ c = sql[i, 1].to_s
+ if !quote.nil?
+ out = out + c
+ quote = nil if c == quote
+ i = i + 1
+ next
end
- i += 1
+ if c == "'" || c == "\""
+ quote = c
+ out = out + c
+ i = i + 1
+ next
+ end
+ if c == "?"
+ if bind < statement.length
+ out = out + ActiveRecord.adapter.escape_value(statement[bind])
+ bind = bind + 1
+ end
+ i = i + 1
+ next
+ end
+ out = out + c
+ i = i + 1
end
out
end
+ # Rails' array-form entry point (`sanitize_sql_array([...])`). Same
+ # positional `?` interleave as `sanitize_sql` — apps that name the
+ # `_array` form (raw upserts, hand-built fragments) must resolve
+ # here (#400). Named Hash / `%s` binds are not modeled yet (would
+ # raise the Bar B untyped residual via Hash[untyped] walks).
+ def self.sanitize_sql_array(statement)
+ sanitize_sql(statement)
+ end
+
# `Model.transaction { ... }` — the block inside BEGIN/COMMIT, with
# ROLLBACK + re-raise on any exception. Flat transactions only: the
# corpus never nests (a nested BEGIN would error in SQLite rather
diff --git a/runtime/ruby/active_record/connection.rbs b/runtime/ruby/active_record/connection.rbs
index 9dc47d58f..80eb9df0d 100644
--- a/runtime/ruby/active_record/connection.rbs
+++ b/runtime/ruby/active_record/connection.rbs
@@ -28,6 +28,7 @@ module ActiveRecord
class Base
def self.connection: () -> Connection
def self.sanitize_sql: (Array[untyped] statement) -> String
+ def self.sanitize_sql_array: (Array[untyped] statement) -> String
def self.transaction: () { () -> untyped } -> untyped
def self.update_counters: (untyped id, Hash[Symbol, untyped] counters) -> Integer
def self.upsert: (Hash[Symbol, untyped] attrs, ?unique_by: untyped, ?on_duplicate: String?, ?returning: untyped) -> Integer
diff --git a/runtime/ruby/active_record/relation.rb b/runtime/ruby/active_record/relation.rb
index e9ef86ba3..f04ff9df8 100644
--- a/runtime/ruby/active_record/relation.rb
+++ b/runtime/ruby/active_record/relation.rb
@@ -57,7 +57,12 @@ def initialize(model)
def with_recursive(ctes)
@records = nil
ctes.each do |name, parts|
- @ctes << "#{name} AS (#{parts.map { |p| p.to_sql }.join(" UNION ALL ")})"
+ # Build the UNION list with pushes rather than `map.join`: the
+ # body typer's Array#map still returns Untyped, so the join
+ # terminal would stay Ty::Var under RBS-seeded typing.
+ sql_parts = []
+ parts.each { |p| sql_parts << p.to_sql }
+ @ctes << "#{name} AS (#{sql_parts.join(" UNION ALL ")})"
end
self
end
@@ -818,7 +823,7 @@ def ==(other)
# projection `select(*specs)` above monomorphic.
def filter
out = []
- to_a.each { |x| out << x if yield x }
+ loaded_records.each { |x| out << x if yield x }
out
end
@@ -910,7 +915,7 @@ def each
# contract; lobsters keys tag filters by id).
def index_by
h = {}
- to_a.each { |x| h[yield x] = x }
+ loaded_records.each { |x| h[yield x] = x }
h
end
@@ -928,8 +933,11 @@ def find_each
self
end
+ # Via loaded_records (not to_a): no shallow Array copy of the
+ # memoized rows. to_a keeps its Rails dup contract for callers that
+ # mutate the returned array.
def map
- to_a.map { |x| yield x }
+ loaded_records.map { |x| yield x }
end
# `collect` is Enumerable's second name for `map`, and Rails
@@ -940,7 +948,7 @@ def map
# definition, not an alias, and a body forwarding to `map` would
# have to forward the block too.
def collect
- to_a.map { |x| yield x }
+ loaded_records.map { |x| yield x }
end
# `group_by { |rec| key }` — Enumerable's grouping over the
@@ -950,7 +958,7 @@ def collect
# `[]=`-chaining on a maybe-missing key.
def group_by
out = {}
- to_a.each do |rec|
+ loaded_records.each do |rec|
k = yield rec
arr = out.fetch(k, nil)
if arr.nil?
@@ -967,7 +975,7 @@ def group_by
# writes `@administrators, @members = users.partition(&:administrator?)`
# straight off a `User.where(...)`.
def partition
- to_a.partition { |x| yield x }
+ loaded_records.partition { |x| yield x }
end
# `detect { |r| … }` — Enumerable's first match, nil when none.
@@ -1203,7 +1211,7 @@ def many?
# call-sites treat it as the array Rails hands back).
def all?
ok = true
- to_a.each { |x| ok = false unless yield x }
+ loaded_records.each { |x| ok = false unless yield x }
ok
end
@@ -1247,7 +1255,7 @@ def more_than?(n)
end
def length
- to_a.length
+ loaded_records.length
end
# Rails' `Relation#size`: length when loaded; COUNT when unloaded
@@ -1407,13 +1415,16 @@ def pick(col)
rows.length == 0 ? nil : rows[0]
end
- # `ids` — primary keys, as integers.
+ # `ids` — primary keys, cast through the model's key type. Reads
+ # `@model.primary_key` (not a hard-coded `id` column) and casts the
+ # way `find` does, so uuid / string keys survive (#310).
def ids
prior = @select_sql
- @select_sql = "#{@table}.id AS v"
+ key = @model.primary_key
+ @select_sql = "#{@table}.#{key} AS v"
rows = ActiveRecord.adapter.select_rows(to_sql)
@select_sql = prior
- rows.map { |row| row["v"].to_i }
+ rows.map { |row| @model._cast_primary_key(row["v"]) }
end
# `find(id)` — the row with that primary key, RAISING
@@ -1605,13 +1616,58 @@ def select_sql_with(default_cols)
sql
end
- def count_sql
- sql = "#{cte_prefix}SELECT COUNT(*) AS n FROM #{from_source}"
+ # JOIN + WHERE only — shared by count_sql / exists_sql so DISTINCT
+ # and GROUP arms do not re-paste the ladder.
+ def append_join_where(sql)
sql = "#{sql} #{@joins.join(" ")}" if @joins.length > 0
sql = "#{sql} WHERE #{@wheres.join(" AND ")}" if @wheres.length > 0
sql
end
+ def append_group_having(sql)
+ sql = "#{sql} GROUP BY #{@groups.join(", ")}" if @groups.length > 0
+ sql = "#{sql} HAVING #{@havings.join(" AND ")}" if @havings.length > 0
+ sql
+ end
+
+ def count_sql
+ # DISTINCT / GROUP BY must count the result-set shape, not the
+ # underlying rows (#343). Mirror exists_sql's DISTINCT-pk
+ # discipline; scalar `count` on a grouped relation counts groups
+ # (Hash form is `group_count`). LIMIT/OFFSET stay off total_count.
+ if !@groups.empty?
+ # Keep an explicit projection so HAVING can name selected
+ # aliases (`select("COUNT(*) AS n").having("n > 1")`).
+ cols = @select_sql.nil? ? "1 AS one" : @select_sql
+ dist = @distinct ? "DISTINCT " : ""
+ inner = append_group_having(
+ append_join_where("#{cte_prefix}SELECT #{dist}#{cols} FROM #{from_source}")
+ )
+ return "SELECT COUNT(*) AS n FROM (#{inner}) AS __rh_count"
+ end
+ if @distinct
+ # `select(:title).distinct.count` counts distinct titles, not pks.
+ # When `from(...)` replaces the model table, drop the model-table
+ # qualifier so the key projects from the active FROM source.
+ cols = if !@select_sql.nil?
+ @select_sql
+ elsif @from.nil?
+ "#{@table}.#{@model.primary_key}"
+ elsif @joins.length > 0
+ # Bare pk is ambiguous once another joined table also has
+ # that column (`from("parents").joins(...).distinct.count`).
+ "#{from_source}.#{@model.primary_key}"
+ else
+ @model.primary_key.to_s
+ end
+ inner = append_join_where(
+ "#{cte_prefix}SELECT DISTINCT #{cols} FROM #{from_source}"
+ )
+ return "SELECT COUNT(*) AS n FROM (#{inner}) AS __rh_count"
+ end
+ append_join_where("#{cte_prefix}SELECT COUNT(*) AS n FROM #{from_source}")
+ end
+
# `SELECT 1 AS one … LIMIT n` for existence probes. Drops ORDER BY
# and never hydrates. Keeps joins / WHERE / GROUP / HAVING / OFFSET.
def exists_sql(n)
@@ -1623,11 +1679,9 @@ def exists_sql(n)
else
"1 AS one"
end
- sql = "#{cte_prefix}SELECT #{cols} FROM #{from_source}"
- sql = "#{sql} #{@joins.join(" ")}" if @joins.length > 0
- sql = "#{sql} WHERE #{@wheres.join(" AND ")}" if @wheres.length > 0
- sql = "#{sql} GROUP BY #{@groups.join(", ")}" if @groups.length > 0
- sql = "#{sql} HAVING #{@havings.join(" AND ")}" if @havings.length > 0
+ sql = append_group_having(
+ append_join_where("#{cte_prefix}SELECT #{cols} FROM #{from_source}")
+ )
# Respect an existing relation LIMIT: many?/one? on limit(1) must
# not look past the window.
lim = n
diff --git a/runtime/ruby/active_record/relation.rbs b/runtime/ruby/active_record/relation.rbs
index c4dd07b11..282af3c50 100644
--- a/runtime/ruby/active_record/relation.rbs
+++ b/runtime/ruby/active_record/relation.rbs
@@ -4,10 +4,24 @@ module ActiveRecord
# type is `untyped` in v1 — the lowered model methods carry the model
# identity; per-element typing is a later (generic-Relation) refinement.
class Relation
- def initialize: (untyped model) -> void
+ # Model handle is `Base` (class object collapsed onto `Ty::Class{Base}`
+ # in send.rs). Do not spell `Class` — that leaves `@model` as Ty::Var
+ # and breaks Bar A. Spinel's emit rewrites this parameter to
+ # `untyped` (`spinel_relation_model_handle`): Spinel treats `Base` as
+ # an instance (`sp_ActiveRecord__Base *`), so `Relation.new(User)`
+ # would otherwise fail campfire AOT.
+ def initialize: (Base model) -> void
def where: (?untyped condition, *untyped args) -> Relation
+ # Query ivars the body reads after builders write them. Declared so
+ # probes / RBS-seeded typing do not freeze on `initialize`'s `nil`
+ # assignment (first-write harvest) and leave `page` / `current_page`
+ # / loaded Enumerable terminals as Ty::Var.
+ @limit: Integer?
+ @offset: Integer?
+ @records: Array[untyped]?
+
@from: String?
@ctes: Array[String]
def with_recursive: (Hash[Symbol, Array[Relation]] ctes) -> Relation
@@ -30,18 +44,19 @@ module ActiveRecord
def add_condition: (untyped condition, Array[untyped] args, bool negate) -> bool
def order: (*untyped parts) -> Relation
# The in-memory sort `order` applies to a LOADED relation (see the
- # .rb). `sorted` is `untyped` for the same reason `preloaded`'s
- # `records` is: the body only indexes and assigns into it.
- def sort_in_place!: (untyped sorted, Array[String] terms) -> bool
+ # .rb). Element type stays untyped until Relation[T]; the array
+ # representation is honest — only index/length/map appear here
+ # (unlike `preloaded`, which must stay bare for Spinel integer seeds).
+ def sort_in_place!: (Array[untyped] sorted, Array[String] terms) -> bool
def order_column: (String term) -> String?
def order_descending?: (String term) -> bool
def reverse_order_term: (String term) -> String
def reverse_one_order_term: (String term) -> String
- def loaded_tail: (untyped loaded, Integer n) -> Array[untyped]
+ def loaded_tail: (Array[untyped] loaded, Integer n) -> Array[untyped]
# The keys are whatever the ordered column holds, and `<=>` on two
# of them is an Integer or nil -- the only honest spellings.
def compare_order_keys: (untyped a, untyped b) -> Integer?
- def order_key_of: (untyped record, String col) -> untyped
+ def order_key_of: (Base record, String col) -> untyped
def where!: (?untyped condition, *untyped args) -> Relation
def order!: (*untyped parts) -> Relation
def reorder: (*untyped parts) -> Relation
@@ -127,7 +142,7 @@ module ActiveRecord
def operand_ids: (untyped other) -> Array[untyped]
def ids_of: (Array[untyped] records) -> Array[untyped]
def id_filter: (Array[untyped] records, Array[untyped] ids, bool keep) -> Array[untyped]
- def include?: (untyped record) -> bool
+ def include?: (Base? record) -> bool
def each: () { (untyped) -> void } -> Relation
def index_by: () { (untyped) -> untyped } -> Hash[untyped, untyped]
def find_each: () { (untyped) -> void } -> Relation
@@ -145,10 +160,10 @@ module ActiveRecord
def sort_by: () { (untyped) -> untyped } -> Array[untyped]
def inject: (untyped initial) { (untyped, untyped) -> untyped } -> untyped
def each_with_object: (untyped memo) { (untyped, untyped) -> untyped } -> untyped
- def first: () -> untyped
- def take: () -> untyped
- def first!: () -> untyped
- def last: () -> untyped
+ def first: () -> Base?
+ def take: () -> Base?
+ def first!: () -> Base
+ def last: () -> Base?
def first_n: (Integer n) -> Array[untyped]
def last_n: (Integer n) -> Array[untyped]
def count: () -> Integer
@@ -190,22 +205,24 @@ module ActiveRecord
#
# `untyped` is the honest type at this level: the column's type is
# not knowable from `Relation`. Typing it precisely means the
- # lowerer monomorphizing per call site from the schema, the way
- # `ids` below can be Array[Integer] because it only ever reads a
- # primary key.
+ # lowerer monomorphizing per call site from the schema.
+ # `ids` is Array[Integer] for integer-key apps; string/uuid apps
+ # widen it via `project::widen_key_contract` (#310).
def pluck: (untyped col) -> Array[untyped]
def pick: (untyped col) -> untyped
def ids: () -> Array[Integer]
def find: (Integer | String | Array[Integer | String] id) -> untyped
def find_ids: (Array[Integer | String] ids) -> Array[untyped]
- def find_by: (untyped conditions) -> untyped
- def find_by!: (untyped conditions) -> untyped
- def first_or_initialize: () -> untyped
- def find_or_create_by: (Hash[Symbol, untyped] conditions) -> untyped
+ def find_by: (untyped conditions) -> Base?
+ def find_by!: (untyped conditions) -> Base
+ def first_or_initialize: () -> Base
+ def find_or_create_by: (Hash[Symbol, untyped] conditions) -> Base
def to_sql: () -> String
def to_subquery_sql: () -> String
def select_sql_with: (String default_cols) -> String
+ def append_join_where: (String sql) -> String
+ def append_group_having: (String sql) -> String
def count_sql: () -> String
def exists_sql: (Integer n) -> String
def hash_conditions: (untyped hash) -> String
diff --git a/runtime/ruby/test/action_text_test.rb b/runtime/ruby/test/action_text_test.rb
index 6ca7f0781..4f212449f 100644
--- a/runtime/ruby/test/action_text_test.rb
+++ b/runtime/ruby/test/action_text_test.rb
@@ -256,8 +256,13 @@ def test_to_html_is_the_stored_markup_and_to_s_wraps_it
def test_blank_tracks_plain_text_not_markup
assert ActionText::Content.new("").blank?
+ assert ActionText::Content.new(" \n\t").blank?
assert ActionText::Content.new("
").blank?
assert ActionText::Content.new("
").blank?
+ # Entity-decoded whitespace (` ` → " ") is blank, matching
+ # ActiveSupport — not only an empty plain-text string.
+ assert ActionText::Content.new(" ").blank?
+ assert ActionText::Content.new("
").blank?
refute ActionText::Content.new("x
").blank?
assert ActionText::Content.new("x
").present?
end
diff --git a/runtime/ruby/test/active_record/base_test.rb b/runtime/ruby/test/active_record/base_test.rb
index 9fb93de06..ba6aec732 100644
--- a/runtime/ruby/test/active_record/base_test.rb
+++ b/runtime/ruby/test/active_record/base_test.rb
@@ -403,6 +403,93 @@ def test_relation_distinct_many_and_one_see_separate_rows
assert ActiveRecord::Relation.new(Item).where(title: "T0").distinct.one?
end
+ def test_relation_distinct_count_counts_distinct_pks
+ 5.times { |i| it = Item.new; it.title = "T#{i % 2}"; it.save() }
+ # Five rows, two title values. Distinct on primary key is still 5 —
+ # count_sql must not answer the underlying non-distinct row total
+ # via a bare COUNT(*) that ignores DISTINCT (#343).
+ assert_equal 5, ActiveRecord::Relation.new(Item).distinct.count
+ sql = ActiveRecord::Relation.new(Item).distinct.count_sql
+ assert_match(/DISTINCT/, sql)
+ assert_match(/__rh_count/, sql)
+ end
+
+ def test_relation_select_distinct_count_uses_projection
+ 5.times { |i| it = Item.new; it.title = "T#{i % 2}"; it.save() }
+ rel = ActiveRecord::Relation.new(Item).select("title").distinct
+ assert_equal 2, rel.count
+ sql = rel.count_sql
+ assert_match(/DISTINCT/, sql)
+ assert_match(/title/, sql)
+ end
+
+ def test_relation_grouped_count_sql_counts_groups
+ 4.times { |i| it = Item.new; it.title = "T#{i % 2}"; it.save() }
+ rel = ActiveRecord::Relation.new(Item).group("title")
+ sql = rel.count_sql
+ assert_match(/GROUP BY/, sql)
+ assert_match(/__rh_count/, sql)
+ assert_equal 2, rel.count
+ end
+
+ def test_relation_grouped_count_sql_keeps_select_aliases
+ rel = ActiveRecord::Relation.new(Item)
+ .select("title, COUNT(*) AS n")
+ .group("title")
+ .having("n > 1")
+ sql = rel.count_sql
+ assert_match(/COUNT\(\*\) AS n/, sql)
+ assert_match(/HAVING/, sql)
+ end
+
+ def test_sanitize_sql_preserves_question_marks_in_quotes
+ sql = ActiveRecord::Base.sanitize_sql_array(
+ ["SELECT '?' AS marker, ? AS value", 42]
+ )
+ assert_equal "SELECT '?' AS marker, 42 AS value", sql
+ end
+
+ def test_sanitize_sql_backslash_is_literal_in_sqlite_quotes
+ # SQLite: backslash does not escape; the second `'` closes the string.
+ sql = ActiveRecord::Base.sanitize_sql_array(
+ ["SELECT '\\' AS slash, ? AS value", 42]
+ )
+ assert_equal "SELECT '\\' AS slash, 42 AS value", sql
+ end
+
+ def test_relation_grouped_distinct_count_sql_keeps_distinct
+ rel = ActiveRecord::Relation.new(Item)
+ .select("title")
+ .group("title")
+ .distinct
+ sql = rel.count_sql
+ assert_match(/DISTINCT/, sql)
+ assert_match(/GROUP BY/, sql)
+ end
+
+ def test_relation_from_distinct_count_sql_uses_bare_primary_key
+ rel = ActiveRecord::Relation.new(Item).from("parents").distinct
+ sql = rel.count_sql
+ assert_match(/FROM parents/, sql)
+ refute_match(/items\.id/, sql)
+ assert_match(/DISTINCT id/, sql)
+ end
+
+ def test_relation_from_joined_distinct_count_qualifies_primary_key
+ Db.exec("CREATE TABLE parents (id INTEGER PRIMARY KEY, title TEXT)")
+ Db.exec("CREATE TABLE children (id INTEGER PRIMARY KEY, parent_id INTEGER, title TEXT)")
+ Db.exec("INSERT INTO parents (id, title) VALUES (1, 'P')")
+ Db.exec("INSERT INTO children (id, parent_id, title) VALUES (10, 1, 'C')")
+ rel = ActiveRecord::Relation.new(Item)
+ .from("parents")
+ .joins("INNER JOIN children ON children.parent_id = parents.id")
+ .distinct
+ sql = rel.count_sql
+ assert_match(/DISTINCT parents\.id/, sql)
+ refute_match(/DISTINCT id FROM/, sql)
+ assert_equal 1, rel.count
+ end
+
def test_relation_each_does_not_rehydrate_and_returns_self
it = Item.new; it.title = "A"; it.save()
rel = ActiveRecord::Relation.new(Item)
diff --git a/src/analyze/mod.rs b/src/analyze/mod.rs
index 7f8f3dc5b..da1fb4b0a 100644
--- a/src/analyze/mod.rs
+++ b/src/analyze/mod.rs
@@ -6118,7 +6118,10 @@ fn is_clean_binding(ty: &Ty) -> bool {
}
}
-pub(crate) fn extract_ivar_assignments(expr: &Expr, out: &mut HashMap) {
+/// Harvest `@ivar = expr` / OpAssign / MultiAssign writes from a typed
+/// body, union-merging repeated assignments. Used by the analyzer's
+/// two-pass library typing and by the Spinel AR RBS probe.
+pub fn extract_ivar_assignments(expr: &Expr, out: &mut HashMap) {
match &*expr.node {
ExprNode::Assign { target: LValue::Ivar { name }, value } => {
if let Some(ty) = value.ty.clone() {
diff --git a/src/analyze/registry/ar.rs b/src/analyze/registry/ar.rs
index 17462d04d..1d3f8aab1 100644
--- a/src/analyze/registry/ar.rs
+++ b/src/analyze/registry/ar.rs
@@ -50,18 +50,28 @@ pub(in crate::analyze) fn register(classes: &mut HashMap) {
// real `active_record/base.rb` library file (none in practice)
// would still win.
{
- let mut base = ClassInfo::default();
+ // Prefer `entry().or_default()` so later registration cannot
+ // drop methods we seed here. Raw-SQL helpers live in
+ // `connection.rbs` / `connection.rb` (`sanitize_sql`,
+ // `sanitize_sql_array`, …) — without them on Base, app calls
+ // fail `send_dispatch` even though the runtime defines them
+ // (#400).
+ let base = classes
+ .entry(ClassId(Symbol::from("ActiveRecord::Base")))
+ .or_default();
for m in [
"transaction",
"connection_pool",
"establish_connection",
] {
- base.class_methods.insert(Symbol::from(m), Ty::Untyped);
+ base.class_methods.entry(Symbol::from(m)).or_insert(Ty::Untyped);
+ }
+ base.class_methods
+ .entry(Symbol::from("connection"))
+ .or_insert_with(connection_ty);
+ for m in ["sanitize_sql", "sanitize_sql_array"] {
+ base.class_methods.entry(Symbol::from(m)).or_insert(Ty::Str);
}
- base.class_methods.insert(Symbol::from("connection"), connection_ty());
- classes
- .entry(ClassId(Symbol::from("ActiveRecord::Base")))
- .or_insert(base);
}
// CollectionProxy — the runtime helper transpiled models use
diff --git a/src/ingest/app.rs b/src/ingest/app.rs
index 22373d47a..ac95f718a 100644
--- a/src/ingest/app.rs
+++ b/src/ingest/app.rs
@@ -362,6 +362,12 @@ pub fn ingest_app_with_vfs(vfs: &V, dir: &Path) -> IngestResult
};
let mut table_prefixes = super::model::TablePrefixes::new();
+ // Action Text engine `isolate_namespace` → `action_text_` prefix.
+ // Writebook's Markdown model lives under `module ActionText` without
+ // an app-declared `table_name_prefix`, but its schema table is
+ // `action_text_markdowns` (not `markdowns`). Seed the framework
+ // prefix so ordinary model ingest matches the gem.
+ table_prefixes.insert("ActionText".to_string(), "action_text_".to_string());
// Qualified enum arrays can live in a later file (e.g. a service
// module). Collect literal inputs before expanding any model DSL.
let mut enum_constants = super::model::EnumConstants::default();
@@ -425,6 +431,7 @@ pub fn ingest_app_with_vfs(vfs: &V, dir: &Path) -> IngestResult
if let Some(maybe_model) =
unwrap_or_record(ingest_model_with_enum_constants(
&source, &path_str, &app.schema, &table_prefixes, &enum_constants,
+ &model_bases,
))?
{
if let Some(model) = maybe_model {
@@ -577,6 +584,7 @@ pub fn ingest_app_with_vfs(vfs: &V, dir: &Path) -> IngestResult
if super::library_class::has_active_record_base(&source, &model_bases) {
match ingest_model_with_enum_constants(
&source, &path_str, &app.schema, &table_prefixes, &enum_constants,
+ &model_bases,
) {
Ok(Some(model)) => {
let outer = model.name.clone();
diff --git a/src/ingest/library_class.rs b/src/ingest/library_class.rs
index 82ea0b64c..5c126fbc2 100644
--- a/src/ingest/library_class.rs
+++ b/src/ingest/library_class.rs
@@ -2314,9 +2314,62 @@ impl ModelBases {
let mut names = std::collections::HashSet::new();
names.insert("ApplicationRecord".to_string());
names.insert("ActiveRecord::Base".to_string());
+ // Rails' Action Text abstract base (`ActionText::Record <
+ // ActiveRecord::Base; self.abstract_class = true`). The gem
+ // file is not ingested, but Writebook's
+ // `lib/rails_ext/action_text_markdown.rb` subclasses the
+ // lexical bare `Record` under `module ActionText`. Seeding the
+ // qualified name lets `has_active_record_base` + lexical
+ // resolution classify that class as a model rather than a
+ // library class that emits `class Markdown < Record`.
+ names.insert("ActionText::Record".to_string());
Self { names }
}
+ /// Is `name` (possibly after lexical qualification) an AR base?
+ pub fn contains(&self, name: &str) -> bool {
+ self.names.contains(name)
+ }
+
+ /// Superclass name written into emitted model IR. Gem abstract bases
+ /// that are seeded for classification but not ingested (today:
+ /// `ActionText::Record`) parent as `ApplicationRecord`, matching
+ /// RichText synthesis — callers must not special-case the name.
+ pub fn emit_superclass(&self, resolved: &str) -> String {
+ if resolved == "ActionText::Record" {
+ "ApplicationRecord".to_string()
+ } else {
+ resolved.to_string()
+ }
+ }
+
+ /// Resolve a superclass path against enclosing modules the way Ruby
+ /// constant lookup walks `module_parents`: bare `Record` under
+ /// `module ActionText` becomes `ActionText::Record` when that base
+ /// is known. Qualified paths are unchanged. Falls back to the
+ /// lexical spelling when no enclosing candidate is a known base.
+ pub fn resolve_superclass(&self, scope: &[String], parent_path: &[String]) -> String {
+ let joined = parent_path.join("::");
+ // Bare names: search enclosing scopes first (Ruby constant
+ // lookup). A global `ApplicationRecord` base must not win over
+ // a closer `Foo::ApplicationRecord` when both are known.
+ if parent_path.len() == 1 {
+ let bare = &parent_path[0];
+ let mut segs = scope.to_vec();
+ while !segs.is_empty() {
+ let candidate = format!("{}::{}", segs.join("::"), bare);
+ if self.contains(&candidate) {
+ return candidate;
+ }
+ segs.pop();
+ }
+ }
+ if self.contains(&joined) {
+ return joined;
+ }
+ joined
+ }
+
/// One file's `class X < Y` pairs, for the closure below — but
/// only for classes that declare themselves ABSTRACT.
///
@@ -2340,18 +2393,28 @@ impl ModelBases {
if !declares_abstract_class(&class) {
continue;
}
- pairs.push((full.join("::"), parent.join("::")));
+ // Resolve bare parents (`Record` under `module ActionText`)
+ // before close_over, which matches on the stored parent
+ // spelling against seeded qualified bases.
+ let parent = self.resolve_superclass(&scope, &parent);
+ pairs.push((full.join("::"), parent));
}
}
/// Close the set: anything whose parent is already a base is one.
/// Iterated rather than recursive because the pairs arrive in file
/// order, and a base can be declared after its user.
+ ///
+ /// `record` stores a bare parent (`MidBase`) when that name is not
+ /// yet a known base. After a later iteration inserts the qualified
+ /// form (`ActionText::MidBase`), match the stored spelling against
+ /// the child's enclosing modules the same way `resolve_superclass`
+ /// does at record time.
pub fn close_over(&mut self, pairs: &[(String, String)]) {
loop {
let before = self.names.len();
for (child, parent) in pairs {
- if self.names.contains(parent) {
+ if self.parent_is_known_base(child, parent) {
self.names.insert(child.clone());
}
}
@@ -2361,9 +2424,28 @@ impl ModelBases {
}
}
- fn contains(&self, name: &str) -> bool {
- self.names.contains(name)
+ fn parent_is_known_base(&self, child: &str, parent: &str) -> bool {
+ if self.names.contains(parent) {
+ return true;
+ }
+ if parent.contains("::") {
+ return false;
+ }
+ let mut segs: Vec<&str> = child.split("::").collect();
+ if segs.len() < 2 {
+ return false;
+ }
+ segs.pop();
+ while !segs.is_empty() {
+ let candidate = format!("{}::{}", segs.join("::"), parent);
+ if self.names.contains(&candidate) {
+ return true;
+ }
+ segs.pop();
+ }
+ false
}
+
}
/// Does this file's first class descend from an ActiveRecord base?
@@ -2376,21 +2458,25 @@ impl ModelBases {
/// own way, and routing it to the model path breaks that.
///
/// So the rule outside `app/models` is ancestry to ActiveRecord, and
-/// nothing else.
+/// nothing else. Lexical superclass resolution applies: bare `Record`
+/// under `module ActionText` matches the seeded `ActionText::Record`
+/// base (Writebook Markdown).
pub fn has_active_record_base(source: &[u8], bases: &ModelBases) -> bool {
let result = parse(source);
let root = result.node();
- let Some(class) = find_first_class(&root) else { return false };
+ let Some((scope, class)) = find_all_classes_with_scope(&root).into_iter().next() else {
+ return false;
+ };
class
.superclass()
.and_then(|n| constant_path_of(&n))
- .is_some_and(|p| bases.contains(&p.join("::")))
+ .is_some_and(|p| bases.contains(&bases.resolve_superclass(&scope, &p)))
}
pub fn classify_class_file(source: &[u8], bases: &ModelBases) -> Option {
let result = parse(source);
let root = result.node();
- let Some(class) = find_first_class(&root) else {
+ let Some((scope, class)) = find_all_classes_with_scope(&root).into_iter().next() else {
// No class node. A bare top-level module under app/models/
// (`module InactiveUser; def self.x; …; end`) is a namespace of
// singleton methods, not a model — classify it as a library
@@ -2407,7 +2493,7 @@ pub fn classify_class_file(source: &[u8], bases: &ModelBases) -> Option IngestResult> {
super::sources::register(file, &String::from_utf8_lossy(source));
let result = super::prism::parse(source, file);
@@ -114,7 +116,7 @@ pub(super) fn ingest_model_with_enum_constants(
let enum_owners = enum_constants.nesting
.get(&(file.to_string(), class.location().start_offset()))
.cloned().unwrap_or_default();
- let mut name_path = scope;
+ let mut name_path = scope.clone();
name_path.extend(class_name_path(&class).ok_or_else(|| IngestError::Unsupported {
file: file.into(),
message: "model class name must be a simple constant or path".into(),
@@ -347,7 +349,10 @@ pub(super) fn ingest_model_with_enum_constants(
}
let parent = class.superclass().and_then(|n| {
- constant_path_of(&n).map(|p| ClassId(Symbol::from(p.join("::"))))
+ constant_path_of(&n).map(|p| {
+ let resolved = model_bases.resolve_superclass(&scope, &p);
+ ClassId(Symbol::from(model_bases.emit_superclass(&resolved)))
+ })
});
let class_loc = class.location();
@@ -446,6 +451,105 @@ pub(super) fn ingest_model_body_items(
})
.collect());
}
+ // `cattr_*` / `mattr_*` — class-attribute expansion library
+ // ingest already applies. Models that carry class attrs
+ // (Writebook `ActionText::Markdown.mattr_accessor :renderer`)
+ // must synthesize the singleton reader/writer or `to_html`
+ // and inventory resolve as unresolved `renderer`.
+ //
+ // Plain `attr_*` stays Unknown here on purpose: concern
+ // `included` blocks share this walker, and
+ // `concern_accessors::{is_candidate,is_supported}` plus
+ // visibility's `included_has_accessor` gate all match the
+ // raw `attr_accessor` Send — expanding those into Method
+ // items made `included_has_accessor` false (so `private;`
+ // inside `included` hard-failed ingest) and dropped
+ // concern virtual accessors from the splice.
+ if matches!(
+ name.as_str(),
+ "cattr_reader"
+ | "cattr_writer"
+ | "cattr_accessor"
+ | "mattr_reader"
+ | "mattr_writer"
+ | "mattr_accessor"
+ ) {
+ let mut names: Vec = Vec::new();
+ let mut has_options = call.block().is_some();
+ if let Some(args) = call.arguments() {
+ for arg in args.arguments().iter() {
+ if let Some(s) = symbol_value(&arg) {
+ names.push(Symbol::from(s));
+ } else {
+ // `default:` / other kwargs are not modeled —
+ // partial expansion would drop the initializer.
+ has_options = true;
+ }
+ }
+ }
+ // Writebook uses `mattr_accessor :renderer, default:` and
+ // `cattr_accessor :preview_renderer do`. Expanding those
+ // would drop the initializer; erroring rewrote inventory
+ // Errors into ingest-gap Infos. Leave the Send unknown.
+ if has_options {
+ // fall through to ingest_model_body_item
+ } else {
+ let want_reader =
+ name.ends_with("_reader") || name.ends_with("_accessor");
+ let want_writer =
+ name.ends_with("_writer") || name.ends_with("_accessor");
+ let mut out = Vec::new();
+ for (i, attr) in names.iter().enumerate() {
+ let lead = if i == 0 {
+ leading_comments.clone()
+ } else {
+ Vec::new()
+ };
+ // mattr/cattr: class accessors plus instance accessors
+ // that share the same @ivar storage approximation used
+ // by library ingest (Rails instance copies read the
+ // class attribute; here both sides use the ivar).
+ let receivers = [
+ crate::dialect::MethodReceiver::Class,
+ crate::dialect::MethodReceiver::Instance,
+ ];
+ let mut first_method = true;
+ for &recv in &receivers {
+ if want_reader {
+ out.push(ModelBodyItem::Method {
+ method: super::library_class::synth_attr_reader(
+ owner, attr, recv,
+ ),
+ leading_comments: if first_method {
+ lead.clone()
+ } else {
+ Vec::new()
+ },
+ leading_blank_line: false,
+ });
+ first_method = false;
+ }
+ if want_writer {
+ out.push(ModelBodyItem::Method {
+ method: super::library_class::synth_attr_writer(
+ owner, attr, recv,
+ ),
+ leading_comments: if first_method {
+ lead.clone()
+ } else {
+ Vec::new()
+ },
+ leading_blank_line: false,
+ });
+ first_method = false;
+ }
+ }
+ }
+ if !out.is_empty() {
+ return Ok(out);
+ }
+ }
+ }
}
}
Ok(vec![ingest_model_body_item(stmt, owner, file, leading_comments)?])
diff --git a/src/lower/each_with_index.rs b/src/lower/each_with_index.rs
new file mode 100644
index 000000000..088167f32
--- /dev/null
+++ b/src/lower/each_with_index.rs
@@ -0,0 +1,419 @@
+//! `recv.each.with_index(offset?) { |item, index| … }` → `each_with_index`.
+//!
+//! Enumerator chaining (`each.with_index`) is a Spinel AOT subset gap when
+//! the block body makes a keyword call that closes over the index — Writebook's
+//! `Positionable#move_to_position` fails with:
+//!
+//! ```text
+//! a String is not yet shared by reference through an Array's chained index
+//! into an appending block
+//! ```
+//!
+//! `Array#each_with_index` compiles and means the same thing when the offset
+//! is zero / omitted. A non-zero offset becomes `index = __with_index_i + offset`
+//! at the top of the block so the caller's binding keeps its name and value.
+//!
+//! Shared home (not a Spinel-only emit patch): every target gets the flatter
+//! call, and CRuby/`each_with_index` semantics match `each.with_index`.
+//! `map.with_index` is left alone — Spinel already accepts that shape.
+
+use crate::app::App;
+use crate::expr::{Expr, ExprNode, LValue, Literal};
+use crate::ident::{Symbol, VarId};
+use crate::ty::Ty;
+
+pub fn apply_each_with_index_lowering(app: &mut App) {
+ super::for_each_hook_body(app, &mut rewrite);
+ for view in &mut app.views {
+ rewrite(&mut view.body);
+ }
+}
+
+fn rewrite(expr: &mut Expr) {
+ expr.node.for_each_child_mut(&mut rewrite);
+
+ let span = expr.span;
+ let (collection, offset, mut block) = {
+ let ExprNode::Send {
+ recv: Some(each_expr),
+ method,
+ args,
+ block,
+ ..
+ } = &mut *expr.node
+ else {
+ return;
+ };
+ if method.as_str() != "with_index" || args.len() > 1 || block.is_none() {
+ return;
+ }
+ let ExprNode::Send {
+ recv: collection,
+ method: each_method,
+ args: each_args,
+ block: each_block,
+ ..
+ } = &*each_expr.node
+ else {
+ return;
+ };
+ if each_method.as_str() != "each" || !each_args.is_empty() || each_block.is_some() {
+ return;
+ }
+
+ // Only a two-param Lambda can receive the offset binding. Method
+ // refs (`&method(:f)`) and rest-param blocks would lose a nonzero
+ // offset if rewritten — keep the dynamic each.with_index path.
+ let block_expr = block.as_ref().expect("checked above");
+ let offset_ok = match &*block_expr.node {
+ ExprNode::Lambda {
+ params,
+ rest_param: None,
+ ..
+ } if params.len() >= 2 => true,
+ _ => false,
+ };
+
+ // Only literal Int offsets are safe to materialize into the
+ // loop (`index = __with_index_i + offset`). A dynamic offset
+ // must evaluate once before iteration — cloning it into the
+ // body would re-run side effects and see reassigned locals.
+ let offset = match args.as_slice() {
+ [] => None,
+ [arg] => match &*arg.node {
+ ExprNode::Lit {
+ value: Literal::Int { value: 0 },
+ } => None,
+ ExprNode::Lit {
+ value: Literal::Int { .. },
+ } => {
+ if !offset_ok {
+ return;
+ }
+ Some(arg.clone())
+ }
+ _ => return,
+ },
+ _ => return,
+ };
+
+ (
+ collection.clone(),
+ offset,
+ block.take().expect("checked above"),
+ )
+ };
+
+ if let Some(offset) = offset {
+ inject_offset_binding(&mut block, offset, span);
+ }
+
+ *expr.node = ExprNode::Send {
+ recv: collection,
+ method: Symbol::from("each_with_index"),
+ args: Vec::new(),
+ block: Some(block),
+ parenthesized: false,
+ };
+}
+
+/// Rename the index parameter to `__with_index_i` and prepend
+/// `index = __with_index_i + offset` so the block body keeps reading
+/// the caller's name at the offset-adjusted value.
+fn inject_offset_binding(block: &mut Expr, offset: Expr, span: crate::span::Span) {
+ let ExprNode::Lambda { params, body, .. } = &mut *block.node else {
+ return;
+ };
+ if params.len() < 2 {
+ return;
+ }
+ let index_name = params[1].clone();
+ let tmp = unique_index_temp(params, body);
+ params[1] = tmp.clone();
+
+ let mut tmp_var = Expr::new(
+ span,
+ ExprNode::Var {
+ id: VarId(0),
+ name: tmp,
+ },
+ );
+ tmp_var.ty = Some(Ty::Int);
+
+ let mut sum = Expr::new(
+ span,
+ ExprNode::Send {
+ recv: Some(tmp_var),
+ method: Symbol::from("+"),
+ args: vec![offset],
+ block: None,
+ parenthesized: false,
+ },
+ );
+ sum.ty = Some(Ty::Int);
+
+ let assign = Expr::new(
+ span,
+ ExprNode::Assign {
+ target: LValue::Var {
+ id: VarId(0),
+ name: index_name,
+ },
+ value: sum,
+ },
+ );
+
+ match &mut *body.node {
+ ExprNode::Seq { exprs } => {
+ exprs.insert(0, assign);
+ }
+ _ => {
+ let old = std::mem::replace(
+ body,
+ Expr::new(
+ span,
+ ExprNode::Lit {
+ value: Literal::Nil,
+ },
+ ),
+ );
+ *body = Expr::new(
+ span,
+ ExprNode::Seq {
+ exprs: vec![assign, old],
+ },
+ );
+ }
+ }
+}
+
+/// Prefer `__with_index_i`. If the block already binds or reads that
+/// name, bump the suffix so the injected param does not shadow it.
+fn unique_index_temp(params: &[Symbol], body: &Expr) -> Symbol {
+ let mut n = 0usize;
+ loop {
+ let name = if n == 0 {
+ "__with_index_i".to_string()
+ } else {
+ format!("__with_index_i{n}")
+ };
+ let taken = params
+ .iter()
+ .enumerate()
+ .any(|(i, p)| i != 1 && p.as_str() == name)
+ || mentions_local(body, &name);
+ if !taken {
+ return Symbol::from(name);
+ }
+ n += 1;
+ }
+}
+
+fn mentions_local(expr: &Expr, name: &str) -> bool {
+ fn walk_lvalue(lv: &LValue, name: &str) -> bool {
+ match lv {
+ LValue::Var { name: n, .. } => n.as_str() == name,
+ LValue::Ivar { .. } | LValue::Const { .. } => false,
+ LValue::Attr { recv, .. } => mentions_local(recv, name),
+ LValue::Index { recv, index } => {
+ mentions_local(recv, name) || mentions_local(index, name)
+ }
+ }
+ }
+ match &*expr.node {
+ ExprNode::Var { name: n, .. } => n.as_str() == name,
+ ExprNode::Assign { target, value } => {
+ walk_lvalue(target, name) || mentions_local(value, name)
+ }
+ _ => {
+ let mut found = false;
+ expr.node.for_each_child(&mut |child| {
+ if !found && mentions_local(child, name) {
+ found = true;
+ }
+ });
+ found
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::span::Span;
+
+ fn lit_int(n: i64) -> Expr {
+ let mut e = Expr::new(
+ Span::synthetic(),
+ ExprNode::Lit {
+ value: Literal::Int { value: n },
+ },
+ );
+ e.ty = Some(Ty::Int);
+ e
+ }
+
+ fn var(name: &str) -> Expr {
+ Expr::new(
+ Span::synthetic(),
+ ExprNode::Var {
+ id: VarId(0),
+ name: Symbol::from(name),
+ },
+ )
+ }
+
+ fn lambda(params: &[&str], body: Expr) -> Expr {
+ Expr::new(
+ Span::synthetic(),
+ ExprNode::Lambda {
+ params: params.iter().map(|p| Symbol::from(*p)).collect(),
+ rest_param: None,
+ block_param: None,
+ body,
+ block_style: Default::default(),
+ },
+ )
+ }
+
+ fn send(recv: Option, method: &str, args: Vec, block: Option) -> Expr {
+ Expr::new(
+ Span::synthetic(),
+ ExprNode::Send {
+ recv,
+ method: Symbol::from(method),
+ args,
+ block,
+ parenthesized: false,
+ },
+ )
+ }
+
+ #[test]
+ fn collapses_each_with_index_without_offset() {
+ let each = send(Some(var("items")), "each", vec![], None);
+ let mut expr = send(
+ Some(each),
+ "with_index",
+ vec![],
+ Some(lambda(&["item", "index"], lit_int(0))),
+ );
+ rewrite(&mut expr);
+ let ExprNode::Send {
+ method,
+ args,
+ block,
+ recv,
+ ..
+ } = &*expr.node
+ else {
+ panic!("expected Send");
+ };
+ assert_eq!(method.as_str(), "each_with_index");
+ assert!(args.is_empty());
+ assert!(recv.is_some());
+ let ExprNode::Lambda { params, .. } = &*block.as_ref().unwrap().node else {
+ panic!("expected Lambda");
+ };
+ assert_eq!(params[1].as_str(), "index");
+ }
+
+ #[test]
+ fn injects_offset_binding_for_nonzero_start() {
+ let each = send(Some(var("items")), "each", vec![], None);
+ let mut expr = send(
+ Some(each),
+ "with_index",
+ vec![lit_int(1)],
+ Some(lambda(&["item", "index"], lit_int(0))),
+ );
+ rewrite(&mut expr);
+ let ExprNode::Send { method, block, .. } = &*expr.node else {
+ panic!("expected Send");
+ };
+ assert_eq!(method.as_str(), "each_with_index");
+ let ExprNode::Lambda { params, body, .. } = &*block.as_ref().unwrap().node else {
+ panic!("expected Lambda");
+ };
+ assert_eq!(params[1].as_str(), "__with_index_i");
+ let ExprNode::Seq { exprs } = &*body.node else {
+ panic!("expected Seq, got {}", body.node.kind_str());
+ };
+ assert!(matches!(&*exprs[0].node, ExprNode::Assign { .. }));
+ }
+
+ #[test]
+ fn leaves_map_with_index_alone() {
+ let map = send(Some(var("items")), "map", vec![], None);
+ let mut expr = send(
+ Some(map),
+ "with_index",
+ vec![lit_int(1)],
+ Some(lambda(&["item", "index"], lit_int(0))),
+ );
+ rewrite(&mut expr);
+ let ExprNode::Send { method, .. } = &*expr.node else {
+ panic!("expected Send");
+ };
+ assert_eq!(method.as_str(), "with_index");
+ }
+
+ #[test]
+ fn leaves_method_ref_block_with_offset_alone() {
+ let each = send(Some(var("items")), "each", vec![], None);
+ let method_ref = Expr::new(
+ Span::synthetic(),
+ ExprNode::MethodRef {
+ recv: None,
+ name: Symbol::from("touch"),
+ },
+ );
+ let mut expr = send(Some(each), "with_index", vec![lit_int(1)], Some(method_ref));
+ rewrite(&mut expr);
+ let ExprNode::Send { method, .. } = &*expr.node else {
+ panic!("expected Send");
+ };
+ assert_eq!(method.as_str(), "with_index");
+ }
+
+ #[test]
+ fn leaves_rest_param_block_with_offset_alone() {
+ let each = send(Some(var("items")), "each", vec![], None);
+ let block = Expr::new(
+ Span::synthetic(),
+ ExprNode::Lambda {
+ params: vec![Symbol::from("item")],
+ rest_param: Some(Symbol::from("rest")),
+ block_param: None,
+ body: lit_int(0),
+ block_style: Default::default(),
+ },
+ );
+ let mut expr = send(Some(each), "with_index", vec![lit_int(1)], Some(block));
+ rewrite(&mut expr);
+ let ExprNode::Send { method, .. } = &*expr.node else {
+ panic!("expected Send");
+ };
+ assert_eq!(method.as_str(), "with_index");
+ }
+
+ #[test]
+ fn offset_temp_avoids_existing_local_name() {
+ let each = send(Some(var("items")), "each", vec![], None);
+ let mut expr = send(
+ Some(each),
+ "with_index",
+ vec![lit_int(1)],
+ Some(lambda(&["item", "index"], var("__with_index_i"))),
+ );
+ rewrite(&mut expr);
+ let ExprNode::Send { method, block, .. } = &*expr.node else {
+ panic!("expected Send");
+ };
+ assert_eq!(method.as_str(), "each_with_index");
+ let ExprNode::Lambda { params, .. } = &*block.as_ref().unwrap().node else {
+ panic!("expected Lambda");
+ };
+ assert_eq!(params[1].as_str(), "__with_index_i1");
+ }
+}
diff --git a/src/lower/mod.rs b/src/lower/mod.rs
index c0849b8b1..1a0b1843d 100644
--- a/src/lower/mod.rs
+++ b/src/lower/mod.rs
@@ -75,6 +75,7 @@ pub mod mocha;
pub mod webmock;
pub mod global_id_locate;
pub mod array_ordinal;
+pub mod each_with_index;
pub mod sti_is_a;
pub mod dead_default;
pub mod errors_add;
@@ -327,6 +328,11 @@ const POST_ANALYZE_PASS_ORDER: &[(&str, &[&str])] = &[
// a typed-Array receiver, which no other pass produces or
// consumes, so no ordering constraints.
("array_ordinal", &[]),
+ // `recv.each.with_index(n?) { }` → `each_with_index` (+ offset bind).
+ // Keys on an Enumerator chain no other pass produces or consumes,
+ // so no ordering constraints. Unblocks Spinel AOT on Writebook
+ // Positionable#move_to_position (keyword call closing over index).
+ ("each_with_index", &[]),
// `save(validate: false)` → `save_after_validation`. Keys on a
// literal `validate: false` kwarg no other pass produces or
// consumes, so no ordering constraints.
@@ -791,6 +797,8 @@ pub fn apply_post_analyze_lowerings(
ran!("global_id_locate");
array_ordinal::apply_array_ordinal_lowering(app);
ran!("array_ordinal");
+ each_with_index::apply_each_with_index_lowering(app);
+ ran!("each_with_index");
save_without_validation::apply_save_without_validation_lowering(app);
ran!("save_without_validation");
assoc_pluck::apply_assoc_pluck_lowering(app);
diff --git a/src/lower/model_to_library/adapter_emit/exists.rs b/src/lower/model_to_library/adapter_emit/exists.rs
new file mode 100644
index 000000000..5780f38cf
--- /dev/null
+++ b/src/lower/model_to_library/adapter_emit/exists.rs
@@ -0,0 +1,76 @@
+//! Exists probes (`_adapter_any?`, `_adapter_exists_by_id?`) — shared
+//! `SELECT 1 … LIMIT 1` MethodDef shell so `adapter_emit` stays under 1k.
+
+use crate::dialect::{AccessorKind, MethodDef, MethodReceiver, Param};
+use crate::effect::EffectSet;
+use crate::ident::{ClassId, Symbol, TableRef};
+use crate::lower::arel::{
+ ArelOp, ArelVisitor, ColumnSpec, LimitSpec, Predicate, Select, SqliteVisitor,
+};
+use crate::schema::{Schema, Table};
+use crate::ty::Ty;
+
+use super::super::fn_sig;
+use super::{eq_id_param, key_ty};
+
+/// Shared `SELECT 1 … LIMIT 1` Exists MethodDef (`_adapter_any?` /
+/// `_adapter_exists_by_id?`). Keeps both probes as one-liner wrappers so
+/// this module does not grow a second copy of the Exists shell.
+fn synth_exists_probe(
+ owner: &ClassId,
+ table: &Table,
+ schema: &Schema,
+ name: &str,
+ conditions: Option,
+ params: Vec ,
+ param_tys: Vec<(Symbol, Ty)>,
+) -> MethodDef {
+ let op = ArelOp::Select(Select {
+ single_record: false,
+ table: TableRef(table.name.clone()),
+ columns: ColumnSpec::Exists,
+ conditions,
+ orders: vec![],
+ limit: Some(LimitSpec(1)),
+ joins: vec![],
+ preloads: vec![],
+ });
+ MethodDef {
+ visibility: crate::dialect::MethodVisibility::Public,
+ unsupported_formals: None,
+ has_anonymous_block: false,
+ name_span: crate::span::Span::synthetic(),
+ name: Symbol::from(name),
+ receiver: MethodReceiver::Class,
+ params,
+ body: SqliteVisitor.visit(&op, schema, owner),
+ signature: Some(fn_sig(param_tys, Ty::Bool)),
+ effects: EffectSet::default(),
+ enclosing_class: Some(owner.0.clone()),
+ kind: AccessorKind::Method,
+ is_async: false,
+ mutates_self: false,
+ block_param: None,
+ }
+}
+
+/// Unscoped emptiness without COUNT(*) — `Base.any?` / `none?` on
+/// Level-3 models. Same Exists emit as `_adapter_exists_by_id?`, no WHERE.
+pub(super) fn synth_adapter_any(owner: &ClassId, table: &Table, schema: &Schema) -> MethodDef {
+ synth_exists_probe(owner, table, schema, "_adapter_any?", None, vec![], vec![])
+}
+
+pub(super) fn synth_adapter_exists_by_id(owner: &ClassId, table: &Table, schema: &Schema) -> MethodDef {
+ let id = Symbol::from("id");
+ let key = key_ty(table);
+ synth_exists_probe(
+ owner,
+ table,
+ schema,
+ "_adapter_exists_by_id?",
+ Some(eq_id_param(table, &id)),
+ vec![Param::positional(id.clone())],
+ vec![(id, key)],
+ )
+}
+
diff --git a/src/lower/model_to_library/adapter_emit.rs b/src/lower/model_to_library/adapter_emit/mod.rs
similarity index 96%
rename from src/lower/model_to_library/adapter_emit.rs
rename to src/lower/model_to_library/adapter_emit/mod.rs
index f940f820e..1d80c2cad 100644
--- a/src/lower/model_to_library/adapter_emit.rs
+++ b/src/lower/model_to_library/adapter_emit/mod.rs
@@ -29,6 +29,7 @@
//! `_adapter_update(id, instance)` — UPDATE WHERE id, returns void
//! `_adapter_delete(id)` — DELETE WHERE id, returns void
//! `_adapter_count` — SELECT COUNT(*), returns Integer
+//! `_adapter_any?` — SELECT 1 LIMIT 1 (table non-empty), returns Bool
//! `_adapter_exists_by_id?(id)` — SELECT 1 LIMIT 1, returns Bool
//! `_adapter_truncate` — DELETE FROM table (test setup)
//! `_columns_sql` — the schema columns as a qualified SELECT list
@@ -48,6 +49,9 @@ use crate::ty::Ty;
use super::{fn_sig, ty_of_column};
+mod exists;
+use exists::{synth_adapter_any, synth_adapter_exists_by_id};
+
pub(super) fn push_adapter_methods(
methods: &mut Vec,
owner: &ClassId,
@@ -61,6 +65,7 @@ pub(super) fn push_adapter_methods(
methods.push(synth_adapter_update(owner, table, schema));
methods.push(synth_adapter_delete(owner, table, schema));
methods.push(synth_adapter_count(owner, table, schema));
+ methods.push(synth_adapter_any(owner, table, schema));
methods.push(synth_adapter_exists_by_id(owner, table, schema));
methods.push(synth_adapter_truncate(owner, table, schema));
methods.push(synth_delete_all(owner, table));
@@ -405,40 +410,6 @@ fn synth_adapter_count(owner: &ClassId, table: &Table, schema: &Schema) -> Metho
}
}
-fn synth_adapter_exists_by_id(owner: &ClassId, table: &Table, schema: &Schema) -> MethodDef {
- let id = Symbol::from("id");
- let key_ty = key_ty(table);
-
- let op = ArelOp::Select(Select {
- single_record: false, // _adapter_exists_by_id — a Bool
- table: TableRef(table.name.clone()),
- columns: ColumnSpec::Exists,
- conditions: Some(eq_id_param(table, &id)),
- orders: vec![],
- limit: Some(LimitSpec(1)),
- joins: vec![],
- preloads: vec![],
- });
-
- MethodDef {
- visibility: crate::dialect::MethodVisibility::Public,
- unsupported_formals: None,
- has_anonymous_block: false,
- name_span: crate::span::Span::synthetic(),
- name: Symbol::from("_adapter_exists_by_id?"),
- receiver: MethodReceiver::Class,
- params: vec![Param::positional(id.clone())],
- body: SqliteVisitor.visit(&op, schema, owner),
- signature: Some(fn_sig(vec![(id, key_ty)], Ty::Bool)),
- effects: EffectSet::default(),
- enclosing_class: Some(owner.0.clone()),
- kind: AccessorKind::Method,
- is_async: false,
- mutates_self: false,
- block_param: None,
- }
-}
-
/// `def self.delete_all` — bulk DELETE with ActiveRecord semantics:
/// rows go, the autoincrement counter stays (`_adapter_truncate` is
/// the sequence-resetting sibling, for test setup). A PUBLIC name —
@@ -913,7 +884,7 @@ fn key_column_name(table: &Table) -> Symbol {
key_column(table).map(|c| c.name.clone()).unwrap_or_else(|| Symbol::from("id"))
}
-fn key_ty(table: &Table) -> Ty {
+pub(super) fn key_ty(table: &Table) -> Ty {
key_column(table).map(|c| ty_of_column(&c.col_type)).unwrap_or(Ty::Int)
}
@@ -923,7 +894,7 @@ fn key_value_type(table: &Table) -> ValueType {
/// `Eq(., Runtime(, ))` — find_by_id /
/// exists_by_id? shape (id arrives as a method param).
-fn eq_id_param(table: &Table, id_param: &Symbol) -> Predicate {
+pub(super) fn eq_id_param(table: &Table, id_param: &Symbol) -> Predicate {
Predicate::Eq(
ColRef { table: TableRef(table.name.clone()), column: key_column_name(table) },
Value::Runtime { expr: var_ref(id_param), ty: key_value_type(table) },
diff --git a/src/lower/model_to_library/associations.rs b/src/lower/model_to_library/associations.rs
index 1b6cd1ddd..6407686fe 100644
--- a/src/lower/model_to_library/associations.rs
+++ b/src/lower/model_to_library/associations.rs
@@ -279,7 +279,27 @@ pub(super) fn push_association_methods(
));
}
}
- Association::BelongsTo { name, target, foreign_key, .. } => {
+ // Unresolved polymorphic (`polymorphic: true` but no inverse
+ // `as:` filled `polymorphic_targets`): do **not** fall through
+ // to the monomorphic synthesizer. The phantom target is often
+ // `Record` (ActionText::RichText / Markdown), and emitting
+ // `Record.find_by` is a boot-time `NameError` if the accessor
+ // is ever called; the monomorphic writer also drops the
+ // `_type` half. Storage via the raw `*_id` / `*_type` columns
+ // still works. Same contract as schema.rs: no writer when
+ // implementors are unresolved.
+ Association::BelongsTo {
+ polymorphic: true,
+ polymorphic_targets,
+ ..
+ } if polymorphic_targets.is_empty() => {}
+ Association::BelongsTo {
+ name,
+ target,
+ foreign_key,
+ polymorphic: false,
+ ..
+ } => {
let sentinel = fk_sentinel(model, foreign_key);
methods.push(synth_belongs_to_reader(owner, name, target, foreign_key, sentinel.clone()));
// Rails provides the writer alongside the reader
diff --git a/src/lower/model_to_library/mod.rs b/src/lower/model_to_library/mod.rs
index 6283d77eb..6799e2368 100644
--- a/src/lower/model_to_library/mod.rs
+++ b/src/lower/model_to_library/mod.rs
@@ -1743,6 +1743,11 @@ pub(crate) fn build_class_info(
"_adapter_count",
fn_sig(vec![], Ty::Int),
);
+ insert_default(
+ &mut info.class_methods,
+ "_adapter_any?",
+ fn_sig(vec![], Ty::Bool),
+ );
insert_default(
&mut info.class_methods,
"_adapter_exists_by_id?",
diff --git a/src/lower/rich_text.rs b/src/lower/rich_text.rs
index 6fbd5da16..c273f767d 100644
--- a/src/lower/rich_text.rs
+++ b/src/lower/rich_text.rs
@@ -145,17 +145,17 @@ pub fn synthesize_record_model(app: &mut App) {
// nothing else complained.
let attributes = crate::ingest::model::row_from_table(table);
// `belongs_to :record, polymorphic: true` is declared (rather than
- // written out) because the has_one side is what needed the scope;
- // this side is an ordinary polymorphic belongs_to and the existing
- // synthesizer produces exactly Rails' reader.
+ // written out) because the has_one side is what needed the scope.
//
// `polymorphic_targets` stays empty on purpose: it is filled by
// `resolve_polymorphic_targets` from the inverse `as:` declarations,
// and no model declares `has_one … as: :record` in source — the
- // owner side is this file's own expansion. The consequence is that
- // `rich_text.record` reads as gradual rather than as a union of the
- // models that use it, which is the honest type for a column that
- // can name any of them.
+ // owner side is this file's own expansion. With an empty implementor
+ // set, association lowering skips both the type-switched reader and
+ // the monomorphic `Record.find_by` fallback (which would NameError),
+ // so `rich_text.record` stays un-synthesized and typed gradual —
+ // honest for a column that can name any owner. Storage uses the raw
+ // `record_id` / `record_type` columns.
let body = vec![ModelBodyItem::Association {
assoc: Association::BelongsTo {
name: Symbol::from("record"),
diff --git a/src/project.rs b/src/project.rs
index bedec8b45..1f04774cf 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -674,6 +674,80 @@ fn widen_key_contract(app: &App, files: &mut [(String, String)]) -> Result<(), S
}
*text = text.replace(narrow, wide);
}
+ // Relation#ids reads the primary key; string/uuid apps need the
+ // same widened element type Spinel sees on Base#id (#310).
+ let Some((_, relation)) =
+ files.iter_mut().find(|(p, _)| p == "sig/runtime/active_record/relation.rbs")
+ else {
+ return Err(
+ "widen_key_contract: sig/runtime/active_record/relation.rbs not in the tree".into(),
+ );
+ };
+ let ids_narrow = " def ids: () -> Array[Integer]\n";
+ let ids_wide = " def ids: () -> Array[Integer | String]\n";
+ if !relation.contains(ids_narrow) {
+ return Err(format!(
+ "widen_key_contract: relation.rbs no longer declares {ids_narrow:?}"
+ ));
+ }
+ *relation = relation.replace(ids_narrow, ids_wide);
+ Ok(())
+}
+
+/// Spinel treats RBS `Base` as an *instance* (`sp_ActiveRecord__Base *`).
+/// Shared `relation.rbs` uses `Base` in two places Roundhouse needs for
+/// Bar A/B; both are rewritten on the Spinel tree only:
+///
+/// 1. `initialize: (Base model)` — callers pass a **class** (`User`,
+/// `self` in a class method). Spinel rejects that as a Base instance.
+/// 2. Terminals (`first` / `find_by` / …) returning `Base` / `Base?` —
+/// callers expect a concrete model (`User*`); Spinel will not convert
+/// Base → User.
+fn spinel_relation_model_handle(files: &mut [(String, String)]) -> Result<(), String> {
+ let idx = files
+ .iter()
+ .position(|(p, _)| {
+ p == "sig/runtime/active_record/relation.rbs"
+ || p == "runtime/active_record/relation.rbs"
+ })
+ .ok_or_else(|| {
+ "spinel_relation_model_handle: active_record/relation.rbs not in the tree".to_string()
+ })?;
+ let relation = &mut files[idx].1;
+ let replacements = [
+ (
+ " def initialize: (Base model) -> void\n",
+ " def initialize: (untyped model) -> void\n",
+ ),
+ (" def first: () -> Base?\n", " def first: () -> untyped\n"),
+ (" def take: () -> Base?\n", " def take: () -> untyped\n"),
+ (" def first!: () -> Base\n", " def first!: () -> untyped\n"),
+ (" def last: () -> Base?\n", " def last: () -> untyped\n"),
+ (
+ " def find_by: (untyped conditions) -> Base?\n",
+ " def find_by: (untyped conditions) -> untyped\n",
+ ),
+ (
+ " def find_by!: (untyped conditions) -> Base\n",
+ " def find_by!: (untyped conditions) -> untyped\n",
+ ),
+ (
+ " def first_or_initialize: () -> Base\n",
+ " def first_or_initialize: () -> untyped\n",
+ ),
+ (
+ " def find_or_create_by: (Hash[Symbol, untyped] conditions) -> Base\n",
+ " def find_or_create_by: (Hash[Symbol, untyped] conditions) -> untyped\n",
+ ),
+ ];
+ for (narrow, wide) in replacements {
+ if !relation.contains(narrow) {
+ return Err(format!(
+ "spinel_relation_model_handle: relation.rbs no longer declares {narrow:?}"
+ ));
+ }
+ *relation = relation.replace(narrow, wide);
+ }
Ok(())
}
@@ -1106,7 +1180,10 @@ pub fn target_files(
}
let files = crate::timings::phase(format_args!("emit {}: assemble", target.as_str()), || match target {
BuildTarget::Blog => blog_files(fixture),
- BuildTarget::Spinel => spinel_files(app, fixture).and_then(|(files, _)| spin_shape(files)),
+ BuildTarget::Spinel => spinel_files(app, fixture).and_then(|(mut files, _)| {
+ spinel_relation_model_handle(&mut files)?;
+ spin_shape(files)
+ }),
// The ruby family gets the bundled-library requires too: the
// table used to live inside `spin_shape` and so reached only
// the spinel tree, which cost campfire two test files on a
@@ -3586,6 +3663,7 @@ pub fn spinel_base_files(app: &App, fixture: &Path) -> Result Result<(Vec<(String, String)>, Vec
)?;
}
widen_key_contract(app, &mut files)?;
+ // Relation Base→untyped rewrite is Spinel-only (see
+ // `spinel_relation_model_handle` call sites). `ruby_family_runtime_files`
+ // shares this tree and must keep Base for CRuby/JRuby Bar A/B.
for stem in [
"rails",
"active_record",
@@ -8077,4 +8158,57 @@ mod tests {
String::from_utf8_lossy(&out.stderr)
);
}
+
+ /// Shared relation.rbs types `initialize` / terminals as `Base` for
+ /// Roundhouse Bar A/B; Spinel emit must rewrite those to `untyped`
+ /// or campfire AOT rejects class handles and User* slots.
+ #[test]
+ fn spinel_emit_rewrites_relation_base_handle_to_untyped() {
+ let fixture = Path::new("fixtures/tiny-blog");
+ if !fixture.is_dir() {
+ eprintln!("skip: fixtures/tiny-blog absent");
+ return;
+ }
+ let app = crate::ingest::ingest_app(fixture).expect("ingest tiny-blog");
+ let files = spinel_base_files(&app, fixture).expect("spinel base files");
+ let relation = files
+ .iter()
+ .find(|(p, _)| p.ends_with("active_record/relation.rbs"))
+ .map(|(_, c)| c.as_str())
+ .expect("relation.rbs in spinel tree");
+ assert!(
+ relation.contains("def initialize: (untyped model) -> void"),
+ "Spinel must not keep initialize:(Base): {relation}"
+ );
+ assert!(
+ relation.contains("def first: () -> untyped"),
+ "Spinel must not keep first:()->Base?: {relation}"
+ );
+ assert!(
+ relation.contains("def find_by: (untyped conditions) -> untyped"),
+ "Spinel must not keep find_by:()->Base?: {relation}"
+ );
+ // Shared source still spells Base for Roundhouse Bar B.
+ let shared = crate::runtime_files::read_to_string("runtime/ruby/active_record/relation.rbs")
+ .expect("shared relation.rbs");
+ assert!(
+ shared.contains("def initialize: (Base model) -> void"),
+ "shared relation.rbs must keep Base for Bar A/B"
+ );
+ // CRuby/JRuby share `spinel_files` but must NOT get the rewrite.
+ let ruby = ruby_runtime_files(&app, fixture).expect("ruby runtime files");
+ let ruby_relation = ruby
+ .iter()
+ .find(|(p, _)| p.ends_with("active_record/relation.rbs"))
+ .map(|(_, c)| c.as_str())
+ .expect("relation.rbs in ruby tree");
+ assert!(
+ ruby_relation.contains("def initialize: (Base model) -> void"),
+ "CRuby relation.rbs must keep Base: {ruby_relation}"
+ );
+ assert!(
+ ruby_relation.contains("def first: () -> Base?"),
+ "CRuby relation.rbs must keep first:()->Base?: {ruby_relation}"
+ );
+ }
}
diff --git a/src/rbs.rs b/src/rbs.rs
index f3a74550e..e032a67de 100644
--- a/src/rbs.rs
+++ b/src/rbs.rs
@@ -94,6 +94,25 @@ pub fn parse_app_signatures(
Ok(out)
}
+/// Parse RBS `@ivar: T` declarations grouped by enclosing class/module.
+/// Names are stored without the leading `@` so they match
+/// `ExprNode::Ivar` / `Ctx::ivar_bindings` keys.
+pub fn parse_app_ivars(
+ source: &str,
+) -> Result>, String> {
+ let signature = parse(source)?;
+ let mut out: std::collections::HashMap> =
+ std::collections::HashMap::new();
+ let decls: Vec> = signature.declarations().iter().collect();
+ let top_aliases = resolve_aliases(&decls, None, &AliasTable::new());
+
+ for decl in decls {
+ walk_ivars(&decl, None, &top_aliases, &mut out)?;
+ }
+
+ Ok(out)
+}
+
/// Extract `include X` declarations from each class/module in an RBS
/// source. Pairs with `parse_app_signatures` for callers that need
/// to flatten included-module methods into the including class
@@ -220,6 +239,75 @@ fn walk_decl(
Ok(())
}
+fn walk_ivars(
+ decl: &Node<'_>,
+ parent: Option<&str>,
+ aliases: &AliasTable,
+ out: &mut std::collections::HashMap>,
+) -> Result<(), String> {
+ match decl {
+ Node::Class(class) => {
+ let name = namespace_join(parent, &declared_name(&class.name()));
+ collect_class_ivars(class.members().iter(), &name, aliases, out)?;
+ }
+ Node::Module(module) => {
+ let name = namespace_join(parent, &declared_name(&module.name()));
+ collect_class_ivars(module.members().iter(), &name, aliases, out)?;
+ }
+ Node::Interface(iface) => {
+ let name = namespace_join(parent, &declared_name(&iface.name()));
+ collect_class_ivars(iface.members().iter(), &name, aliases, out)?;
+ }
+ _ => {}
+ }
+ Ok(())
+}
+
+fn collect_class_ivars<'a, I: Iterator- >>(
+ members: I,
+ class_name: &str,
+ outer: &AliasTable,
+ out: &mut std::collections::HashMap
>,
+) -> Result<(), String> {
+ let class_id = ClassId(Symbol::new(class_name));
+ let members: Vec> = members.collect();
+ let aliases = resolve_aliases(&members, Some(class_name), outer);
+ let aliases = &aliases;
+ let ctx = TyCtx {
+ scope: Some(class_name),
+ self_is_instance: true,
+ aliases,
+ };
+ for member in members {
+ match member {
+ Node::InstanceVariable(ivar) => {
+ let raw = ivar.name().as_str().to_string();
+ let bare = raw.strip_prefix('@').unwrap_or(raw.as_str());
+ let ty = ty_from_node(&ivar.type_(), ctx)?;
+ out.entry(class_id.clone())
+ .or_default()
+ .insert(Symbol::new(bare), ty);
+ }
+ // `self.@foo` — class-instance variable on the module/class
+ // object (what `def self.` bodies read). Same ivar map key as
+ // `@foo`; the runtime typer seeds both from this harvest.
+ Node::ClassInstanceVariable(ivar) => {
+ let raw = ivar.name().as_str().to_string();
+ let bare = raw.strip_prefix('@').unwrap_or(raw.as_str());
+ let ty = ty_from_node(&ivar.type_(), ctx)?;
+ out.entry(class_id.clone())
+ .or_default()
+ .insert(Symbol::new(bare), ty);
+ }
+ Node::Class(_) | Node::Module(_) | Node::Interface(_) => {
+ walk_ivars(&member, Some(class_name), aliases, out)?;
+ }
+ _ => {}
+ }
+ }
+ Ok(())
+}
+
fn collect_class_methods<'a, I: Iterator- >>(
members: I,
class_name: &str,
@@ -1432,6 +1520,60 @@ end
assert!(post_methods.contains_key(&Symbol::from("title")));
}
+ #[test]
+ fn app_ivars_group_by_class_without_at_prefix() {
+ let src = "\
+module ActiveRecord
+ class Relation
+ @limit: Integer?
+ @records: Array[untyped]?
+ def page: (Integer n) -> Relation
+ end
+ class Base
+ @errors: Array[String]
+ @persisted: bool
+ end
+end
+";
+ let out = parse_app_ivars(src).expect("parses");
+ let rel = &out[&ClassId(Symbol::from("ActiveRecord::Relation"))];
+ assert_eq!(rel[&Symbol::from("limit")], Ty::Union {
+ variants: vec![Ty::Int, Ty::Nil],
+ });
+ assert!(matches!(
+ &rel[&Symbol::from("records")],
+ Ty::Union { variants } if variants.iter().any(|v| matches!(v, Ty::Nil))
+ && variants.iter().any(|v| matches!(v, Ty::Array { .. }))
+ ));
+ assert!(!rel.contains_key(&Symbol::from("@limit")));
+ let base = &out[&ClassId(Symbol::from("ActiveRecord::Base"))];
+ assert_eq!(
+ base[&Symbol::from("errors")],
+ Ty::Array {
+ elem: Box::new(Ty::Str),
+ }
+ );
+ assert_eq!(base[&Symbol::from("persisted")], Ty::Bool);
+ }
+
+ #[test]
+ fn app_ivars_harvest_self_at_class_instance_variables() {
+ let src = "\
+module ActionView
+ module ViewHelpers
+ self.@sanitize_default_tags: Array[String]?
+ def self.sanitize_default_tags: () -> Array[String]
+ end
+end
+";
+ let out = parse_app_ivars(src).expect("parses");
+ let helpers = &out[&ClassId(Symbol::from("ActionView::ViewHelpers"))];
+ assert!(
+ helpers.contains_key(&Symbol::from("sanitize_default_tags")),
+ "self.@sanitize_* must seed the ivar map: {helpers:?}"
+ );
+ }
+
#[test]
fn app_sigs_namespace_nested_classes() {
let src = "\
diff --git a/tests/action_text_markdown_ingest.rs b/tests/action_text_markdown_ingest.rs
new file mode 100644
index 000000000..1ce8fc2a2
--- /dev/null
+++ b/tests/action_text_markdown_ingest.rs
@@ -0,0 +1,214 @@
+//! ActionText::Markdown model recognition (storage only).
+//!
+//! Writebook ships `lib/rails_ext/action_text_markdown.rb` as
+//! `module ActionText; class Markdown < Record`. Without lexical
+//! superclass resolution + the framework `action_text_` table prefix,
+//! that file lands as a library class and emits `class Markdown < Record`
+//! → `NameError` at boot. This suite pins the storage-side fix:
+//! model in `app.models`, table `action_text_markdowns`, attr `content`.
+//!
+//! Does **not** claim `has_markdown`, Page `#body`, renderer, or uploads.
+
+use std::collections::HashMap;
+use std::path::PathBuf;
+
+use roundhouse::ingest::ingest_app_from_tree;
+use roundhouse::App;
+
+const SCHEMA: &str = r#"ActiveRecord::Schema.define(version: 1) do
+ create_table "action_text_markdowns", force: :cascade do |t|
+ t.text "content"
+ t.string "name", null: false
+ t.bigint "record_id", null: false
+ t.string "record_type", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+end
+"#;
+
+const MARKDOWN: &str = r#"module ActionText
+ class Markdown < Record
+ mattr_accessor :renderer
+ belongs_to :record, polymorphic: true
+
+ def to_html
+ renderer
+ end
+ end
+end
+"#;
+
+const APPLICATION_RECORD: &str = r#"class ApplicationRecord < ActiveRecord::Base
+ primary_abstract_class
+end
+"#;
+
+fn ingest(files: &[(&str, &str)]) -> App {
+ let tree: HashMap
> = files
+ .iter()
+ .map(|(p, c)| (PathBuf::from(p), c.as_bytes().to_vec()))
+ .collect();
+ ingest_app_from_tree(tree).expect("ingest tree")
+}
+
+fn writebook_shaped() -> App {
+ ingest(&[
+ ("db/schema.rb", SCHEMA),
+ ("app/models/application_record.rb", APPLICATION_RECORD),
+ ("lib/rails_ext/action_text_markdown.rb", MARKDOWN),
+ ])
+}
+
+#[test]
+fn markdown_under_action_text_ingests_as_model() {
+ let app = writebook_shaped();
+ let md = app
+ .models
+ .iter()
+ .find(|m| m.name.0.as_str() == "ActionText::Markdown")
+ .expect("ActionText::Markdown must be a model, not a library class");
+ assert!(
+ !app.library_classes
+ .iter()
+ .any(|c| c.name.0.as_str() == "ActionText::Markdown"),
+ "must not also remain a library class"
+ );
+ assert_eq!(md.table.0.as_str(), "action_text_markdowns");
+ assert_eq!(
+ md.parent.as_ref().map(|p| p.0.as_str()),
+ Some("ApplicationRecord"),
+ "gem ActionText::Record is classified, then parented ApplicationRecord for emit"
+ );
+ assert!(
+ md.attributes
+ .fields
+ .contains_key(&roundhouse::ident::Symbol::from("content")),
+ "content column from schema; got {:?}",
+ md.attributes.fields.keys().collect::>()
+ );
+ assert!(
+ md.body.iter().any(|item| matches!(
+ item,
+ roundhouse::dialect::ModelBodyItem::Method { method, .. }
+ if method.name.as_str() == "renderer"
+ && method.receiver == roundhouse::dialect::MethodReceiver::Class
+ )),
+ "mattr_accessor :renderer must synthesize a class reader on the model"
+ );
+ assert!(
+ md.body.iter().any(|item| matches!(
+ item,
+ roundhouse::dialect::ModelBodyItem::Method { method, .. }
+ if method.name.as_str() == "renderer"
+ && method.receiver == roundhouse::dialect::MethodReceiver::Instance
+ )),
+ "mattr_accessor :renderer must also synthesize an instance reader for to_html"
+ );
+}
+
+#[test]
+fn nested_abstract_chain_with_bare_parent_ingests_as_model() {
+ // `class LeafBase < MidBase` stores the parent as the bare spelling
+ // until close_over discovers `ActionText::MidBase`. Matching only
+ // the stored string would leave Markdown a library class.
+ let app = ingest(&[
+ ("db/schema.rb", SCHEMA),
+ ("app/models/application_record.rb", APPLICATION_RECORD),
+ (
+ "lib/rails_ext/action_text_mid_base.rb",
+ r#"module ActionText
+ class MidBase < Record
+ self.abstract_class = true
+ end
+end
+"#,
+ ),
+ (
+ "lib/rails_ext/action_text_leaf_base.rb",
+ r#"module ActionText
+ class LeafBase < MidBase
+ self.abstract_class = true
+ end
+end
+"#,
+ ),
+ (
+ "lib/rails_ext/action_text_markdown.rb",
+ r#"module ActionText
+ class Markdown < LeafBase
+ end
+end
+"#,
+ ),
+ ]);
+ assert!(
+ app.models
+ .iter()
+ .any(|m| m.name.0.as_str() == "ActionText::Markdown"),
+ "Markdown through a nested abstract chain must be a model; models={:?} library={:?}",
+ app.models.iter().map(|m| m.name.0.as_str()).collect::>(),
+ app.library_classes
+ .iter()
+ .map(|c| c.name.0.as_str())
+ .collect::>(),
+ );
+ assert!(
+ !app.library_classes
+ .iter()
+ .any(|c| c.name.0.as_str() == "ActionText::Markdown"),
+ "must not remain a library class"
+ );
+}
+
+#[test]
+fn optioned_mattr_on_a_model_does_not_fail_ingest() {
+ // Writebook's ActionText::Markdown uses `mattr_accessor :renderer, default:`.
+ // Expanding without the initializer would drop the default; erroring
+ // turns existing unresolved sends into ingest-gap Infos. Leave unknown.
+ let app = ingest(&[
+ ("db/schema.rb", SCHEMA),
+ ("app/models/application_record.rb", APPLICATION_RECORD),
+ (
+ "lib/rails_ext/action_text_markdown.rb",
+ r#"module ActionText
+ class Markdown < Record
+ mattr_accessor :renderer, default: Object.new
+ end
+end
+"#,
+ ),
+ ]);
+ let md = app
+ .models
+ .iter()
+ .find(|m| m.name.0.as_str() == "ActionText::Markdown")
+ .expect("still a model");
+ assert!(
+ !md.body.iter().any(|item| matches!(
+ item,
+ roundhouse::dialect::ModelBodyItem::Method { method, .. }
+ if method.name.as_str() == "renderer"
+ )),
+ "optioned mattr must not synthesize a reader that drops default:"
+ );
+}
+
+#[test]
+fn bare_record_outside_action_text_stays_library() {
+ let app = ingest(&[
+ ("db/schema.rb", SCHEMA),
+ ("app/models/application_record.rb", APPLICATION_RECORD),
+ ("lib/other.rb", "class Markdown < Record\nend\n"),
+ ]);
+ assert!(
+ app.models.iter().all(|m| m.name.0.as_str() != "Markdown"),
+ "top-level Record must not become a model"
+ );
+ assert!(
+ app.library_classes
+ .iter()
+ .any(|c| c.name.0.as_str() == "Markdown"),
+ "top-level Markdown < Record stays a library class"
+ );
+}
diff --git a/tests/each_with_index_lowering.rs b/tests/each_with_index_lowering.rs
new file mode 100644
index 000000000..ccc2ee043
--- /dev/null
+++ b/tests/each_with_index_lowering.rs
@@ -0,0 +1,183 @@
+//! `each.with_index` → `each_with_index` lowering.
+//!
+//! Writebook's `Positionable#move_to_position` emits
+//! `all_to_move.each.with_index(1) { |item, index| item.update!(…) }`,
+//! which Spinel AOT rejects when the block makes a keyword call that
+//! closes over the index. The shared lowerer flattens the Enumerator
+//! chain; this pin checks the emitted Ruby, not IR alone.
+
+use std::collections::HashMap;
+use std::path::PathBuf;
+
+use roundhouse::emit::ruby;
+use roundhouse::ingest::ingest_app_from_tree;
+
+const SCHEMA: &str = r#"ActiveRecord::Schema.define do
+ create_table "rooms", force: :cascade do |t|
+ t.string "name", null: false
+ end
+end
+"#;
+
+fn emit_lib(src: &str, stem: &str) -> String {
+ let mut tree: HashMap> = HashMap::new();
+ tree.insert(PathBuf::from("db/schema.rb"), SCHEMA.as_bytes().to_vec());
+ tree.insert(
+ PathBuf::from("app/models/room.rb"),
+ b"class Room < ApplicationRecord\nend\n".to_vec(),
+ );
+ tree.insert(PathBuf::from(format!("app/models/{stem}.rb")), src.as_bytes().to_vec());
+ let mut app = ingest_app_from_tree(tree).expect("ingest");
+ roundhouse::session::analyze_and_lower(&mut app);
+ ruby::emit_library(&app)
+ .into_iter()
+ .find(|f| f.path.to_string_lossy().ends_with(&format!("{stem}.rb")))
+ .unwrap_or_else(|| panic!("no {stem}.rb emitted"))
+ .content
+}
+
+#[test]
+fn each_with_index_offset_collapses_to_each_with_index() {
+ let src = emit_lib(
+ r#"class Mover
+ def self.reposition(items, before, gap)
+ items.each.with_index(1) do |item, index|
+ item.update!(score: before + index * gap)
+ end
+ end
+end
+"#,
+ "mover",
+ );
+ assert!(
+ src.contains("each_with_index"),
+ "each.with_index(1) should become each_with_index:\n{src}"
+ );
+ assert!(
+ !src.contains("each.with_index"),
+ "Enumerator chain must not survive:\n{src}"
+ );
+ assert!(
+ src.contains("__with_index_i") && src.contains("index ="),
+ "nonzero offset should bind index = __with_index_i + offset:\n{src}"
+ );
+}
+
+#[test]
+fn each_with_index_offset_does_not_shadow_existing_temp_name() {
+ let src = emit_lib(
+ r#"class Mover
+ def self.reposition(items, __with_index_i)
+ items.each.with_index(1) do |item, index|
+ item.update!(score: before_plus(__with_index_i, index))
+ end
+ end
+
+ def self.before_plus(captured, index)
+ captured + index
+ end
+end
+"#,
+ "mover",
+ );
+ assert!(
+ src.contains("each_with_index"),
+ "still flatten each.with_index:\n{src}"
+ );
+ assert!(
+ src.contains("__with_index_i1") || !src.contains("|item, __with_index_i|"),
+ "injected index temp must not reuse the captured local name:\n{src}"
+ );
+}
+
+#[test]
+fn each_with_index_without_offset_needs_no_binding() {
+ let src = emit_lib(
+ r#"class Mover
+ def self.reposition(items)
+ items.each.with_index do |item, index|
+ item.update!(score: index)
+ end
+ end
+end
+"#,
+ "mover",
+ );
+ assert!(
+ src.contains("each_with_index"),
+ "bare each.with_index should become each_with_index:\n{src}"
+ );
+ assert!(
+ !src.contains("__with_index_i"),
+ "zero-start needs no synthetic index binding:\n{src}"
+ );
+}
+
+#[test]
+fn map_with_index_is_left_alone() {
+ let src = emit_lib(
+ r##"class Mapper
+ def self.label(items)
+ items.map.with_index(1) { |s, i| "#{i}:#{s}" }
+ end
+end
+"##,
+ "mapper",
+ );
+ assert!(
+ src.contains("map.with_index") || src.contains(".with_index"),
+ "map.with_index must not be rewritten to each_with_index:\n{src}"
+ );
+ assert!(
+ !src.contains("each_with_index"),
+ "map.with_index must not become each_with_index:\n{src}"
+ );
+}
+
+#[test]
+fn each_with_index_dynamic_offset_is_left_alone() {
+ let src = emit_lib(
+ r#"class Mover
+ def self.reposition(items, start)
+ items.each.with_index(start) do |item, index|
+ item.update!(score: index)
+ end
+ end
+end
+"#,
+ "mover",
+ );
+ assert!(
+ src.contains("each.with_index") || src.contains(".with_index"),
+ "non-literal offset must keep each.with_index (eval once):\n{src}"
+ );
+ assert!(
+ !src.contains("each_with_index"),
+ "dynamic offset must not materialize into each_with_index:\n{src}"
+ );
+}
+
+#[test]
+fn each_with_index_method_ref_offset_is_left_alone() {
+ let src = emit_lib(
+ r#"class Mover
+ def self.touch(item, index)
+ item.update!(score: index)
+ end
+
+ def self.reposition(items)
+ items.each.with_index(1, &method(:touch))
+ end
+end
+"#,
+ "mover",
+ );
+ assert!(
+ src.contains("with_index"),
+ "method-ref block must keep each.with_index so offset is not dropped:\n{src}"
+ );
+ assert!(
+ !src.contains("each_with_index"),
+ "method-ref + nonzero offset must not rewrite (would drop offset):\n{src}"
+ );
+}
diff --git a/tests/emit_and_run.rs b/tests/emit_and_run.rs
index 570b7b63d..27bd9cc4c 100644
--- a/tests/emit_and_run.rs
+++ b/tests/emit_and_run.rs
@@ -499,6 +499,179 @@ fn named_binds_reach_the_query_on_spinel() {
named_binds_app().run_spinel(&script).assert_passes();
}
+/// `sanitize_sql_array` is the documented array-form entry point (#400).
+fn sanitize_sql_array_app() -> emit_and_run::Overlay {
+ emit_and_run::empty_app()
+ .write(
+ "app/models/application_record.rb",
+ "class ApplicationRecord < ActiveRecord::Base\n self.abstract_class = true\nend\n",
+ )
+ .write(
+ "app/controllers/application_controller.rb",
+ "class ApplicationController < ActionController::Base\nend\n",
+ )
+ .write(
+ "db/schema.rb",
+ r#"ActiveRecord::Schema.define do
+ create_table "widgets", force: :cascade do |t|
+ t.string "name"
+ end
+end
+"#,
+ )
+ .write(
+ "app/models/widget.rb",
+ r#"class Widget < ApplicationRecord
+ def self.quoted(value)
+ ActiveRecord::Base.sanitize_sql_array(["SELECT ? AS v", value])
+ end
+end
+"#,
+ )
+ .write(
+ "config/routes.rb",
+ "Rails.application.routes.draw do\n get \"/widgets\", to: \"widgets#index\"\nend\n",
+ )
+ .write(
+ "app/controllers/widgets_controller.rb",
+ r##"class WidgetsController < ApplicationController
+ def index
+ render plain: Widget.quoted(1)
+ end
+end
+"##,
+ )
+}
+
+#[test]
+fn sanitize_sql_array_is_supported() {
+ sanitize_sql_array_app()
+ .run_ruby(
+ r#"
+require_relative "app/controllers/widgets_controller"
+sql = Widget.quoted(1)
+raise "sanitize_sql_array: #{sql.inspect}" unless sql == "SELECT 1 AS v"
+controller = WidgetsController.new
+controller.process_action(:index)
+raise "controller: #{controller.body}" unless controller.body == "SELECT 1 AS v"
+puts "sanitize_sql_array passed"
+"#,
+ )
+ .assert_passes();
+}
+
+/// Relation#ids must preserve uuid / named string keys (#310).
+fn relation_ids_uuid_app() -> emit_and_run::Overlay {
+ emit_and_run::empty_app()
+ .write(
+ "app/models/application_record.rb",
+ "class ApplicationRecord < ActiveRecord::Base\n self.abstract_class = true\nend\n",
+ )
+ .write(
+ "app/controllers/application_controller.rb",
+ "class ApplicationController < ActionController::Base\nend\n",
+ )
+ .write(
+ "db/schema.rb",
+ r#"ActiveRecord::Schema[8.1].define(version: 1) do
+ create_table "widgets", id: :uuid, force: :cascade do |t|
+ t.string "name"
+ t.boolean "active", default: true
+ end
+end
+"#,
+ )
+ .write("app/models/widget.rb", "class Widget < ApplicationRecord\nend\n")
+ .write(
+ "config/routes.rb",
+ "Rails.application.routes.draw do\n get \"/widget_ids\", to: \"widgets#ids\"\nend\n",
+ )
+ .write(
+ "app/controllers/widgets_controller.rb",
+ r##"class WidgetsController < ApplicationController
+ def ids
+ render plain: Widget.where(active: true).ids.join(",")
+ end
+end
+"##,
+ )
+}
+
+#[test]
+fn relation_ids_preserves_uuid_keys() {
+ relation_ids_uuid_app()
+ .run_ruby(
+ r#"
+require_relative "app/controllers/widgets_controller"
+uid = "44444444-4444-4444-8444-444444444441"
+Widget.create!(id: uid, name: "a", active: true)
+controller = WidgetsController.new
+controller.process_action(:ids)
+raise "uuid ids: #{controller.body.inspect}" unless controller.body == uid
+puts "relation ids uuid passed"
+"#,
+ )
+ .assert_passes();
+}
+
+/// Writebook-shaped `ActionText::Markdown < Record` under `module ActionText`
+/// in `lib/` is an ordinary model (table `action_text_markdowns`, attr
+/// `content`). Storage-only — does not claim `has_markdown`.
+#[test]
+fn action_text_markdown_saves_and_reloads_content() {
+ emit_and_run::empty_app()
+ .write(
+ "app/models/application_record.rb",
+ "class ApplicationRecord < ActiveRecord::Base\n self.abstract_class = true\nend\n",
+ )
+ .write(
+ "app/controllers/application_controller.rb",
+ "class ApplicationController < ActionController::Base\nend\n",
+ )
+ .write(
+ "db/schema.rb",
+ r#"ActiveRecord::Schema.define(version: 1) do
+ create_table "action_text_markdowns", force: :cascade do |t|
+ t.text "content"
+ t.string "name", null: false
+ t.bigint "record_id", null: false
+ t.string "record_type", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+end
+"#,
+ )
+ .write(
+ "lib/rails_ext/action_text_markdown.rb",
+ r#"module ActionText
+ class Markdown < Record
+ belongs_to :record, polymorphic: true
+ end
+end
+"#,
+ )
+ .write(
+ "config/routes.rb",
+ "Rails.application.routes.draw do\nend\n",
+ )
+ .run_ruby(
+ r##"
+m = ActionText::Markdown.new
+m.content = "# Hello"
+m.name = "body"
+m.record_type = "Article"
+m.record_id = 1
+m.save!
+reloaded = ActionText::Markdown.find(m.id)
+raise "content lost: #{reloaded.content.inspect}" unless reloaded.content == "# Hello"
+raise "name lost: #{reloaded.name.inspect}" unless reloaded.name == "body"
+puts "action_text markdown storage passed"
+"##,
+ )
+ .assert_passes();
+}
+
/// Rails 7.2's query assertions and the notification they are built on,
/// over the runtime's statement capture, with `connection.select_rows`
/// answering Arrays: campfire's tests count queries, assert none match a
diff --git a/tests/fixtures/writebook-inventory.json b/tests/fixtures/writebook-inventory.json
index e1207c0a8..4d75f5cfa 100644
--- a/tests/fixtures/writebook-inventory.json
+++ b/tests/fixtures/writebook-inventory.json
@@ -4,6 +4,7 @@
"models": [
"Access",
"Account",
+ "ActionText::Markdown",
"ApplicationRecord",
"Book",
"Edit",
@@ -17,7 +18,6 @@
"library_classes": [
"Account::Joinable",
"ActionText::HasMarkdown",
- "ActionText::Markdown",
"ActionText::Markdown::Uploads",
"ActionText::TagHelper",
"ActionView::Helpers::FormBuilder",
@@ -79,6 +79,7 @@
"PagesController",
"PicturesController",
"QrCodeController",
+ "Rails::HealthController",
"SectionsController",
"Sessions::TransfersController",
"SessionsController",
@@ -126,6 +127,7 @@
"Get /first_run FirstRunsController#show | first_run() named=true",
"Get /join/:join_code UsersController#new | join(join_code) named=true",
"Get /manifest Rails::PwaController#manifest | pwa_manifest() named=true",
+ "Get /pages/:page_id/edits/:id Pages::EditsController#show | page_edit(page_id,id) named=true",
"Get /qr_code/:id QrCodeController#show | qr_code(id) named=true",
"Get /service-worker Rails::PwaController#service_worker | pwa_service_worker() named=true",
"Get /session/new SessionsController#new | new_session() named=true",
@@ -261,6 +263,7 @@
"Accounts::JoinCodesControllerTest",
"ActionText::Markdown::UploadsControllerTest",
"ActiveStorageAuthenticationTest",
+ "ApplicationCable::ConnectionTest",
"Book::AccessableTest",
"BookTest",
"Books::BookmarksControllerTest",
@@ -319,6 +322,8 @@
"ActiveStorageAuthenticationTest|disk service download endpoint stays public",
"ActiveStorageAuthenticationTest|disk service upload endpoint allows authenticated callers",
"ActiveStorageAuthenticationTest|disk service upload endpoint rejects anonymous callers",
+ "ApplicationCable::ConnectionTest|connects with cookies",
+ "ApplicationCable::ConnectionTest|rejects connection without cookies",
"Book::AccessableTest|update_access always grants read access to everyone when everyone_access is set",
"Book::AccessableTest|update_access removes stale accesses",
"Book::AccessableTest|update_access updates existing access",
@@ -825,14 +830,11 @@
"Error|ivar_unresolved|app/views/sections/edit.html.erb:34:53|@section has no known type": 1,
"Error|ivar_unresolved|app/views/sections/new.html.erb:18:53|@section has no known type": 1,
"Error|send_dispatch_failed|app/controllers/action_text/markdown/uploads_controller.rb:36:17|no known method `locate_signed` on GlobalID::Locator": 1,
- "Error|unsupported|app/controllers/action_text/markdown/uploads_controller.rb:48:21|constant not supported (all targets): ActiveStorage::Attachment": 1,
- "Error|send_dispatch_failed|app/controllers/books/leaves/moves_controller.rb:7:5|no known method `drop` on Leaf": 1,
"Error|send_dispatch_failed|app/controllers/concerns/set_book_leaf.rb:19:50|no known method `leafable` on Leaf": 1,
"Error|send_dispatch_failed|app/controllers/concerns/set_book_leaf.rb:27:7|no known method `constantize` on String": 1,
"Error|send_dispatch_failed|app/controllers/leafables_controller.rb:52:16|no known method `model_name` on bot": 1,
"Error|send_dispatch_failed|app/controllers/leafables_controller.rb:70:7|no known method `broadcast_render_later_to` on Turbo::StreamsChannel": 1,
"Error|send_dispatch_failed|app/models/book.rb:17:36|no known method `leafable` on Leaf": 1,
- "Error|unsupported|app/models/leafable.rb:27:26|constant not supported (all targets): ActiveModel::Name": 1,
"Error|send_dispatch_failed|app/models/page.rb:21:19|no known method `first` on String": 1,
"Error|send_dispatch_failed|app/views/leafables/show.html.erb:15:7|no known method `section?` on Leaf": 1,
"Error|send_dispatch_failed|app/views/leafables/show.html.erb:16:50|no known method `leafable` on Leaf": 1,
@@ -859,28 +861,21 @@
"Error|send_dispatch_failed|app/views/leaves/_sidebar.html.erb:9:54|no known method `section?` on Leaf": 1,
"Error|send_dispatch_failed|app/views/pages/edits/show.html.erb:54:28|no known method `body` on Relation[Edit]": 1,
"Error|send_dispatch_failed|app/views/pages/edits/show.html.erb:65:26|no known method `body` on Relation[Leaf]": 1,
+ "Error|unsupported|app/controllers/action_text/markdown/uploads_controller.rb:48:21|constant not supported (all targets): ActiveStorage::Attachment": 1,
+ "Error|unsupported|app/models/leafable.rb:27:26|constant not supported (all targets): ActiveModel::Name": 1,
+ "Info|unresolved_type|lib/rails_ext/action_text_markdown.rb:30:31|method call `renderer` has unresolved type — likely roundhouse coverage, not an app error (ingest gap in lib/rails_ext/action_text_markdown.rb: `ActiveSupport.on_load)": 1,
+ "Info|unresolved_type|lib/rails_ext/action_text_markdown.rb:30:8|method call `renderer` has unresolved type — likely roundhouse coverage, not an app error (ingest gap in lib/rails_ext/action_text_markdown.rb: `ActiveSupport.on_load)": 1,
"Warning|gradual_untyped|app/controllers/action_text/markdown/uploads_controller.rb:22:8|expression resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/controllers/action_text/markdown/uploads_controller.rb:22:8|ivar read resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/controllers/action_text/markdown/uploads_controller.rb:22:8|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/controllers/action_text/markdown/uploads_controller.rb:23:18|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/controllers/action_text/markdown/uploads_controller.rb:25:18|method call resolves to RBS `untyped` (gradual escape)": 1,
- "Warning|gradual_untyped|app/controllers/action_text/markdown/uploads_controller.rb:38:15|method call resolves to RBS `untyped` (gradual escape)": 1,
- "Warning|gradual_untyped|app/controllers/action_text/markdown/uploads_controller.rb:40:30|ivar read resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/controllers/action_text/markdown/uploads_controller.rb:44:30|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/controllers/action_text/markdown/uploads_controller.rb:49:15|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/controllers/action_text/markdown/uploads_controller.rb:58:30|expression resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/controllers/action_text/markdown/uploads_controller.rb:58:30|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/controllers/action_text/markdown/uploads_controller.rb:58:51|expression resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/controllers/action_text/markdown/uploads_controller.rb:58:51|method call resolves to RBS `untyped` (gradual escape)": 1,
- "Warning|gradual_untyped|app/controllers/books_controller.rb:10:14|method call resolves to RBS `untyped` (gradual escape)": 1,
- "Warning|gradual_untyped|app/controllers/books_controller.rb:10:5|expression resolves to RBS `untyped` (gradual escape)": 1,
- "Warning|gradual_untyped|app/controllers/books_controller.rb:52:15|method call resolves to RBS `untyped` (gradual escape)": 1,
- "Warning|gradual_untyped|app/controllers/books_controller.rb:65:9|method call resolves to RBS `untyped` (gradual escape)": 1,
- "Warning|gradual_untyped|app/controllers/concerns/authentication.rb:35:9|method call resolves to RBS `untyped` (gradual escape)": 1,
- "Warning|gradual_untyped|app/controllers/concerns/authentication.rb:50:9|method call resolves to RBS `untyped` (gradual escape)": 1,
- "Warning|gradual_untyped|app/controllers/concerns/authentication.rb:56:7|method call resolves to RBS `untyped` (gradual escape)": 1,
- "Warning|gradual_untyped|app/controllers/concerns/book_scoped.rb:8:15|method call resolves to RBS `untyped` (gradual escape)": 1,
- "Warning|gradual_untyped|app/controllers/concerns/set_book_leaf.rb:11:15|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/controllers/concerns/version_headers.rb:10:39|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/controllers/concerns/version_headers.rb:10:7|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/controllers/concerns/version_headers.rb:11:35|method call resolves to RBS `untyped` (gradual escape)": 1,
@@ -890,17 +885,8 @@
"Warning|gradual_untyped|app/controllers/sessions/transfers_controller.rb:8:15|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/controllers/sessions/transfers_controller.rb:8:8|expression resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/controllers/sessions/transfers_controller.rb:9:29|local read resolves to RBS `untyped` (gradual escape)": 1,
- "Warning|gradual_untyped|app/models/book/accessable.rb:12:9|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/models/concerns/positionable.rb:20:9|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/models/concerns/positionable.rb:24:9|method call resolves to RBS `untyped` (gradual escape)": 1,
- "Warning|gradual_untyped|app/models/leaf.rb:15:5|method call resolves to RBS `untyped` (gradual escape)": 1,
- "Warning|gradual_untyped|app/models/leaf/searchable.rb:124:35|method call resolves to RBS `untyped` (gradual escape)": 1,
- "Warning|gradual_untyped|app/models/leaf/searchable.rb:125:33|local read resolves to RBS `untyped` (gradual escape)": 1,
- "Warning|gradual_untyped|app/models/leaf/searchable.rb:21:7|method call resolves to RBS `untyped` (gradual escape)": 1,
- "Warning|gradual_untyped|app/models/leaf/searchable.rb:25:10|expression resolves to RBS `untyped` (gradual escape)": 1,
- "Warning|gradual_untyped|app/models/leaf/searchable.rb:25:18|method call resolves to RBS `untyped` (gradual escape)": 1,
- "Warning|gradual_untyped|app/models/leaf/searchable.rb:26:33|local read resolves to RBS `untyped` (gradual escape)": 1,
- "Warning|gradual_untyped|app/models/leaf/searchable.rb:38:45|local read resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/models/page.rb:21:5|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/models/page.rb:30:19|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/models/page.rb:31:31|local read resolves to RBS `untyped` (gradual escape)": 1,
@@ -908,14 +894,9 @@
"Warning|gradual_untyped|app/models/session.rb:15:31|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/views/accounts/custom_styles/edit.html.erb:23:38|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/views/action_text/markdown/uploads/create.json.jbuilder:4:1|method call resolves to RBS `untyped` (gradual escape)": 1,
- "Warning|gradual_untyped|app/views/books/_create_buttons.html.erb:13:5|method call resolves to RBS `untyped` (gradual escape)": 1,
- "Warning|gradual_untyped|app/views/books/_create_buttons.html.erb:1:5|method call resolves to RBS `untyped` (gradual escape)": 1,
- "Warning|gradual_untyped|app/views/books/_create_buttons.html.erb:23:5|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/views/books/_form.html.erb:46:13|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/views/books/_form.html.erb:52:13|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/views/books/_form.html.erb:56:13|method call resolves to RBS `untyped` (gradual escape)": 1,
- "Warning|gradual_untyped|app/views/books/index.html.erb:27:12|ivar read resolves to RBS `untyped` (gradual escape)": 1,
- "Warning|gradual_untyped|app/views/books/index.html.erb:29:16|ivar read resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/views/books/publications/_publication.html.erb:33:17|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/views/books/publications/_publication.html.erb:44:13|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/views/books/publications/_publication.html.erb:49:13|method call resolves to RBS `untyped` (gradual escape)": 1,
@@ -923,30 +904,23 @@
"Warning|gradual_untyped|app/views/books/searches/_result.html.erb:2:73|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/views/books/searches/_result.html.erb:5:15|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/views/books/searches/_result.html.erb:5:73|method call resolves to RBS `untyped` (gradual escape)": 1,
- "Warning|gradual_untyped|app/views/books/show.html.erb:76:7|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/views/first_runs/show.html.erb:17:11|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/views/first_runs/show.html.erb:25:11|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/views/first_runs/show.html.erb:9:11|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/views/layouts/application.html.erb:18:11|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/views/leafables/_edit_header.html.erb:4:7|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/views/leafables/show.html.erb:21:43|method call resolves to RBS `untyped` (gradual escape)": 1,
- "Warning|gradual_untyped|app/views/leaves/_edit.html.erb:1:5|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/views/leaves/_edit.html.erb:9:9|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/views/leaves/_edit_footer.html.erb:5:9|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/views/leaves/_header.html.erb:28:13|method call resolves to RBS `untyped` (gradual escape)": 1,
- "Warning|gradual_untyped|app/views/leaves/_header.html.erb:2:7|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/views/leaves/_header.html.erb:5:9|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/views/leaves/_leaf.html.erb:27:11|method call resolves to RBS `untyped` (gradual escape)": 1,
- "Warning|gradual_untyped|app/views/leaves/_leaf.html.erb:2:7|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/views/leaves/_navigation.html.erb:2:7|method call resolves to RBS `untyped` (gradual escape)": 1,
- "Warning|gradual_untyped|app/views/pages/_form.html.erb:1:5|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/views/pages/edit.html.erb:9:9|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/views/pages/edits/show.html.erb:54:11|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/views/pages/edits/show.html.erb:65:9|method call resolves to RBS `untyped` (gradual escape)": 1,
- "Warning|gradual_untyped|app/views/pictures/_form.html.erb:1:5|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/views/pictures/_form.html.erb:9:9|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/views/pictures/edit.html.erb:9:9|method call resolves to RBS `untyped` (gradual escape)": 1,
- "Warning|gradual_untyped|app/views/sections/_form.html.erb:1:5|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/views/sections/edit.html.erb:9:9|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/views/sessions/new.html.erb:10:11|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/views/sessions/new.html.erb:18:11|method call resolves to RBS `untyped` (gradual escape)": 1,
@@ -967,7 +941,8 @@
"Warning|gradual_untyped|app/views/users/profiles/edit.html.erb:27:11|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/views/users/profiles/edit.html.erb:34:11|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|gradual_untyped|app/views/users/profiles/edit.html.erb:41:11|method call resolves to RBS `untyped` (gradual escape)": 1,
- "Warning|unresolved_type|app/controllers/books/leaves/moves_controller.rb:17:25|method call `Array` has unresolved type": 1,
+ "Warning|gradual_untyped|lib/rails_ext/action_text_markdown.rb:30:7|method call resolves to RBS `untyped` (gradual escape)": 1,
+ "Warning|gradual_untyped|lib/rails_ext/action_text_markdown.rb:30:8|method call resolves to RBS `untyped` (gradual escape)": 1,
"Warning|unresolved_type|app/controllers/books/leaves/moves_controller.rb:8:50|local read `followed_by` has unresolved type": 1,
"Warning|unresolved_type|app/controllers/books/leaves/moves_controller.rb:8:5|local read `leaf` has unresolved type": 1,
"Warning|unresolved_type|app/controllers/books/publications_controller.rb:14:17|method call `book_slug_url` has unresolved type": 1,
@@ -976,8 +951,6 @@
"Warning|unresolved_type|app/controllers/concerns/authentication.rb:61:7|method call `reset_session` has unresolved type": 1,
"Warning|unresolved_type|app/controllers/concerns/set_book_leaf.rb:19:7|method call `instance_variable_set` has unresolved type": 1,
"Warning|unresolved_type|app/controllers/leafables_controller.rb:42:33|method call `book_slug_url` has unresolved type": 1,
- "Warning|unresolved_type|app/models/book/accessable.rb:11:10|local read `user` has unresolved type": 1,
- "Warning|unresolved_type|app/models/book/accessable.rb:19:7|local read `user` has unresolved type": 1,
"Warning|unresolved_type|app/models/concerns/positionable.rb:10:71|local read `other` has unresolved type": 1,
"Warning|unresolved_type|app/models/concerns/positionable.rb:11:71|local read `other` has unresolved type": 1,
"Warning|unresolved_type|app/models/concerns/positionable.rb:19:7|method call `define_method` has unresolved type": 1,
@@ -1100,7 +1073,6 @@
"Warning|unresolved_type|app/views/leaves/_edit.html.erb:9:57|method call `leaf` has unresolved type": 1,
"Warning|unresolved_type|app/views/leaves/_edit_footer.html.erb:8:26|method call `leafable_path` has unresolved type": 1,
"Warning|unresolved_type|app/views/leaves/_header.html.erb:10:31|method call `book_slug_path` has unresolved type": 1,
- "Warning|unresolved_type|app/views/leaves/_history.html.erb:2:15|method call `page_edit_path` has unresolved type": 1,
"Warning|unresolved_type|app/views/leaves/_leaf.html.erb:12:21|method call `edit_leafable_path` has unresolved type": 1,
"Warning|unresolved_type|app/views/leaves/_leaf.html.erb:16:21|method call `leafable_slug_path` has unresolved type": 1,
"Warning|unresolved_type|app/views/leaves/_leaf.html.erb:20:21|method call `leafable_slug_path` has unresolved type": 1,
@@ -1121,8 +1093,6 @@
"Warning|unresolved_type|app/views/pages/_house_toolbar.html.erb:32:7|method call `house_toolbar_button` has unresolved type": 1,
"Warning|unresolved_type|app/views/pages/_house_toolbar.html.erb:37:7|method call `house_toolbar_file_upload_button` has unresolved type": 1,
"Warning|unresolved_type|app/views/pages/_house_toolbar.html.erb:7:7|method call `house_toolbar_button` has unresolved type": 1,
- "Warning|unresolved_type|app/views/pages/edits/show.html.erb:26:23|method call `page_edit_path` has unresolved type": 1,
- "Warning|unresolved_type|app/views/pages/edits/show.html.erb:42:23|method call `page_edit_path` has unresolved type": 1,
"Warning|unresolved_type|app/views/pages/edits/show.html.erb:60:19|method call `edit_leafable_path` has unresolved type": 1,
"Warning|unresolved_type|app/views/pages/edits/show.html.erb:6:17|method call `edit_leafable_path` has unresolved type": 1,
"Warning|unresolved_type|app/views/pages/update.turbo_stream.erb:1:5|method call `turbo_stream` has unresolved type": 1,
@@ -1146,16 +1116,8 @@
"app/helpers/leaves_helper.rb|anonymous `**` keyword forwarding not yet supported": 3,
"app/views/books/show.md.erb|view template not ingested: erb (non-html format)": 1,
"app/views/leafables/show.md.erb|view template not ingested: erb (non-html format)": 1,
- "config/routes.rb|resources :pages `only:` is not a literal list of actions": 1,
"lib/rails_ext/action_text_has_markdown.rb|`ActiveSupport.on_load(:active_record)` contains `include ActionText::HasMarkdown`, which is not carried: load-hook mixin installation is unsupported": 1,
- "lib/rails_ext/action_text_has_markdown.rb|unsupported expression node: SourceFileNode([])": 1,
- "lib/rails_ext/action_text_has_markdown.rb|unsupported expression node: SourceLineNode()": 1,
"lib/rails_ext/action_text_markdown.rb|`ActiveSupport.on_load(:active_storage_attachment)` reopens `ActionText::Markdown`, which is not carried: a reopen of a framework class inside a load hook is dropped (the tolerant-sgid `ActionText::Attachment.from_node` shape is the one read)": 1,
- "test/controllers/books/searches_controller_test.rb|unsupported expression node: MatchRequiredNode(LocalVariableReadNode(31, 1), HashPatternNode(None, [AssocNode(SymbolNode(None, Some(\"elements\"), Some(\":\"), [101, 108, 101, 109, 101, 110, 116, 115]), ArrayPatternNode(None, [HashPatternNode(None, [AssocNode(SymbolNode(None, Some(\"name\"), Some(\":\"), [110, 97, 109, 101]), StringNode(Some(\"\\\"\"), \"strong\", Some(\"\\\"\"), [115, 116, 114, 111, 110, 103]), None), AssocNode(SymbolNode(None, Some(\"content\"), Some(\":\"), [99, 111, 110, 116, 101, 110, 116]), RegularExpressionNode(\"/\", \"Handbook\", \"/\", [72, 97, 110, 100, 98, 111, 111, 107]), None)], None, Some(\"{\"), Some(\"}\")), HashPatternNode(None, [AssocNode(SymbolNode(None, Some(\"name\"), Some(\":\"), [110, 97, 109, 101]), StringNode(Some(\"\\\"\"), \"mark\", Some(\"\\\"\"), [109, 97, 114, 107]), None), AssocNode(SymbolNode(None, Some(\"content\"), Some(\":\"), [99, 111, 110, 116, 101, 110, 116]), StringNode(Some(\"\\\"\"), \"findme\", Some(\"\\\"\"), [102, 105, 110, 100, 109, 101]), None)], None, Some(\"{\"), Some(\"}\"))], None, [], Some(\"[\"), Some(\"]\")), None)], None, Some(\"{\"), Some(\"}\")), \"=>\")": 1,
- "test/controllers/books/searches_controller_test.rb|unsupported expression node: MatchRequiredNode(LocalVariableReadNode(31, 1), HashPatternNode(None, [AssocNode(SymbolNode(None, Some(\"elements\"), Some(\":\"), [101, 108, 101, 109, 101, 110, 116, 115]), ArrayPatternNode(None, [HashPatternNode(None, [AssocNode(SymbolNode(None, Some(\"name\"), Some(\":\"), [110, 97, 109, 101]), StringNode(Some(\"\\\"\"), \"strong\", Some(\"\\\"\"), [115, 116, 114, 111, 110, 103]), None), AssocNode(SymbolNode(None, Some(\"content\"), Some(\":\"), [99, 111, 110, 116, 101, 110, 116]), StringNode(Some(\"\\\"\"), \"Safe Title:\", Some(\"\\\"\"), [83, 97, 102, 101, 32, 84, 105, 116, 108, 101, 58]), None)], None, Some(\"{\"), Some(\"}\")), HashPatternNode(None, [AssocNode(SymbolNode(None, Some(\"name\"), Some(\":\"), [110, 97, 109, 101]), StringNode(Some(\"\\\"\"), \"mark\", Some(\"\\\"\"), [109, 97, 114, 107]), None), AssocNode(SymbolNode(None, Some(\"content\"), Some(\":\"), [99, 111, 110, 116, 101, 110, 116]), StringNode(Some(\"\\\"\"), \"findme\", Some(\"\\\"\"), [102, 105, 110, 100, 109, 101]), None)], None, Some(\"{\"), Some(\"}\"))], None, [], Some(\"[\"), Some(\"]\")), None)], None, Some(\"{\"), Some(\"}\")), \"=>\")": 2,
- "test/controllers/books/searches_controller_test.rb|unsupported expression node: MatchRequiredNode(LocalVariableReadNode(31, 1), HashPatternNode(None, [AssocNode(SymbolNode(None, Some(\"elements\"), Some(\":\"), [101, 108, 101, 109, 101, 110, 116, 115]), ArrayPatternNode(None, [HashPatternNode(None, [AssocNode(SymbolNode(None, Some(\"name\"), Some(\":\"), [110, 97, 109, 101]), StringNode(Some(\"\\\"\"), \"strong\", Some(\"\\\"\"), [115, 116, 114, 111, 110, 103]), None), AssocNode(SymbolNode(None, Some(\"elements\"), Some(\":\"), [101, 108, 101, 109, 101, 110, 116, 115]), ArrayPatternNode(None, [HashPatternNode(None, [AssocNode(SymbolNode(None, Some(\"name\"), Some(\":\"), [110, 97, 109, 101]), StringNode(Some(\"\\\"\"), \"mark\", Some(\"\\\"\"), [109, 97, 114, 107]), None), AssocNode(SymbolNode(None, Some(\"content\"), Some(\":\"), [99, 111, 110, 116, 101, 110, 116]), StringNode(Some(\"\\\"\"), \"findme\", Some(\"\\\"\"), [102, 105, 110, 100, 109, 101]), None), AssocNode(SymbolNode(None, Some(\"attributes\"), Some(\":\"), [97, 116, 116, 114, 105, 98, 117, 116, 101, 115]), ArrayPatternNode(None, [], None, [], Some(\"[\"), Some(\"]\")), None)], None, Some(\"{\"), Some(\"}\")), HashPatternNode(None, [AssocNode(SymbolNode(None, Some(\"name\"), Some(\":\"), [110, 97, 109, 101]), StringNode(Some(\"\\\"\"), \"mark\", Some(\"\\\"\"), [109, 97, 114, 107]), None), AssocNode(SymbolNode(None, Some(\"content\"), Some(\":\"), [99, 111, 110, 116, 101, 110, 116]), StringNode(Some(\"\\\"\"), \"fake\", Some(\"\\\"\"), [102, 97, 107, 101]), None), AssocNode(SymbolNode(None, Some(\"attributes\"), Some(\":\"), [97, 116, 116, 114, 105, 98, 117, 116, 101, 115]), ArrayPatternNode(None, [], None, [], Some(\"[\"), Some(\"]\")), None)], None, Some(\"{\"), Some(\"}\"))], None, [], Some(\"[\"), Some(\"]\")), None)], None, Some(\"{\"), Some(\"}\")), HashPatternNode(None, [AssocNode(SymbolNode(None, Some(\"name\"), Some(\":\"), [110, 97, 109, 101]), StringNode(Some(\"\\\"\"), \"mark\", Some(\"\\\"\"), [109, 97, 114, 107]), None), AssocNode(SymbolNode(None, Some(\"content\"), Some(\":\"), [99, 111, 110, 116, 101, 110, 116]), StringNode(Some(\"\\\"\"), \"findme\", Some(\"\\\"\"), [102, 105, 110, 100, 109, 101]), None)], None, Some(\"{\"), Some(\"}\"))], None, [], Some(\"[\"), Some(\"]\")), None)], None, Some(\"{\"), Some(\"}\")), \"=>\")": 1,
- "test/controllers/books/searches_controller_test.rb|unsupported expression node: MatchRequiredNode(LocalVariableReadNode(31, 1), HashPatternNode(None, [AssocNode(SymbolNode(None, Some(\"elements\"), Some(\":\"), [101, 108, 101, 109, 101, 110, 116, 115]), ArrayPatternNode(None, [HashPatternNode(None, [AssocNode(SymbolNode(None, Some(\"name\"), Some(\":\"), [110, 97, 109, 101]), StringNode(Some(\"\\\"\"), \"strong\", Some(\"\\\"\"), [115, 116, 114, 111, 110, 103]), None), AssocNode(SymbolNode(None, Some(\"elements\"), Some(\":\"), [101, 108, 101, 109, 101, 110, 116, 115]), ArrayPatternNode(None, [HashPatternNode(None, [AssocNode(SymbolNode(None, Some(\"name\"), Some(\":\"), [110, 97, 109, 101]), StringNode(Some(\"\\\"\"), \"mark\", Some(\"\\\"\"), [109, 97, 114, 107]), None), AssocNode(SymbolNode(None, Some(\"content\"), Some(\":\"), [99, 111, 110, 116, 101, 110, 116]), StringNode(Some(\"\\\"\"), \"findme\", Some(\"\\\"\"), [102, 105, 110, 100, 109, 101]), None)], None, Some(\"{\"), Some(\"}\"))], None, [], Some(\"[\"), Some(\"]\")), None), AssocNode(SymbolNode(None, Some(\"content\"), Some(\":\"), [99, 111, 110, 116, 101, 110, 116]), StringNode(Some(\"\\\"\"), \"findme bold:\", Some(\"\\\"\"), [102, 105, 110, 100, 109, 101, 32, 98, 111, 108, 100, 58]), None)], None, Some(\"{\"), Some(\"}\"))], None, [], Some(\"[\"), Some(\"]\")), None)], None, Some(\"{\"), Some(\"}\")), \"=>\")": 1,
- "test/controllers/books/searches_controller_test.rb|unsupported expression node: MatchRequiredNode(LocalVariableReadNode(31, 1), HashPatternNode(None, [AssocNode(SymbolNode(None, Some(\"elements\"), Some(\":\"), [101, 108, 101, 109, 101, 110, 116, 115]), ArrayPatternNode(None, [HashPatternNode(None, [AssocNode(SymbolNode(None, Some(\"name\"), Some(\":\"), [110, 97, 109, 101]), StringNode(Some(\"\\\"\"), \"strong\", Some(\"\\\"\"), [115, 116, 114, 111, 110, 103]), None), AssocNode(SymbolNode(None, Some(\"elements\"), Some(\":\"), [101, 108, 101, 109, 101, 110, 116, 115]), ArrayPatternNode(None, [HashPatternNode(None, [AssocNode(SymbolNode(None, Some(\"name\"), Some(\":\"), [110, 97, 109, 101]), StringNode(Some(\"\\\"\"), \"mark\", Some(\"\\\"\"), [109, 97, 114, 107]), None), AssocNode(SymbolNode(None, Some(\"content\"), Some(\":\"), [99, 111, 110, 116, 101, 110, 116]), StringNode(Some(\"\\\"\"), \"findme\", Some(\"\\\"\"), [102, 105, 110, 100, 109, 101]), None)], None, Some(\"{\"), Some(\"}\"))], None, [], Some(\"[\"), Some(\"]\")), None)], None, Some(\"{\"), Some(\"}\")), HashPatternNode(None, [AssocNode(SymbolNode(None, Some(\"name\"), Some(\":\"), [110, 97, 109, 101]), StringNode(Some(\"\\\"\"), \"mark\", Some(\"\\\"\"), [109, 97, 114, 107]), None), AssocNode(SymbolNode(None, Some(\"content\"), Some(\":\"), [99, 111, 110, 116, 101, 110, 116]), StringNode(Some(\"\\\"\"), \"findme\", Some(\"\\\"\"), [102, 105, 110, 100, 109, 101]), None)], None, Some(\"{\"), Some(\"}\"))], None, [], Some(\"[\"), Some(\"]\")), None)], None, Some(\"{\"), Some(\"}\")), \"=>\")": 1,
"test/fixtures/active_storage/blobs.yml|not a fixture set: `handbook_reading_image_blob` is a scalar, where Rails expects label → fields": 1
},
"lowering": {
@@ -1215,7 +1177,6 @@
"emit": {
"ruby": {
"Warning|lower_residue||scope `start!` (on Session) gets no ActiveRecord::Relation mid-chain delegate: shape on Session is not required positionals only; a mid-chain call raises NoMethodError": 1,
- "Warning|lower_residue||template statement dropped (unknown stmt) — it contributes no output and runs no side effect in the emitted view": 1,
"Warning|lower_residue|app/channels/application_cable/connection.rb:5:5|`identified_by` in the body of `ApplicationCable::Connection` is not modelled and is dropped; only a class extending a base roundhouse does NOT model (a gem's, whose DSL is the class) replays its class body verbatim": 1,
"Warning|lower_residue|app/controllers/books_controller.rb:29:7|`respond_to` arm `format.md` dropped (format not modeled) — this action answers every format with its html branch, so a request that negotiates `md` (including a route pinned with `:format => \"md\"`) gets HTML back": 1,
"Warning|lower_residue|app/controllers/concerns/active_storage_authentication.rb:2:3|`extend` in the body of `ActiveStorageAuthentication` is not modelled and is dropped; only a class extending a base roundhouse does NOT model (a gem's, whose DSL is the class) replays its class body verbatim": 1,
@@ -1255,24 +1216,13 @@
"Warning|lower_residue|app/models/user/role.rb:2:3|`extend` in the body of `User::Role` is not modelled and is dropped; only a class extending a base roundhouse does NOT model (a gem's, whose DSL is the class) replays its class body verbatim": 1,
"Warning|lower_residue|app/models/user/role.rb:4:3|`included` in the body of `User::Role` is not modelled and is dropped; only a class extending a base roundhouse does NOT model (a gem's, whose DSL is the class) replays its class body verbatim": 1,
"Warning|lower_residue|app/models/user/transferable.rb:2:3|`extend` in the body of `User::Transferable` is not modelled and is dropped; only a class extending a base roundhouse does NOT model (a gem's, whose DSL is the class) replays its class body verbatim": 1,
- "Warning|lower_residue|app/views/accounts/custom_styles/edit.html.erb:1:26|template statement dropped (unknown stmt) — it contributes no output and runs no side effect in the emitted view": 1,
"Warning|lower_residue|app/views/books/edit.html.erb:1:26|template statement dropped (unknown stmt) — it contributes no output and runs no side effect in the emitted view": 1,
- "Warning|lower_residue|app/views/books/index.html.erb:1:26|template statement dropped (unknown stmt) — it contributes no output and runs no side effect in the emitted view": 1,
- "Warning|lower_residue|app/views/books/new.html.erb:1:26|template statement dropped (unknown stmt) — it contributes no output and runs no side effect in the emitted view": 1,
- "Warning|lower_residue|app/views/first_runs/show.html.erb:1:26|template statement dropped (unknown stmt) — it contributes no output and runs no side effect in the emitted view": 1,
"Warning|lower_residue|app/views/leafables/create.turbo_stream.erb:7:5|`turbo_stream.scroll_into_view` left in source shape — the option form (partial:/collection:/locals:) and the block form need the partial machinery a `render` call site gets, so this template will not render": 1,
"Warning|lower_residue|app/views/leafables/show.html.erb:1:26|template statement dropped (unknown stmt) — it contributes no output and runs no side effect in the emitted view": 1,
"Warning|lower_residue|app/views/pages/edit.html.erb:1:26|template statement dropped (unknown stmt) — it contributes no output and runs no side effect in the emitted view": 1,
"Warning|lower_residue|app/views/pages/edits/show.html.erb:1:26|template statement dropped (unknown stmt) — it contributes no output and runs no side effect in the emitted view": 1,
- "Warning|lower_residue|app/views/pages/new.html.erb:1:26|template statement dropped (unknown stmt) — it contributes no output and runs no side effect in the emitted view": 1,
"Warning|lower_residue|app/views/pictures/edit.html.erb:1:26|template statement dropped (unknown stmt) — it contributes no output and runs no side effect in the emitted view": 1,
- "Warning|lower_residue|app/views/pictures/new.html.erb:1:26|template statement dropped (unknown stmt) — it contributes no output and runs no side effect in the emitted view": 1,
"Warning|lower_residue|app/views/sections/edit.html.erb:1:26|template statement dropped (unknown stmt) — it contributes no output and runs no side effect in the emitted view": 1,
- "Warning|lower_residue|app/views/sections/new.html.erb:1:26|template statement dropped (unknown stmt) — it contributes no output and runs no side effect in the emitted view": 1,
- "Warning|lower_residue|app/views/sessions/new.html.erb:1:26|template statement dropped (unknown stmt) — it contributes no output and runs no side effect in the emitted view": 1,
- "Warning|lower_residue|app/views/users/index.html.erb:1:26|template statement dropped (unknown stmt) — it contributes no output and runs no side effect in the emitted view": 1,
- "Warning|lower_residue|app/views/users/new.html.erb:1:26|template statement dropped (unknown stmt) — it contributes no output and runs no side effect in the emitted view": 1,
- "Warning|lower_residue|app/views/users/profiles/edit.html.erb:1:26|template statement dropped (unknown stmt) — it contributes no output and runs no side effect in the emitted view": 1,
"Warning|lower_residue|lib/rails_ext/action_text_has_markdown.rb:3:5|`extend` in the body of `ActionText::HasMarkdown` is not modelled and is dropped; only a class extending a base roundhouse does NOT model (a gem's, whose DSL is the class) replays its class body verbatim": 1,
"Warning|lower_residue|lib/rails_ext/action_text_markdown.rb:36:3|`extend` in the body of `ActionText::Markdown::Uploads` is not modelled and is dropped; only a class extending a base roundhouse does NOT model (a gem's, whose DSL is the class) replays its class body verbatim": 1,
"Warning|lower_residue|lib/rails_ext/action_text_markdown.rb:38:3|`included` in the body of `ActionText::Markdown::Uploads` is not modelled and is dropped; only a class extending a base roundhouse does NOT model (a gem's, whose DSL is the class) replays its class body verbatim": 1,
@@ -1280,11 +1230,11 @@
"Warning|lower_residue|lib/rails_ext/active_storage_sluggable.rb:4:3|`included` in the body of `ActiveStorage::Sluggable` is not modelled and is dropped; only a class extending a base roundhouse does NOT model (a gem's, whose DSL is the class) replays its class body verbatim": 1,
"Warning|unsupported|app/models/account.rb:7:3|serialize not supported (all targets): model DSL call on `Account` not lowered": 1,
"Warning|unsupported|app/models/edit.rb:3:3|delegated_type not supported (all targets): model DSL call on `Edit` not lowered": 1,
- "Warning|unsupported|app/models/edit.rb:7:3|after_destroy not supported (all targets): model DSL call on `Edit` not lowered": 1,
"Warning|unsupported|app/models/leaf.rb:5:3|delegated_type not supported (all targets): model DSL call on `Leaf` not lowered": 1,
"Warning|unsupported|app/models/leaf.rb:8:3|delegate not supported (all targets): model DSL call on `Leaf` not lowered": 1,
"Warning|unsupported|app/models/page.rb:4:3|cattr_accessor not supported (all targets): model DSL call on `Page` not lowered": 1,
- "Warning|unsupported|app/models/page.rb:9:3|has_markdown not supported (all targets): model DSL call on `Page` not lowered": 1
+ "Warning|unsupported|app/models/page.rb:9:3|has_markdown not supported (all targets): model DSL call on `Page` not lowered": 1,
+ "Warning|unsupported|lib/rails_ext/action_text_markdown.rb:24:5|mattr_accessor not supported (all targets): model DSL call on `ActionText::Markdown` not lowered": 1
},
"spinel": {
"Error|unsupported|app/helpers/arrangement_helper.rb:2:7|keyword rest declaration not supported (spinel): native Ruby forwarding is preserved; this target's argument/block carrier is not verified": 1,
@@ -1293,7 +1243,6 @@
"Error|unsupported|app/helpers/leaves_helper.rb:19:7|keyword rest declaration not supported (spinel): native Ruby forwarding is preserved; this target's argument/block carrier is not verified": 1,
"Error|unsupported|app/helpers/leaves_helper.rb:2:7|keyword rest declaration not supported (spinel): native Ruby forwarding is preserved; this target's argument/block carrier is not verified": 1,
"Warning|lower_residue||scope `start!` (on Session) gets no ActiveRecord::Relation mid-chain delegate: shape on Session is not required positionals only; a mid-chain call raises NoMethodError": 1,
- "Warning|lower_residue||template statement dropped (unknown stmt) — it contributes no output and runs no side effect in the emitted view": 1,
"Warning|lower_residue|app/channels/application_cable/connection.rb:5:5|`identified_by` in the body of `ApplicationCable::Connection` is not modelled and is dropped; only a class extending a base roundhouse does NOT model (a gem's, whose DSL is the class) replays its class body verbatim": 1,
"Warning|lower_residue|app/controllers/books_controller.rb:29:7|`respond_to` arm `format.md` dropped (format not modeled) — this action answers every format with its html branch, so a request that negotiates `md` (including a route pinned with `:format => \"md\"`) gets HTML back": 1,
"Warning|lower_residue|app/controllers/concerns/active_storage_authentication.rb:2:3|`extend` in the body of `ActiveStorageAuthentication` is not modelled and is dropped; only a class extending a base roundhouse does NOT model (a gem's, whose DSL is the class) replays its class body verbatim": 1,
@@ -1333,24 +1282,13 @@
"Warning|lower_residue|app/models/user/role.rb:2:3|`extend` in the body of `User::Role` is not modelled and is dropped; only a class extending a base roundhouse does NOT model (a gem's, whose DSL is the class) replays its class body verbatim": 1,
"Warning|lower_residue|app/models/user/role.rb:4:3|`included` in the body of `User::Role` is not modelled and is dropped; only a class extending a base roundhouse does NOT model (a gem's, whose DSL is the class) replays its class body verbatim": 1,
"Warning|lower_residue|app/models/user/transferable.rb:2:3|`extend` in the body of `User::Transferable` is not modelled and is dropped; only a class extending a base roundhouse does NOT model (a gem's, whose DSL is the class) replays its class body verbatim": 1,
- "Warning|lower_residue|app/views/accounts/custom_styles/edit.html.erb:1:26|template statement dropped (unknown stmt) — it contributes no output and runs no side effect in the emitted view": 1,
"Warning|lower_residue|app/views/books/edit.html.erb:1:26|template statement dropped (unknown stmt) — it contributes no output and runs no side effect in the emitted view": 1,
- "Warning|lower_residue|app/views/books/index.html.erb:1:26|template statement dropped (unknown stmt) — it contributes no output and runs no side effect in the emitted view": 1,
- "Warning|lower_residue|app/views/books/new.html.erb:1:26|template statement dropped (unknown stmt) — it contributes no output and runs no side effect in the emitted view": 1,
- "Warning|lower_residue|app/views/first_runs/show.html.erb:1:26|template statement dropped (unknown stmt) — it contributes no output and runs no side effect in the emitted view": 1,
"Warning|lower_residue|app/views/leafables/create.turbo_stream.erb:7:5|`turbo_stream.scroll_into_view` left in source shape — the option form (partial:/collection:/locals:) and the block form need the partial machinery a `render` call site gets, so this template will not render": 1,
"Warning|lower_residue|app/views/leafables/show.html.erb:1:26|template statement dropped (unknown stmt) — it contributes no output and runs no side effect in the emitted view": 1,
"Warning|lower_residue|app/views/pages/edit.html.erb:1:26|template statement dropped (unknown stmt) — it contributes no output and runs no side effect in the emitted view": 1,
"Warning|lower_residue|app/views/pages/edits/show.html.erb:1:26|template statement dropped (unknown stmt) — it contributes no output and runs no side effect in the emitted view": 1,
- "Warning|lower_residue|app/views/pages/new.html.erb:1:26|template statement dropped (unknown stmt) — it contributes no output and runs no side effect in the emitted view": 1,
"Warning|lower_residue|app/views/pictures/edit.html.erb:1:26|template statement dropped (unknown stmt) — it contributes no output and runs no side effect in the emitted view": 1,
- "Warning|lower_residue|app/views/pictures/new.html.erb:1:26|template statement dropped (unknown stmt) — it contributes no output and runs no side effect in the emitted view": 1,
"Warning|lower_residue|app/views/sections/edit.html.erb:1:26|template statement dropped (unknown stmt) — it contributes no output and runs no side effect in the emitted view": 1,
- "Warning|lower_residue|app/views/sections/new.html.erb:1:26|template statement dropped (unknown stmt) — it contributes no output and runs no side effect in the emitted view": 1,
- "Warning|lower_residue|app/views/sessions/new.html.erb:1:26|template statement dropped (unknown stmt) — it contributes no output and runs no side effect in the emitted view": 1,
- "Warning|lower_residue|app/views/users/index.html.erb:1:26|template statement dropped (unknown stmt) — it contributes no output and runs no side effect in the emitted view": 1,
- "Warning|lower_residue|app/views/users/new.html.erb:1:26|template statement dropped (unknown stmt) — it contributes no output and runs no side effect in the emitted view": 1,
- "Warning|lower_residue|app/views/users/profiles/edit.html.erb:1:26|template statement dropped (unknown stmt) — it contributes no output and runs no side effect in the emitted view": 1,
"Warning|lower_residue|lib/rails_ext/action_text_has_markdown.rb:3:5|`extend` in the body of `ActionText::HasMarkdown` is not modelled and is dropped; only a class extending a base roundhouse does NOT model (a gem's, whose DSL is the class) replays its class body verbatim": 1,
"Warning|lower_residue|lib/rails_ext/action_text_markdown.rb:36:3|`extend` in the body of `ActionText::Markdown::Uploads` is not modelled and is dropped; only a class extending a base roundhouse does NOT model (a gem's, whose DSL is the class) replays its class body verbatim": 1,
"Warning|lower_residue|lib/rails_ext/action_text_markdown.rb:38:3|`included` in the body of `ActionText::Markdown::Uploads` is not modelled and is dropped; only a class extending a base roundhouse does NOT model (a gem's, whose DSL is the class) replays its class body verbatim": 1,
@@ -1358,11 +1296,11 @@
"Warning|lower_residue|lib/rails_ext/active_storage_sluggable.rb:4:3|`included` in the body of `ActiveStorage::Sluggable` is not modelled and is dropped; only a class extending a base roundhouse does NOT model (a gem's, whose DSL is the class) replays its class body verbatim": 1,
"Warning|unsupported|app/models/account.rb:7:3|serialize not supported (all targets): model DSL call on `Account` not lowered": 1,
"Warning|unsupported|app/models/edit.rb:3:3|delegated_type not supported (all targets): model DSL call on `Edit` not lowered": 1,
- "Warning|unsupported|app/models/edit.rb:7:3|after_destroy not supported (all targets): model DSL call on `Edit` not lowered": 1,
"Warning|unsupported|app/models/leaf.rb:5:3|delegated_type not supported (all targets): model DSL call on `Leaf` not lowered": 1,
"Warning|unsupported|app/models/leaf.rb:8:3|delegate not supported (all targets): model DSL call on `Leaf` not lowered": 1,
"Warning|unsupported|app/models/page.rb:4:3|cattr_accessor not supported (all targets): model DSL call on `Page` not lowered": 1,
- "Warning|unsupported|app/models/page.rb:9:3|has_markdown not supported (all targets): model DSL call on `Page` not lowered": 1
+ "Warning|unsupported|app/models/page.rb:9:3|has_markdown not supported (all targets): model DSL call on `Page` not lowered": 1,
+ "Warning|unsupported|lib/rails_ext/action_text_markdown.rb:24:5|mattr_accessor not supported (all targets): model DSL call on `ActionText::Markdown` not lowered": 1
}
}
}
diff --git a/tests/inference_on_spinel_blog_runtime_with_rbs.rs b/tests/inference_on_spinel_blog_runtime_with_rbs.rs
index 1289d01b2..d5243228c 100644
--- a/tests/inference_on_spinel_blog_runtime_with_rbs.rs
+++ b/tests/inference_on_spinel_blog_runtime_with_rbs.rs
@@ -30,11 +30,23 @@ use roundhouse::dialect::{LibraryClass, MethodDef};
use roundhouse::expr::{Expr, ExprNode, InterpPart};
use roundhouse::ident::{ClassId, Symbol};
use roundhouse::ingest::ingest_library_classes;
-use roundhouse::rbs::parse_app_signatures;
+use roundhouse::rbs::{parse_app_ivars, parse_app_signatures};
use roundhouse::ty::Ty;
const RUNTIME_DIR: &str = "runtime/ruby/active_record";
+/// Sidecars outside `active_record/` that AR bodies call. Without these
+/// the probe reports Ty::Var on real, already-declared surfaces (Db,
+/// Rails.application, MessageVerifier, ActiveSupport.json_time) — the
+/// same gap `runtime_src_integration` avoids via `insert_db_stub` /
+/// stdlib seeding. Not an app inventory; shared-runtime contracts only.
+const DEPENDENCY_RBS: &[&str] = &[
+ "runtime/ruby/db.rbs",
+ "runtime/ruby/rails.rbs",
+ "runtime/ruby/action_controller/message_verifier.rbs",
+ "runtime/ruby/active_support_time_parsing.rbs",
+];
+
/// Walk a typed expression tree, collecting every node whose `ty` is
/// missing or `Ty::Var`. Same shape as the no-RBS probe so the two
/// numbers are directly comparable.
@@ -217,59 +229,113 @@ fn collect_untyped(e: &Expr, path: &str, out: &mut Vec) {
}
}
-/// Walk one method body collecting every `@x = expr` assignment so
-/// the second typing pass can seed `ivar_bindings` with discovered
-/// types. Mirrors the analyzer's two-pass discipline (model + library
-/// passes).
-fn extract_ivar_assignments(expr: &Expr, out: &mut HashMap) {
- match &*expr.node {
- ExprNode::Assign {
- target: roundhouse::expr::LValue::Ivar { name },
- value,
- } => {
- if let Some(ty) = value.ty.clone() {
- out.entry(name.clone()).or_insert(ty);
- }
+/// Seed ivar_bindings: union-merge flow assignments (so `initialize`'s
+/// `@limit = nil` does not permanently beat `limit`'s `@limit = n`),
+/// then overlay RBS-declared ivars for this class (single source of
+/// truth — no parallel class-name match table).
+///
+/// `ActiveRecord::Base` methods are split across stems (`base.rb`,
+/// `connection.rb`, …). Callers must merge seeds for the same
+/// `ClassId` — a per-stem insert would drop `initialize`'s writes.
+fn seed_ivars_for_class(
+ class_id: &ClassId,
+ methods: &[roundhouse::dialect::MethodDef],
+ rbs_ivars: &HashMap>,
+) -> HashMap {
+ let mut flow: HashMap = HashMap::new();
+ for method in methods {
+ roundhouse::analyze::extract_ivar_assignments(&method.body, &mut flow);
+ }
+ if let Some(declared) = rbs_ivars.get(class_id) {
+ for (name, ty) in declared {
+ flow.insert(name.clone(), ty.clone());
}
- ExprNode::Seq { exprs } => {
- for e in exprs {
- extract_ivar_assignments(e, out);
+ }
+ // Short-name RBS keys (rare) — only when exact ClassId missed.
+ // Do not merge short-only ivars onto a class that already has its
+ // own declaration map (would widen unrelated siblings).
+ if !rbs_ivars.contains_key(class_id) {
+ if let Some(short) = class_id.0.as_str().rsplit("::").next() {
+ let short_id = ClassId(Symbol::new(short));
+ if short_id != *class_id {
+ if let Some(declared) = rbs_ivars.get(&short_id) {
+ for (name, ty) in declared {
+ flow.entry(name.clone()).or_insert_with(|| ty.clone());
+ }
+ }
}
}
- ExprNode::If { then_branch, else_branch, .. } => {
- extract_ivar_assignments(then_branch, out);
- extract_ivar_assignments(else_branch, out);
- }
- ExprNode::BoolOp { left, right, .. } => {
- extract_ivar_assignments(left, out);
- extract_ivar_assignments(right, out);
- }
- ExprNode::Lambda { body, .. } => extract_ivar_assignments(body, out),
- ExprNode::BeginRescue { body, rescues, else_branch, ensure, .. } => {
- extract_ivar_assignments(body, out);
- for r in rescues {
- extract_ivar_assignments(&r.body, out);
- }
- if let Some(e) = else_branch {
- extract_ivar_assignments(e, out);
+ }
+ flow
+ .into_iter()
+ .map(|(name, ty)| {
+ // Already nilable overlays stay; flow Int becomes Int|Nil.
+ let wrapped = match &ty {
+ Ty::Union { variants } if variants.iter().any(|v| matches!(v, Ty::Nil)) => ty,
+ other => Ty::Union {
+ variants: vec![other.clone(), Ty::Nil],
+ },
+ };
+ (name, wrapped)
+ })
+ .collect()
+}
+
+/// Union-merge two ivar maps (same join as the analyzer harvest).
+fn merge_ivar_maps(into: &mut HashMap, from: HashMap) {
+ for (name, ty) in from {
+ match into.remove(&name) {
+ Some(prev) => {
+ into.insert(name, match (prev, ty) {
+ (a, b) if a == b => a,
+ (a, b) => {
+ // Flatten nested unions so `Array[Int]|Nil` ⨝ `Nil`
+ // stays a flat union (collection_elem still sees Int).
+ let mut variants = Vec::new();
+ let mut push = |t: Ty| match t {
+ Ty::Union { variants: inner } => {
+ for v in inner {
+ if !variants.iter().any(|e| e == &v) {
+ variants.push(v);
+ }
+ }
+ }
+ other => {
+ if !variants.iter().any(|e| e == &other) {
+ variants.push(other);
+ }
+ }
+ };
+ push(a);
+ push(b);
+ match variants.len() {
+ 0 => Ty::Nil,
+ 1 => variants.pop().unwrap(),
+ _ => Ty::Union { variants },
+ }
+ }
+ });
}
- if let Some(e) = ensure {
- extract_ivar_assignments(e, out);
+ None => {
+ into.insert(name, ty);
}
}
- ExprNode::Return { value } => extract_ivar_assignments(value, out),
- _ => {}
}
}
-/// Build a `ClassInfo` for each class declared in any of the
-/// `.rbs` files, keyed by the class's last name segment. Mirrors
-/// the existing `runtime_src_integration` registry-building
-/// pattern (lines 300–348 of that file): RBS uses fully-qualified
-/// names (`ActiveRecord::Base`) but the body-typer dispatches via
-/// `Ty::Class { id }` whose id comes from `Const { path }.last()`.
-/// Stripping to the last segment keeps lookups consistent.
-fn build_class_registry() -> (HashMap, HashMap>) {
+/// Preserve canonical RBS class identities for method parameters and
+/// dispatch. This probe has no ConstantResolver, so unique short-name
+/// aliases also serve bare constants. Ambiguous suffixes must never
+/// merge unrelated classes.
+///
+/// Also merges dependency sidecars + the Db stub the production /
+/// Bar-A paths already seed, so AR bodies are not scored as unresolved
+/// for calls that are already contracted elsewhere in `runtime/ruby/`.
+fn build_class_registry() -> (
+ HashMap,
+ HashMap>,
+ HashMap>,
+) {
let dir = Path::new(RUNTIME_DIR);
let mut entries: Vec<_> = fs::read_dir(dir)
.unwrap_or_else(|e| panic!("read_dir {RUNTIME_DIR}: {e}"))
@@ -278,13 +344,12 @@ fn build_class_registry() -> (HashMap, HashMap = HashMap::new();
- // Keep the full Ty::Fn signatures alongside (the registry stores
- // return types after unwrap_fn_ret; the body Ctx needs the full
- // params Vec).
let mut sigs: HashMap> = HashMap::new();
-
+ let mut ivars: HashMap> = HashMap::new();
for path in entries {
let source = fs::read_to_string(&path).unwrap_or_else(|e| {
panic!("read {}: {e}", path.display())
@@ -293,28 +358,59 @@ fn build_class_registry() -> (HashMap, HashMap>,
+) -> HashMap {
+ let mut registry: HashMap = HashMap::new();
+ let mut aliases: HashMap> = HashMap::new();
+ for (class_id, methods) in sigs {
+ let entry = registry.entry(class_id.clone()).or_default();
+ entry.instance_methods = methods.clone();
+ entry.class_methods = methods.clone();
+ let short = ClassId(Symbol::new(
+ class_id.0.as_str().rsplit("::").next().unwrap_or(class_id.0.as_str()),
+ ));
+ aliases
+ .entry(short)
+ .and_modify(|owner| {
+ if owner.as_ref() != Some(class_id) {
+ *owner = None;
+ }
+ })
+ .or_insert_with(|| Some(class_id.clone()));
+ }
+ for (short, owner) in aliases {
+ if let Some(owner) = owner {
+ registry.entry(short).or_insert_with(|| {
+ let mut info = ClassInfo::default();
+ info.instance_methods = sigs[&owner].clone();
+ info.class_methods = sigs[&owner].clone();
+ info
+ });
+ }
+ }
+ registry
}
/// Build a method-body Ctx by seeding `self_ty` from the enclosing
@@ -368,9 +464,74 @@ fn ingest_runtime_classes() -> Vec<(String, LibraryClass)> {
out
}
+/// Existing nested runtime classes must receive their declared parameter
+/// types, and fully-qualified receiver types must resolve their readers.
+#[test]
+fn qualified_runtime_signatures_seed_contexts_and_results() {
+ let (registry, sigs, _) = build_class_registry();
+ let classes = ingest_runtime_classes();
+ let row = Ty::Hash {
+ key: Box::new(Ty::Str),
+ value: Box::new(Ty::Untyped),
+ };
+ let rows = Ty::Array { elem: Box::new(row) };
+ for (class, method, name, expected) in [
+ ("ActiveRecord::Result", "initialize", "rows", rows.clone()),
+ ("ActiveRecord::Connection", "quote_string", "str", Ty::Str),
+ ] {
+ let (_, lc) = classes
+ .iter()
+ .find(|(_, lc)| lc.name.0.as_str() == class)
+ .expect("runtime class");
+ let method = lc.methods.iter().find(|m| m.name.as_str() == method).unwrap();
+ let ctx = build_method_ctx(&lc.name, method, &sigs, &HashMap::new());
+ assert_eq!(
+ ctx.local_bindings.get(&Symbol::new(name)),
+ Some(&expected),
+ "{class}.{name}"
+ );
+ }
+ let source = b"class Probe; def rows(result); result.rows; end; end";
+ let typer = BodyTyper::new(®istry);
+ for class in ["ActiveRecord::Result", "Result"] {
+ // Each spelling starts untyped so prior annotations cannot mask a miss.
+ let mut probes = ingest_library_classes(source, "probe.rb").unwrap();
+ let mut ctx = Ctx::default();
+ ctx.local_bindings.insert(
+ Symbol::new("result"),
+ Ty::Class {
+ id: ClassId(Symbol::new(class)),
+ args: vec![],
+ },
+ );
+ typer.analyze_expr(&mut probes[0].methods[0].body, &ctx);
+ assert_eq!(
+ probes[0].methods[0].body.ty,
+ Some(rows.clone()),
+ "{class}"
+ );
+ }
+}
+
+/// A short alias may not combine identically named classes in distinct modules.
+#[test]
+fn ambiguous_short_aliases_do_not_merge_signatures() {
+ let signatures = parse_app_signatures(
+ "module One\n class Shared\n def first: () -> Integer\n end\nend\n\
+ module Two\n class Shared\n def second: () -> String\n end\nend\n",
+ )
+ .unwrap();
+ let registry = registry_with_unique_aliases(&signatures);
+ assert!(!registry.contains_key(&ClassId(Symbol::new("Shared"))));
+ let one = ®istry[&ClassId(Symbol::new("One::Shared"))].instance_methods;
+ let two = ®istry[&ClassId(Symbol::new("Two::Shared"))].instance_methods;
+ assert!(one.contains_key(&Symbol::new("first")) && !one.contains_key(&Symbol::new("second")));
+ assert!(two.contains_key(&Symbol::new("second")) && !two.contains_key(&Symbol::new("first")));
+}
+
#[test]
fn untyped_subexpressions_with_rbs_baseline() {
- let (registry, sigs) = build_class_registry();
+ let (registry, sigs, rbs_ivars) = build_class_registry();
let mut classes = ingest_runtime_classes();
let typer = BodyTyper::new(®istry);
@@ -384,25 +545,18 @@ fn untyped_subexpressions_with_rbs_baseline() {
}
}
- // Harvest ivar assignments per class. Then re-type with the
- // discovered ivars (each wrapped in `Union` to reflect
- // a possible pre-write nil read) seeded into Ctx.
+ // Harvest ivars per ClassId across every stem (Base spans base.rb
+ // + connection.rb). Union-merge so a later stem cannot wipe
+ // initialize's writes. Overlay from parsed RBS `@ivar` decls.
let mut ivars_by_class: HashMap> = HashMap::new();
for (_, lc) in &classes {
- let entry = ivars_by_class.entry(lc.name.clone()).or_default();
- for method in &lc.methods {
- extract_ivar_assignments(&method.body, entry);
- }
+ let seeded = seed_ivars_for_class(&lc.name, &lc.methods, &rbs_ivars);
+ merge_ivar_maps(ivars_by_class.entry(lc.name.clone()).or_default(), seeded);
}
for (_, lc) in &mut classes {
let lc_name = lc.name.clone();
- let mut wrapped: HashMap = HashMap::new();
- if let Some(found) = ivars_by_class.get(&lc_name) {
- for (k, v) in found {
- wrapped.insert(k.clone(), Ty::Union { variants: vec![v.clone(), Ty::Nil] });
- }
- }
+ let wrapped = ivars_by_class.get(&lc_name).cloned().unwrap_or_default();
for method in &mut lc.methods {
let ctx = build_method_ctx(&lc_name, method, &sigs, &wrapped);
typer.analyze_expr(&mut method.body, &ctx);
@@ -444,550 +598,11 @@ fn untyped_subexpressions_with_rbs_baseline() {
}
}
- // Loose ceiling — current measurement plus some headroom. Tighten
- // as authoring/inference improves; failing low is good (un-pin
- // and record the new lower bound). 2026-07-23: 500 → 520 —
- // measurement moved to 509 when Relation#column_predicate grew the
- // record/collection runtime-dispatch arms (`where(comment:
- // comments)` IN-of-records support); those arms inspect untyped
- // hash values by design. 2026-07-27: 520 → 535 — measurement moved
- // to 521 when `Arel::Table`/`Attribute` came off the CRuby overlay
- // into the shared file (this harness does not apply the sidecar RBS
- // to a nested class, so their `initialize` params read untyped the
- // same way `SelectManager`'s already did). 2026-07-30: 535 → 610 —
- // measurement moved to 595 with `Base.upsert`/`upsert_all`
- // (connection.rb 71 → 124) and `Relation#pick` (304 → 311). The upsert
- // builder is untyped for the same reason `update_counters` and
- // `build_where` beside it are: it assembles SQL text out of a
- // heterogeneous `Hash[Symbol, untyped]` row, and `unique_by` /
- // `on_duplicate` are Rails-shaped options that arrive as a String, a
- // Symbol, or an Array. The sidecar RBS does not reach it — this
- // harness skips nested classes, and `ActiveRecord::Base` here is a
- // reopen. `Base.primary_key` added 0: it returns a literal. NOTE the
- // pre-existing headroom was already spent — the tree measured exactly
- // 535 before this change, so the next author gets 15, not 74.
- // 2026-07-30 (second bump, same day): 610 -> 625 — measurement moved
- // to 613 when `has_attribute?` and the two `saved_change_to_attribute`
- // readers each bound their collection to a local before calling a
- // collection method on it. That binding is not style: elixir's
- // receiver typing does not see through a method-call receiver, so the
- // direct chain lowers `include?`/`key?` as a struct-field access
- // (`saved_changes(record).__struct__`) and fails mix's
- // warnings-as-errors gate. Each local is one more untyped site here —
- // the cost of keeping the elixir lane compiling.
- // 2026-08-11: 625 -> 626 — `ActiveRecord::RecordNotUnique` joined
- // errors.rb so campfire's `rescue ActiveRecord::RecordNotUnique`
- // resolves (without the constant, the rescue clause raises NameError
- // when EVALUATED, taking the happy path down with it). Its one
- // untyped node is the `super(message)` call, identical in shape to
- // the `RecordNotFound` and `ValueTooLong` bodies already counted
- // here — `super` has no signature for the typer to resolve. A
- // like-for-like +1 from a corpus that grew by one class, not a
- // typing regression.
- // 2026-08-13: 626 -> 633 — `Relation#first_n` / `#last_n`, the
- // COUNTED forms of `first`/`last`. They are separate methods because
- // the counted forms answer an Array where the bare forms answer one
- // record, and one method cannot carry both return types on a strict
- // target. The 7 nodes are the same shapes already counted all over
- // this file: an `@limit = n` assign plus its `n` read, a `to_a` call,
- // and a `to_a.last(n)` send with its receiver and argument. Nothing
- // here is newly untypable — RBS parameter types do not reach this
- // runtime's body typer for ANY method (see the `SelectManager
- // #initialize` / `Table#initialize` entries in the dump, whose `sql`
- // and `name` params are untyped the same way). A like-for-like
- // addition of two small methods, not a typing regression.
- // 2026-08-15: 633 -> 637 — `Relation#where_scope` / `#scope_attributes`,
- // the association-scope pair. Rails derives a relation's create-scope
- // from its equality conditions (`where_values_hash` /
- // `scope_for_create`); ours records them where the association seed
- // sets them, so `user.sessions.start!` can merge `user_id` into the
- // `create!` inside `Session.start!`. The 4 nodes are the assign
- // `@scope_attributes = condition` with its `condition` read, and the
- // `add_condition(condition, [], false)` call with the same read again
- // — the RBS-parameter shape already counted for `SelectManager
- // #initialize`'s `sql` and `Table#initialize`'s `name`, which are
- // untyped for the same reason (RBS parameter types do not reach this
- // runtime's body typer for ANY method). Like-for-like, not a typing
- // regression.
- // 2026-08-16: 637 -> 645 — `Relation#excluding`, Rails' "everything
- // except these records". All 8 nodes sit in that one method and all 8
- // root in its splat parameter: the `if` and both branches, the
- // `records.length` send with its `records` receiver, and the two
- // branches' hash values (`records[0]` and bare `records`) plus the
- // former's own receiver. A `*records` splat is untyped here for
- // exactly the reason `SelectManager#initialize`'s `sql` is — RBS
- // parameter types do not reach this runtime's body typer. The method
- // delegates the typed work to `not`; nothing new became untypable.
- // 2026-08-16: 645 -> 656 — `Relation#destroy_all` (8) and the
- // `to_sql` / `to_subquery_sql` / `select_sql_with` split (3).
- //
- // `destroy_all`'s 8 all root in `to_a`, which answers
- // `Array[untyped]` because this Relation is not generic over its
- // model: the method node, the `records = to_a` assign and its
- // value, the `records.each` send and its receiver, the block body
- // `r.destroy` and its `r` receiver, and the trailing `records` read.
- // Exactly the shape `find` and `first` already contribute here, for
- // the same reason. The method has to exist separately from
- // `delete_all` because Rails draws the line there too — one runs
- // callbacks, the other is a single DELETE.
- //
- // The other 3 are bookkeeping from splitting one method into three:
- // `to_sql` became a one-line delegate, `to_subquery_sql` is a second
- // delegate (a relation used as a CONDITION value must project one
- // column, not `*`), and the shared builder moved to
- // `select_sql_with`, whose `default_cols` parameter is untyped for
- // the same RBS-parameter reason as `SelectManager#initialize`'s
- // `sql`. The builder's own nodes moved with it rather than
- // multiplying.
- //
- // Like-for-like: two Rails methods this runtime lacked, plus a
- // refactor. Nothing that was typed became untyped.
- // 2026-08-17: 656 -> 673 — the Enumerable surface campfire's own
- // suite reaches for: `partition`, `detect`, `sort_by`, the block
- // form of `select`, and `without` (Rails' own alias for
- // `excluding`). Every one of them is `to_a. { |x| yield x }`,
- // and every added node roots in `to_a` answering `Array[untyped]`
- // — the same shape `find`, `first`, `map`, `group_by` and
- // `destroy_all` already contribute here, and for the same reason:
- // this Relation is not generic over its model. A generic
- // `Relation[T]` is what retires the whole class of them at once;
- // adding these one Enumerable method at a time neither helps nor
- // hurts that. Nothing that was typed became untyped.
- // 2026-08-17: 673 -> 685 — `Relation#destroy_by` / `#delete_by`,
- // Rails' condition-taking bulk writes, measured at 679. Three nodes
- // each: the method body `where(conditions).destroy_all`, its
- // `where(conditions)` receiver, and the `conditions` parameter read.
- // The parameter is untyped for the reason every RBS parameter in
- // this file is — RBS parameter types do not reach this runtime's
- // body typer for ANY method (see `SelectManager#initialize`'s `sql`
- // and `Table#initialize`'s `name` in the dump) — and the two sends
- // ride on it. Both bodies delegate straight to the already-counted
- // `where` + `destroy_all`/`delete_all`; nothing that was typed
- // became untyped, and the pair exists separately for the same
- // reason `destroy_all` and `delete_all` do, one running callbacks
- // and the other issuing a single DELETE.
- //
- // campfire's `Room has_many :memberships do def revoke_from(users)
- // destroy_by user: users end end` is the caller. Headroom left: 6.
- // 2026-08-17: 685 -> 687 — `Relation#exists?(id)` and `Base#touch`,
- // two methods `src/catalog/mod.rs` has declared for as long as it
- // has existed with nothing implementing them. ONE node each, and
- // both are the same RBS-parameter shape as every other entry here:
- // `exists?`'s optional `id` parameter read (the `@wheres <<`
- // interpolation that consumes it), and `touch`'s `fill_timestamps
- // (false)` self-send. Neither method delegates to anything new.
- // Nothing that was typed became untyped.
- //
- // 2026-08-18: 687 -> 705 — `active_record/signed_id.rb`, and this
- // one is 18 nodes for TWO methods, well past the 1-3 budget below.
- // The reason is structural, not sloppiness: signed_id.rb is the
- // first file in `active_record/` that calls OUT of the directory
- // this test measures. Every send into
- // `ActionController::MessageVerifier` (`envelope`, `iso8601_ms`,
- // `verified_json`) and every `Rails.application.secret_key_base`
- // read resolves against a tree whose RBS this harness does not
- // load, so the send and each node hanging off it counts —
- // `generate` contributes 11 and `verified_id` 7, and the dump
- // shows every one of them rooted in either a cross-tree send or
- // the RBS-parameter limitation the entries above describe.
- // Nothing that was typed became untyped, and no node here is one
- // a better-typed signed_id.rb could retire; widening the harness
- // to load action_controller's RBS is what would.
- //
- // 2026-08-18 705 -> 707 — `Relation#scoped_write_where`, the WHERE
- // clause `delete_all` / `update_all` take when the scope carries a
- // JOIN (SQL has no place for one in a DELETE, so the write is
- // scoped by an `IN (SELECT …)` subquery instead). TWO nodes, both
- // the shape every entry above describes: the `@model.primary_key`
- // send and the `key` local it binds. `@model` is the constructor's
- // untyped parameter, so the send off it is untyped and the local
- // rides on it. Nothing that was typed became untyped.
- //
- // 2026-08-18 707 -> 712 — `Relation#join_fragment`, the guard that
- // makes `joins(:assoc)` RAISE instead of appending the bare symbol
- // as SQL (`FROM rooms users`, which SQLite reads as an alias and
- // answers the wrong rows). FIVE nodes, every one rooted in the
- // `untyped spec` parameter `joins` has always declared: the param,
- // the `is_a?` send off it, the guarded return, and the two nodes of
- // the raise's interpolation. Above the 1-3 budget below, and worth
- // it: the alternative is a wrong answer nothing reports.
- //
- // 2026-08-19 712 -> 727 — the ActiveModel::Dirty VALUE half:
- // `attribute_previously_was` in both base.rb (the compile surface,
- // ending in a `saved_changes` read) and connection.rb (the real
- // one, indexing slot 0 of the `[prev, value]` pair), plus
- // `_note_hydrated` taking the hydration baseline. FIFTEEN nodes,
- // every one hanging off the same two untyped things this file's
- // entries always hang off: the `name` parameter, which is a caller-
- // chosen Symbol, and the diff's values, which are a heterogeneous
- // Hash by construction. Nothing that was typed became untyped.
- //
- // Above the 1-3 budget below, and the reason is the SPLIT rather
- // than the feature: the same method exists twice, once per layer,
- // because the strict lanes cannot index the pair. Each copy pays
- // its own nodes.
- //
- // This ceiling moved on EVERY runtime/ruby method this session, and
- // `cargo test` is the only gate that reads it — budget ~1-3 nodes
- // per added method before touching the number here.
- //
- // +4 for `Base.sanitize_sql(statement)`. Its parameter is
- // `Array[untyped]` and honestly so: Rails' array form is a String
- // fragment followed by ARBITRARY bind values, which is the one
- // shape a typed element cannot describe. The four nodes are the
- // reads off it — `statement[0]`, `statement.length`,
- // `statement[bind]`, and the escape call's argument.
- // 2026-08-20 731 -> 735 — two surface methods the campfire suite
- // named, both inside the 1-3 budget: `Relation#collect` (map's
- // second name, paying what map pays) and `Base#destroy!` (~1 — it
- // calls `destroy`, which is typed `Base`).
- //
- // A `Relation#new` landed here too and was REVERTED at 735: see the
- // note in runtime/ruby/active_record/relation.rb — a method named
- // `new` on a class cannot exist under spinel, whose constructor for
- // that class is already `sp_Relation_new`.
- //
- // The STI recast that landed with them adds NOTHING here, and that
- // is the load-bearing part: its first draft was a shared-runtime
- // `Base#becomes_state_from` looping over `source.attributes`, worth
- // 14 nodes AND a rust_toolchain failure (`no method named
- // attributes for struct Base` — the generic loop needs the
- // polymorphic dispatch the strict targets are built to avoid). The
- // copy moved into the lowerer, which knows the schema and unrolls
- // it column by column. Both problems had the same fix.
- // 2026-08-20 735 -> 737, +1 each for `Relation#first` and
- // `Relation#find` closing the same terminal leak `pluck` had — the
- // saved `prior` limit and the `@wheres.pop` are both untyped reads
- // off containers declared untyped. Worth naming: this pair greened
- // ZERO tests. It is here because the previous commit wrote the
- // terminal invariant into docs/pipeline/runtime.md and named these
- // two as the outstanding violations of it; a documented invariant
- // with known live exceptions is worse than two nodes.
- // 2026-08-21 737 -> 738, +1 for `Relation#one?` — the `count == 1`
- // reads `count`, whose return the relation's own rbs leaves
- // untyped, exactly like its `any?` sibling next to it. One node,
- // and it greened a first-run test that had no other way to ask
- // "does this user have exactly one room?" now that a `has_many
- // :through` reader answers a Relation rather than an Array.
- // 2026-08-21 738 -> 746, +8 for `Relation#find_or_create_by`. Every
- // one is a read off something the relation's own RBS declares
- // untyped: the `conditions` parameter is `Hash[Symbol, untyped]`,
- // `@model` is the model handle, and `find_by`/`new` both answer
- // untyped — the same four sources `find_by!` and
- // `first_or_initialize` beside it already draw on. It is EIGHT
- // rather than two because the method is genuinely two operations,
- // a find and a scoped build, and the build's
- // `scope_attributes.merge(conditions)` is three of them on its own.
- // Trimming the obvious one (a local holding the merge, hoisted
- // above the find) took it from 10 to 8; the rest are the method.
- //
- // It greened campfire's `searches_controller_test` 5/5: `Search
- // .record` is `find_or_create_by(query: query).touch`, reached as
- // `user.searches.record(q)`, and until this existed the emitted
- // body called a method nothing defined. Doing it in the runtime
- // rather than as a lower-time macro-inline is what lets the SCOPE
- // reach the create — `merge_scope_attributes` would have had to
- // merge into a call whose query half needs the same hash.
- // 2026-08-21 746 -> 758, +12 for `Relation#==` — Rails' relation
- // equality, which nothing in the emitted trees had. Every node is a
- // read off the ONE parameter it takes or off an element of a list
- // the relation's own RBS declares `Array[untyped]`, and the
- // parameter is untyped for the reason equality always is: `==`
- // answers for whatever it is handed. The twelve are the two `is_a?`
- // receivers, the `other.to_a` send and its receiver, the local that
- // holds the result, the `length` compare, and the `.id` read on
- // each side of the loop.
- //
- // Well above the 1-3 budget, and the number is the method: it is a
- // type test, a length test and an element walk, and the walk is
- // where two thirds of it lives. What it buys is not a convenience —
- // `assert_equal [ message ], Message.search("eel")` was an
- // assertion NO emitted tree could pass, because Ruby hands an
- // `Array == ` comparison to the other
- // operand and `Object#==` is reference identity between an Array
- // and a Relation. campfire's suite went 156 -> 160 of 240, 25 -> 26
- // files, with `message_searchable_test` green.
- //
- // Its missing twin `Base#==` adds NOTHING here, and that is worth
- // naming: written in base.rb it cost 4 more nodes AND broke both
- // python overlay tests, because no emitter renames an operator
- // DEFINITION to its host's spelling and `def ==(self, other)`
- // reached python verbatim. Moved to the ruby-family connection.rb
- // it cost an UNTYPED `@id`: that reopen is analyzed on its own and
- // an ivar's type comes from an assignment in the same unit, which a
- // reopen has none of (the `@x: T` lines in these .rbs files are
- // documentation — `collect_class_methods` reads methods only). So
- // the id comparison lives inside this method, where a relation's
- // records being one model already decides the class half.
- // 2026-08-22 758 -> 760: `Relation#find` raising `RecordNotFound`
- // on no match — Rails' distinction between it and `find_by`, and
- // what makes a missing record a 404 instead of a nil that
- // NoMethodErrors later. TWO sites, both the `record` local the
- // terminal answered: the `nil?` guard and the read that returns it.
- // This counter charges the receiver as well as the send, which is
- // why it moves by two where the runtime_src_integration ceiling
- // moves by one.
- // 2026-08-23 760 -> 767: `SignedId.verified_id!` — the raising twin
- // of the sentinel read, so `find_signed!` answers
- // `InvalidSignature` for a token that does not verify and
- // `RecordNotFound` only for one that does and names no row. The
- // NAME is what a `rescue_from` matches. SEVEN sites, all one
- // comparison's worth: this gate's registry carries instance methods
- // only, so a class-method send stays a TyVar and everything reading
- // its result goes with it — the assign, its value, the `id` read,
- // the `==`, its receiver, the guarded raise and the trailing read.
- // Writing the verify call out a second time instead cost eight.
- // +8 for the three RELATION TERMINALS that now give their
- // borrowed state back (`find_by`, `exists?(id)`, `first_n`): each
- // needs a local to hold the answer across the restore, and every
- // read of `@wheres`/`@limit` is untyped here because relation.rbs
- // declares no ivar types. That is the debt this number tracks —
- // typing those four ivars would retire far more than eight — not
- // a reason to leave a terminal narrowing the relation it was
- // asked on. Campfire's room page rendered zero of forty messages
- // on exactly that leak.
- // 2026-08-23 775 -> 778, +3 for `Relation#preloaded` — the seam a
- // `has_many :through` reader hands its eager-loaded records to, in
- // place of the `return @_cache if @_loaded` guard that
- // made such a reader answer an Array from one path and a Relation
- // from the other (`emit::ruby::library::through_reader_body`). The
- // three are the whole method: the `if` modifier, the ivar assign it
- // guards, and the assign's value — the `records` parameter, which
- // relation.rbs declares `Array[untyped]` because a relation is not
- // generic in its element here, so every read of it is a TyVar. The
- // `loaded` flag (bool) and the trailing `self` cost nothing.
- //
- // Cheap for what it retires: the two-typed reader was a latent
- // `Array#includes` NoMethodError on a preloaded through
- // association, and under spinel's AOT — which judges a seeded
- // return as of its 2368afd7 — a hard compile stop that took the
- // lobsters bench lane's AOT row down for three days
- // (`--rbs seed contradicted: User#upvoted_stories is declared to
- // return Relation but this returns int_array`).
- // 2026-09-02 778 -> 915, +137 for `Relation#order` sorting a LOADED
- // relation in memory (`sort_in_place!`, `order_column`,
- // `order_descending?`, `compare_order_keys`, `order_key_of`): a
- // stable insertion sort over the loaded records, keyed by
- // `attributes[col]`. The records are `untyped` (the element type this
- // Relation has never had), the keys are whatever a column holds, and
- // `order`'s own `*untyped parts` is untyped by declaration -- so every
- // local the sort derives from them is a TyVar, some sixty in the sort
- // alone; and this gate resolves none of Relation's self-sends, so
- // `sort_in_place!`/`compare_order_keys` results count again at each
- // call site. MEASURED, not derived: 881 with a pair-returning helper,
- // 921 after splitting it into a `String?` and a `bool` for the
- // sibling gate in runtime_src_integration.rs (which counts declared
- // `untyped` and went 431 -> 403 on the same change), 915 once the
- // loaded copy is built by pushes instead of `dup` (spinel would not
- // assign a nilable read's `dup` into the typed records field). What
- // it retires:
- // the last N+1 on campfire's room page. `message.boosts.ordered`
- // under `includes(boosts: :booster)` re-queried once per message
- // because the scope's `order` dropped the memo the association had
- // just filled; 40 round trips -> 0, and the page sits at 14 against
- // Rails' 13. Typing Relation's element (the generic-Relation
- // refinement its header names) would retire most of this along with
- // the rest of the file's 400-odd.
- // 2026-09-06 915 -> 916, +1 for the `after_touch` call `Base#touch`
- // now makes. MEASURED both ways on this gate: 915 with the line
- // commented out, 916 with it. It is one more of the same shape the
- // other eleven lifecycle-hook calls already contribute — the hooks
- // are declared `() -> void` and this gate resolves none of the
- // class's own self-sends, so a hook call in statement position is a
- // TyVar however it is spelled. What it buys: `belongs_to … touch:
- // true` becomes TRANSITIVE, which is what carries campfire's stamp
- // from a boost through its Message to its Room. See
- // docs/pipeline/runtime.md, "`belongs_to … touch:` cascades".
- // 2026-09-10 916 -> 922, +6, MEASURED one method at a time on this
- // gate: +3 for the blank family (`blank?`/`present?`/`presence`, each
- // a self-send of `empty?`), +3 for `include?` (`record.nil?` and
- // `record.id` on the untyped parameter, plus the `ids` self-send).
- // Every one is the self-send shape the paragraphs above describe;
- // none is a new declared `untyped`. What it buys: campfire's has_many
- // extension `revise(granted: [])` and `room.users.include?(user)` on
- // the compiled lane — record `==` exists only on the CRuby overlay,
- // so a compiled target compared pointers.
- // 2026-09-14 922 -> 931, +9, MEASURED by diffing the dump: the
- // Relation load path. `Base._columns_sql`, the `_hydrate_all`
- // compile stub, and connection.rb's Hash fallback (+6: the
- // `table_name`/`name`/`instantiate` self-sends and the adapter
- // chain, the same shapes `_adapter_all` already carries) and
- // `Relation#load_records` (+3 net: `to_sql`/`select_sql_with`/
- // `load_records` self-sends, the `rows` map moved out of `to_a`).
- // Every one is the self-send shape; none is a new declared
- // `untyped`. What it buys: a Relation without `select(...)`
- // hydrates through the model's positional `from_stmt` and no
- // String-keyed Hash is built per row.
- // 2026-09-16 931 -> 933, +2: `take` on the class and on Relation,
- // each one `first` self-send under the same declared `untyped`
- // return `first` has always had. What it buys: the Rails 8
- // authentication generator's `User.take` test setup.
- // 2026-09-21 933 -> 937, +4: `ViewHelpers.attr_value_text(name, v)`,
- // the self-send `render_attrs` now routes an attribute's text
- // through (the ruby family reopens it to render an Array as Rails
- // does; the shared body is `v.to_s`). The self-send shape the
- // paragraphs above describe, plus the untyped `v` at both ends.
- // 2026-09-23 937 -> 969, +32, MEASURED by grouping the dump by
- // method: the before-save Dirty half. base.rb's `attribute_changed?`
- // and `attribute_was` stubs (+6 each, exactly what their
- // `saved_change_to_attribute?`/`saved_change_to_attribute` twins
- // carry), and connection.rb's real `changes_to_save` (+8, the
- // `__track_saved_changes` diff over the untyped baseline ivar) and
- // `attribute_was` (+12, the `[prev, value]` pair and the
- // `attributes[name]` fallback). What it buys: ` _changed?` /
- // ` _was` answer at all; lobsters' `validate_username_timeouts`
- // stopped 183 of its model specs.
- // 2026-09-23 969 -> 978, +9, MEASURED by grouping the dump by
- // method: `Connection#exec_update` (+5) and `#exec_delete` (+4), the
- // raw-SQL DML-with-binds pair. The binds Array and Rails' `name`
- // log label are declared untyped (they are anything a caller
- // binds); the rest is the adapter self-send chain `update_counters`
- // already carries. What it buys: lobsters' comment score recompute
- // (`exec_update … unhex(?)`), which every comment and vote save
- // reaches, and FullTextSearch's index-row delete.
- // 2026-09-25 978 -> 979, +1, MEASURED from the dump: connection.rb's
- // `Base.none`, the body `Relation.new(self).none` — the same chained
- // shape its `where` neighbour already carries. What it buys:
- // lobsters' `searched_model.none` (#132), a NoMethodError before.
- // 2026-09-25 979 -> 991, +12, MEASURED by grouping the dump by
- // method: `Relation#each_with_object` (+6, the `to_a.each` / `yield`
- // shape `inject` beside it carries, and a memo that is whatever the
- // caller seeds), `Connection#exec_insert` (+4, `exec_update`'s
- // untyped `name`/`binds` passed on) and `#select_all` (+2, the
- // `execute(sql)` forward `exec_query` already carries). What it
- // buys: lobsters' vote lookup tables on every comment listing, its
- // FullTextSearch index insert and TrafficHelper's activity range.
- // 2026-09-25 991 -> 1001, +10, MEASURED from the dump: all
- // `Relation#touch_all` — the adapter self-sends (`escape_value`,
- // `execute_ddl`, `changes`) and the `now` / `name` reads in its SQL
- // interpolation, the shape `update_all` beside it already carries.
- // `load_async` (a forward to `load`) adds none. What it buys:
- // lobsters' inbox `after_action :update_read_at`, which now runs.
- // 2026-09-25 1001 -> 1021, +20, MEASURED by grouping the dump by
- // method: `joins` / `left_outer_joins` (+3 each, the `frag` local
- // read twice now that an identical join is added once, as Rails
- // uniq's `joins_values`), `with_recursive` (+8, the CTE name/parts
- // block and each part's `to_sql`), `select_sql_with` / `count_sql`
- // (+3/+2, the `cte_prefix` / `from_source` interpolations) and
- // `from` (+2 — its param is typed `String` so the runtime ratchet
- // stays put, and the bare assignment reads here as a local). What it buys: lobsters' profile page (two scopes each
- // `joins(:story)`) and its comment reply page (`Comment#parents`, a
- // recursive CTE read `from("parents")`).
- // 2026-09-25 1021 -> 1031, +10, MEASURED from the dump: connection.rb's
- // ruby-family `Base#_note_unloaded` (+6) and the `has_attribute?` that
- // honours it (+4) — `self.class.schema_columns`, the row, and each
- // column read, the shape base.rb's own `has_attribute?` already
- // carries here (this probe has no concrete model to type them). What
- // it buys: a partial `select` answers `has_attribute?` false for the
- // columns it left out, as Rails does, so lobsters' Token guard no
- // longer mints a TypeID per `User.select(*attrs)` row (66 -> 2 per
- // benchmark pass, the 2 being records genuinely built new).
- // 2026-09-26 1031 -> 1055, +24, MEASURED by grouping the dump by
- // method: Relation's set operations, `&` / `|` / `-` / `+` (they
- // were 3 each as one-liners) now load a Relation operand and match
- // by id through `id_filter`, `ids_of`, `operand_ids` and
- // `set_operand` — every record read a TyVar, as this probe has no
- // concrete model. A block per operator cost 35. What it buys:
- // lobsters' `story.tags & filtered_tags`, which raised TypeError on
- // every story page, and intersections that no longer come back empty
- // for two sides that loaded the same rows.
- // SQL identifier metadata: 1055 -> 1056, +1, MEASURED against the
- // unchanged upstream corpus by method. Base#_table_sql delegates to
- // table_name for native ordinary models; this probe does not resolve
- // class self-sends. Emitted models return a typed SQL identifier
- // literal instead. Relation's count is unchanged, and the separate
- // every_runtime_method_body_is_fully_typed gate remains in force.
- // 1056 -> 1123, +67 (original baseline 1055 -> 1122), MEASURED:
- // array find_ids +59, scalar find +6, to_a -1 (preloading now
- // belongs to load_records), connection's primary-key cast +3.
- // This historical probe lacks the full runtime typing context and
- // has no concrete model for the record/key reads. Inputs remain
- // Integer/String scalars or arrays, not new untyped parameters.
- // runtime_src_integration's zero-unresolved-type gate remains green;
- // emit_and_run pins the result and exception/state semantics.
- // 1123 -> 1126, +3 MEASURED: connection's Integer serialization
- // guard's is_a? / match? receiver reads and match negation.
- // This limited probe lacks branch-local union narrowing;
- // the full-context runtime still has 519 gradual sites and zero
- // unresolved types. The concrete cast result adds nil, never untyped.
- // 2026-10-02 1126 -> 1130, +4, MEASURED against the pre-merge meta
- // tree (upstream alone: 1056 -> 1060). Only connection.rb changes,
- // 204 -> 208: `upsert_all`'s `_conflict_predicate(...)` self-send and its
- // assignment (+2), and the two reads of the result (+2). Like
- // `_table_sql` above, this probe does not resolve class self-sends;
- // the method is typed `(String) -> String` in connection.rbs. What
- // it buys: `upsert_all(unique_by:)` names a partial unique index
- // with its `WHERE`, as Rails does, which SQLite needs to match it.
- // 2026-10-02 1130 -> 1179, +49, MEASURED by method against main
- // e0d8610e: Kaminari's surface on Relation (relation.rb 691 -> 738)
- // and `Base.page` (connection.rb 208 -> 210). Almost all of it is
- // the readers calling one another bare (`next_page` reads
- // `current_page` and `total_pages`, `total_count` is `count`), which
- // this probe does not resolve; the rest is the untyped `num`
- // parameter of `page` / `per`. The full-context gate in
- // runtime_src_integration counts three. What it buys: Kaminari's
- // `page` / `per` chains and readers run.
- // 2026-10-03 1179 -> 1209, +30, MEASURED by method with and without
- // the new file: active_record/token_for.rb (has_secure_password's
- // reset token), and nothing else. generate 8, secure_password_data 9,
- // data_id 8, verified_data 5 — every one a read of a parameter
- // (`data_json`, `purpose`, `expires_in`, `digest`, `token`, `data`)
- // or a call taking one, which this probe leaves as a TyVar where
- // token_for.rbs declares String/Integer. The full-context gate in
- // runtime_src_integration counts ONE site (`Time.now + expires_in`).
- // What it buys: the Rails 8 authentication generator's password
- // reset flow, in Rails' own token format.
- // 2026-10-05 1209 -> 1273, +64, MEASURED: Relation#last_n SQL tail
- // plus reversing comma-separated order terms (relation.rb 738 ->
- // 802). This probe does not resolve self-sends or `@orders`
- // indexing; the full-context gate counts 16. What it buys: campfire
- // room pages LIMIT the last 40 in SQL, including `order(a:, b:)`.
- // 2026-10-05 1273 -> 1280, +7, MEASURED with and without the three
- // methods (relation.rb 802 -> 809): Relation#reorder,
- // #skip_preloading! and #preload_associations. This probe leaves the
- // `*parts` splat, the `records` parameter and the `@model` send as
- // TyVars; the full-context gate in runtime_src_integration counts
- // ONE (reorder's splat). What it buys: campfire's message paging after
- // basecamp/once-campfire#292, and #304/#312's `reorder`.
- // 2026-10-05 1280 -> 1336, +56, MEASURED (relation.rb 809 -> 865):
- // Relation#substitute_named_binds and its two character tests, plus
- // #to_set. This probe resolves neither self-sends nor `sql[i, 1]`
- // slices, which a character scan is made of; the full-context gate
- // in runtime_src_integration counts ZERO new sites. What it buys:
- // named binds in `where`/`having`, without which campfire's direct-
- // room lookup (basecamp/once-campfire#310) read NULL and created a
- // room on every Ping.
- // 2026-10-05 1336 -> 1341, +5, MEASURED (connection.rb 210 -> 215):
- // Connection#select_rows, a block over the adapter's untyped rows.
- // What it buys: campfire's tests reading an EXPLAIN QUERY PLAN with
- // `select_rows(…).map(&:last)`.
- // 2026-10-05 1341 -> 1350, +9, MEASURED (relation.rb 865 -> 874):
- // Relation#offset_row_exists?, which this probe sees through the same
- // unresolved self-sends and `@limit` reads every terminal pays. What
- // it buys: `offset(n).exists?` asks for a row past n, campfire's
- // `paged?` (basecamp/once-campfire#297), where a COUNT ignored it.
- // 2026-10-05 1350 -> 1380, +30, MEASURED (relation.rb 874 -> 904):
- // `exists_sql` / `probe_existence` (cardinality without COUNT),
- // `nil_primary_key_lookup?` (find_by id: nil short-circuit), the
- // size / one? / many? / last_page? paths that share them, and
- // `find_each`'s duplicated zero-copy loop. Same unresolved
- // self-send / `@limit` residual every terminal pays.
- // 2026-10-05 1380 -> 1386, +6, MEASURED (relation.rb 904 -> 910):
- // limited `size` counts a `SELECT 1` subquery (orders cleared for
- // the inner select), and `exists_sql` projects `DISTINCT pk` so
- // `distinct.many?`/`one?` see separate rows. The find_by(id: nil)
- // short-circuit that this ceiling once paid for is gone; the net
- // still rises by six unresolved self-send / `@orders` sites.
- // 2026-10-05 1386 -> 1395, +9, MEASURED (relation.rb / base.rb):
- // Relation#more_than? (COUNT>n as LIMIT 1 OFFSET n), loaded_records
- // shared by each/find_each, Base.any?/none? via exists?. Dropped
- // offset_row_exists?. Residual is the same unresolved self-send /
- // `@joins` / `@wheres` every SQL composer pays.
- // 2026-10-05 1395 -> 1400, +5, MEASURED: Base.any?/none? restored
- // to COUNT for strict targets; Relation.exists? forms moved to
- // connection.rb reopen. last_page? requires non-empty short page.
- // This probe sees the connection reopen's new self-sends.
- const CEILING: usize = 1400;
+ // Soft ratchet under canonical class-ID lookup (Fixes #435), then
+ // dependency RBS + cross-stem ivar merge + Relation/Base overlays.
+ // Fails only when the residual rises. Not a substitute for Bar B.
+ const CEILING: usize = 0;
+
assert!(
all_untyped.len() <= CEILING,
"{} untyped sub-expressions exceeds ceiling of {CEILING}.\n\
diff --git a/tests/model_lowerer.rs b/tests/model_lowerer.rs
index b7dc01b73..431d2ee0f 100644
--- a/tests/model_lowerer.rs
+++ b/tests/model_lowerer.rs
@@ -170,6 +170,7 @@ fn article_lowers_with_schema_methods() {
"_adapter_all",
"_adapter_last",
"_adapter_count",
+ "_adapter_any?",
"_adapter_exists_by_id?",
"_adapter_truncate",
"delete_all",
@@ -192,6 +193,21 @@ fn article_lowers_with_schema_methods() {
);
}
}
+
+ let any = lc
+ .methods
+ .iter()
+ .find(|m| m.name.as_str() == "_adapter_any?")
+ .expect("_adapter_any? synthesized");
+ let any_body = format!("{:?}", any.body);
+ assert!(
+ any_body.contains("SELECT 1 FROM") && any_body.contains("LIMIT 1"),
+ "_adapter_any? must probe existence, not COUNT(*): {any_body}"
+ );
+ assert!(
+ !any_body.contains("COUNT(*)"),
+ "_adapter_any? must not COUNT: {any_body}"
+ );
}
#[test]
diff --git a/tests/polymorphic_associations.rs b/tests/polymorphic_associations.rs
index f515ad956..69beced17 100644
--- a/tests/polymorphic_associations.rs
+++ b/tests/polymorphic_associations.rs
@@ -244,3 +244,69 @@ end
"targets from SQL fragment + where-hash literals"
);
}
+
+/// Unresolved polymorphic belongs_to (no inverse `as:`, no body
+/// literals) must not fall through to the monomorphic synthesizer.
+/// ActionText::Markdown / RichText declare `belongs_to :record,
+/// polymorphic: true` with phantom target `Record`; emitting
+/// `Record.find_by` is a NameError if the accessor is called, and the
+/// monomorphic writer drops `record_type`.
+#[test]
+fn unresolved_polymorphic_skips_monomorphic_synth() {
+ let app = app_from(vec![
+ (
+ "db/schema.rb",
+ "ActiveRecord::Schema.define(version: 1) do\n create_table :action_text_markdowns do |t|\n t.text :content\n t.string :name, null: false\n t.bigint :record_id, null: false\n t.string :record_type, null: false\n end\nend\n",
+ ),
+ (
+ "app/models/action_text/markdown.rb",
+ "module ActionText\n class Markdown < ApplicationRecord\n self.table_name = \"action_text_markdowns\"\n belongs_to :record, polymorphic: true\n end\nend\n",
+ ),
+ ]);
+ let md = app
+ .models
+ .iter()
+ .find(|m| m.name.0.as_str() == "ActionText::Markdown")
+ .expect("Markdown model");
+ let Association::BelongsTo {
+ polymorphic,
+ polymorphic_targets,
+ target,
+ ..
+ } = md.associations().next().expect("belongs_to")
+ else {
+ panic!("expected BelongsTo");
+ };
+ assert!(polymorphic);
+ assert!(
+ polymorphic_targets.is_empty(),
+ "no inverse as: → unresolved; got {polymorphic_targets:?}"
+ );
+ assert_eq!(target.0.as_str(), "Record", "Rails phantom target");
+
+ let (lcs, _registry) = lower_models_with_registry_and_params(
+ &app.models,
+ &app.schema,
+ vec![],
+ &Default::default(),
+ );
+ let lowered = lcs
+ .iter()
+ .find(|lc| lc.name.0.as_str() == "ActionText::Markdown")
+ .expect("lowered Markdown");
+ assert!(
+ !lowered.methods.iter().any(|m| {
+ m.name.as_str() == "record" && m.receiver == roundhouse::dialect::MethodReceiver::Instance
+ }),
+ "must not synth monomorphic `record` → Record.find_by; methods: {:?}",
+ lowered
+ .methods
+ .iter()
+ .map(|m| m.name.as_str())
+ .collect::>()
+ );
+ assert!(
+ !lowered.methods.iter().any(|m| m.name.as_str() == "record="),
+ "must not synth monomorphic writer that drops record_type"
+ );
+}
diff --git a/tests/runtime_src_integration.rs b/tests/runtime_src_integration.rs
index c4d0b24e1..4dfcbcdf7 100644
--- a/tests/runtime_src_integration.rs
+++ b/tests/runtime_src_integration.rs
@@ -793,799 +793,13 @@ fn every_runtime_method_body_concretely_typed() {
eprintln!(" {stem}.rb: {count}");
}
- // Ceiling — soft tracker. Tighten as Pattern A/B/C/D closures
- // land. Failing low is good (lower the ceiling and record).
- // 2026-05-11 Phase 2.5(b) follow-up: HWIA source deleted (no
- // live require sites), dropping its 26 untyped contributions;
- // ceiling lowered 210 → 180 to lock in the gain.
- // 2026-05-11 Jbuilder Phase 2: json_builder.rb's encode_value
- // dispatches on `untyped` by design (JSON entry point); +7
- // untyped sites at the chained `v.is_a?(...)` / `v.to_s` calls;
- // ceiling raised 180 → 190.
- // 2026-07-18 relation-type-plan R5: Relation gains the Rails
- // array-delegation surface (`to_ary`, block `filter`, `&`/`|`/`-`)
- // — record-typed like `to_a`/`+` (element type is the model,
- // `untyped` in the runtime RBS by the same convention); +5 sites;
- // ceiling raised 190 → 195.
- // 2026-07-21 saved-change tracking (ActiveModel::Dirty subset):
- // base.rb's `__track_saved_changes` diffs the subclass
- // `attributes` hash, whose values are `untyped` by declaration —
- // the before/after value reads are irreducibly untyped; +2 sites.
- // Same day: Relation gains block `all?` (element-typed `untyped`
- // by the R5 delegation convention) for the Relation-returning
- // `Base.where` fallback; +2 sites; ceiling raised 195 → 199.
- // 2026-07-22 request/session two-layer split: ActionDispatch::
- // Request#format= takes `untyped` (Rails accepts a Symbol, the
- // writer coerces to the canonical String) and the `env` compat
- // bag's values are `untyped` by declaration (scratch keys like
- // `exception_notifier.exception_data`); +3 sites; ceiling raised
- // 199 → 202.
- // 2026-07-23 Relation#column_predicate record/collection dispatch
- // arms (`where(comment: comments)` IN-of-records): hash values are
- // `untyped` by declaration and the arms inspect them (`x` in the
- // ids map, `x.id`, `val.id`) — irreducible at this seam; +3 sites;
- // ceiling raised 202 → 205.
- // 2026-07-24 ActiveRecord::ValueTooLong (apps construct it to
- // reject over-long input — lobsters' Keystore): same optional-
- // message `super(...)` shape RecordNotFound already carries, whose
- // default-argument site is untyped by the same convention; +1
- // site; ceiling raised 205 → 206.
- // 2026-07-25 ActionController::CookieJar (controller `cookies[:k]`
- // access, ruby-family reopen replacing the CRuby-only overlay so
- // spinel-on-lobsters can read cookies): `[]`/`[]=`/`delete` take an
- // `untyped` key — cookies are indexed with both Symbol constants and
- // String literals, normalized via `key.to_s` — so the three key
- // params are untyped by declaration; +3 sites; ceiling raised
- // 206 → 209.
- // 2026-07-25 Inflector.pluralize_word (ActiveSupport String#pluralize
- // (count) grounded off the built-in String for spinel): `count` is
- // untyped — Rails compares `count == 1` and call sites pass either an
- // Integer or a whole collection (`"category".pluralize(@categories)`);
- // +1 site; ceiling raised 209 → 210.
- // 2026-07-26 ActionController::CookieJar#each (+#to_h): lobsters'
- // `remove_unknown_cookies` iterates the jar, so the typed CookieJar
- // needs the whole-jar walk Rails' Enumerable CookieJar provides. The
- // `yield k, v` expression is untyped — verified irreducible: the site
- // stays Ty::Untyped whatever the RBS block return is declared to be
- // (`-> void` and `-> String` both count), exactly as the same
- // `x.each { |e| yield e }` shape does in Relation#each/#find_each;
- // #to_h adds none (`@inbound.merge(@out)` stays concretely typed,
- // which is why it isn't an empty-literal accumulator); +1 site;
- // ceiling raised 210 → 211.
- // 2026-07-27 Arel::Table/Attribute (off the CRuby-only overlay into
- // the shared file so spinel-on-lobsters can compile
- // `Tag.arel_table[:id]`): `attribute`/`project` take an untyped
- // column (call sites pass Symbols and the `Arel.star` String alike)
- // and `in`/`not_in` an untyped subquery (anything answering
- // `to_sql` — a SelectManager or a Relation); +6 sites; ceiling
- // raised 211 → 217.
- // 2026-07-27 ActiveSupport.blank?/present?/presence (the runtime
- // grounding `src/lower/blank.rs` sends a receiver to when no static
- // type can answer): `value` is untyped BY CONTRACT — the whole
- // point is a predicate that branches on a value nothing typed — and
- // it is read across three entry points; +8 sites; ceiling raised
- // 217 → 232.
- // 2026-07-30 Base.upsert/upsert_all + Relation#pick (lobsters'
- // Keystore reads and writes every counter through them): the row
- // values are untyped BY CONTRACT — an upsert row is a
- // `Hash[Symbol, untyped]` whose values reach `escape_value`, the
- // same seam `update_counters` and `build_where` already sit on —
- // and `unique_by`/`on_duplicate` are Rails-shaped options arriving
- // as a String, a Symbol, or an Array. `pick` adds the one site
- // `pluck` beside it already has (the projected column's value).
- // `Base.primary_key` adds none: it returns a literal. +7 sites;
- // ceiling raised 232 → 245.
- // 2026-08-09 SignedCookieJar (`cookies.signed`, campfire's session
- // token): the KEY of every jar method is untyped by the same
- // contract the plain jar already documents — controllers index with
- // Symbol constants and String literals both — and `value_of`'s
- // parameter is genuinely poly, since Rails takes either a bare
- // value or an options Hash whose values are String, bool and Symbol
- // together. Confining that read to one class method is what keeps
- // it off the jar's own String-typed surface. +10 sites; ceiling
- // raised 245 → 255.
- // 2026-08-11 ActiveRecord::RecordNotUnique (campfire's first-run
- // screen rescues it to turn a lost race into a redirect): the one
- // site is the `super(message)` call in its constructor, the same
- // shape `RecordNotFound` and `ValueTooLong` already contribute here
- // — `super` has no signature for the typer to resolve. +1 site;
- // ceiling raised 255 -> 256. Growth from one more class in the
- // corpus, not from typing getting worse.
- // 2026-08-16 `Relation#excluding` (campfire's messages#create skips
- // the poster when it fans a new message out to bot webhooks): the
- // two new sites are in relation.rb, the only body added, and both
- // are its `*records` splat — untyped BY CONTRACT for the same reason
- // `where`'s condition is. Rails' `excluding` takes a record, an
- // array of records, or a relation, and this method's whole job is to
- // hand that straight to `column_predicate`, which already dispatches
- // on all three; typing the parameter would mean picking one. +2
- // sites; ceiling raised 256 -> 258.
- // 2026-08-16 `Request.for` (the shared constructor the test harness
- // uses to build a request on either target): the one site is the
- // `env.each { |k, v| r.env[k] = v }` widening copy. `@env` is
- // `Hash[String, untyped]` BY CONTRACT — this file's own header says
- // callers write scratch keys of any type into it
- // (`exception_notifier.exception_data`) — while a caller's env
- // literal is `Hash[String, String]`, so the copy is what bridges the
- // two. Assigning instead of copying is a real type error that only a
- // strict target notices, and spinel named it exactly once matz's
- // `77cc33c9` began failing the build on wrong-typed pointers.
- // Typing the block params would mean claiming env values are
- // Strings, which is the thing that is false. +1 site; ceiling raised
- // 258 -> 259.
- // 2026-08-16 the cookie jar's WRITE side, wired up for campfire's
- // integration tests. Three sites, and each is a value this runtime
- // has no business narrowing:
- // - `CookieJar#[]=` / `#raw_set`'s value (2). A cookie is a String
- // on the wire and app code writes whatever it has —
- // campfire's TrackedRoomVisit writes `@room.id`, an Integer.
- // `raw_set` coerces, so the STORE stays String→String and the
- // RETURN stays String; only the argument is untyped, which is
- // the one thing that is actually true of it. Declaring `String`
- // here (what it said before) was the lie that let an Integer
- // into the map unremarked.
- // - `ActionDispatch::Cookies::CookieJar.build`'s request (1).
- // Rails threads a request through for the key generator and for
- // host-scoping `domain: :all`; this runtime models neither, so
- // the parameter exists to match the documented call shape and is
- // never read. Typing it would claim it participates.
- // +3 sites; ceiling raised 259 -> 262.
- // 2026-08-16 `Relation#destroy_all` — Rails' callback-running
- // counterpart to `delete_all` (campfire prunes a user's search
- // history through it, and the `after_destroy` hooks are the whole
- // point). Its three sites are the `Array[untyped]` return and the
- // per-record reads in `records.each { |r| r.destroy }`. The element
- // type is untyped because this Relation is NOT generic over its
- // model — `to_a`, `first` and `find` beside it say the same thing,
- // and narrowing one of them means narrowing all of them. Rails'
- // return value (the destroyed records) is kept rather than swapped
- // for a count: `delete_all` already answers a count, and having the
- // two differ only in callbacks is the distinction worth preserving.
- // +3 sites; ceiling raised 262 -> 265.
- // 2026-08-17 the Enumerable surface campfire's suite reaches for —
- // `partition`, `detect`, `sort_by`, `without` (Rails' own alias for
- // `excluding`), and the block form of `select`. Every one is
- // `to_a. { |x| yield x }`, and every new site is the
- // `Array[untyped]` element `to_a` hands back plus the block's
- // parameter read: exactly what `map`, `group_by`, `find_each` and
- // `destroy_all` already contribute, and untyped for the one reason
- // they all share — this Relation is not generic over its model. A
- // generic `Relation[T]` retires the class of them at once; adding
- // Enumerable methods one at a time neither helps nor hurts that.
- // +8 sites; ceiling raised 265 -> 273.
- // 2026-08-17 `Relation#destroy_by` / `#delete_by` — Rails'
- // condition-taking bulk writes, the pair that stands to
- // `destroy_all`/`delete_all` as `find_by` stands to `find`. Each
- // contributes its `conditions` parameter (an untyped condition hash,
- // exactly as `where`'s already is beside it) and the untyped result
- // it forwards from the terminal it delegates to. Neither body does
- // anything but `where(conditions).`; nothing that was
- // concrete became gradual. campfire's `Room has_many :memberships do
- // def revoke_from(users) destroy_by user: users end end` is the
- // caller that wanted them.
- // +2 sites; ceiling raised 273 -> 275.
- // 2026-08-18 `active_record/signed_id.rb` — ONE site for the whole
- // file: `Time.now + expires_in`. `Time#+` is deliberately
- // `Ty::Untyped` in `time_method` (the receiver-only dispatch cannot
- // tell a Duration arg, which gives a Time, from a Time arg, which
- // gives a Float), and this is the ordinary consumer of that. The
- // result feeds `iso8601_ms`, whose `::Time` parameter absorbs it.
- // Its neighbour `action_controller/message_verifier.rb` gained
- // three methods in the same change and contributes nothing here.
- // +1 site; ceiling raised 275 -> 276.
- // 2026-08-18 `Request.for`'s `params` copy — the sibling of the `env`
- // copy two entries above, and untyped for the same declared reason
- // (`@params` is `Hash[String, untyped]`). It stopped being an
- // assignment because a bare `{}` default is Symbol-keyed on a strict
- // target, so the block's value read is the one new site. Same change
- // put `.to_s` on the seven `env[...]` reads, which coerce a declared
- // `untyped` into the String the attribute holds and add nothing here.
- // +1 site; ceiling raised 276 -> 277.
- // 2026-08-18 `Relation#scoped_write_where` — the WHERE a bulk write
- // takes, an `IN (SELECT …)` subquery when the scope carries a JOIN
- // (SQL gives a DELETE nowhere to put one). Two sites, both rooted in
- // `@model`: the constructor's parameter is untyped by declaration,
- // so `@model.primary_key` is a gradual send and the `key` local it
- // binds inherits that. Exactly the shape `@wheres`/`@joins` reads
- // already contribute across this file; the two callers
- // (`delete_all`, `update_all`) each lost a line and gained none.
- // +2 sites; ceiling raised 277 -> 279.
- // 2026-08-18 `Relation#join_fragment` — the guard that makes
- // `joins(:assoc)` RAISE rather than append the bare symbol as SQL,
- // where SQLite reads it as a table alias and answers the wrong rows
- // instead of failing. Two sites, both on `spec`, whose `untyped` is
- // declared by `joins` itself: the guarded `return spec` and the
- // interpolation in the raise. The check is a type test on a value
- // whose type the caller decides, which is what this file's other
- // `untyped`-parameter sites all are.
- // +2 sites; ceiling raised 279 -> 281.
- // 2026-08-19 `Params.require_key` — Rails' `params.require(:url)`,
- // which asserts a parameter was supplied and answers its value.
- // Two sites, both on the value read out of the params tree: it is a
- // `Roundhouse::ParamValue` (the String | Hash | Array union), so
- // the `fetch` and the value it binds are gradual by declaration —
- // the same shape every other reader in params.rb contributes.
- // +2 sites; ceiling raised 281 -> 283.
- // 2026-08-19 the ActiveModel::Dirty VALUE half —
- // `attribute_previously_was` on both layers plus `_note_hydrated`.
- // Four sites, all on the `name` parameter (a caller-chosen Symbol)
- // and the diff's heterogeneous values, which is what every other
- // entry in params.rb and this file's Dirty surface already
- // contributes. The method exists twice because the strict lanes
- // cannot index the `[prev, value]` pair, so each layer pays its own.
- // +2 sites; ceiling raised 283 -> 285.
- // 2026-08-20 285 -> 291, five methods the campfire suite named, an
- // average of two sites each:
- // relation.rb +2. `collect` is `map`'s second name and pays
- // exactly what `map` pays — the element type is `untyped` in
- // the runtime RBS by the R5 delegation convention, so the block
- // param and the `yield` are both untyped. (A `Relation#new`
- // added 6 more here and was reverted: spinel already names that
- // class's constructor `sp_Relation_new`.)
- // flash.rb +4. `mark_shown` and `FlashNow`'s `[]`/`[]=` take an
- // untyped key for the same reason CookieJar's do — flash is
- // indexed with Symbol constants and String literals alike, and
- // the body normalizes via `key.to_s`.
- // base.rb +0. `destroy!` calls `destroy`, which is typed `Base`.
- // The STI recast contributes nothing: its column copy lives in
- // src/lower/sti_scope.rs, not here — see the note on the other
- // ceiling in tests/inference_on_spinel_blog_runtime_with_rbs.rs for
- // why that placement was not optional.
- // 2026-08-20 291 -> 293: `Relation#first` and `#find` restoring the
- // state they set (the terminal invariant — see the note on the
- // other ceiling). One site each, and zero tests moved.
- // 2026-08-21 293 -> 298: `ViewHelpers.mail_to`. FIVE sites, one per
- // mail header lifted out of the html options — each is an
- // `opts.fetch(:cc, nil)` bound to a local, the same shape (and the
- // same reason) as `button_to`'s `opts.fetch(:method, nil)` beside
- // it: the opts hash is `Hash[Symbol, untyped]` by declaration, so
- // every read out of it is gradual. They are five rather than one
- // because the list is UNROLLED — a `next` inside an `each` over a
- // constant list is not a shape the Rust emitter lowers. Everything
- // downstream is typed: `mail_query_append` takes three Strings, so
- // the gradual value never crosses a call boundary.
- // 2026-08-21 298 -> 308: `Relation#find_or_create_by`, the same ten
- // sites the other ceiling itemizes (it counts eight; this counter
- // also charges the two `nil?`/`save` sends on the untyped locals).
- // Reads off `conditions` (`Hash[Symbol, untyped]`), off `@model`,
- // and off what `find_by`/`new` answer — the four sources every
- // relation method in this file already draws on. campfire's
- // `Search.record` is `find_or_create_by(query: query).touch`
- // reached through `user.searches`, and until this existed the
- // emitted body called a method nothing defined.
- // 2026-08-21 308 -> 312: `Relation#==`, four sites where the other
- // ceiling counts twelve (that one charges receivers as well). The
- // four are the `other` parameter, the `theirs` local it flows into,
- // and the `.id` read on an element of each side — `Array[untyped]`
- // by the same R5 delegation convention every method in this file
- // pays. Nothing gradual crosses a call boundary: what leaves is a
- // `bool`.
- //
- // See the note on the other ceiling for why the id comparison is
- // HERE rather than in a `Base#==`: an operator definition in
- // base.rb reaches every strict target and no emitter renames one
- // (python emitted `def ==(self, other)` and every tree stopped at a
- // SyntaxError), while the ruby-family reopen in connection.rb has
- // no assignment to type `@id` from.
- // 2026-08-21 312 -> 315: `ActionText::Fragment`, three sites. Two
- // are what the `replace` block answers — a filter returns markup,
- // a sanitizer returns nil, and `.to_s` is what reconciles them,
- // which is Rails' own contract for that block. The third is
- // `Fragment.wrap`'s parameter, whose whole job is to accept either
- // a Fragment or a String. Everything the scanner itself touches is
- // typed: `Node` carries three declared fields and `Selector` six,
- // classes rather than hashes precisely so no bag appears here.
- // 2026-08-22 315 -> 316: `Relation#find` raising `RecordNotFound`
- // on no match, which is Rails' whole distinction between it and
- // `find_by` — and what turns a missing record into a 404 rather
- // than a nil that NoMethodErrors somewhere later. ONE site: the
- // `record.nil?` guard on what the terminal answered, which is
- // `untyped` by the same R5 delegation convention every terminal in
- // this file pays (`find_by!` two lines down has the identical
- // guard and the identical site). Nothing new became gradual — the
- // method already read that local to return it.
- // 2026-08-23 316 -> 317: `SignedId.verified_id!`, the raising twin
- // of the sentinel read — so `find_signed!` answers
- // `InvalidSignature` for a token that does not verify and
- // `RecordNotFound` only for one that does and names no row. ONE
- // site: `ActiveRecord::SignedId.verified_id(...)` is a class-method
- // send, and this gate's registry is built from the runtime's own
- // `.rbs` INSTANCE methods, so its result is `untyped` here however
- // the sidecar declares it. The comparison against it is the whole
- // method.
- // 2026-08-23 317 -> 319: the three relation TERMINALS that borrow
- // relation state and now give it back — `find_by` and `exists?(id)`
- // pop the predicate they pushed, `first_n` restores the limit it
- // borrowed, the same discipline `find` and `pick` already spell
- // out one screen up. TWO sites, both the locals the restore
- // forces: `find_by`'s `record` and `exists?`'s `found` hold the
- // answer across the pop, and each is the R5 `untyped` every
- // terminal in this file already pays. Not optional bookkeeping:
- // campfire's `find_messages` probes `find_by(id: params[
- // :message_id])` and then pages THE SAME relation, so on a plain
- // /rooms/1 the leftover `WHERE id IS NULL` made a room of a
- // hundred messages render zero — behind a 200 and a well-formed
- // page, which is why no test in the app saw it.
- //
- // 319 → 324: `ActiveSupport.index_by(list) { … }`, the plain-
- // collection twin of `Relation#index_by`. Five sites, all the same
- // one — the collection is `untyped` because this function exists
- // precisely to serve a receiver no target can dispatch on, and its
- // key type is whatever the caller's block returns. Typing it would
- // need an RBS type VARIABLE (`[T, K] (Array[T]) { (T) -> K } ->
- // Hash[K, T]`), which no signature in this corpus uses yet; the
- // Relation twin pays the identical `Hash[untyped, untyped]` two
- // files over.
- //
- // 324 -> 325: `ActionView::ViewHelpers.h`. ONE site, the `value
- // .to_s` — and it is the method's whole reason for existing.
- // Rails' `h` takes a String or a value OBJECT (campfire hands it an
- // `ActionText::Content`) and coerces first; typing the parameter
- // `String` would make the corpus's actual call site a type error,
- // and there is no union that names "anything with to_s".
- //
- // 325 -> 329: `ActionView::ViewHelpers.hidden_field_tag`, moved off
- // the spinel CRuby overlay into the ruby-family runtime so both
- // lanes render it from one body. FOUR sites, and each names a type
- // the corpus itself refuses to make narrower:
- // `name` — `"boost[content]"` at one call site and
- // `:push_subscription_endpoint` at another. Declaring `String`
- // would make the Symbol call a seed contradiction, which is
- // exactly the class of error this move was made to clear.
- // `value` — a String, an `Integer` id, a `Bool` predicate and
- // `nil` across the six corpus call sites.
- // the two `opts` reads — `Hash[Symbol, untyped]` by declaration,
- // the same contract (and the same cost) `image_tag` and
- // `mail_to` already pay beside it.
- //
- // 329 -> 332: `ActionView::ViewHelpers.capture`, moved off the
- // spinel CRuby overlay for the same reason `hidden_field_tag` was —
- // one body, both lanes. THREE sites, and they are one fact: what a
- // BLOCK returns. `value = yield` and the two reads of it
- // (`value.is_a?(String)`, `value.to_s`) are the whole method, and
- // no signature can name the type — the blocks that reach it are
- // emitted helper bodies whose value is a String, and Rails' own
- // contract is to answer "" for anything else, which is a decision
- // taken ON the untyped value rather than around it. The buffer
- // STACK the overlay carried did NOT move (it would have added ten
- // more, all `Fiber[:k]` reads): every `concat` the corpus has is
- // already inlined into an accumulator by `lower::capture_inline`,
- // so nothing can push to it. `polymorphic_url`, landing in the same
- // file in the same change, adds NONE — its body raises and never
- // reads its parameter.
- // 332 -> 336: `ActiveRecord::Relation#preloaded`'s `records`
- // parameter, widened from `Array[untyped]` to `untyped`. FOUR
- // sites, all in that one method (relation.rb 145 -> 149), and they
- // are the parameter and its flow into `@records` — the body is
- // `@records = records if loaded; self`.
- //
- // The trade is deliberate and the OTHER side of it is a wall.
- // `Array[untyped]` is not "any array", it is a REPRESENTATION
- // claim, and `--rbs` seeds are trusted: campfire's
- // `User#reachable_messages` passes `@reachable_messages_cache`,
- // which the constructor seeds `[]` and spinel therefore infers as
- // an int array, and spinel#4151's new diagnostic named it
- // (`parameter records of preloaded is declared Array[untyped] but
- // this call passes Array[Integer]`). This method never inspects
- // the array's shape, so `untyped` is what it actually promises.
- //
- // What would buy the four sites back is NOT a narrower signature
- // here — no type names "the element type of whatever cache the
- // caller holds". It is declaring the association-cache ivars in
- // the generated model RBS: `assoc_cache_ivar_bindings` already
- // computes `Array[]` for each, but the model `.rbs` we emit
- // carries ZERO ivar declarations, so the `[]` seed wins. That needs
- // ivar types plumbed onto `LibraryClass` and is its own change.
- //
- // 336 -> 339: `ActiveSupport.many?`'s `list` parameter, widened
- // from `Array[untyped]` to `untyped` — the same trade as
- // `preloaded` above, in a method whose whole body is
- // `list.length > 1`. THREE sites (active_support_ext.rb 16 -> 19,
- // MEASURED at the old tree, not derived): the parameter, the
- // `length` receiver, and the comparison.
- //
- // The wall on the other side: campfire's `directs/edit` asks
- // `many?(room.users)` and hands it an ASSOCIATION. `Array[untyped]`
- // is a representation claim and spinel acts on it — it coerced the
- // `sp_Relation *` to `sp_PolyArray *` at the call and the C did not
- // compile. Nothing here inspects the argument's shape, so `untyped`
- // is what the method actually promises; `index_by` beside it
- // already says so.
- //
- // 339 -> 341: blockless `ActionView::ViewHelpers.form_with`, the
- // runtime fallback for a `form_with` written in a HELPER body (the
- // view walker macro-inlines the template ones and cannot reach
- // that). TWO sites, and the split is MEASURED, one change at a time
- // (view_helpers.rb 25 -> 26 -> 27):
- //
- // * ONE for `form_with`'s own body — it reads its options out of
- // an untyped hash, which is the whole reason it exists: a
- // helper forwards `attributes.merge(data: data)`, a runtime
- // value no lowering can walk.
- // * ONE for widening `method_override_input` from `Symbol` to
- // `untyped`. Its body is `to_s` and two string comparisons, so
- // `Symbol` was an over-claim that made the honest caller the
- // illegal one — `form_with` reads the method out of that same
- // untyped hash. Third instance of that lesson in as many days,
- // after `Relation#preloaded` and `ActiveSupport.many?`.
- //
- // What it bought is not a narrower type anywhere — it is a LINKED
- // BINARY. Unqualified, `form_with` in `FormsHelper` was a bare name
- // nothing defines: spinel dropped the helper's whole body and the
- // linker failed on the caller's reference to the vanished symbol.
- //
- // 341 -> 345: `Rails.logger` stopped being a no-op. FOUR sites, one
- // per printing severity (info/error/warn/fatal — debug stays
- // silent), each the `message` parameter read inside `message.to_s`.
- // The parameter is `untyped` because that is the logger's actual
- // contract — Ruby's Logger takes any object — and narrowing it to
- // String would make the honest caller the illegal one. What the
- // sites bought: every `Rails.logger.error` an app writes inside a
- // `rescue` is now visible on stderr, instead of the operator
- // splicing a reporter into the emitted tree to learn what raised.
- //
- // 345 -> 403: `Relation#order` sorts a LOADED relation in memory
- // (relation.rb `sort_in_place!` and its helpers). FIFTY-EIGHT sites,
- // MEASURED one declaration at a time (431 with a pair-returning
- // helper and untyped `terms`; 403 with `terms: Array[String]`, the
- // helper split into `order_column -> String?` and
- // `order_descending? -> bool`, and `compare_order_keys -> Integer?`).
- // What stays `untyped` is what IS untyped: `sorted`, a copy of the
- // loaded records -- the same representation trade `preloaded` makes
- // above, and for the same reason (a caller's typed array must not be
- // coerced to a poly one); each `record` the sort reads a key from;
- // and the keys themselves, which are whatever the ordered column
- // holds. The insertion sort touches those on every comparison, which
- // is where the count comes from. What it bought: campfire's
- // `message.boosts.ordered` under `includes(boosts: :booster)` no
- // longer re-queries per message -- 40 round trips -> 0 on the room
- // page, 14 against Rails' 13.
- //
- // 403 -> 405: `ActiveStorage::Blob.from_row`, the one constructor
- // every blob read (the proxy's join, the batch preloader, `find`)
- // goes through. TWO sites, MEASURED: the row's `metadata` and
- // `blob_key` reads, `Hash[String, untyped]` values like every other
- // adapter row (binding the six reads to locals first was measured
- // at SIX, so the nested form stays). What it bought: the blob is a
- // value with a key, a size and dimensions, which is what a storage
- // service and a variant need to serve it.
- //
- // 405 -> 409: `ViewHelpers.to_query`, `Hash#to_query` for a route
- // helper's `params:` and splatted options. FOUR sites, all the one
- // `value` a query pair carries: the `nil?` that renders the bare
- // key and the `to_s` that renders the rest, TWICE — once in
- // `to_query_pairs`, whose loop inlines the scalar rendering (a
- // Hash `each` block's value is a borrowed reference on the rust
- // emit and cannot cross a by-value untyped parameter), and once in
- // `to_query_value`, the method the ruby family's reopen replaces
- // with the nested walk. `untyped` because that is the contract — a
- // query value is any object, the same reason the logger's
- // `message` is — and the walk that would read it as a Hash or
- // Array is that reopen (runtime/spinel/hash_to_query.rb), off this
- // tree. What it bought: the pairs `query_suffix` renders are
- // CGI-escaped through one function instead of an IR-built loop,
- // and campfire's `rooms_closed_url(room, params: {…})` and
- // `user_push_subscriptions_url(params: {…})` reach their actions.
- //
- // 409 -> 414: `WebPush::ResponseError`, the gem's error hierarchy
- // ported into the façade so the app's `rescue
- // WebPush::ExpiredSubscription` names a class on every lane. FIVE
- // sites, all the one `response` the gem's constructor takes: the
- // ivar it is stored in, the reader that answers it, and the
- // `inspect` the message renders it with. `untyped` because that is
- // the gem's contract — a `Net::HTTPResponse` from a real delivery,
- // a `Struct.new(:body)` from campfire's own test — and the
- // hierarchy is the whole point: `WebPush::Pool#deliver` drops a
- // subscription on exactly this class. What it bought: the push
- // pool's invalidation path runs on both lanes, and the suite's
- // `destroys invalid subscriptions` passes through the slot's
- // `raises` link rather than the bridge.
- //
- // 414 -> 416: `Relation#load_records`, the typed load path. TWO
- // sites, both reads of the one `@model` the Relation already held
- // untyped: `_columns_sql` (the projection) and `_hydrate_all` (the
- // hydrate), beside the `instantiate` the explicit-`select` branch
- // keeps. `untyped` for the reason `@model` always was — the
- // Relation is one class over every model, and the class object it
- // carries has no type on any lane. What it bought: a Relation
- // without `select(...)` hydrates typed records straight from the
- // statement through the model's `from_stmt`, and the String-keyed
- // Hash per row — the largest single allocation on campfire's
- // 600-row room page — is gone from that path.
- //
- // 416 -> 417: `Relation#take`, one `first` self-send under the
- // declared `untyped` return `first` has always had. What it
- // bought: the Rails 8 authentication generator's `User.take`.
- //
- // 417 -> 418: `ActiveStorage::DiskKey.expiry`, the `Time.now + 300`
- // that `signed_id.rb`'s entry above already explains (`Time#+` is
- // untyped by design; `iso8601_ms`'s `::Time` parameter absorbs it).
- // ONE site for the module: the disk-route token and the
- // direct-upload token both take their expiry from it, and the
- // module moved into the shared runtime from the ruby family's
- // `active_storage_disk.rb` so `Blob#url` can be Rails' service url
- // on every target. What it bought: Active Storage's direct-upload
- // pair (`POST /rails/active_storage/direct_uploads`, the disk
- // service's `PUT`), served and guarded by campfire's initializer.
- //
- // 418 -> 425: Action Text's attachable dispatch, SEVEN sites in
- // three methods, all the one `attachable` a node resolves to —
- // which is poly by Rails' own contract (campfire's
- // `OpengraphEmbed` built from the node, a `User` from its sgid, a
- // `MissingAttachable` when neither answers) and was already the
- // declared `untyped` return of `Attachment#attachable`. FOUR in
- // `attachable`: the content-type read now taken first
- // (`Content.content_type_attachable(self)`, generated per app) is
- // bound, nil-tested and returned. TWO in `to_partial_path`, Rails'
- // `delegate_missing_to :attachable` for the one reader a
- // class-side render asks. ONE in `Fragment#update`: what the block
- // answers, discarded — Rails' `update` yields the source for
- // writing and returns the fragment, the same block contract
- // `replace` already pays one site for. What it bought: a test that
- // builds an embed node by hand gets the embed back, not a
- // `MissingAttachable`; `ApplicationController.render partial:
- // attachment.to_partial_path` lowers to the generated dispatch; and
- // campfire's two mutating content filters (`inner_html=`,
- // `at_css(...)["class"] =`) run — opengraph-embed 7/8,
- // content_filters 12/14.
- //
- // 425 -> 428: `ViewHelpers.attr_value_text(name, v)`, the one
- // method an attribute's TEXT now goes through — `v.to_s` here, and
- // the ruby family's reopen (runtime/spinel/attr_value_text.rb)
- // renders an Array as Rails does. THREE sites, all the one value
- // under the attribute bag's `Hash[Symbol, untyped]` that
- // `render_attrs` already read: the argument at the call, the
- // parameter, and its `to_s`. The Array walk itself is OFF this
- // tree on purpose: an `is_a?(Array)` arm here red the Rust, C# and
- // Elixir lanes on one push. What it bought: campfire's sidebar room
- // links render `class="direct"` instead of the array's `inspect`.
- //
- // 428 -> 429: `ActiveSupport.sole(list)`, Rails' `Enumerable#sole`
- // — the one element, or a raise. ONE site, the element read that
- // is its answer: the parameter is `Array[untyped]` (so the count
- // checks are typed), and what an Array of anything holds is the
- // untyped a generic function returns on this runtime, exactly as
- // `presence_in` answers its `value`. What it bought: campfire's
- // unread_rooms_channel_test reads `subscription.streams.sole` —
- // the confirmed stream and the assertion that there is exactly
- // one, in one call.
- //
- // 429 -> 452: `runtime/ruby/logger.rb`, 23 sites in three seams,
- // each of which is a place Ruby's own contract is open.
- //
- // THE MESSAGE, and it is most of them. Ruby's `Logger` renders ANY
- // object — `msg2str` is there to turn an Exception or an arbitrary
- // value into a line — so `message` is untyped at the formatter's
- // parameter and at each of the ten level methods that forward it
- // (five on `ActiveSupport::Logger`, five on `TaggedLogging`). Same
- // for `severity` (a String from Ruby's Logger, an Integer from a
- // caller passing a level) and `progname`. `time` is concrete
- // (`::Time`) because every path really does pass one. The .rbs
- // beside the file carries a note NOT to narrow these without
- // re-probing: a `String` there made campfire's own
- // `LogScrubbingFormatter` override fail its C compile.
- //
- // `@io` — what an `ActiveSupport::Logger` writes to, `STDOUT` in
- // production and a `StringIO` under test. Two unrelated classes in
- // Ruby (StringIO does not subclass IO) with one method in common,
- // so a declared type would be a claim one of the two callers
- // falsifies.
- //
- // The block `TaggedLogging#tagged` yields to: `logger.tagged("req")
- // { … }` hands back whatever the block answers, which is the
- // block's business — the shape `index_by`'s block already pays for.
- //
- // What it bought: campfire's `LogScrubbingFormatter <
- // ::Logger::Formatter` has a `super` to reach, so a bot key in a
- // request path is redacted before the line is written —
- // log_scrubbing_formatter_test 6/6, and the stack production.rb
- // wires exists for the day request logging does.
- //
- // 452 -> 459: `ActionDispatch::TestRequest.create(env)`, SEVEN
- // sites, all one value: a Rack env's. Its KEYS are Strings by the
- // spec and declared so; its VALUES are not — `rack.input` is an
- // IO, `rack.errors` a stream — so the parameter is
- // `Hash[String, untyped]` and each value the mapping takes is
- // `.to_s`'d at the assignment. The block's pair, the seven
- // comparisons' right-hand side and the `env=` that keeps the whole
- // env readable carry the consequence. What it bought: campfire's
- // opengraph-embed test can name the host its own links must be
- // dropped for (`Current.set request: TestRequest.create("HTTP_HOST"
- // => …)`), which is the only way that file states the rule it is
- // testing.
- //
- // 459 -> 465: `runtime/ruby/tempfile.rb`, SIX sites in two seams.
- // The FILE — `File.open` answers untyped because this runtime has
- // no File type and wants none: what a caller does with the handle
- // is `write`/`flush`/`path`/`close`, and a declared class here
- // would be a claim about a CRuby object the ported targets do not
- // have. The open, the local, the close and the argument the block
- // is yielded carry it. The other is the BLOCK: `create` hands back
- // whatever its block answers, which is the block's business —
- // `index_by` and `TaggedLogging#tagged` already pay the same.
- // `basename` is untyped for a stated reason rather than a shrug:
- // Ruby takes a String or a two-element Array there, and
- // `split_basename` is the narrowing.
- // What it bought: campfire's vips policy test writes each probe
- // image to a temp file, because `vips_foreign_find_load` takes a
- // path — twelve tests on the compiled lane that had no `Tempfile`
- // to reach for.
- //
- // 465 -> 474: `ActiveSupport.sole`'s parameter goes back to
- // `untyped` from `Array[untyped]`, and the nine sites are the
- // price of not over-claiming. The narrower form was declared to
- // SAVE these nine, which is not a reason to declare anything: the
- // one receiver in the corpus is a channel's `streams`, an
- // `Array[String]`, and spinel refuses that against a declared
- // `Array[untyped]` — "a seed is trusted, so the emitted code would
- // reinterpret the value rather than convert it". The whole
- // `unread_rooms_channel_test` stopped linking, which only the
- // compiled lane can report, and the note beside `many?` in the
- // .rbs had already said why. A ceiling is a ledger of debt, not a
- // budget to fit a declaration into.
- //
- // 474 -> 475: `WebPush.deliver`'s `connection`, ONE site. The
- // façade's `payload_send` already took it untyped — it is the
- // caller's `Net::HTTP::Persistent`, a class no strict target has
- // and none should claim — and passing it on to the unstubbed hook
- // is the read. spinel's port (runtime/spinel/web_push.rb) hands it
- // to the gem's `@options`, which campfire's prepended
- // `PersistentRequest#perform` reads. What it bought: the hook the
- // port redefines, so push delivery on the compiled lane is the
- // gem's rather than a raise.
- //
- // 475 -> 479: the before-save half of ActiveModel::Dirty, FOUR
- // sites, all one value: an attribute's, out of the `attributes`
- // Hash, whose values are heterogeneous by construction — the same
- // reason `attribute_previously_was` already answers untyped. Base's
- // `attribute_was` stub reads `changes[name]`; the ruby-family
- // reopen reads the baseline entry, the pair's `[0]` and the
- // `attributes[name]` fallback. What it bought: ` _changed?` and
- // ` _was` exist at all — lobsters' User guards a validation on
- // `username_changed?` that 183 of its model specs reach, and
- // campfire's `direct_rooms_keep_their_type` reads `type_was`.
- //
- // 479 -> 480: `Connection#exec_update`'s binds, ONE site. Rails'
- // `exec_update(sql, name, binds)` takes whatever the caller binds,
- // and `sanitize_sql` escapes each one by its runtime class — the
- // same untyped statement tail that method has always taken. What it
- // bought: lobsters' comment score recompute, reached by every
- // comment and vote save (18 more of its model specs pass).
- //
- // 480 -> 487: three lobsters raw-SQL/enumerable surfaces, each
- // untyped for the value's own reason. `Relation#each_with_object`,
- // FIVE: the memo is whatever the caller seeds, the same shape as
- // `inject` beside it (8); lobsters' vote lookup tables are built
- // this way on every comment listing. `ActiveSupport.
- // symbolize_keys`, ONE: a raw-SQL row's values are heterogeneous by
- // construction (`Result` rows are `Hash[String, untyped]`), and
- // FlaggedCommenters reads its aggregate row through it.
- // `Connection#exec_insert`, ONE: Rails' `name` log label passed on to
- // `exec_update`, the site that method already carries; lobsters'
- // FullTextSearch indexes new rows through it.
- //
- // 487 -> 489: `Relation#touch_all`, TWO sites — `escape_value`'s
- // answer (it escapes whatever it is handed, so it answers untyped)
- // and the adapter's `changes`, the same pair `update_all` beside it
- // carries (4). What it bought: lobsters' inbox marks itself read
- // (`after_action :update_read_at`), which runs now that after
- // filters reach the dispatcher.
- //
- // 489 -> 494: `ActiveSupport.to_param(value)`, FIVE sites, all reads
- // of its one parameter (active_support_ext.rb 29 -> 34, MEASURED):
- // the Hash and Array tests and their arms, and the record send. The
- // parameter is `untyped` because the method's whole job is taking a
- // receiver inference could not type (`lower::to_param_residue`), the
- // same trade as `many?` above. What it bought: lobsters' anonymous
- // story lists, whose cache key interpolates `v.to_param` over
- // `true`, an Integer and a Hash — every one 500'd on spinel. An
- // earlier spelling with a branch per scalar kind cost 14; `to_s` is
- // Rails' answer for all of them, so they share one tail.
- //
- // 494 -> 501: Relation's set operations, SEVEN sites net (relation.rb
- // 219 -> 226, MEASURED). Each operator still reads its untyped
- // `other` once, as the one-liners it replaces did; what is new is
- // the one `id_filter` block the three share (a record and its `id`),
- // `ids_of`'s, and `set_operand`'s Relation test on `other`. A record
- // is `untyped` here because `to_a` is `Array[untyped]`, the same
- // reason `==` beside them pays for `mine[i].id`. What it bought:
- // `relation & relation` — lobsters' `story.tags & filtered_tags` on
- // every story page, which raised TypeError on spinel — and id
- // membership, without which two sides that loaded the same rows
- // intersected to nothing. A block per operator cost 11; a seen-list
- // per operator 20.
- //
- // 501 -> 504: `ActiveSupport.use_zone`'s thread-local slot, THREE
- // sites (active_support_ext.rb, MEASURED): `current_zone`'s read of
- // `Thread.current[:rh_time_zone]`, `use_zone`'s read of the previous
- // zone, and the block value it hands back. The slot is untyped
- // because a thread-local holds whatever was put in it. What it bought:
- // a per-request zone that a concurrent request cannot see, where
- // swapping `ENV["TZ"]` for the block would have changed every
- // thread's clock. Taking the zone as `untyped` cost 8; `String?` is
- // what the corpus passes (`company.timezone_name`).
- //
- // 504 -> 507: `ActiveSupport.cast_boolean(value)` reads its untyped
- // parameter twice (`nil?`, `to_s`) and `stringify_keys` passes each
- // untyped value through once (active_support_ext.rb, MEASURED). The
- // parameter is untyped because `ActiveModel::Type::Boolean#cast`
- // takes whatever a param or a setting holds, the same trade as
- // `blank?`. What it bought: `ActiveModel::Type::Boolean.new.cast(…)`
- // in five corpus apps (discourse, chatwoot, mastodon, lobsters,
- // forem), which had no method to reach on spinel.
- // SQL identifier metadata: 507 -> 510, Relation 226 -> 229,
- // MEASURED against unchanged upstream; other files are unchanged.
- // first!, find and find_by! read raw @model.table_name for their
- // error messages once @table holds the SQL spelling. These are
- // three additional gradual sites through the existing untyped
- // model contract, not new untyped signatures or relaxed Bar A.
- //
- // 510 -> 519: Relation's array finder, NINE sites net (relation.rb
- // 229 -> 238; original baseline 226 -> 235, MEASURED).
- // Inputs are concrete Integer/String scalars
- // or arrays, NOT untyped. The residual is the model-dependent keys
- // and hydrated records read from the existing dynamic model seam,
- // as in the set operators above. No parameter contract was erased.
- //
- // 519 -> 520 (517 -> 520 measured on main e0d8610e, which sat two
- // under the ceiling): Kaminari's `page` / `per`, THREE sites
- // (relation.rb 238 -> 240, connection.rb 40 -> 41, MEASURED). Each
- // is a read of the `num` parameter, untyped because `page(params[
- // :page])` hands over whatever the request held (a String, nil, or
- // an Integer from app code); the bodies read it once, through
- // `to_s`. The readers (`current_page`, `total_pages`, …) add none.
- // What it buys: Kaminari chains, which the catalog already typed
- // as builders, run instead of raising NoMethodError.
- //
- // 520 -> 521: `ActiveRecord::TokenFor.generate`'s `Time.now +
- // expires_in` (active_record/token_for.rb, ONE site, MEASURED) —
- // the `Time#+` that `signed_id.rb`'s entry explains, absorbed by
- // `iso8601_ms`'s `::Time` parameter. What it bought:
- // `has_secure_password`'s password-reset token, which the Rails 8
- // authentication generator's PasswordsController and mailer use.
- //
- // 521 -> 537, +16 MEASURED: Relation#last_n as a SQL tail
- // (relation.rb). reverse_order_term / reverse_one_order_term /
- // loaded_tail / last_n itself. The residual is the same untyped
- // @orders / @records / to_a seam every other terminal already
- // pays. What it bought: campfire's ordered.last(PAGE_SIZE) is
- // ORDER BY … DESC LIMIT n, not the whole room history.
- //
- // 537 -> 538, ONE MEASURED (relation.rb 256 -> 257): `Relation#reorder`
- // hands its untyped `*parts` to `order`, the read `order` and `order!`
- // already pay. `skip_preloading!` and `preload_associations` beside it
- // cost nothing once `records` is typed `Array[untyped]`. What it bought:
- // campfire's message paging after basecamp/once-campfire#292 (and #304,
- // #312, which `reorder` a page's relation).
- //
- // 538 -> 543, FIVE MEASURED (active_support_ext.rb 40 -> 44,
- // active_job.rb 1 -> 2): `ActiveSupport::JSON.encode` reads its
- // untyped Hash's pairs and each scalar once (a `case`, not a chain of
- // tests, which cost nine), and `ActiveJob.perform_held` calls a held
- // Proc, the `.call` `drain` already pays. What it bought: campfire's
- // unread notice, encoded once and broadcast `coder: nil`
- // (basecamp/once-campfire#292), and #296's tests, which post first
- // and perform the held fanout job after.
- //
- // 543 -> 571, +28 MEASURED (relation.rb): `exists_sql` /
- // `probe_existence` / `nil_primary_key_lookup?`, plus `size` /
- // `one?` / `many?` / `empty?` / `any?` / `last_page?` routing through
- // them, and `find_each`'s duplicated zero-copy loop (same residual
- // as `each`). What it bought: room-page cardinality without
- // COUNT(*) scans, and find_messages' nil message_id probe without
- // `WHERE id IS NULL LIMIT 1`.
- //
- // 571 -> 560, -11 MEASURED (relation.rb / base.rb): more_than?,
- // loaded_records, Base.any? via exists?; offset_row_exists? gone.
- // Net drop because each/find_each share one load helper.
- //
- // 560 -> 562, +2 MEASURED: Base.any?/none? stay on COUNT (strict
- // targets have no Relation); the SELECT-1 forms live only in the
- // ruby-family connection.rb reopen. last_page? short-circuit
- // requires a non-empty short page. Earlier claim of 521 was a
- // mis-measure — the reopen still pays Relation.new typing sites
- // this probe counts, so the residual landed at 562.
- const CEILING: usize = 562;
+ // Soft Bar B ratchet: count of exact `Ty::Untyped` sites across
+ // `runtime/ruby`. Fails only when the residual rises. Tighten the
+ // ceiling after a measured drop — never raise without a ledgered
+ // feature. Current residual is dominated by `relation.rb`
+ // (polymorphic SQL / Enumerable) and `Hash[Symbol, untyped]` helper
+ // opts; `Relation[T]` is the longer-term fix.
+ const CEILING: usize = 435;
assert!(
total_gradual <= CEILING,
"{total_gradual} Ty::Untyped sites exceeds ceiling of {CEILING}",