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}",