Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
c12d47c
Tighten Relation RBS: Base model handle and Array helpers
cursoragent Oct 5, 2026
2e6daab
Skip Relation to_a dups on Enumerable; cheap blank?/attrs
cursoragent Oct 5, 2026
55931a7
Memoize sanitize allow-lists; batch HTML text runs
cursoragent Oct 5, 2026
0711829
Type Relation terminals as Base?/Base (−23 Bar B)
cursoragent Oct 5, 2026
81061c9
Count DISTINCT and GROUP BY result sets (#343 slice)
cursoragent Oct 5, 2026
b5fb06b
Content#blank? skips scans on whitespace-only markup
cursoragent Oct 5, 2026
c15a5eb
Level-3 Base.any? probes with SELECT 1 (_adapter_any?)
cursoragent Oct 5, 2026
09a50f6
Drop historical measurement changelogs from typing ratchets
cursoragent Oct 5, 2026
2369bd0
Preserve canonical class IDs in the RBS runtime probe
cursoragent Oct 5, 2026
286e27d
Model ActiveRecord::Base.sanitize_sql_array (#400)
cursoragent Oct 5, 2026
8d72c60
Register sanitize_sql(_array) on ActiveRecord::Base
cursoragent Oct 5, 2026
af2dd1f
Relation#ids preserves uuid and named primary keys (#310)
cursoragent Oct 5, 2026
f5a84db
Lower each.with_index to each_with_index for Spinel AOT
cursoragent Oct 5, 2026
e4c43d4
Ingest ActionText::Markdown as a real model (storage only)
cursoragent Oct 5, 2026
9a25c07
Drive Spinel AR RBS probe residual 57→0
cursoragent Oct 5, 2026
5d2b8b5
Harvest RBS @ivar decls for the Spinel AR probe
cursoragent Oct 5, 2026
4fbaba4
Address Thermos maintainability on hill-climb tip
cursoragent Oct 5, 2026
57e6894
Expand mattr/cattr/attr on model ingest for Markdown.renderer
cursoragent Oct 5, 2026
30e1ec5
Skip monomorphic synth for unresolved polymorphic belongs_to
cursoragent Oct 5, 2026
61aaea5
Address CodeRabbit findings on hill-climb tip
cursoragent Oct 5, 2026
ab5853f
Keep plain attr_* Unknown on model body walk for concerns
cursoragent Oct 5, 2026
f7eb592
Address second-pass CodeRabbit findings on tip
cursoragent Oct 5, 2026
18275be
Keep sanitize_sql_array on positional ? for Bar B
cursoragent Oct 5, 2026
b762b81
Spinel: rewrite Relation Base handle/terminals to untyped
cursoragent Oct 5, 2026
51f01b0
Confine Relation Base rewrite to Spinel; qualify from+joins distinct …
cursoragent Oct 5, 2026
0df3482
Fix hosted CI: portable render_attrs, Content#blank?, optioned mattr
cursoragent Oct 6, 2026
532640a
Keep optioned-mattr skip without reformatting model.rs
cursoragent Oct 6, 2026
9175584
Pin Writebook inventory for optioned mattr/cattr leftovers
cursoragent Oct 6, 2026
f9112f9
Fix nested abstract close_over and each_with_index temp shadowing
cursoragent Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 46 additions & 3 deletions runtime/ruby/action_text.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1165,8 +1165,37 @@ def as_json
@html
end

# Empty / whitespace-only markup is blank without scanning. Non-empty
# shells (`<div></div>`, `<div><br></div>`) 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 (`&nbsp;` → " ") 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?
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 7 additions & 5 deletions runtime/ruby/action_view/view_helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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,
Expand Down
86 changes: 70 additions & 16 deletions runtime/ruby/action_view/view_helpers_ext.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions runtime/ruby/action_view/view_helpers_ext.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 12 additions & 4 deletions runtime/ruby/active_record/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 —
Expand Down
6 changes: 6 additions & 0 deletions runtime/ruby/active_record/base.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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?
Expand Down
48 changes: 40 additions & 8 deletions runtime/ruby/active_record/connection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
thomasklemm marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
Expand Down
1 change: 1 addition & 0 deletions runtime/ruby/active_record/connection.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading