Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ Older release notes are archived under [`docs/`](docs/) when the leading version

### Fixed

- **[engine]** A project `.rbs` file (or a plugin-synthesized inline-RBS contribution) that is not valid UTF-8 is now quarantined with a warning naming the file, instead of reaching the RBS parser — where it crashed `rigor check` with a bare `ArgumentError` on rbs 4.1, and could hang the process outright on older rbs releases ([#230](https://github.com/rigortype/rigor/pull/230)).
- **[inference]** `Resolv.new([Resolv::Hosts.new, ...])` no longer false-fires `call.argument-type-mismatch` when the installed `rbs` predates 4.1; a core overlay backports the array-of-resolvers overload upstream added in ruby/rbs#2960 ([#230](https://github.com/rigortype/rigor/pull/230)).
- **[docs]** Nine `README.md` links to the documentation site carried a stale `/reference/` path segment and 404'd; they now resolve ([#223](https://github.com/rigortype/rigor/pull/223), thank you @f440!).

## [0.3.1] - 2026-07-29
Expand Down
26 changes: 26 additions & 0 deletions data/core_overlay/resolv.rbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Rigor core overlay — supplemental core signatures.
#
# --- Resolv#initialize ---
#
# `rbs` before 4.1 declares `Resolv#initialize` as only:
#
# def initialize: (?Resolv::Hosts | Resolv::DNS resolvers) -> untyped
#
# But `Resolv.new` takes an *array* of resolvers — the runtime default is
# `[Hosts.new, DNS.new]`, and `each_address` iterates the argument — so the
# documented, idiomatic call passes an Array:
#
# Resolv.new([Resolv::Hosts.new, Resolv::DNS.new(nameserver: ...)])
#
# Against the pre-4.1 signature every overload rejects that Array and
# `call.argument-type-mismatch` false-fires (measured on Mastodon's
# `app/lib/request.rb`).
#
# `rbs` 4.1.0 fixed the signature upstream (ruby/rbs#2960); the overlay
# backports that overload for the older releases the gemspec still supports
# (`>= 3.0, < 5.0`). On 4.1 the appended overload duplicates the upstream
# one, which costs nothing. Drop this file when the rbs floor reaches 4.1.
class Resolv
def initialize: (?(Array[Resolv::Hosts | Resolv::DNS] | Hash[Symbol, untyped])? resolvers, ?use_ipv6: bool?) -> untyped
| ...
end
49 changes: 40 additions & 9 deletions lib/rigor/environment/rbs_loader.rb
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ def build_env_for(libraries:, signature_paths:, virtual_rbs: [])
# tell a collision-dropped virtual entry (parses, but absent from the env) from a parse-failed one
# (the synthesizer's own WD6 skip, reported separately).
def parseable_rbs?(content)
# Pre-parser encoding guard ({.invalid_encoding?}): invalid UTF-8 raises `ArgumentError` (not
# `ParsingError`) out of `RBS::Parser.magic_comment`'s regex on rbs 4.1, escaping the rescue below,
# and could hang the C lexer outright on the older releases the gemspec supports.
return false if invalid_encoding?(content)

::RBS::Parser.parse_signature(::RBS::Buffer.new(name: "(rigor: virtual parse check)", content: content))
true
rescue ::RBS::BaseError
Expand Down Expand Up @@ -240,25 +245,48 @@ def add_project_signatures(env, signature_paths)
end
end

# The quarantine note for a file rejected by {.invalid_encoding?} — worded to be distinct from any
# rbs-emitted parse error so specs (and users) can tell Rigor's pre-parser skip from the parser's own
# UTF-8 diagnostics.
INVALID_ENCODING_NOTE = "not valid UTF-8 — skipped before reaching the RBS parser"

# Pre-parser guard for content Rigor hands to `RBS::Parser`. On rbs 4.1+ an invalid UTF-8 byte is a
# clean `ParsingError` (ruby/rbs#2983), but on the older releases the gemspec supports (`>= 3.0,
# < 5.0`) the C lexer could infinite-loop or abort on it (fixed upstream in ruby/rbs#2973) — a hang no
# `rescue` can catch, and the one failure mode the quarantine's fail-soft rescues cannot absorb. So
# the check runs before the parser on every rbs version: uniform behaviour, and the quarantine note
# stays actionable ("fix the file's encoding") rather than version-dependent.
def invalid_encoding?(content)
!content.valid_encoding?
end

# Parse one project `.rbs` into `[buffer, directives, decls]`, or nil when it is unparseable /
# unreadable. Mirrors `RBS::EnvironmentLoader#each_signature`'s per-file parse so the decls register
# identically to the loader's batch path.
# unreadable / not valid UTF-8. Mirrors `RBS::EnvironmentLoader#each_signature`'s per-file parse so
# the decls register identically to the loader's batch path.
def parse_signature_file(file)
buffer = ::RBS::Buffer.new(name: file, content: File.read(file, encoding: "UTF-8"))
content = File.read(file, encoding: "UTF-8")
return nil if invalid_encoding?(content)

buffer = ::RBS::Buffer.new(name: file, content: content)
_buffer, directives, decls = ::RBS::Parser.parse_signature(buffer)
[buffer, directives, decls]
rescue ::RBS::ParsingError, Errno::ENOENT, Errno::EISDIR, Errno::EACCES
nil
end

# The project `signature_paths:` files that FAIL to parse, as `[absolute_path, first_error_line]` pairs
# (sorted, deterministic). Detection is independent of {.add_project_signatures} so the warning fires
# even on a cache hit (where the env was already built with the file quarantined). Cheap: it only
# re-parses the user's own (usually small) `sig/` set, and returns empty immediately when there is no
# `signature_paths:`.
# The project `signature_paths:` files that FAIL to parse (or are not valid UTF-8), as
# `[absolute_path, first_error_line]` pairs (sorted, deterministic). Detection is independent of
# {.add_project_signatures} so the warning fires even on a cache hit (where the env was already built
# with the file quarantined). Cheap: it only re-parses the user's own (usually small) `sig/` set, and
# returns empty immediately when there is no `signature_paths:`.
def quarantined_project_signatures(signature_paths)
project_sig_files(signature_paths).sort.filter_map do |file|
buffer = ::RBS::Buffer.new(name: file, content: File.read(file, encoding: "UTF-8"))
# The note carries the path itself because the warn composer prints only this element — a
# `ParsingError` message embeds its `path:line:` prefix, so the composer never adds one.
content = File.read(file, encoding: "UTF-8")
next [file, "#{file}: #{INVALID_ENCODING_NOTE}"] if invalid_encoding?(content)

buffer = ::RBS::Buffer.new(name: file, content: content)
::RBS::Parser.parse_signature(buffer)
nil
rescue ::RBS::ParsingError => e
Expand Down Expand Up @@ -389,6 +417,9 @@ def add_virtual_rbs(env, virtual_rbs)

virtual_rbs.each do |filename, content|
next if content.nil? || content.empty?
# Same pre-parser guard as {.parse_signature_file}: a synthesizer echoing project bytes can carry
# invalid UTF-8, which pre-4.1 rbs lexers could hang on — and a hang escapes the rescue below.
next if invalid_encoding?(content.to_s)

buffer = ::RBS::Buffer.new(name: filename.to_s, content: content.to_s)
_, directives, decls = ::RBS::Parser.parse_signature(buffer)
Expand Down
49 changes: 49 additions & 0 deletions spec/rigor/environment/rbs_loader_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,55 @@
end
end

describe "invalid-UTF-8 project signature quarantine (pre-parser guard)" do
# rbs 4.1 turned an invalid UTF-8 byte into a clean `ParsingError` (ruby/rbs#2983), but on the older
# releases the gemspec supports the C lexer could infinite-loop or abort on it (ruby/rbs#2973) — a hang
# the quarantine's rescue can never catch. Rigor therefore rejects the content BEFORE the parser on every
# rbs version; these specs pin the guard by asserting Rigor's own note, which no rbs-emitted message
# contains.
let(:tmpdir) { Dir.mktmpdir("rigor-rbs-loader-encoding-spec-") }

after { FileUtils.rm_rf(tmpdir) }

before do
File.write(File.join(tmpdir, "good.rbs"),
"module Acme\n class Widget\n def size: () -> Integer\n end\nend\n")
# `\xE9` is a bare Latin-1 é — an invalid byte in UTF-8. The declarations around it are well-formed, so
# only the encoding (not the grammar) makes the file unusable.
File.binwrite(File.join(tmpdir, "bad_encoding.rbs"),
"module Acme\n class Broken\n def size: () -> Integer\n end\nend\n# caf\xE9\n")
end

it "keeps the valid sigs and skips the invalid-encoding file before it reaches the parser" do
loader = described_class.new(signature_paths: [tmpdir])
allow(loader).to receive(:warn)
expect(loader.send(:env)).not_to be_nil
expect(loader.class_decl_paths["::Acme::Widget"]).to eq(File.join(tmpdir, "good.rbs"))
expect(loader.class_known?("Acme::Broken")).to be(false)
end

it "warns once, naming the file and Rigor's pre-parser note" do
loader = described_class.new(signature_paths: [tmpdir])
messages = []
allow(loader).to receive(:warn) { |msg| messages << msg }
3.times { loader.send(:env) }
expect(messages.size).to eq(1)
expect(messages.first).to include("QUARANTINED")
expect(messages.first).to include(File.join(tmpdir, "bad_encoding.rbs"))
expect(messages.first).to include("skipped before reaching the RBS parser")
end

it "skips an invalid-encoding virtual RBS contribution without pulling the env down" do
bad_virtual = ["virtual:test:/app/lib/bad.rb", "module VirtualBad\nend\n# caf\xE9\n".b.force_encoding(Encoding::UTF_8)]
clean_virtual = ["virtual:test:/app/lib/ok.rb", "module VirtualOk\nend\n"]
loader = described_class.new(signature_paths: [tmpdir], virtual_rbs: [bad_virtual, clean_virtual])
allow(loader).to receive(:warn)
expect(loader.send(:env)).not_to be_nil
expect(loader.class_known?("VirtualOk")).to be(true)
expect(loader.class_known?("VirtualBad")).to be(false)
end
end

describe "missing-namespace synthesis (ADR-5 robustness)" do
# A project sig set that declares qualified names without ever declaring the enclosing namespace is invalid upstream
# (`rbs validate` rejects it); pre-fix every method on every such class degraded to Dynamic[Top] because
Expand Down