From 625ac4a389600232003bf93c1b8a4f466a6c78b2 Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Sun, 13 Sep 2026 18:05:53 +0400 Subject: [PATCH 01/14] Keep the YARD cache out of version control .yardoc is a build artifact: `rake yard` regenerates it, and every doc build dirtied five tracked files, three of them binary. The cache also stores its own copy of every doc comment, so the repository carried a stale duplicate of the sources that no source grep would ever reach. Ignored now; the files stay on disk. --- .gitignore | 1 + .yardoc/checksums | 25 ------------------------- .yardoc/complete | 0 .yardoc/object_types | Bin 16720 -> 0 bytes .yardoc/objects/root.dat | Bin 246347 -> 0 bytes .yardoc/proxy_types | Bin 4 -> 0 bytes 6 files changed, 1 insertion(+), 25 deletions(-) delete mode 100644 .yardoc/checksums delete mode 100644 .yardoc/complete delete mode 100644 .yardoc/object_types delete mode 100644 .yardoc/objects/root.dat delete mode 100644 .yardoc/proxy_types diff --git a/.gitignore b/.gitignore index 68c0024..930def9 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ Gemfile.lock .DS_Store /doc/api/ +/.yardoc/ diff --git a/.yardoc/checksums b/.yardoc/checksums deleted file mode 100644 index 9837058..0000000 --- a/.yardoc/checksums +++ /dev/null @@ -1,25 +0,0 @@ -lib/audition.rb fffcc8c0ef406b910a2d818ba1e2c74154084be3 -lib/audition/cli.rb 9535b5b2b5916f0e3d2020d643b468372de68848 -lib/audition/fixer.rb 442b6a8d2f2c4334489883f5c5e4e853f517ff27 -lib/audition/config.rb 1e47c602d6541b80fd2d79440f2a2b988b9c3755 -lib/audition/report.rb 166bc8d4bc00734deb6ed64eabde9186aaea30da -lib/audition/target.rb 754dd75ebda7effe94792f048c0ec9f7776a9bb7 -lib/audition/finding.rb f54540fbf59b18a3133c80c8523a12e969028446 -lib/audition/version.rb 7c2cfcef5de6d89622aa767b63c366c41ea242c1 -lib/audition/baseline.rb ea5a9291b5b4fc765d0397691b777d41225dbff8 -lib/audition/rewriters.rb ddbdac282297621d0856d4be12cbeddfc6a672b2 -lib/audition/directives.rb b381dc9212b76aa295d48d59c0ae16b0fb6d3e7b -lib/audition/bundle_sweep.rb 0c605c06ba1cb4b4b50d1ffeb347747f23ec5f4b -lib/audition/static/checks.rb 6b19aaebb00f32d3ad85d12a9405d3ada1495dfc -lib/audition/dynamic/prober.rb 8581edcdb200c8e4dfbab242d0ef47fbab907d1d -lib/audition/dynamic/harness.rb 14170146c441c6077795bcf38c16b6003de8ff71 -lib/audition/static/analyzer.rb 4c4d5956a3eb4177d5e7f16053f60d0f28fef207 -lib/audition/static/checks/base.rb 6b6fbdd384da7009bbfad879f29f43d45c983764 -lib/audition/static/graph_audit.rb 9fcb7c8b6742f1cb6a7dd3e01109bb7547dc3faa -lib/audition/static/source_file.rb a793eef1f586ae4f01ef4a274613688ebbf58f91 -lib/audition/static/literal_classifier.rb b6ee1ba0267c1621c0f3350bc238ac98b8866e68 -lib/audition/static/checks/unsafe_calls.rb bd1aeda2049f361e8f33d55f2da605eb16f500d5 -lib/audition/static/checks/runtime_require.rb c52e6c71bf211baed188ec35a35d6f7456b9927b -lib/audition/static/checks/global_variables.rb 0f2c57e93ab2364f79c795ec1d8271be36ca9011 -lib/audition/static/checks/ractor_isolation.rb 2f5f0f49e67363cfa631a9ee606e21f95c577ca3 -lib/audition/static/checks/mutable_constants.rb d14f0ad56d9c421f76ad43ee747dfb167ca73e4b diff --git a/.yardoc/complete b/.yardoc/complete deleted file mode 100644 index e69de29..0000000 diff --git a/.yardoc/object_types b/.yardoc/object_types deleted file mode 100644 index 53f44740a8ed9063c3bd2a4654b24dce862c7c43..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16720 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 diff --git a/.yardoc/proxy_types b/.yardoc/proxy_types deleted file mode 100644 index beefda1ae32c2cef8eb53a4f3c8407a532a22b51..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4 LcmZSKsAd2F0U`j1 From 0f0c106d7f60fe4b64d009cc4322723f98280fa3 Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Sun, 13 Sep 2026 18:05:54 +0400 Subject: [PATCH 02/14] Narrate scan progress on stderr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A scan of a few thousand files went several seconds with nothing on screen, and stdout has to stay pipeable, so the narration goes to stderr: one line per phase, rewritten in place on a terminal, `◆ Audition checking 50/919 5% (0.2s, on 8 ractors)`. A phase running in Ractors says how many, next to the clock rather than the counts, because the counts move too fast to read. On above 200 files and for every bundle sweep, off for `--format json` and `--format github`, and `--progress` / `--no-progress` force either way. Progress::SILENT is the default everywhere, so a caller that wants nothing passes nothing. --- lib/audition/progress.rb | 418 +++++++++++++++++++++++++++++++++ spec/audition/progress_spec.rb | 236 +++++++++++++++++++ 2 files changed, 654 insertions(+) create mode 100644 lib/audition/progress.rb create mode 100644 spec/audition/progress_spec.rb diff --git a/lib/audition/progress.rb b/lib/audition/progress.rb new file mode 100644 index 0000000..9824682 --- /dev/null +++ b/lib/audition/progress.rb @@ -0,0 +1,418 @@ +# frozen_string_literal: true + +module Audition + # Narrates a scan phase by phase on stderr. This class tracks + # only where the scan has got to; what the narration looks + # like—a line rewritten in place, a line per phase, or nothing + # at all—belongs to the renderer. + class Progress + # Below this many files a scan ends before a reader could read + # the first redraw. + AUTO_THRESHOLD = 200 + + # Shared by the renderers: both write to a stream that may go + # away, both spell out counts and durations the same way, and + # both paint with the palette the report uses. + class Renderer + def self.terminal?(io) + io.respond_to?(:tty?) && io.tty? + rescue IOError + false + end + + # @param io [IO] + # @param style [Report::Style] defaults to whatever the + # stream supports, so a redirected run comes out plain + def initialize(io, style: nil) + @io = io + @style = style || Report::Style.detect(io: io) + @live = true + end + + def update(progress) = nil + + # A named unit is still just progress unless the renderer + # has a reason to treat it differently. + def item(progress) = update(progress) + + def phase_done(progress) = nil + + def clear = nil + + private + + def fraction(progress) + count = commas(progress.count) + total = progress.total + total ? "#{count}/#{commas(total)}" : count + end + + # Narration is cosmetic: a stream that has gone away ends it, + # never the scan. + def write(text) + return unless @live + + @io.write(text) + @io.flush if @io.respond_to?(:flush) + rescue IOError, SystemCallError + @live = false + end + + def seconds(value) + format("%.1fs", value) + end + + def commas(value) + value.to_s.reverse.scan(/\d{1,3}/).join(",").reverse + end + + # Named rather than dynamic dispatch, so the check for unsafe + # sends needs no exception here. + def paint(text, color) + case color + when :bold then @style.bold(text) + when :dim then @style.dim(text) + when :cyan then @style.cyan(text) + when :green then @style.green(text) + when :magenta then @style.magenta(text) + else text + end + end + end + + # One status line, rewritten in place for the whole scan and + # erased when it ends. + class Line < Renderer + # Redraws closer together than this are invisible and cost a + # syscall on every file. + REDRAW_INTERVAL = 0.1 + + # Stands in for the counter while a stage runs work that + # cannot be counted, so the line still moves. + SPINNER = %w[| / - \\].freeze + + DEFAULT_WIDTH = 80 + MIN_WIDTH = 24 + + def self.width + columns = ENV["COLUMNS"].to_i + (columns >= MIN_WIDTH) ? columns : DEFAULT_WIDTH + end + + # @param interval [Float] seconds between redraws within one + # stage; zero draws every update + def initialize(io, style: nil, interval: REDRAW_INTERVAL) + super(io, style: style) + @interval = interval + @width = self.class.width + @drawn = 0 + @heading = nil + @redrawn_at = 0.0 + @spin = 0 + @mutex = Mutex.new + @heartbeat = nil + end + + def update(progress) + if progress.countable? + stop_heartbeat + else + start_heartbeat(progress) + end + draw(progress) + end + + def clear + stop_heartbeat + @mutex.synchronize { erase } + end + + private + + # Work with nothing to count would leave the line frozen, + # which reads as a hang, so a thread keeps the clock and the + # spinner moving until something countable starts. + def start_heartbeat(progress) + return if @heartbeat + + @heartbeat = Thread.new do + loop do + sleep(REDRAW_INTERVAL) + @spin += 1 + draw(progress) + end + end + nil + end + + # `Mutex#synchronize` releases through `ensure`, so killing a + # drawing thread cannot leave the lock held. + def stop_heartbeat + @heartbeat&.kill + @heartbeat = nil + end + + # A new phase or stage is drawn at once; redraws within one + # are throttled. The scan clock doubles as the throttle + # clock, so nothing here keeps time of its own. + def draw(progress) + @mutex.synchronize do + heading = heading(progress) + now = progress.elapsed + fresh = heading != @heading + next if !fresh && now - @redrawn_at < @interval + + @heading = heading + @redrawn_at = now + render(status(progress, now)) + end + end + + def heading(progress) + [progress.label, progress.stage_label].compact.join(" ") + end + + # The phase carries the weight, the stage and the clock are + # secondary, and the one moving number gets the accent color. + def status(progress, now) + [ + [@style.glyph(:section), :dim], + ["Audition", :bold], + [progress.label, :bold], + [progress.stage_label, :dim], + *measure(progress), + [aside(progress, now), :dim] + ].reject { |text, _| text.nil? || text.empty? } + end + + def measure(progress) + return [[spinner, :magenta]] unless progress.countable? + + cells = [[fraction(progress), :cyan]] + percent = progress.percent + cells << ["#{percent}%", :green] if percent + cells + end + + # How the run is going rather than what it is scanning, kept + # apart from the counts so neither reads as the other. + def aside(progress, now) + count = progress.ractors + on = count ? ", on #{count} ractors" : "" + "(#{seconds(now)}#{on})" + end + + def spinner + SPINNER[@spin % SPINNER.size] + end + + # Escape sequences make a painted string's own length useless, + # so the plain text is what gets measured: padded to the + # previous width so a shorter status leaves no tail behind, + # and one column short of the edge so the cursor never wraps. + # A line too long to fit is trimmed unpainted, since a cut + # through an escape sequence would corrupt the terminal. + def render(cells) + plain = cells.map(&:first).join(" ") + if plain.length > @width - 1 + plain = plain[0, @width - 1] + text = plain + else + text = cells.map { |cell| paint(*cell) }.join(" ") + end + write("\r#{text}#{" " * [@drawn - plain.length, 0].max}\r") + @drawn = plain.length + end + + def erase + return unless @drawn.positive? + + write("\r#{" " * @drawn}\r") + @drawn = 0 + end + end + + # One completion line per phase. A log is the only trace a + # non-interactive run leaves, and a rewritten line would fill + # it with control characters. + class Log < Renderer + # A unit worth naming is worth a line of its own, since + # nothing here rewrites what came before. + def item(progress) + write( + "#{paint("Audition", :bold)}: #{progress.label} " \ + "#{paint(progress.stage_label.to_s, :bold)} " \ + "#{paint("(#{fraction(progress)})", :dim)}\n" + ) + end + + # Phases that walk the tree more than once report a duration + # and no count: each of their stage counts is a fraction of + # the work. + def phase_done(progress) + total = progress.phase_total + scanned = total ? ": #{commas(total)} #{progress.unit}" : "" + count = progress.ractors + on = count ? " on #{count} ractors" : "" + write( + "#{paint("Audition", :bold)}: " \ + "#{paint(progress.label, :bold)}#{scanned} in " \ + "#{paint(seconds(progress.phase_elapsed), :dim)}#{on}\n" + ) + end + end + + class << self + # The one clock in play: the renderers throttle and animate + # against the scan's own elapsed time rather than keeping + # time of their own. + def now + Process.clock_gettime(Process::CLOCK_MONOTONIC) + end + + # @param units [Integer, nil] work items ahead; nil for work + # slow enough to narrate whatever its size, such as a + # sweep, where every unit is a scan of its own + # @param wanted [Boolean, nil] --progress or --no-progress; + # nil defers to the unit count, the format and the stream + # @param format [Symbol] machine formats are never narrated + # @param io [IO] never the stream carrying the report + # @param style [Report::Style, nil] nil detects from the + # stream; --plain passes a plain one + # @return [Progress] + def for(units: nil, wanted: nil, format: :text, io: $stderr, + style: nil) + renderer = renderer_for(units, wanted, format, io, style) + renderer ? new(renderer: renderer) : SILENT + end + + private + + def renderer_for(units, wanted, format, io, style) + return nil if wanted == false + + terminal = Renderer.terminal?(io) + auto = terminal && format == :text && + (units.nil? || units >= AUTO_THRESHOLD) + return nil unless wanted || auto + + klass = terminal ? Line : Log + klass.new(io, style: style) + end + end + + attr_reader :label, :stage_label, :count, :total, :renderer + + # What the phase as a whole covers; its stages divide that up. + attr_reader :phase_total, :unit + + # How many Ractors the phase is running on, nil while it is + # serial. + attr_reader :ractors + + # @param renderer [Renderer, nil] nil narrates nothing + def initialize(renderer:) + @renderer = renderer + @count = 0 + @started = self.class.now + @phase_started = @started + end + + # Handed to analysis entry points as their default, so none of + # them has to ask whether narration is wanted. Shareable, so a + # worker can hold it, and the guards below keep it that way. + SILENT = Ractor.make_shareable(new(renderer: nil)) + + def enabled? + !@renderer.nil? + end + + # Narrates one named phase for the duration of the block. + # + # @param label [String] phase name shown to the reader + # @param total [Integer, nil] units expected, nil when unknown + # @param unit [String] what the total counts + def phase(label, total: nil, unit: "files") + return yield self unless @renderer + + @label = label + @phase_total = total + @unit = unit + @phase_started = self.class.now + @ractors = nil + restart(nil, total) + yield self + ensure + @renderer&.phase_done(self) + end + + # Set by whoever spawns the workers, so the narration can say + # how much of the machine is at work. The guard keeps {SILENT} + # frozen and so shareable. + # + # @param count [Integer, nil] nil for serial work + def ractors=(count) + @ractors = count if @renderer + end + + # Renames the work within the current phase and restarts its + # count. A nil total marks a step whose length is not known + # until it ends, which the renderer is then free to animate. + def stage(label, total: nil) + restart(label, total) if @renderer + end + + def tick(count = 1) + return unless @renderer + + @count += count + @renderer.update(self) + end + + # Counts one unit and names it, for phases whose units are few + # enough to name. The name takes the stage slot, so nothing in + # the renderers has to make room for it. + def item(label) + return unless @renderer + + @stage_label = label + @count += 1 + @renderer.item(self) + end + + # Clears whatever the narration left behind. + def finish + @renderer&.clear + end + + # Seconds since the scan began. + def elapsed + self.class.now - @started + end + + # Seconds since the current phase began. + def phase_elapsed + self.class.now - @phase_started + end + + # @return [Integer, nil] 0..100, nil when nothing bounds it + def percent + return nil unless @total&.positive? + + (100 * @count / @total).clamp(0, 100) + end + + def countable? + !@total.nil? || @count.positive? + end + + private + + def restart(stage_label, total) + @stage_label = stage_label + @total = total + @count = 0 + @renderer.update(self) + end + end +end diff --git a/spec/audition/progress_spec.rb b/spec/audition/progress_spec.rb new file mode 100644 index 0000000..07f05e9 --- /dev/null +++ b/spec/audition/progress_spec.rb @@ -0,0 +1,236 @@ +# frozen_string_literal: true + +require "stringio" + +RSpec.describe Audition::Progress do + let(:plain) do + Audition::Report::Style.new(color: false, hyperlinks: false) + end + + def terminal + io = StringIO.new + allow(io).to receive(:tty?).and_return(true) + io + end + + # Zero interval so every update leaves a frame to assert on. + def line(io) + described_class.new( + renderer: described_class::Line.new(io, style: plain, + interval: 0) + ) + end + + def log(io) + described_class.new( + renderer: described_class::Log.new(io, style: plain) + ) + end + + describe ".for" do + it "stays silent below the auto threshold" do + expect(described_class.for(units: 10, io: terminal)) + .not_to be_enabled + end + + it "stays silent when the stream is not a terminal" do + expect(described_class.for(units: 10_000, io: StringIO.new)) + .not_to be_enabled + end + + it "stays silent for machine-readable formats" do + io = terminal + + %i[json github].each do |format| + expect(described_class.for(units: 10_000, format: format, + io: io)).not_to be_enabled + end + end + + it "narrates a large tree on a terminal" do + expect(described_class.for(units: 10_000, io: terminal)) + .to be_enabled + end + + it "honors --progress on a small tree and a plain stream" do + expect(described_class.for(units: 1, wanted: true, + io: StringIO.new)).to be_enabled + end + + it "honors --no-progress on a large tree" do + expect(described_class.for(units: 10_000, wanted: false, + io: terminal)).not_to be_enabled + end + + it "rewrites a line on a terminal and logs off one" do + expect(described_class.for(units: 1, wanted: true, + io: terminal).renderer).to be_a(described_class::Line) + expect(described_class.for(units: 1, wanted: true, + io: StringIO.new).renderer).to be_a(described_class::Log) + end + end + + describe "the status line" do + it "rewrites one line and erases it at the end" do + io = StringIO.new + progress = line(io) + + progress.phase("checking", total: 4) { |p| p.tick(2) } + progress.finish + + frames = io.string.split("\r").reject(&:empty?) + expect(frames.first).to include("Audition checking 0/4 0%") + expect(io.string).not_to include("\n") + expect(frames.last.strip).to be_empty + end + + it "names the stage inside a phase" do + io = StringIO.new + + line(io).phase("gem calls") do |p| + p.stage("scanning", total: 10) + p.tick(5) + end + + expect(io.string).to include("gem calls scanning 5/10 50%") + end + + it "says how many Ractors the phase is running on" do + io = StringIO.new + + line(io).phase("checking", total: 10) do |p| + p.ractors = 8 + p.tick(5) + end + + expect(io.string) + .to match(/checking 5\/10 50% \(\d+\.\ds, on 8 ractors\)/) + end + + it "claims no Ractors while the work is serial" do + io = StringIO.new + + progress = line(io) + progress.phase("checking", total: 2) { |p| p.ractors = 4 } + progress.phase("graph", total: 2) { |p| p.tick } + + expect(io.string.split("graph").last).not_to include("ractors") + end + + it "spins when there is nothing to count" do + io = StringIO.new + + line(io).phase("graph") { |p| p.stage("resolving") } + + expect(io.string).to match(%r{graph resolving [|/\\-] \(\d+\.\ds\)}) + end + + it "clamps a count that overruns its total" do + io = StringIO.new + + line(io).phase("checking", total: 2) { |p| p.tick(50) } + + expect(io.string).to include(" 100% (") + end + + it "keeps the visible width inside the terminal" do + io = StringIO.new + progress = nil + with_columns("40") { progress = line(io) } + + progress.phase("a phase with a very long name indeed", + total: 1_000_000) { |p| p.tick } + + widest = io.string.split("\r").map(&:length).max + expect(widest).to be <= 39 + end + + it "survives a stream that goes away" do + io = StringIO.new + progress = line(io) + allow(io).to receive(:write).and_raise(IOError) + + expect { progress.phase("checking", total: 1, &:tick) } + .not_to raise_error + end + + def with_columns(value) + previous = ENV["COLUMNS"] + ENV["COLUMNS"] = value + yield + ensure + ENV["COLUMNS"] = previous + end + end + + describe "named units" do + it "counts and names without restarting the count" do + io = StringIO.new + + line(io).phase("sweep", total: 2, unit: "gems") do |p| + p.item("pastel") + p.item("tty-link") + end + + expect(io.string).to include("sweep tty-link 2/2 100%") + end + end + + describe "the log" do + it "writes one completion line per phase" do + io = StringIO.new + progress = log(io) + + progress.phase("learning", total: 30) { |p| p.tick } + progress.phase("graph") { |p| p.stage("resolving") } + progress.finish + + lines = io.string.lines.map(&:chomp) + expect(lines.first).to match(/\AAudition: learning: 30 files in/) + expect(lines.last).to match(/\AAudition: graph in \d+\.\ds\z/) + expect(io.string).not_to include("\r") + end + + it "reports the Ractor count a phase ran on" do + io = StringIO.new + + log(io).phase("checking", total: 30) { |p| p.ractors = 8 } + + expect(io.string.lines.last.chomp).to match( + /\AAudition: checking: 30 files in \d+\.\ds on 8 ractors\z/ + ) + end + + it "writes a line per named unit and counts the unit" do + io = StringIO.new + + log(io).phase("sweep", total: 2, unit: "gems") do |p| + p.item("pastel") + end + + lines = io.string.lines.map(&:chomp) + expect(lines.first).to eq("Audition: sweep pastel (1/2)") + expect(lines.last).to match(/\AAudition: sweep: 2 gems in/) + end + end + + describe "when silent" do + it "yields itself and absorbs every call" do + silent = described_class::SILENT + + expect(silent).not_to be_enabled + expect(silent.phase("x", total: 2) { |p| p.tick || 7 }).to eq(7) + expect(silent.count).to eq(0) + expect { silent.stage("y", total: 1) }.not_to raise_error + expect { silent.ractors = 4 }.not_to raise_error + expect(silent.ractors).to be_nil + expect { silent.finish }.not_to raise_error + end + + # The default every analysis entry point holds, so a worker + # can be handed one. + it "is shareable" do + expect(Ractor.shareable?(described_class::SILENT)).to be(true) + end + end +end From b64ea8ead1cfccffab1e89f603a25c6924c397e6 Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Sun, 13 Sep 2026 18:05:54 +0400 Subject: [PATCH 03/14] Deal scan files onto Ractors largest-first Contiguous slices put neighbouring files on one worker, and neighbours in a tree are alike in size, so one worker could draw a slice weighing several times the mean and still be running once the rest had finished. Files are now dealt heaviest-first onto whichever worker is carrying least (longest-processing-time-first, which finishes within 4/3 of an optimal split). 2,500 files went 2.3s to 1.1s on 8 workers. The worker count is every core the Ractor pool can actually run, `Etc.nprocessors` capped by `RUBY_MAX_CPU`, where it was one less than the core count: Ractors past that cap add no parallelism. `-j` / `--workers` still overrides. --- lib/audition/static/analyzer.rb | 103 ++++++++++++++++++++---- lib/audition/static/work_split.rb | 54 +++++++++++++ spec/audition/static/analyzer_spec.rb | 60 +++++++++++++- spec/audition/static/work_split_spec.rb | 68 ++++++++++++++++ 4 files changed, 265 insertions(+), 20 deletions(-) create mode 100644 lib/audition/static/work_split.rb create mode 100644 spec/audition/static/work_split_spec.rb diff --git a/lib/audition/static/analyzer.rb b/lib/audition/static/analyzer.rb index d59e416..a7f7c71 100644 --- a/lib/audition/static/analyzer.rb +++ b/lib/audition/static/analyzer.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require "etc" +require_relative "work_split" module Audition module Static @@ -27,6 +27,10 @@ def analyze_path(path) PARALLEL_THRESHOLD = 16 + # Workers report in batches: a port message per file would + # cost more than the redraw it feeds. + TICK_STRIDE = 25 + # Scans files across Ractors when there are enough of them to # be worth the spawn cost. Checks are plain shareable classes # with deeply frozen catalogs, findings copy back through @@ -34,47 +38,112 @@ def analyze_path(path) # serial path. # # @param paths [Array] files to analyze - # @param workers [Integer] Ractor count (defaults to - # processor count minus one) + # @param workers [Integer, nil] Ractor count (defaults to + # {#default_workers}) # @param threshold [Integer] minimum file count before # Ractors are used at all + # @param progress [Progress] ticked per file # @return [Array] - def analyze_paths(paths, workers: default_workers, - threshold: PARALLEL_THRESHOLD) + def analyze_paths(paths, workers: nil, + threshold: PARALLEL_THRESHOLD, progress: Progress::SILENT) + workers ||= default_workers if paths.size < threshold || workers <= 1 - return paths.flat_map { |path| analyze_path(path) } + return serial_analyze(paths, progress) end - parallel_analyze(paths, workers) + parallel_analyze(paths, workers, progress) rescue Ractor::Error => e # The silent fallback would otherwise mask a check that is # itself Ractor-hostile; surface it under -w. if $VERBOSE - warn "audition: parallel scan fell back to serial: " \ + warn "Audition: parallel scan fell back to serial: " \ "#{e.class}: #{e.message}" end - paths.flat_map { |path| analyze_path(path) } + progress.ractors = nil + serial_analyze(paths, progress) end private - def parallel_analyze(paths, workers) + def serial_analyze(paths, progress) + paths.flat_map do |path| + findings = analyze_path(path) + progress.tick + findings + end + end + + # A port carries counts out of the workers while they run: + # the alternative is a status line frozen for the whole + # parallel phase, which is most of a large scan. + def parallel_analyze(paths, workers, progress) experimental = Warning[:experimental] Warning[:experimental] = false - slice = (paths.size / workers.to_f).ceil checks = @checks - paths.each_slice(slice).map do |chunk| - Ractor.new(chunk, checks) do |files, active_checks| - analyzer = Analyzer.new(checks: active_checks) - files.flat_map { |file| analyzer.analyze_path(file) } + port = progress.enabled? ? Ractor::Port.new : nil + chunks = balanced_chunks(paths, workers) + progress.ractors = chunks.size + ractors = chunks.map do |chunk| + # The sentinel goes out through `ensure` so a worker that + # raises still releases the drain loop; the exception + # itself still surfaces from `Ractor#value`. + Ractor.new(chunk, checks, port) do |files, active, tap| + analyzer = Analyzer.new(checks: active) + pending = 0 + begin + files.flat_map do |file| + findings = analyzer.analyze_path(file) + pending += 1 + if tap && pending >= TICK_STRIDE + tap.send(pending) + pending = 0 + end + findings + end + ensure + tap&.send(pending) + tap&.send(:done) + end end - end.flat_map(&:value) + end + drain(port, ractors.size, progress) if port + ractors.flat_map(&:value) ensure Warning[:experimental] = experimental end + def drain(port, workers, progress) + done = 0 + while done < workers + message = port.receive + if message == :done + done += 1 + else + progress.tick(message) + end + end + rescue + # Narration is cosmetic; a closed port ends it quietly and + # `Ractor#value` still reports what went wrong. + nil + end + def default_workers - [Etc.nprocessors - 1, 1].max + WorkSplit.workers + end + + # Byte size stands in for parse cost, and the stat it costs + # is nothing beside the parse it schedules. + def balanced_chunks(paths, workers) + WorkSplit.chunks( + paths.map { |path| [path, file_size(path)] }, workers + ) + end + + def file_size(path) + File.size(path) + rescue SystemCallError + 0 end def analyze_file(file) diff --git a/lib/audition/static/work_split.rb b/lib/audition/static/work_split.rb new file mode 100644 index 0000000..ba81ec0 --- /dev/null +++ b/lib/audition/static/work_split.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "etc" + +module Audition + module Static + # How a scan is divided between Ractor workers. + module WorkSplit + # Ractors run on a fixed pool of native threads sized by + # RUBY_MAX_CPU, which only the environment can set. Spawning + # past it buys no parallelism and costs a Ractor each. + RACTOR_CPU_DEFAULT = 8 + + module_function + + # One worker per core the Ractor pool can actually run. The + # main Ractor only waits while workers scan, so no core is + # held back for it. + # + # @return [Integer] + def workers + Etc.nprocessors.clamp(1, ractor_cpu_limit) + end + + def ractor_cpu_limit + limit = ENV["RUBY_MAX_CPU"].to_i + limit.positive? ? limit : RACTOR_CPU_DEFAULT + end + + # Longest-processing-time-first: deal the heaviest item onto + # the lightest worker. Consecutive files are neighbors in the + # tree and so alike in size, so contiguous slices come out + # lopsided: one worker can draw a slice weighing several times + # the mean and still be running once the rest have finished. + # Greedy is enough here: LPT finishes within 4/3 of an + # optimal split. + # + # @param weighted [Array] `[item, weight]` pairs + # @param count [Integer] worker count + # @return [Array] one chunk of items per busy worker + def chunks(weighted, count) + chunks = Array.new(count) { [] } + loads = Array.new(count, 0) + weighted.sort_by { |item, weight| [-weight, item] } + .each do |item, weight| + lightest = loads.index(loads.min) + chunks[lightest] << item + loads[lightest] += weight + end + chunks.reject(&:empty?) + end + end + end +end diff --git a/spec/audition/static/analyzer_spec.rb b/spec/audition/static/analyzer_spec.rb index 07abb39..17e06e9 100644 --- a/spec/audition/static/analyzer_spec.rb +++ b/spec/audition/static/analyzer_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "tmpdir" +require "stringio" RSpec.describe Audition::Static::Analyzer do it "produces identical findings in serial and parallel" do @@ -18,12 +19,65 @@ # Called directly so a broken Ractor path raises instead of # being masked by the serial fallback. parallel = described_class.new.send( - :parallel_analyze, paths, 4 + :parallel_analyze, paths, 4, Audition::Progress::SILENT ) expect(serial.size).to eq(40) - expect(parallel.map { |f| [f.location, f.check] }) - .to eq(serial.map { |f| [f.location, f.check] }) + # Chunks are packed by size, so the concatenation order is + # the scheduler's; the report sorts before anyone reads it. + expect(parallel.map { |f| [f.location, f.check] }.sort) + .to eq(serial.map { |f| [f.location, f.check] }.sort) + end + end + + it "narrates the Ractor count it is scanning on" do + Dir.mktmpdir do |dir| + paths = 4.times.map do |i| + path = File.join(dir, "f#{i}.rb") + File.write(path, "X#{i} = {}\n") + path + end + progress = Audition::Progress.new( + renderer: Audition::Progress::Renderer.new(StringIO.new) + ) + + described_class.new.send( + :parallel_analyze, paths, 2, progress + ) + + expect(progress.ractors).to eq(2) + end + end + + # The split itself is WorkSplit's; this is the weight the + # Analyzer gives it. + describe "#balanced_chunks" do + it "weighs files by size and isolates the heaviest" do + Dir.mktmpdir do |dir| + heavy = File.join(dir, "heavy.rb") + File.write(heavy, "# padding\n" * 5_000) + light = 8.times.map do |i| + path = File.join(dir, "light#{i}.rb") + File.write(path, "X = #{i}\n") + path + end + + chunks = described_class.new.send( + :balanced_chunks, light + [heavy], 3 + ) + + expect(chunks.size).to eq(3) + expect(chunks.flatten).to match_array(light + [heavy]) + expect(chunks.find { |c| c.include?(heavy) }.size).to eq(1) + end + end + + it "weighs a file it cannot stat as nothing" do + chunks = described_class.new.send( + :balanced_chunks, ["/nonexistent/gone.rb"], 2 + ) + + expect(chunks).to eq([["/nonexistent/gone.rb"]]) end end end diff --git a/spec/audition/static/work_split_spec.rb b/spec/audition/static/work_split_spec.rb new file mode 100644 index 0000000..04f1f35 --- /dev/null +++ b/spec/audition/static/work_split_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +RSpec.describe Audition::Static::WorkSplit do + describe ".workers" do + def workers(nprocessors:, limit:) + allow(Etc).to receive(:nprocessors).and_return(nprocessors) + stub_const("ENV", ENV.to_h.merge("RUBY_MAX_CPU" => limit)) + described_class.workers + end + + it "uses every core the Ractor pool can run" do + expect(workers(nprocessors: 4, limit: nil)).to eq(4) + expect(workers(nprocessors: 32, limit: "24")).to eq(24) + end + + it "never spawns past the Ractor CPU limit" do + expect(workers(nprocessors: 32, limit: nil)).to eq(8) + expect(workers(nprocessors: 32, limit: "4")).to eq(4) + end + + it "always keeps one worker" do + expect(workers(nprocessors: 1, limit: "0")).to eq(1) + end + end + + describe ".chunks" do + it "keeps one heavy item from trailing the whole scan" do + light = 8.times.map { |i| ["light#{i}", 1] } + + chunks = described_class.chunks(light + [["heavy", 500]], 3) + + expect(chunks.size).to eq(3) + expect(chunks.flatten).to match_array( + light.map(&:first) + ["heavy"] + ) + expect(chunks.find { |c| c.include?("heavy") }.size).to eq(1) + end + + it "leaves no worker idle when items outnumber workers" do + weighted = 6.times.map { |i| ["f#{i}", i + 1] } + + chunks = described_class.chunks(weighted, 3) + + expect(chunks.map(&:size)).to all(be_positive) + end + + it "returns one chunk per item when workers outnumber items" do + chunks = described_class.chunks([["a", 1], ["b", 1]], 8) + + expect(chunks.map(&:size)).to eq([1, 1]) + end + + it "splits equal weights the same way every time" do + weighted = 10.times.map { |i| ["f#{i}", 10] } + + expect(described_class.chunks(weighted, 4)) + .to eq(described_class.chunks(weighted.shuffle, 4)) + end + + it "balances the load, not the item count" do + weighted = [["a", 30], ["b", 10], ["c", 10], ["d", 10]] + + chunks = described_class.chunks(weighted, 2) + + expect(chunks).to contain_exactly(["a"], %w[b c d]) + end + end +end From 6e0068a6fcf5fad60b01f1561f3b2c8579dfda53 Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Sun, 13 Sep 2026 18:05:55 +0400 Subject: [PATCH 04/14] Run the graph audit in Ractors and report its blind spots The four whole-tree walks ran serially after the per-file pass. They now run in the same Ractors above 100 files: each worker parses its slice once and keeps the trees, one round reports the class and module names its slice declares, and once those are merged a second round walks the same trees against them. 3,400 files went 2.4s to 1.2s on 8 workers, the whole phase 3.8s to 2.6s. Below the threshold the serial path still runs, and says so under `-w`. The same pass gained the `static-scan` check, for the walks' own blind spots. rubydex reports every expression it could not resolve; where the shape could hide what the class-state and constant walks look for, the hole is now a finding instead of a clean line: a singleton opened on a runtime receiver that writes class-level state, a runtime superclass or mixin argument, a constant assigned through an unresolved path. Only class and module bodies are read, so an `include` matcher in a spec stays silent, and a line another check already reported keeps its own finding. --- lib/audition/static/graph_audit.rb | 999 ++++++++++++++++++++++- spec/audition/static/graph_audit_spec.rb | 757 ++++++++++++++++- 2 files changed, 1745 insertions(+), 11 deletions(-) diff --git a/lib/audition/static/graph_audit.rb b/lib/audition/static/graph_audit.rb index 855e8d2..11cc387 100644 --- a/lib/audition/static/graph_audit.rb +++ b/lib/audition/static/graph_audit.rb @@ -2,6 +2,7 @@ require "rubydex" require_relative "../rewriters" +require_relative "work_split" module Audition module Static @@ -65,40 +66,172 @@ class GraphAudit "containers) before spawning Ractors. Configuration " \ "that cannot be shareable needs per-Ractor state or a " \ "main-Ractor proxy instead." + DERIVED_WHY = + "The value comes from another constant whose own " \ + "definition is already flagged: freezing this one is " \ + "shallow and does not change what the referent holds, " \ + "so a non-main Ractor reading it can raise the same " \ + "Ractor::IsolationError." + DERIVED_FIX = + "Make the referenced constant deeply shareable first; " \ + "this finding follows its verdict." + CONTAINED_WHY = + "The assigned expression is flagged on this same line: " \ + "the constant holds whatever unshareable value that " \ + "expression produces, so a non-main Ractor reading the " \ + "constant hits the same problem." + SINGLETON_SCAN_WHY = + "The receiver is a runtime value, so the graph cannot " \ + "attribute the state this body writes to any class. If " \ + "the receiver is one, these are class-level instance " \ + "variables and a non-main Ractor raises " \ + "Ractor::IsolationError writing them." + SINGLETON_SCAN_FIX = + "Open the singleton on the constant itself so the state " \ + "resolves, or confirm the receiver with the dynamic " \ + "probe, which reads the live object graph." + ANCESTOR_SCAN_WHY = + "The ancestor is a runtime value, so whatever it " \ + "contributes—class-level state, class variables, " \ + "hostile APIs—is invisible here. A clean report for " \ + "this class covers only what the class itself declares." + ANCESTOR_SCAN_FIX = + "Name the superclass or module directly where the " \ + "hierarchy is static. Where it cannot be, audit the " \ + "candidates separately; the dynamic probe sweeps the " \ + "ancestors that are actually in play." + CONSTANT_SCAN_WHY = + "The path starts from a runtime value, so the constant " \ + "this assignment defines holds something the shareability " \ + "checks never saw." + CONSTANT_SCAN_FIX = + "Reference the constant by its static path, or let the " \ + "dynamic probe check the value with Ractor.shareable?." + + # Findings from these per-file checks seed the derived- + # constant propagation. + PROPAGATED_CHECKS = [ + "mutable-constants", "unshareable-reads", "native-gem-calls" + ].freeze + + # Checks that flag an expression rather than the constant + # it may be assigned to; when such a finding sits inside a + # constant assignment, the constant inherits it by name. + EXPRESSION_CHECKS = + ["unshareable-reads", "native-gem-calls"].freeze + + # The expressions rubydex reports as unresolvable. Each is a + # hole in the walks above, so where the shape could hide + # something they look for, the hole is reported. rubydex's + # parse rules are left out—the per-file syntax check already + # reports those—as are its visibility rules, which say + # nothing about Ractors. + SCAN_RULES = { + "DynamicSingletonDefinition" => :singleton, + "DynamicAncestor" => :ancestor, + "DynamicConstantReference" => :constant + }.freeze + + MIXINS = ["include", "extend", "prepend"].freeze + + STATE_WRITES = [ + Prism::InstanceVariableWriteNode, + Prism::InstanceVariableOrWriteNode, + Prism::InstanceVariableAndWriteNode, + Prism::InstanceVariableOperatorWriteNode, + Prism::ClassVariableWriteNode, + Prism::ClassVariableOrWriteNode, + Prism::ClassVariableAndWriteNode, + Prism::ClassVariableOperatorWriteNode + ].freeze # @param sources [Hash{String => String}] path => source + # @param constant_findings [Array] per-file findings + # whose definition sites seed derived-constant propagation + # @param progress [Progress] # @return [Array] - def analyze_sources(sources) + def analyze_sources(sources, constant_findings: [], + workers: nil, progress: Progress::SILENT) graph = Rubydex::Graph.new + progress.stage("indexing", total: sources.size) sources.each do |path, code| + progress.tick graph.index_source(path, code, "ruby") end @sources = sources + @workers = workers @frozen_memos = frozen_memo_map(sources) - audit(graph) + @reported_lines = constant_findings + .map { |f| [f.path, f.line] }.to_set + @constant_findings = constant_findings.select do |f| + PROPAGATED_CHECKS.include?(f.check) + end + audit(graph, progress) end # rubydex's index_all descends directories but skips bare file # lists, so files are fed through index_source individually. # # @param paths [Array] files to index and audit + # @param constant_findings [Array] see + # {#analyze_sources} + # @param progress [Progress] # @return [Array] - def analyze_paths(paths) + def analyze_paths(paths, constant_findings: [], workers: nil, + progress: Progress::SILENT) sources = {} + progress.stage("reading", total: paths.size) paths.each do |path| + progress.tick sources[path] = File.read(path) rescue SystemCallError next end - analyze_sources(sources) + analyze_sources(sources, constant_findings: constant_findings, + workers: workers, progress: progress) + end + + # Names one slice of a scan declares, for a parallel audit to + # merge before any walk emits. + # + # @api private + # @param sources [Hash{String => String}] path => source + # @return [Hash{Symbol => Object}] + def gathered_names(sources) + @sources = sources + {writers: declared_writers, declared: declared_names, + extended: extended_names} + end + + # The class-state walks over one slice, against names + # gathered from the whole scan. + # + # @api private + # @param sources [Hash{String => String}] path => source + # @param seen [Set] path/line pairs a declaration claimed + # @param names [Hash] from {#gathered_names}, merged + # @return [Array>] one batch per walk + def walk_batches(sources, seen, names) + @sources = sources + [singleton_attr_findings, + extended_module_findings(seen, names[:extended]), + dynamic_ivar_findings(seen, names[:declared]), + attribute_write_findings(seen, names[:writers])] end private - def audit(graph) + # Resolution is one opaque call inside rubydex; everything + # after it walks declarations rather than files, so the stage + # names say which pass a waiting reader is watching. + def audit(graph, progress = Progress::SILENT) + progress.stage("resolving") graph.resolve findings = [] - graph.declarations.each do |decl| + declarations = graph.declarations + progress.stage("declarations", total: declarations.size) + declarations.each do |decl| + progress.tick case decl when Rubydex::ClassVariable findings.concat(class_variable_findings(decl)) @@ -106,9 +239,491 @@ def audit(graph) findings.concat(class_state_findings(decl)) end end + seen = findings.map { |f| [f.path, f.line] }.to_set + class_state_batches(seen, progress).each do |batch| + findings.concat(batch) + batch.each { |f| seen << [f.path, f.line] } + end + progress.stage("constants") + findings.concat(derived_constant_findings(graph)) + progress.stage("diagnostics") + findings.concat(static_scan_findings(graph, findings)) findings.sort_by { |f| [f.path, f.line] } end + # rubydex says which expressions it could not resolve; this + # turns the ones that matter into findings, so a blind spot + # reads as a blind spot rather than as a clean line. A + # diagnostic is dropped where something else already reported + # that line, and where the shape cannot hide what the walks + # look for. + def static_scan_findings(graph, found) + reported = @reported_lines + + found.map { |f| [f.path, f.line] } + seen = Set.new + graph.diagnostics.filter_map do |diagnostic| + kind = SCAN_RULES[diagnostic.rule.rule_name] + next unless kind + + path = path_from_uri(diagnostic.location.uri) + line = diagnostic.location.start_line + 1 + next unless seen.add?([path, line, kind]) + next if reported.include?([path, line]) + + scan_finding(kind, path, line) + end + end + + def scan_finding(kind, path, line) + root = diagnostic_root(path) + return nil unless root + + case kind + when :singleton then singleton_scan(root, path, line) + when :ancestor then ancestor_scan(root, path, line) + else constant_scan(root, path, line) + end + end + + # Only the files a diagnostic points at are parsed here. The + # walks may have run in Ractors, whose trees never reach this + # one, and the shapes are rare enough that re-reading the + # whole tree would cost more than the checks on it. + def diagnostic_root(path) + @diagnostic_roots ||= {} + return @diagnostic_roots[path] if @diagnostic_roots.key?(path) + + code = @sources[path] + file = code && SourceFile.new(source: code, path: path) + @diagnostic_roots[path] = file&.valid_syntax? ? file.root : nil + end + + # A runtime singleton target only matters when the body it + # opens writes state: that is what the graph could not + # attribute to a class. + def singleton_scan(root, path, line) + node = node_at(root, line) do |candidate| + candidate.is_a?(Prism::SingletonClassNode) || + (candidate.is_a?(Prism::DefNode) && candidate.receiver) + end + return nil unless node && state_writes?(node) + + scan(path, line, severity: :warning, + message: "class-level state behind an unresolved " \ + "singleton receiver", + why: SINGLETON_SCAN_WHY, fix: SINGLETON_SCAN_FIX) + end + + # A runtime superclass always defines a class. A runtime + # mixin argument only names an ancestor inside a class or + # module body: a matcher called `include` in a test reads the + # same way to the graph and has to stay silent. + def ancestor_scan(root, path, line) + klass = node_at(root, line) do |candidate| + candidate.is_a?(Prism::ClassNode) && candidate.superclass && + constant_slice(candidate.superclass).nil? + end + mixin = klass ? nil : body_mixins(path, root)[line] + return nil unless klass || mixin + + subject = + if klass + "superclass of #{klass.constant_path.slice}" + else + "#{mixin[0]} argument in #{mixin[1]}" + end + scan(path, line, severity: :warning, + message: "unresolved #{subject}", + why: ANCESTOR_SCAN_WHY, fix: ANCESTOR_SCAN_FIX) + end + + # A runtime constant path only matters where it defines a + # constant: elsewhere there is no name whose shareability + # anything would have checked. + def constant_scan(root, path, line) + name = constant_assigned_at(root, line) + return nil unless name + + scan(path, line, severity: :info, + message: "unresolved constant path in #{name}", + why: CONSTANT_SCAN_WHY, fix: CONSTANT_SCAN_FIX) + end + + def scan(path, line, severity:, message:, why:, fix:) + Finding.new( + check: "static-scan", + severity: severity, + message: message, + why: why, + fix: fix, + path: path, + line: line, + source: source_line(path, line) + ) + end + + def node_at(root, line) + queue = [root] + until queue.empty? + node = queue.shift + if node.location.start_line == line && yield(node) + return node + end + + queue.concat(node.compact_child_nodes) + end + nil + end + + # Nested classes and modules are skipped: their state is + # their own, and the graph resolves it. + def state_writes?(node) + queue = node.compact_child_nodes + until queue.empty? + child = queue.shift + return true if STATE_WRITES.include?(child.class) + next if child.is_a?(Prism::ClassNode) || + child.is_a?(Prism::ModuleNode) + + queue.concat(child.compact_child_nodes) + end + false + end + + def body_mixins(path, root) + @body_mixins ||= {} + @body_mixins[path] ||= collect_body_mixins(root) + end + + # Method and block bodies are skipped: a receiverless call in + # one is not a mixin into the enclosing class. + def collect_body_mixins(root) + found = {} + queue = root.compact_child_nodes.map { |node| [node, nil] } + until queue.empty? + node, owner = queue.shift + case node + when Prism::ClassNode, Prism::ModuleNode + owner = node.constant_path.slice + when Prism::DefNode, Prism::BlockNode, Prism::LambdaNode + next + when Prism::CallNode + record_mixin(found, node, owner) + end + queue.concat( + node.compact_child_nodes.map { |child| [child, owner] } + ) + end + found + end + + # Keyed by every line the call spans, since the diagnostic + # points at the argument rather than the call. + def record_mixin(found, node, owner) + return unless owner && node.receiver.nil? && + MIXINS.include?(node.name.to_s) + + location = node.location + (location.start_line..location.end_line).each do |line| + found[line] ||= [node.name.to_s, owner] + end + end + + def constant_assigned_at(root, line) + queue = [root] + until queue.empty? + node = queue.shift + location = node.location + next unless line + .between?(location.start_line, location.end_line) + + name = constant_target_name(node) + return name if name + + queue.concat(node.compact_child_nodes) + end + nil + end + + # The constant a node assigns, nil for anything else. + def constant_target_name(node) + case node + when Prism::ConstantWriteNode, Prism::ConstantOrWriteNode + node.name.to_s + when Prism::ConstantPathWriteNode, + Prism::ConstantPathOrWriteNode + node.target.location.slice + end + end + + # Below this many files the Ractor spawn and the copy of the + # sources cost more than the walks they divide. + PARALLEL_THRESHOLD = 100 + + # Every batch is computed against the declaration findings + # alone, so the dedup set only suppresses lines a declaration + # already claimed. Each walks every parsed source once, which + # is why they are named separately. + def class_state_batches(seen, progress) + workers = @workers || WorkSplit.workers + if @sources.size < PARALLEL_THRESHOLD || workers <= 1 + serial_batches(seen, progress) + else + parallel_batches(seen, progress, workers) + end + rescue Ractor::Error, Ractor::ClosedError => e + # The silent fallback would otherwise mask a walk that is + # itself Ractor-hostile; surface it under -w. ClosedError + # is not a Ractor::Error: it is what sending to a worker + # that already died raises. + if $VERBOSE + warn "Audition: parallel graph audit fell back to " \ + "serial: #{e.class}: #{e.message}" + end + progress.ractors = nil + serial_batches(seen, progress) + end + + def serial_batches(seen, progress) + progress.stage("parsing", total: @sources.size) + source_roots(progress) + progress.stage("singletons") + singletons = singleton_attr_findings + progress.stage("extends") + extended = extended_module_findings(seen) + progress.stage("ivars") + ivars = dynamic_ivar_findings(seen) + progress.stage("writers") + [singletons, extended, ivars, + attribute_write_findings(seen)] + end + + # A walk may only emit once every name the whole scan + # declares is known, so the workers run in two rounds: gather + # the names of a slice, then walk it against the merged set. + # Parsed trees cannot cross a Ractor boundary, so the workers + # stay alive between the rounds and keep theirs. + def parallel_batches(seen, progress, workers) + experimental = Warning[:experimental] + Warning[:experimental] = false + port = Ractor::Port.new + chunks = chunks_for(workers) + progress.ractors = chunks.size + progress.stage("parsing") + ractors = chunks.map { |paths| worker(paths, port) } + names = merge_names(gather(port, ractors)) + progress.stage("state") + claimed = seen.to_a + ractors.each { |ractor| ractor.send([claimed, names]) } + collect_batches(ractors) + ensure + Warning[:experimental] = experimental + end + + def chunks_for(workers) + WorkSplit.chunks( + @sources.map { |path, code| [path, code.bytesize] }, workers + ) + end + + def worker(paths, port) + Ractor.new(slice(paths), port) do |sources, out| + audit = GraphAudit.new + begin + names = audit.gathered_names(sources) + ensure + # Sent from `ensure` so a gather that raises still + # releases the main Ractor; `Ractor#value` reports why. + out.send(names) + end + # Ruby cannot copy a Set whose elements are compound, so + # the claimed lines travel as pairs. + claimed, gathered = Ractor.receive + audit.walk_batches(sources, claimed.to_set, gathered) + end + end + + def gather(port, ractors) + parts = ractors.map { port.receive } + # nil stands in for a worker that died gathering; asking + # for its value raises what killed it. + ractors.each(&:value) if parts.any?(&:nil?) + parts + end + + # Each worker returns one batch per walk, in the order + # {#serial_batches} runs them; the batches concatenate walk + # by walk so the dedup a batch carries still holds. + def collect_batches(ractors) + ractors.map(&:value).transpose.map { |batch| batch.flatten(1) } + end + + def slice(paths) + paths.to_h { |path| [path, @sources[path]] } + end + + def merge_names(parts) + merged = {writers: {}, declared: Set.new, extended: Set.new} + parts.each do |part| + part[:writers].each do |owner, ivars| + (merged[:writers][owner] ||= Set.new).merge(ivars) + end + merged[:declared].merge(part[:declared]) + merged[:extended].merge(part[:extended]) + end + merged + end + + # A constant defined from another constant—an alias + # (`DEFAULT = PRIMARY`) or a frozen container of references + # (`ALL = [A, B].freeze`)—inherits the referent's problem, + # which the per-file classifier cannot see. The graph knows + # every reference, so flagged definitions propagate to any + # constant assignment that references them, transitively. + def derived_constant_findings(graph) + flagged = {} + @constant_findings.each do |f| + flagged[[f.path, f.line]] ||= f + end + return [] if flagged.empty? + + spans = assignment_spans + by_site = constant_declarations_by_site(graph) + results = [] + queue = flagged.keys.flat_map { |site| by_site[site] }.uniq + contained_findings(spans, flagged, results) do |site| + queue.concat(by_site[site]) + end + until queue.empty? + decl = queue.shift + source = flagged_source(decl, flagged) + next unless source + + decl.references.each do |ref| + path = path_from_uri(ref.location.uri) + line = ref.location.start_line + 1 + span = spans[path].find { |s| s[:lines].cover?(line) } + next unless span + + site = [path, span[:line]] + next if flagged.key?(site) + + finding = Finding.new( + check: "derived-constants", + severity: source.severity, + message: "constant #{span[:name]} references " \ + "#{decl.name}, itself flagged", + why: DERIVED_WHY, + fix: DERIVED_FIX, + path: path, + line: span[:line], + source: source_line(path, span[:line]) + ) + flagged[site] = finding + results << finding + queue.concat(by_site[site]) + end + end + results + end + + # An expression-check finding inside a constant assignment + # marks the constant itself: the assignment captures the + # flagged value under a name the graph can then follow. + def contained_findings(spans, flagged, results) + emitted = Set.new + flagged.to_a.each do |(path, line), seed| + next unless EXPRESSION_CHECKS.include?(seed.check) + + span = spans[path].find { |s| s[:lines].cover?(line) } + next unless span + + site = [path, span[:line]] + next unless emitted.add?(site) + + already = flagged[site] + next if already && !EXPRESSION_CHECKS.include?(already.check) + + finding = Finding.new( + check: "derived-constants", + severity: seed.severity, + message: "constant #{span[:name]} is assigned a " \ + "value flagged on this line", + why: CONTAINED_WHY, + fix: DERIVED_FIX, + path: path, + line: span[:line], + source: source_line(path, span[:line]) + ) + flagged[site] ||= finding + results << finding + yield site + end + end + + def constant_declarations_by_site(graph) + by_site = Hash.new { |h, k| h[k] = [] } + graph.declarations.each do |decl| + next unless constant_declaration?(decl) + + each_local_definition(decl).each do |defn| + site = [path_from_uri(defn.location.uri), + defn.location.start_line + 1] + by_site[site] << decl + end + end + by_site + end + + # rubydex leaves a declaration as a Todo when some reference + # keeps it from settling on a type. One with a constant name + # still carries definitions and references, and propagation + # already requires a flagged definition site plus references + # inside constant assignments, so it qualifies. + def constant_declaration?(decl) + case decl + when Rubydex::Constant, Rubydex::ConstantAlias + true + when Rubydex::Todo + decl.name.split("::").last&.match?(/\A[A-Z]/) || false + else + false + end + end + + def flagged_source(decl, flagged) + each_local_definition(decl).filter_map do |defn| + flagged[[path_from_uri(defn.location.uri), + defn.location.start_line + 1]] + end.first + end + + # Line spans of every constant assignment, so a reference + # landing inside one attributes to the constant it defines. + def assignment_spans + spans = Hash.new { |h, k| h[k] = [] } + @sources.each do |path, code| + file = SourceFile.new(source: code, path: path) + next unless file.valid_syntax? + + queue = [file.root] + until queue.empty? + node = queue.shift + queue.concat(node.child_nodes.compact) + name = constant_target_name(node) + next unless name + + location = node.location + spans[path] << { + lines: (location.start_line..location.end_line), + line: location.start_line, + name: name + } + end + end + spans + end + def class_variable_findings(decl) variable = decl.name.split("#").last owner = display_owner(decl.owner) @@ -164,6 +779,376 @@ def class_state_findings(decl) end end + # Parsed once: the passes below all walk every file. + def source_roots(progress = Progress::SILENT) + @source_roots ||= @sources.filter_map do |path, code| + progress.tick + file = SourceFile.new(source: code, path: path) + [path, file.root] if file.valid_syntax? + end.to_h + end + + # An attribute writer on a singleton class declares + # class-level state the same way an assignment does, but + # no line assigns the ivar, so the graph never sees it. + # A reader alone is left to the graph: whatever writes + # the ivar is already a declaration. + SINGLETON_ATTRS = ["attr_accessor", "attr_writer"].freeze + + def singleton_attr_findings + singleton_attrs.flat_map do |path, attrs| + attrs.map do |owner, ivar, line| + Finding.new( + check: "class-level-state", + severity: :error, + message: "class-level instance variable " \ + "@#{ivar} on #{owner}", + why: STATE_WHY, + fix: STATE_FIX, + path: path, + line: line, + source: source_line(path, line) + ) + end + end + end + + def singleton_attrs + @singleton_attrs ||= source_roots.to_h do |path, root| + attrs = [] + walk_singletons(root, [], attrs) + [path, attrs] + end + end + + # Assigning a declared singleton attribute writes the + # class-level instance variable behind it, which a non-main + # Ractor cannot do at all. The declaration is flagged where + # it sits; this is the assignment, which is a line of its + # own. Readers are left out: a read raises only on an + # unshareable value, and the declaration already says so. + def attribute_write_findings(seen, writers = declared_writers) + return [] if writers.empty? + + source_roots.flat_map do |path, root| + calls = [] + collect_writes(root, writers, calls) + calls.filter_map do |owner, name, line| + next if seen.include?([path, line]) + + Finding.new( + check: "class-level-state", + severity: :error, + message: "class-level instance variable " \ + "@#{name} on #{owner}", + why: STATE_WHY, + fix: STATE_FIX, + path: path, + line: line, + source: source_line(path, line) + ) + end + end + end + + # Attribute names by their owner's last segment: a write + # site and the declaration rarely spell the path the same + # way. + def declared_writers + writers = {} + singleton_attrs.each_value do |attrs| + attrs.each do |owner, ivar, _| + key = owner.split("::").last + (writers[key] ||= Set.new) << ivar + end + end + writers + end + + def collect_writes(node, writers, out) + if node.is_a?(Prism::CallNode) + name = node.name.to_s + owner = name.end_with?("=") && + constant_slice(node.receiver) + if owner && writers[owner.split("::").last] + &.include?(name.chomp("=")) + out << [owner, name.chomp("="), + node.location.start_line] + end + end + node.compact_child_nodes.each do |child| + collect_writes(child, writers, out) + end + end + + # Extending a module makes its instance methods run with + # a class as self, so the ivars they assign live on that + # class. Which class is not knowable from the module, so + # the finding lands on the assignment. + IVAR_WRITES = [ + Prism::InstanceVariableWriteNode, + Prism::InstanceVariableOrWriteNode, + Prism::InstanceVariableAndWriteNode, + Prism::InstanceVariableOperatorWriteNode + ].freeze + + def extended_module_findings(seen, names = extended_names) + return [] if names.empty? + + source_roots.flat_map do |path, root| + writes = [] + walk_extended(root, [], names, nil, writes) + writes.filter_map do |owner, ivar, line| + next if seen.include?([path, line]) + + Finding.new( + check: "class-level-state", + severity: :error, + message: "class-level instance variable " \ + "#{ivar} on #{owner}", + why: STATE_WHY, + fix: STATE_FIX, + path: path, + line: line, + source: source_line(path, line) + ) + end + end + end + + # Writing an instance variable through its name reaches + # the same class-level state an assignment does, and the + # graph indexes assignments only. Verified on Ruby 4.0: + # set and remove always raise in a non-main Ractor, while + # get raises only on an unshareable value, so it rides on + # the declaration the way a plain read does. + DYNAMIC_WRITES = Ractor.make_shareable( + Set.new(%i[instance_variable_set remove_instance_variable]) + ) + + # Ruby hands these hooks the class that triggered them. + CLASS_HOOKS = Ractor.make_shareable( + Set.new(%i[inherited included extended prepended]) + ) + + def dynamic_ivar_findings(seen, declared = declared_names) + source_roots.flat_map do |path, root| + writes = [] + walk_dynamic(root, Context.new([], false, false, {}), + declared, writes) + writes.filter_map do |owner, ivar, line| + next if seen.include?([path, line]) + + Finding.new( + check: "class-level-state", + severity: :error, + message: "class-level instance variable " \ + "#{ivar} on #{owner}", + why: STATE_WHY, + fix: STATE_FIX, + path: path, + line: line, + source: source_line(path, line) + ) + end + end + end + + # Every class and module the target declares, by last name + # segment: a receiver spelled one way at the call site and + # another at the definition still names the same thing. + def declared_names + names = Set.new + source_roots.each_value do |root| + collect_declared(root, names) + end + names + end + + def collect_declared(node, names) + case node + when Prism::ClassNode, Prism::ModuleNode + names << node.constant_path.slice.split("::").last + end + node.compact_child_nodes.each do |child| + collect_declared(child, names) + end + end + + # nesting: enclosing class and module names. + # singleton: whether self is a class or module here. + # sclass: whether a bare def here defines a class method. + # hooks: locals a class hook bound to a class. + Context = Struct.new(:nesting, :singleton, :sclass, :hooks) + + def walk_dynamic(node, context, declared, out) + context = descend_dynamic(node, context) + if node.is_a?(Prism::CallNode) + record_dynamic(node, context, declared, out) + end + node.compact_child_nodes.each do |child| + walk_dynamic(child, context, declared, out) + end + end + + # A class body has the class as self, but a bare def in + # one defines an instance method, where self is not. Only + # a receiver or an enclosing singleton class makes it a + # class method again. + def descend_dynamic(node, context) + case node + when Prism::ClassNode, Prism::ModuleNode + Context.new( + context.nesting + [node.constant_path.slice], + true, false, {} + ) + when Prism::SingletonClassNode + Context.new(context.nesting, true, true, context.hooks) + when Prism::DefNode + singleton = !node.receiver.nil? || context.sclass + Context.new(context.nesting, singleton, false, + singleton ? hook_locals(node) : {}) + else + context + end + end + + def hook_locals(node) + return {} unless CLASS_HOOKS.include?(node.name) + + first = node.parameters&.requireds&.first + return {} unless first.is_a?(Prism::RequiredParameterNode) + + {first.name => true} + end + + def record_dynamic(node, context, declared, out) + return unless DYNAMIC_WRITES.include?(node.name) + + name = node.arguments&.arguments&.first + return unless name.is_a?(Prism::SymbolNode) && + name.unescaped.start_with?("@") + + owner = dynamic_owner(node.receiver, context, declared) + return unless owner + + out << [owner, name.unescaped, node.location.start_line] + end + + # Only receivers that are a class or module for certain: + # self in a singleton, a constant the target declares, a + # hook's class argument, or anything's own class. + def dynamic_owner(receiver, context, declared) + case receiver + when nil, Prism::SelfNode + context.nesting.last if context.singleton + when Prism::ConstantReadNode, Prism::ConstantPathNode + name = constant_slice(receiver) + name if declared.include?(name.split("::").last) + when Prism::LocalVariableReadNode + receiver.slice if context.hooks[receiver.name] + when Prism::CallNode + receiver.slice if receiver.name == :class + end + end + + # A module reached through its last name segment: the + # extend site and the definition rarely spell the path + # the same way. + def extended_names + names = Set.new + source_roots.each_value { |root| collect_extends(root, names) } + names + end + + def collect_extends(node, names) + if node.is_a?(Prism::CallNode) && node.name == :extend + Array(node.arguments&.arguments).each do |arg| + name = constant_slice(arg) + names << name.split("::").last if name + end + end + node.compact_child_nodes.each { |c| collect_extends(c, names) } + end + + def constant_slice(node) + case node + when Prism::ConstantReadNode, Prism::ConstantPathNode + node.slice.delete_prefix("::") + end + end + + # A def with a receiver writes the module's own state, + # which the graph already owns; a nested class starts + # its own instance side. + def walk_extended(node, nesting, names, owner, out) + case node + when Prism::ModuleNode + name = node.constant_path.slice + nesting += [name] + owner = nesting.join("::") if + names.include?(name.split("::").last) + when Prism::ClassNode + nesting += [node.constant_path.slice] + owner = nil + when Prism::DefNode + return if node.receiver + else + if owner && IVAR_WRITES.any? { |k| node.is_a?(k) } + out << [owner, node.name.to_s, node.location.start_line] + end + end + node.compact_child_nodes.each do |child| + walk_extended(child, nesting, names, owner, out) + end + end + + # Tracks the lexical nesting so `class << self` resolves + # to the class it sits in, and `class << Name` to that name. + def walk_singletons(node, nesting, out) + case node + when Prism::ClassNode, Prism::ModuleNode + nesting += [node.constant_path.slice] + when Prism::SingletonClassNode + owner = singleton_owner(node, nesting) + collect_attrs(node.body, owner, out) if owner + end + node.compact_child_nodes.each do |child| + walk_singletons(child, nesting, out) + end + end + + def singleton_owner(node, nesting) + case node.expression + when Prism::SelfNode then nesting.last + when Prism::ConstantReadNode, Prism::ConstantPathNode + node.expression.slice + end + end + + # Only the body's own statements count: a nested def or + # block calling attr_accessor defines something else. + def collect_attrs(body, owner, out) + return unless body.is_a?(Prism::StatementsNode) + + body.body.each do |stmt| + next unless stmt.is_a?(Prism::CallNode) && + stmt.receiver.nil? && + SINGLETON_ATTRS.include?(stmt.name.to_s) + + Array(stmt.arguments&.arguments).each do |arg| + name = attr_name(arg) + out << [owner, name, stmt.location.start_line] if name + end + end + end + + def attr_name(node) + case node + when Prism::SymbolNode, Prism::StringNode then node.unescaped + end + end + def each_local_definition(decl) decl.definitions.reject do |defn| defn.location.uri.start_with?("rubydex:") @@ -353,7 +1338,7 @@ def frozen_memo_value?(value, classifier) end end - # "Payments::" reads as noise; show "Payments". + # "Widget::" reads as noise; show "Widget". # Nested singleton owners produce nested angle brackets, so # the strip repeats until the tail is gone. def display_owner(owner) diff --git a/spec/audition/static/graph_audit_spec.rb b/spec/audition/static/graph_audit_spec.rb index a5a9b02..5d98b2d 100644 --- a/spec/audition/static/graph_audit_spec.rb +++ b/spec/audition/static/graph_audit_spec.rb @@ -26,14 +26,14 @@ def bump = (@@count += 1) it "unifies class-level ivars across contexts and files" do findings = findings_for( "a.rb" => <<~RUBY, - class Payments + class Widget @registry = {} def self.reset! = (@registry = {}) - def pay = (@amount = 1) + def tag = (@label = 1) end RUBY "b.rb" => <<~RUBY - class Payments + class Widget class << self def prime = (@registry ||= {}) end @@ -47,7 +47,7 @@ def prime = (@registry ||= {}) ) expect(state).to all(have_attributes(severity: :error)) expect(state.first.message).to include("@registry") - expect(state.first.message).to include("Payments") + expect(state.first.message).to include("Widget") expect(state.first.fix).to include("store_if_absent") end @@ -179,4 +179,753 @@ def self.reset! = (@all = nil) expect(findings.first.message).to include("@config") expect(findings.first.message).to include("Settings") end + + describe "singleton attributes" do + it "flags a writable attribute on a singleton class" do + findings = findings_for( + "a.rb" => <<~RUBY + class Vault + class << self + attr_accessor :current + end + end + RUBY + ) + + expect(findings.size).to eq(1) + expect(findings.first).to have_attributes( + check: "class-level-state", line: 3, severity: :error + ) + expect(findings.first.message).to include("@current") + expect(findings.first.message).to include("Vault") + end + + it "names the class a reopened singleton belongs to" do + findings = findings_for( + "a.rb" => <<~RUBY + class << Vault + attr_writer :store, "backup" + end + RUBY + ) + + expect(findings.map(&:message)).to contain_exactly( + a_string_including("@store on Vault"), + a_string_including("@backup on Vault") + ) + end + + it "leaves a lone reader to the graph" do + findings = findings_for( + "a.rb" => <<~RUBY + class Vault + class << self + attr_reader :current + end + end + RUBY + ) + + expect(findings).to be_empty + end + + it "ignores an attribute on the instance side" do + findings = findings_for( + "a.rb" => "class Vault\n attr_accessor :name\nend\n" + ) + + expect(findings).to be_empty + end + + it "ignores an attribute call nested inside a def" do + findings = findings_for( + "a.rb" => <<~RUBY + class Vault + class << self + def install = attr_accessor(:current) + end + end + RUBY + ) + + expect(findings).to be_empty + end + + it "flags an assignment to a declared attribute" do + findings = findings_for( + "a.rb" => <<~RUBY, + class Vault + class << self + attr_accessor :current + end + end + RUBY + "b.rb" => "Vault.current = Vault.new\n" + ) + + expect(findings.map { |f| [f.path, f.line] }) + .to contain_exactly(["a.rb", 3], ["b.rb", 1]) + expect(findings.last.message).to include("@current on Vault") + end + + it "matches a write spelled through a longer path" do + findings = findings_for( + "a.rb" => <<~RUBY, + module Kit + class Vault + class << self + attr_writer :store + end + end + end + RUBY + "b.rb" => "Kit::Vault.store = {}\n" + ) + + expect(findings.map(&:path)).to contain_exactly("a.rb", "b.rb") + end + + it "leaves a read of a declared attribute alone" do + findings = findings_for( + "a.rb" => <<~RUBY, + class Vault + class << self + attr_accessor :current + end + end + RUBY + "b.rb" => "Vault.current\n" + ) + + expect(findings.map(&:path)).to contain_exactly("a.rb") + end + + it "leaves a writer nothing declares alone" do + findings = findings_for( + "a.rb" => "Vault.current = 1\n" + ) + + expect(findings).to be_empty + end + end + + describe "extended modules" do + it "flags an ivar written in a module something extends" do + findings = findings_for( + "a.rb" => <<~RUBY, + module Scoped + def scope(name) + @scope = name + end + end + RUBY + "b.rb" => "class Vault\n extend Scoped\nend\n" + ) + + expect(findings.size).to eq(1) + expect(findings.first).to have_attributes( + path: "a.rb", line: 3, severity: :error + ) + expect(findings.first.message).to include("@scope on Scoped") + end + + it "matches an extend written through a longer path" do + findings = findings_for( + "a.rb" => <<~RUBY, + module Kit + module Scoped + def scope(name) = (@scope = name) + end + end + RUBY + "b.rb" => "class Vault\n extend Kit::Scoped\nend\n" + ) + + expect(findings.map(&:message)).to contain_exactly( + a_string_including("@scope on Kit::Scoped") + ) + end + + it "leaves a module nothing extends alone" do + findings = findings_for( + "a.rb" => <<~RUBY, + module Plain + def plain(name) + @plain = name + end + end + RUBY + "b.rb" => "class Vault\n include Plain\nend\n" + ) + + expect(findings).to be_empty + end + + it "starts a nested class on its own instance side" do + findings = findings_for( + "a.rb" => <<~RUBY, + module Scoped + class Row + def scope(name) = (@scope = name) + end + end + RUBY + "b.rb" => "class Vault\n extend Scoped\nend\n" + ) + + expect(findings).to be_empty + end + end + + describe "dynamic instance variable writes" do + it "flags a write through a class hook's argument" do + findings = findings_for( + "a.rb" => <<~RUBY + class Widget + def self.inherited(subclass) + subclass.instance_variable_set(:@name, @name) + end + end + RUBY + ) + + expect(findings.size).to eq(1) + expect(findings.first).to have_attributes( + path: "a.rb", line: 3, severity: :error + ) + expect(findings.first.message).to include("@name on subclass") + end + + it "flags a removal on implicit self in a singleton" do + findings = findings_for( + "a.rb" => <<~RUBY + class Widget + def self.reset + remove_instance_variable(:@cache) + end + end + RUBY + ) + + expect(findings.map(&:message)).to contain_exactly( + a_string_including("@cache on Widget") + ) + end + + it "flags a write on a constant the target declares" do + findings = findings_for( + "a.rb" => "class Vault\nend\n", + "b.rb" => "Vault.instance_variable_set(:@store, {})\n" + ) + + expect(findings.map(&:path)).to contain_exactly("b.rb") + end + + it "leaves a constant the target never declares alone" do + findings = findings_for( + "a.rb" => "Absent::Thing.instance_variable_set(:@x, 1)\n" + ) + + expect(findings).to be_empty + end + + it "leaves a receiver it cannot prove is a class alone" do + findings = findings_for( + "a.rb" => <<~RUBY + class Widget + def self.touch(other) + other.instance_variable_set(:@x, 1) + end + end + RUBY + ) + + expect(findings).to be_empty + end + + it "reads a receiver's own class as a class" do + findings = findings_for( + "a.rb" => <<~RUBY + class Widget + def self.touch(other) + other.class.instance_variable_set(:@x, 1) + end + end + RUBY + ) + + expect(findings.map(&:line)).to contain_exactly(3) + end + + it "leaves the instance side alone" do + findings = findings_for( + "a.rb" => <<~RUBY + class Widget + def touch + instance_variable_set(:@x, 1) + end + end + RUBY + ) + + expect(findings).to be_empty + end + + it "leaves a name built at runtime alone" do + findings = findings_for( + "a.rb" => <<~RUBY + class Widget + def self.touch(key) + instance_variable_set("@\#{key}", 1) + end + end + RUBY + ) + + expect(findings).to be_empty + end + end + + describe "derived constants" do + def seed(path, line, severity: :error, + check: "mutable-constants") + Audition::Finding.new( + check: check, + severity: severity, + message: "constant seed", + why: "w", + fix: "f", + path: path, + line: line + ) + end + + def derived_for(sources, seeds) + described_class.new + .analyze_sources(sources, constant_findings: seeds) + .select { |f| f.check == "derived-constants" } + end + + it "flags an alias of a flagged constant" do + findings = derived_for( + {"a.rb" => "PRIMARY = Object.new\nDEFAULT = PRIMARY\n"}, + [seed("a.rb", 1)] + ) + + expect(findings.size).to eq(1) + expect(findings.first).to have_attributes( + path: "a.rb", line: 2, severity: :error + ) + expect(findings.first.message).to include("DEFAULT") + expect(findings.first.message).to include("PRIMARY") + end + + it "flags a frozen container holding a flagged constant" do + findings = derived_for( + {"a.rb" => <<~RUBY}, + module Registry + WIDGET = Object.new + ALL = [WIDGET].freeze + end + RUBY + [seed("a.rb", 2)] + ) + + expect(findings.map(&:line)).to contain_exactly(3) + expect(findings.first.message).to include("ALL") + end + + it "propagates through chains and across files" do + findings = derived_for( + { + "a.rb" => "module Config\n BASE = Object.new\nend\n", + "b.rb" => <<~RUBY + module Config + DEFAULT = BASE + FALLBACK = DEFAULT + end + RUBY + }, + [seed("a.rb", 2)] + ) + + expect(findings.map { |f| [f.path, f.line] }) + .to contain_exactly(["b.rb", 2], ["b.rb", 3]) + end + + it "propagates a declaration rubydex could not settle" do + findings = derived_for( + { + "a.rb" => <<~RUBY, + module Kit + module Ops + Fn = T.let(->(v) { v }, Proc) + end + end + RUBY + "b.rb" => <<~RUBY, + class KitTest + Short = Kit::Ops::Fn + def go + Short.call(1) + end + end + RUBY + "c.rb" => <<~RUBY + module Shared + def self.build(data, v) + {x: data.fetch(:x, Kit::Ops::Fn.call(v))} + end + end + RUBY + }, + [seed("a.rb", 3)] + ) + + expect(findings.map { |f| [f.path, f.line] }) + .to include(["b.rb", 2]) + end + + it "attributes a reference inside a multi-line assignment " \ + "to the assignment line" do + findings = derived_for( + {"a.rb" => <<~RUBY}, + GADGET = Object.new + TABLE = { + default: GADGET + }.freeze + RUBY + [seed("a.rb", 1)] + ) + + expect(findings.map(&:line)).to contain_exactly(2) + end + + it "names a constant assigned an expression another check " \ + "flagged" do + findings = derived_for( + {"a.rb" => "TOKEN = Vault::API_TOKEN\n"}, + [seed("a.rb", 1, severity: :warning, + check: "unshareable-reads")] + ) + + expect(findings.size).to eq(1) + expect(findings.first).to have_attributes( + line: 1, severity: :warning + ) + expect(findings.first.message).to include("constant TOKEN") + end + + it "attributes a contained seed to the assignment line and " \ + "follows the new name" do + findings = derived_for( + {"a.rb" => <<~RUBY}, + WIDGET = Kit.build( + Vault::API_TOKEN + ) + COPY = WIDGET + RUBY + [seed("a.rb", 2, check: "native-gem-calls")] + ) + + expect(findings.map(&:line)).to contain_exactly(1, 4) + expect(findings.first.message).to include("WIDGET") + end + + it "defers to a constant finding already at the line" do + findings = derived_for( + {"a.rb" => "TOKEN = Vault::API_TOKEN\n"}, + [seed("a.rb", 1), + seed("a.rb", 1, check: "unshareable-reads")] + ) + + expect(findings).to be_empty + end + + it "leaves expression findings outside assignments alone" do + findings = derived_for( + {"a.rb" => "def use = Vault::API_TOKEN\n"}, + [seed("a.rb", 1, check: "unshareable-reads")] + ) + + expect(findings).to be_empty + end + + it "skips sites the per-file pass already flagged" do + findings = derived_for( + {"a.rb" => "PRIMARY = Object.new\nCOPY = PRIMARY\n"}, + [seed("a.rb", 1), seed("a.rb", 2)] + ) + + expect(findings).to be_empty + end + + it "emits one finding per site and follows the seed severity" do + findings = derived_for( + {"a.rb" => <<~RUBY}, + FIRST = Object.new + SECOND = Object.new + BOTH = [FIRST, SECOND].freeze + RUBY + [seed("a.rb", 1, severity: :warning), + seed("a.rb", 2, severity: :warning)] + ) + + expect(findings.size).to eq(1) + expect(findings.first).to have_attributes( + line: 3, severity: :warning + ) + end + + it "leaves reads outside constant assignments alone" do + findings = derived_for( + {"a.rb" => "GADGET = Object.new\ndef use = GADGET\n"}, + [seed("a.rb", 1)] + ) + + expect(findings).to be_empty + end + + it "ignores findings from other checks" do + other = Audition::Finding.new( + check: "class-variables", + severity: :error, + message: "class variable @@x on A", + why: "w", + fix: "f", + path: "a.rb", + line: 1 + ) + findings = derived_for( + {"a.rb" => "PRIMARY = Object.new\nDEFAULT = PRIMARY\n"}, + [other] + ) + + expect(findings).to be_empty + end + end + + # The parallel path falls back to serial on any Ractor error, so + # only an explicit check keeps the walks—and everything they + # cross a Ractor boundary with—Ractor-safe. + describe "parallel walks" do + def wide_sources(count) + count.times.to_h do |i| + ["f#{i}.rb", <<~RUBY] + class Wide#{i} + @registry = {} + class << self + attr_accessor :cache + end + def self.prime = (@seen ||= []) + end + Wide#{i}.cache = {} + RUBY + end + end + + it "reports what the serial walks report" do + sources = wide_sources( + Audition::Static::GraphAudit::PARALLEL_THRESHOLD + 4 + ) + + serial = described_class.new + .analyze_sources(sources, workers: 1) + parallel = described_class.new + .analyze_sources(sources, workers: 4) + + expect(parallel.size).to be > sources.size + expect(parallel.map(&:location)).to eq(serial.map(&:location)) + expect(parallel.map(&:message)).to eq(serial.map(&:message)) + end + + it "runs the walks in Ractors rather than falling back" do + audit = described_class.new + expect(audit).not_to receive(:serial_batches) + + findings = audit.analyze_sources( + wide_sources( + Audition::Static::GraphAudit::PARALLEL_THRESHOLD + 4 + ), + workers: 4 + ) + + expect(findings).not_to be_empty + end + end + + describe "static scan" do + def scans_for(sources) + findings_for(sources).select { |f| f.check == "static-scan" } + end + + # Silence is the claim under test as much as the findings are: + # rubydex reports far more than matters here, and a check that + # fired on all of it would be unreadable. + it "flags class-level state behind a runtime receiver" do + scans = scans_for( + "a.rb" => <<~RUBY + target = Registry.lookup(:thing) + class << target + @cache = {} + end + RUBY + ) + + expect(scans.map(&:line)).to contain_exactly(2) + expect(scans.first).to have_attributes(severity: :warning) + expect(scans.first.message).to include("singleton receiver") + expect(scans.first.why).to include("Ractor::IsolationError") + end + + it "flags a def on a runtime receiver that writes state" do + scans = scans_for( + "a.rb" => <<~RUBY + obj = build + def obj.tweak = (@state = 1) + RUBY + ) + + expect(scans.map(&:line)).to contain_exactly(2) + end + + it "ignores a runtime singleton that writes no state" do + expect( + scans_for( + "a.rb" => <<~RUBY + target = Registry.lookup(:thing) + class << target + def pure(x) = x + 1 + end + RUBY + ) + ).to be_empty + end + + # A nested class owns its own state and the graph resolves it, + # so the unresolved receiver hides nothing. + it "ignores state a class nested in the singleton declares" do + expect( + scans_for( + "a.rb" => <<~RUBY + thing = pick + class << thing + class Inner + @own = {} + end + end + RUBY + ) + ).to be_empty + end + + it "flags an unresolved superclass" do + scans = scans_for( + "a.rb" => <<~RUBY + base = Struct.new(:a) + class Widget < base + end + RUBY + ) + + expect(scans.map(&:message)) + .to contain_exactly("unresolved superclass of Widget") + expect(scans.first).to have_attributes(severity: :warning) + end + + it "flags an unresolved mixin in a class body" do + scans = scans_for( + "a.rb" => <<~RUBY + class Widget + include Object.const_get(name) + extend Kernel.const_get("Helpers") + end + RUBY + ) + + expect(scans.map(&:message)).to contain_exactly( + "unresolved include argument in Widget", + "unresolved extend argument in Widget" + ) + end + + # RSpec's include matcher parses as a mixin with a runtime + # argument. It is the single loudest shape in a spec tree, so + # the check has to stay out of method and block bodies. + it "ignores a mixin-shaped call in a block" do + expect( + scans_for( + "a_spec.rb" => <<~RUBY + RSpec.describe Thing do + it "works" do + expect(subject.body).to include("substring") + end + end + RUBY + ) + ).to be_empty + end + + it "flags an unresolved path assigned to a constant" do + scans = scans_for( + "a.rb" => <<~RUBY + TABLE = mod::Lookup + RUBY + ) + + expect(scans.map(&:message)) + .to contain_exactly("unresolved constant path in TABLE") + expect(scans.first).to have_attributes(severity: :info) + end + + it "ignores an unresolved path outside an assignment" do + expect( + scans_for( + "a.rb" => <<~RUBY + def use = mod::Elsewhere + RUBY + ) + ).to be_empty + end + + # rubydex can report the same location twice. + it "reports one finding per location" do + expect( + scans_for( + "a.rb" => <<~RUBY + TABLE = mod::Lookup + RUBY + ).size + ).to eq(1) + end + + it "leaves a line another check already reported alone" do + sources = { + "a.rb" => <<~RUBY + base = Struct.new(:a) + class Widget < base + end + RUBY + } + claimed = Audition::Finding.new( + check: "mutable-constants", severity: :error, message: "x", + why: "x", fix: "x", path: "a.rb", line: 2 + ) + + expect( + described_class.new + .analyze_sources(sources, constant_findings: [claimed]) + .select { |f| f.check == "static-scan" } + ).to be_empty + end + + # The rule names are rubydex's public labels, and a rename + # would leave the check silently reporting nothing. + it "names rules rubydex still ships" do + shipped = Rubydex::Rules.constants.map(&:to_s) + + expect(shipped) + .to include(*described_class::SCAN_RULES.keys) + end + end end From c868ef208c44e14c04b7f1f9b1bac9f8bb456ef3 Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Sun, 13 Sep 2026 18:06:03 +0400 Subject: [PATCH 05/14] Add the native-gem-calls check A call into a C extension that never declared rb_ext_ractor_safe raises Ractor::UnsafeError, and the extension is outside the tree, so the call site is what can be flagged. Which gems count comes from the target's own Gemfile.lock, its installed extension binaries and its generated type stubs, never a gem list, and a platform build Audition cannot inspect is reported as unverified rather than assumed either way. Taint then follows the values: chained calls, locals and ivars, sig/T.let/T.cast types, case/when and is_a? narrowing, block element parameters, arguments into other methods, reopened classes, mixin bodies, and across files until nothing new is learned. Two checks come with it: dependency-class-state, for class-level state a stub attributes to a dependency, and unshareable-reads, for reads of a constant another gem froze nothing into. --- lib/audition.rb | 2 + lib/audition/static/checks.rb | 7 +- .../static/checks/dependency_class_state.rb | 160 ++ .../static/checks/unshareable_reads.rb | 259 +++ lib/audition/static/gem_calls.rb | 1770 +++++++++++++++++ .../checks/dependency_class_state_spec.rb | 132 ++ .../static/checks/unshareable_reads_spec.rb | 153 ++ spec/audition/static/gem_calls_spec.rb | 1364 +++++++++++++ 8 files changed, 3845 insertions(+), 2 deletions(-) create mode 100644 lib/audition/static/checks/dependency_class_state.rb create mode 100644 lib/audition/static/checks/unshareable_reads.rb create mode 100644 lib/audition/static/gem_calls.rb create mode 100644 spec/audition/static/checks/dependency_class_state_spec.rb create mode 100644 spec/audition/static/checks/unshareable_reads_spec.rb create mode 100644 spec/audition/static/gem_calls_spec.rb diff --git a/lib/audition.rb b/lib/audition.rb index 8810006..5288ab6 100644 --- a/lib/audition.rb +++ b/lib/audition.rb @@ -1,12 +1,14 @@ # frozen_string_literal: true require_relative "audition/version" +require_relative "audition/progress" require_relative "audition/finding" require_relative "audition/target" require_relative "audition/static/source_file" require_relative "audition/static/literal_classifier" require_relative "audition/static/checks" require_relative "audition/static/graph_audit" +require_relative "audition/static/gem_calls" require_relative "audition/static/native_extensions" require_relative "audition/static/analyzer" require_relative "audition/dynamic/prober" diff --git a/lib/audition/static/checks.rb b/lib/audition/static/checks.rb index c52778e..ba3630c 100644 --- a/lib/audition/static/checks.rb +++ b/lib/audition/static/checks.rb @@ -1,18 +1,21 @@ # frozen_string_literal: true require_relative "checks/base" +require_relative "checks/dependency_class_state" require_relative "checks/global_variables" require_relative "checks/mutable_constants" require_relative "checks/ractor_isolation" require_relative "checks/runtime_require" require_relative "checks/unsafe_calls" +require_relative "checks/unshareable_reads" module Audition module Static module Checks BUILT_IN = [ - GlobalVariables, MutableConstants, RactorIsolation, - RuntimeRequire, UnsafeCalls + DependencyClassState, GlobalVariables, MutableConstants, + RactorIsolation, RuntimeRequire, UnsafeCalls, + UnshareableReads ].freeze # Expression-level checks, run per file. Class variables and diff --git a/lib/audition/static/checks/dependency_class_state.rb b/lib/audition/static/checks/dependency_class_state.rb new file mode 100644 index 0000000..a9a5b28 --- /dev/null +++ b/lib/audition/static/checks/dependency_class_state.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +module Audition + module Static + module Checks + # Class-level state owned by a dependency. A gem's own source + # is outside the scanned tree, so the graph audit never sees + # the ivar and the call site is the only place left to flag. + # Generated type stubs in the target's tree record it: an + # attribute reader on a singleton class is a class-level ivar + # read by definition, and the generator marks those methods + # apart from ones that compute their answer. + class DependencyClassState < Base + explain :read, + severity: :warning, + message: "%{owner}.%{name} reads class-level state " \ + "owned by a dependency", + why: "The stub for %{owner} declares %{name} as an " \ + "attribute on its singleton class, so the call " \ + "returns a class-level instance variable. A " \ + "non-main Ractor raises Ractor::IsolationError " \ + "(\"can not get unshareable values from instance " \ + "variables of classes/modules\") unless whatever " \ + "the value happens to be is shareable.", + fix: "Read it once on the main Ractor and pass the " \ + "value in, or make the dependency's assignment " \ + "deeply shareable before any Ractor starts." + + explain :write, + severity: :error, + message: "%{owner}.%{name}= writes class-level state " \ + "owned by a dependency", + why: "Assigning an attribute on %{owner}'s singleton " \ + "class writes a class-level instance variable, " \ + "which a non-main Ractor cannot do at all: it " \ + "raises Ractor::IsolationError regardless of what " \ + "the value is.", + fix: "Configure the dependency at boot on the main " \ + "Ractor and leave it alone afterwards." + + # The generator documents a reader it generated from an + # attribute; a hand-written method of the same shape gets + # its own prose and stays quiet. + STUB_READER = /Returns the value of attribute (\w+)/ + STUB_SINGLETON = /\A\s*class\s+<<\s+self\b/ + STUB_SCOPE = /\A\s*(?:class|module)\s+([A-Za-z0-9_:]+)/ + STUB_DEF = /\A\s*def ([a-z_][A-Za-z0-9_]*)[(;]/ + STUB_COMMENT = /\A\s*#/ + + class << self + attr_reader :attributes + + # Owner name => set of attribute names, kept shareable + # for parallel scanning. + def attributes=(pairs) + grouped = pairs.group_by(&:first) + .transform_values { |v| v.map(&:last).uniq } + @attributes = Ractor.make_shareable( # audition:disable + grouped + ) + end + + # @param paths [Array] scanned files; only + # generated type stubs carry this evidence + # @return [void] + def learn(paths) + pairs = [] + paths.each do |path| + next unless path.end_with?(".rbi") + + read_stub(path, pairs) + end + self.attributes = pairs + end + + private + + # Stub indentation tracks nesting exactly, so the scope + # stack follows it rather than parsing the file. + def read_stub(path, pairs) + scope = [] + singleton = [] + indents = [] + documented = nil + File.foreach(path) do |raw| + line = raw.rstrip + next if line.empty? + + indent = line[/\A */].size + while indents.any? && indent <= indents.last + indents.pop + scope.pop + singleton.pop + end + documented = + step_stub(line, scope, singleton, indents, + documented, pairs) + end + rescue SystemCallError + nil + end + + def step_stub(line, scope, singleton, indents, + documented, pairs) + case line + when STUB_COMMENT + return STUB_READER.match(line)&.[](1) || documented + when STUB_SINGLETON + scope.push(scope.last) + singleton.push(true) + indents.push(line[/\A */].size) + when STUB_SCOPE + name = Regexp.last_match(1) + scope.push(scope.empty? ? name : "#{scope.last}::#{name}") + singleton.push(false) + indents.push(line[/\A */].size) + when STUB_DEF + name = Regexp.last_match(1) + if singleton.last && scope.last && documented == name + pairs << [scope.last, name] + end + end + nil + end + end + + self.attributes = [] + + on :call_node do |node| + examine(node) + end + + private + + def examine(node) + owner = constant_name(node.receiver) + return unless owner + + name = node.name.to_s + writer = name.end_with?("=") + attribute = writer ? name.chomp("=") : name + names = self.class.attributes[owner] + return unless names&.include?(attribute) + + flag(node, writer ? :write : :read, + owner: owner, name: attribute) + end + + def constant_name(node) + case node + when Prism::ConstantReadNode + node.name.to_s + when Prism::ConstantPathNode + node.location.slice.delete_prefix("::") + end + end + end + end + end +end diff --git a/lib/audition/static/checks/unshareable_reads.rb b/lib/audition/static/checks/unshareable_reads.rb new file mode 100644 index 0000000..6a5ac72 --- /dev/null +++ b/lib/audition/static/checks/unshareable_reads.rb @@ -0,0 +1,259 @@ +# frozen_string_literal: true + +module Audition + module Static + module Checks + # Constants known to hold unshareable objects. Their + # definitions live outside the scanned tree (or are swept + # from it at setup), so the read site is the only place a + # static pass can flag. Learned type aliases match only in + # Sorbet type positions, where a constant can only name a + # type—a class sharing a name stays quiet. Namespaces that + # build constants with const_set at boot get their constant + # reads flagged too: the values never pass through a literal + # the analyzer could classify. + class UnshareableReads < Base + explain :sorbet_type_alias, + severity: :warning, + message: "read of %{name}, a Sorbet type alias that " \ + "is not Ractor-shareable", + why: "T.type_alias wraps its block in an unfrozen " \ + "T::Private::Types::TypeAlias that also memoizes " \ + "on first use, so a non-main Ractor evaluating " \ + "the reference raises Ractor::IsolationError. " \ + "sig blocks evaluate lazily, on the first call " \ + "of the method they annotate, so the raise can " \ + "surface there too.", + fix: "Evaluate sigs at boot on the main Ractor " \ + "(T::Utils.run_all_sig_blocks) and avoid runtime " \ + "T.let casts against the alias on Ractor code " \ + "paths, or rebind the constant to a deeply " \ + "shareable equivalent at boot." + + explain :dynamic_constant, + severity: :warning, + message: "read of %{name}, a constant its namespace " \ + "defines dynamically at boot", + why: "The owning namespace binds this constant with " \ + "const_set from runtime data, so no literal ever " \ + "reaches the analyzer; loaders of this shape " \ + "typically bind unfrozen strings or hashes, which " \ + "a non-main Ractor cannot read from a constant.", + fix: "Make each value shareable as it is bound " \ + "(Ractor.make_shareable) or hold the data in a " \ + "frozen registry instead of loose constants." + + KNOWN = Ractor.make_shareable( + {"T::Boolean" => :sorbet_type_alias} + ) + + # Stub generators write rbi definitions fully qualified on + # one line, so a regex sweep is enough there. + ALIAS_DEFINITION = + /^\s*((?:[A-Z]\w*::)*[A-Z]\w*)\s*=\s*T\.type_alias\b/ + + VALUE_NAME = /\A[A-Z][A-Z0-9_]*\z/ + + class << self + attr_reader :learned, :dynamic + + # Both hold names as segment arrays, written eagerly and + # kept shareable so parallel scans read them from + # non-main Ractors. + def learned=(names) + @learned = Ractor.make_shareable( # audition:disable + names.uniq.group_by(&:last) + ) + end + + def dynamic=(names) + @dynamic = Ractor.make_shareable( # audition:disable + names.uniq + ) + end + + # Sweeps the tree for type-alias assignments and for + # namespaces that const_set under a computed name. + # Ruby sources are parsed so definitions keep their + # nesting; rbi files are line-scanned. + def learn(paths, progress: Progress::SILENT) + aliases = [] + owners = [] + paths.each do |path| + progress.tick + source = begin + File.read(path) + rescue SystemCallError + next + end + if path.end_with?(".rbi") + source.scan(ALIAS_DEFINITION) do |(name)| + aliases << name.split("::") + end + else + result = Prism.parse(source) + next unless result.success? + + sweep(result.value, [], aliases, owners) + end + end + self.learned = aliases + self.dynamic = owners + end + + private + + def sweep(node, nesting, aliases, owners) + case node + when Prism::ClassNode, Prism::ModuleNode + nesting = [*nesting, *segments(node.constant_path)] + when Prism::ConstantWriteNode + if alias_value?(node.value) + aliases << [*nesting, node.name.to_s] + end + when Prism::ConstantPathWriteNode + if alias_value?(node.value) + aliases << [*nesting, *segments(node.target)] + end + when Prism::CallNode + if dynamic_definer?(node) && + (owner = owner_of(node, nesting)) + owners << owner + end + end + node.compact_child_nodes.each do |child| + sweep(child, nesting, aliases, owners) + end + end + + def segments(node) + node.location.slice.delete_prefix("::").split("::") + end + + def alias_value?(value) + value.is_a?(Prism::CallNode) && + value.name == :type_alias && + value.receiver&.location&.slice + &.delete_prefix("::") == "T" + end + + # A literal name would be classifiable on its own; the + # loader shape worth learning computes the name. + def dynamic_definer?(node) + return false unless node.name == :const_set + + name = node.arguments&.arguments&.first + !(name.nil? || + name.is_a?(Prism::SymbolNode) || + name.is_a?(Prism::StringNode)) + end + + def owner_of(node, nesting) + owner = case node.receiver + when nil, Prism::SelfNode + nesting + when Prism::ConstantReadNode, Prism::ConstantPathNode + segments(node.receiver) + end + owner unless owner.nil? || owner.empty? + end + end + + self.learned = [] + self.dynamic = [] + + on :call_node do |node| + note_type_position(node) + end + + on :constant_path_node, :constant_read_node do |node| + examine(node) + end + + def initialize(file) + super + @typed = Set.new + end + + private + + def examine(node) + name = node.location.slice.delete_prefix("::") + if (key = KNOWN[name]) + flag(node, key, name: name) + elsif @typed.include?(node.object_id) && alias_read?(name) + flag(node, :sorbet_type_alias, name: name) + elsif dynamic_read?(name) + flag(node, :dynamic_constant, name: name) + end + end + + # The read has to line up with a definition's tail; a + # definition swept without nesting only pins its own name, + # a qualified one pins the namespace too. + def tail_match?(read, full) + overlap = [read.size, full.size].min + full.last(overlap) == read.last(overlap) + end + + def alias_read?(name) + segments = name.split("::") + self.class.learned[segments.last]&.any? do |full| + tail_match?(segments, full) + end + end + + # Only SCREAMING_CASE reads count: a loader binds values, + # and classes nested under the namespace stay quiet. + def dynamic_read?(name) + segments = name.split("::") + return false if segments.size < 2 || + !segments.last.match?(VALUE_NAME) + + parent = segments[0..-2] + self.class.dynamic.any? do |owner| + tail_match?(parent, owner) + end + end + + # Constants under a sig block or a T type argument name + # types, so learned aliases may match there. + def note_type_position(node) + if node.name == :sig + mark(node.block) + elsif t_receiver?(node) + case node.name + when :let, :cast, :assert_type! + mark(node.arguments&.arguments&.dig(1)) + when :nilable, :any, :all, :class_of, :type_alias + node.arguments&.arguments&.each { |arg| mark(arg) } + mark(node.block) + end + end + end + + def t_receiver?(node) + receiver = node.receiver + case receiver + when Prism::ConstantReadNode + receiver.name == :T + when Prism::ConstantPathNode + receiver.location.slice.delete_prefix("::") == "T" + else + false + end + end + + def mark(node) + return if node.nil? + + case node + when Prism::ConstantReadNode, Prism::ConstantPathNode + return @typed << node.object_id + end + node.each_child_node { |child| mark(child) } + end + end + end + end +end diff --git a/lib/audition/static/gem_calls.rb b/lib/audition/static/gem_calls.rb new file mode 100644 index 0000000..4086137 --- /dev/null +++ b/lib/audition/static/gem_calls.rb @@ -0,0 +1,1770 @@ +# frozen_string_literal: true + +require "rbconfig" + +module Audition + module Static + # Call sites into bundled gems whose compiled extensions do + # not declare Ractor safety (rb_ext_ractor_safe), so calling + # into them from a non-main Ractor raises Ractor::UnsafeError. + # The extension's code is outside the scanned tree; the call + # site is the only place a static pass can flag. + # + # Rules are derived from the target's own bundle, never from a + # gem list: a pinned gem whose installed extension lacks the + # declaration—or that pins a platform-specific build Audition + # cannot inspect (the running Ruby's own copy of the extension + # settles it when one is shipped)—gets its call sites + # flagged, anchored to the + # gem's own namespace (read from its entry file's module + # nesting, or the require-path convention when the gem is not + # installed). Matched calls taint: the value handed back is + # presumed to still live in the extension, so calls chained + # onto it, or on the local or ivar it is assigned to, are + # flagged and keep the taint moving; a predicate ends the + # chain, and freeze, tap, itself, and class pass it along + # silently. Sorbet + # annotations extend the reach—a sig param, T.let, or T.cast + # typed with a constant under a flagged gem's namespace taints + # the annotated variable the same way, and a plain type clears + # the guess. A per-class pre-pass makes the body order-free: + # a method whose return expression or sig return type is + # rooted in a flagged namespace hands the taint to callers on + # self, self.class, or the class's own constant, and an ivar + # assigned such a value anywhere in the body—directly or + # through its attr writer—is tainted throughout. A pass over + # the whole tree first promotes classes that hold extension + # values in their instances—or subclass one that does—to + # rules of their own, so constructing or receiving one in + # another file carries the taint across files. + class GemCalls + UNSAFE_WHY = + "%{gem}'s compiled extension does not declare Ractor " \ + "safety (rb_ext_ractor_safe), so its methods raise " \ + "Ractor::UnsafeError (\"ractor unsafe method called from " \ + "not main ractor\") from any non-main Ractor." + + UNVERIFIED_WHY = + "%{gem} pins a compiled extension Audition could not " \ + "inspect (the gem is not installed here). An extension " \ + "that does not declare Ractor safety " \ + "(rb_ext_ractor_safe) raises Ractor::UnsafeError from " \ + "any non-main Ractor; install the bundle to verify." + + MESSAGE = + "%{receiver}.%{method} calls into %{gem}, whose compiled " \ + "extension does not declare Ractor safety" + + UNVERIFIED_MESSAGE = + "%{receiver}.%{method} calls into %{gem}, which pins a " \ + "compiled extension Audition could not inspect" + + DERIVED_MESSAGE = + "%{method} called on a value handed out by %{gem}'s " \ + "native extension" + + FIX = + "Keep calls into %{gem} on the main Ractor and share " \ + "only extracted plain data (frozen strings, numbers) " \ + "between Ractors, or get the extension to declare " \ + "rb_ext_ractor_safe(true)." + + # Object/Kernel identity methods that never enter the + # extension, even on a gem object. + CORE_METHODS = Ractor.make_shareable( + Set.new( + %i[nil? is_a? kind_of? instance_of? respond_to? frozen? + equal? class object_id itself hash tap then freeze] + ) + ) + + # Core methods that hand back the receiver—or, for class, + # the extension's own class object: no finding, but a + # tainted receiver's taint passes through. + CHAIN_METHODS = Ractor.make_shareable( + Set.new(%i[class itself tap freeze]) + ) + + CHECK = "native-gem-calls" + + EMPTY_NESTING = Ractor.make_shareable([]) + + # Predicates whose constant argument proves the receiver's + # class when they gate a branch. + TYPE_CHECKS = Ractor.make_shareable( + Set.new(%i[is_a? kind_of? instance_of?]) + ) + + EVAL_REOPENINGS = Ractor.make_shareable( + Set.new(%i[class_eval module_eval]) + ) + + # Methods that hand the receiver itself to their block. + YIELD_SELF = Ractor.make_shareable( + Set.new(%i[then yield_self]) + ) + + EMPTY_SET = Ractor.make_shareable(Set.new) + + # What a stub has to show before its gem counts as compiled: + # one source location holding this share of the gem's located + # methods, across at least this many classes. + STUB_SHARE = 0.6 + STUB_OWNERS = 2 + STUB_SOURCE = %r{# source://(\S+)} + STUB_DEF = /\A\s*def [\w\[\]<>=!+\-*\/%~^&|?]/ + STUB_SCOPE = /\A\s*(?:class|module)\s+([A-Za-z0-9_:]+)/ + + # Everything an object answers before any extension gets + # involved: a bare call to anything else inside a reopened + # bound class lands in the extension. + RUBY_METHODS = Ractor.make_shareable( + Set.new( + [Object, Kernel, Module, Class].flat_map do |mod| + mod.instance_methods + mod.private_instance_methods + end + ) + ) + + # Core iterators hand elements to their block; these + # positions carry the memo or index instead. + BLOCK_MEMO_POSITIONS = Ractor.make_shareable( + {each_with_object: 1, with_object: 1, each_with_index: 1, + with_index: 1, inject: 0, reduce: 0} + ) + + # One flagged gem: verified means its installed extension + # binary was read and lacks the declaration; unverified + # means the lockfile proves compiled code exists but no + # binary was available to read. A bound rule's namespace IS + # the extension's own class or module—the gem namespace, + # a constant assigned an extension-rooted value, or a + # subclass of either—so reopening it defines methods on + # extension instances; an unbound rule marks app code that + # merely holds extension values. + Rule = Data.define(:gem, :namespace, :verified, :bound) do + def initialize(gem:, namespace:, verified:, bound: false) + super + end + end + + # @param root [String] target root, where Gemfile.lock lives + # @param stubs [Array] the target's .rbi stubs + # @param rules [Array, nil] override bundle resolution + def initialize(root:, stubs: [], rules: nil) + @stubs = stubs + @rules = rules || resolve(root) + @nesting = EMPTY_NESTING + @self_rule = nil + @self_defs = EMPTY_SET + @param_seeds = {} + @return_taints = {} + reset_seed_ledger + index_rules + end + + # @param paths [Array] files to scan + # @param progress [Progress] narrates the + # three passes this phase makes over the tree + # @return [Array] + def analyze_paths(paths, progress: Progress::SILENT) + return [] if @rules.empty? + + progress.stage("subclasses", total: paths.size) + @rules += derived_class_rules(paths, progress) + index_rules + @param_seeds = {} + reset_seed_ledger + findings = {} + progress.stage("scanning", total: paths.size) + paths.each do |path| + progress.tick + source = read_source(path) + findings[path] = analyze_file(path, source) if source + end + settle_seeds(findings, progress) + findings.values.flatten(1) + end + + private + + # A call in one file seeds a parameter of a method defined + # in another, so a file walked before the call was seen has + # to walk again. Rounds are capped: each one costs a walk of + # every file the round before it taught something new. + SEED_ROUNDS = 3 + + def reset_seed_ledger + @seed_clock = 0 + @seed_at = {} + @def_keys = {} + @walked_at = {} + end + + def settle_seeds(findings, progress = Progress::SILENT) + SEED_ROUNDS.times do |round| + stale = findings.keys.select { |path| stale?(path) } + break if stale.empty? + + progress.stage("settling #{round + 1}", total: stale.size) + stale.each do |path| + progress.tick + source = read_source(path) + findings[path] = analyze_file(path, source) if source + end + end + end + + def stale?(path) + walked = @walked_at[path] + return false unless walked + + @def_keys[path].any? do |key| + (@seed_at[key] || -1) > walked + end + end + + def read_source(path) + File.read(path) + rescue SystemCallError + nil + end + + def resolve(root) + lockfile = File.join(root, "Gemfile.lock") + return [] unless File.file?(lockfile) + + pinned(lockfile).flat_map do |name, version, platform| + rules_for(name, version, platform) + end + end + + # Lockfile rows collapse platform variants into one gem with + # a platform mark: a platform suffix on any variant proves + # the gem ships compiled code. + def pinned(lockfile) + rows = {} + File.foreach(lockfile) do |line| + match = line.match(/\A ([A-Za-z0-9_-]+) \(([^)\s]+)\)/) + next unless match + + version, platform = + match[2].match(/\A([0-9][\w.]*?)(?:-(.+))?\z/)&.captures + next unless version + + row = rows[match[1]] ||= [match[1], version, nil] + row[2] ||= platform + end + rows.values + end + + # An installed copy is the best evidence. Without one, the + # generated type stubs in the target's own tree name the + # classes whose methods are not Ruby-defined, and a + # platform pin proves compiled code ships under the gem's + # own name: both hold, so both are flagged. + def rules_for(name, version, platform) + spec = installed_spec(name, version) + return Array(installed_rule(name, spec)) if spec + + namespaces = stub_namespaces(name, version) + namespaces += [convention_namespace(name)] if platform + namespaces.uniq.filter_map { |ns| unverified_rule(name, ns) } + end + + def installed_rule(name, spec) + compiled = Target.compiled_for(spec) + if compiled.any? + return nil if compiled.all? { |path| declares_safety?(path) } + + Rule.new(gem: name, namespace: namespace_for(name, spec), + verified: true, bound: true) + elsif spec.extensions.any? + unverified_rule(name, namespace_for(name, spec)) + end + end + + # A pin with no readable binary—a platform build the + # bundle did not install, or a default gem whose extension + # lives outside its require paths—can still be judged when + # the running Ruby ships the same extension: Ruby's own + # binary is the evidence. + def unverified_rule(name, namespace) + shipped = ruby_shipped_extension(name) + unless shipped + return Rule.new(gem: name, namespace: namespace, + verified: false, bound: true) + end + return nil if declares_safety?(shipped) + + Rule.new(gem: name, namespace: namespace, verified: true, + bound: true) + end + + def ruby_shipped_extension(name) + base = File.join(RbConfig::CONFIG["archdir"], + name.tr("-", "/")) + Dir["#{base}.{so,bundle}"].first + end + + # Bundler hides gems outside the current bundle from + # find_by_name, so fall back to reading gemspecs straight + # from the installed specification directories. + def installed_spec(name, version) + Gem::Specification.find_by_name(name, version) + rescue Gem::LoadError + Gem.path.each do |base| + pattern = File.join(base, "specifications", + "#{name}-#{version}{,-*}.gemspec") + Dir[pattern].each do |path| + spec = Gem::Specification.load(path) + return spec if spec&.name == name + end + end + nil + end + + def declares_safety?(path) + File.binread(path).include?(NativeExtensions::SYMBOL) + rescue SystemCallError + false + end + + # A generated type stub is the target's own record of what a + # gem's methods look like. The generator writes each method's + # Ruby source location, and methods a compiled extension + # defines all land on the one line that loads it: a single + # location holding most of a gem's methods, spread over + # several classes, is that shape. A metaprogramming block + # defines its methods on the class it sits in, so one owner + # is not evidence. The owners of that cluster are the + # extension's own classes, which name it better than any + # convention can. + # Generators name a gem's stub for the gem and version it + # documents, which identifies it wherever the generator was + # told to write. + def stub_namespaces(name, version) + wanted = "#{name}@#{version}.rbi" + path = @stubs.find { |s| File.basename(s) == wanted } + return [] unless path + + clusters = stub_clusters(path) + located = clusters.sum { |_, owners| owners.values.sum } + return [] if located.zero? + + owners = clusters.each_value.max_by { |o| o.values.sum } + return [] if owners.size < STUB_OWNERS || + owners.values.sum < located * STUB_SHARE + + owners.keys + end + + def stub_clusters(path) + clusters = Hash.new { |h, k| h[k] = Hash.new(0) } + scope = [] + indents = [] + source = nil + File.foreach(path) do |raw| + line = raw.rstrip + next if line.empty? + + indent = line[/\A */].size + while indents.any? && indent <= indents.last + indents.pop + scope.pop + end + if (match = line.match(STUB_SCOPE)) + scope.push(stub_scope_name(scope, match[1])) + indents.push(indent) + source = nil + elsif (match = line.match(STUB_SOURCE)) + source = match[1] + else + if source && scope.last && line.match?(STUB_DEF) + clusters[source][scope.last] += 1 + end + source = nil + end + end + clusters + rescue SystemCallError + {} + end + + # Stubs write top-level definitions under their full path and + # nested ones relative to the enclosing scope. + def stub_scope_name(scope, written) + scope.empty? ? written : "#{scope.last}::#{written}" + end + + # The entry file's module nesting names the gem's namespace + # more reliably than name conventions do. + def namespace_for(name, spec) + entry = spec.full_require_paths + .map { |rp| File.join(rp, "#{name.tr("-", "/")}.rb") } + .find { |path| File.file?(path) } + (entry && nesting_namespace(entry)) || + convention_namespace(name) + end + + def nesting_namespace(path) + result = Prism.parse(File.read(path)) + return nil unless result.success? + + names = [] + body = result.value.statements.body + while (mod = sole_module(body)) + names << mod.constant_path.location.slice + .delete_prefix("::") + body = + mod.body.is_a?(Prism::StatementsNode) ? mod.body.body : [] + end + names.join("::") unless names.empty? + rescue SystemCallError + nil + end + + def sole_module(statements) + mods = statements.select do |node| + node.is_a?(Prism::ModuleNode) || node.is_a?(Prism::ClassNode) + end + mods.first if mods.size == 1 + end + + def convention_namespace(name) + name.split("-").map do |seg| + seg.split("_").map(&:capitalize).join + end.join("::") + end + + # Each pass may learn argument seeds and computed return + # taints that change what an earlier line would flag, so + # the file re-walks until a pass learns nothing new and + # only that pass's findings stand. + def analyze_file(path, source) + file = SourceFile.new(source: source, path: path) + return [] unless file.valid_syntax? + + @path = path + + @return_taints = {} + @def_keys[path] = Set.new + scan = nil + loop do + before = knowledge_size + @registry = nil + @singleton = false + @nesting = EMPTY_NESTING + @self_rule = nil + @self_defs = EMPTY_SET + scan = Scan.new(path: path, findings: [], producers: {}) + walk(file.root, {}, {}, scan) + break if knowledge_size == before + end + @walked_at[path] = @seed_clock + scan.findings + end + + # Seeds and return taints only grow, so the loop stops at + # the first pass that learns nothing. + def knowledge_size + @param_seeds.sum { |_, seeds| seeds.size } + + @return_taints.size + end + + Scan = Data.define(:path, :findings, :producers) + + # Ordered walk carrying the taint state: tainted maps a local + # name to the rule whose call produced its value, ivars does + # the same for instance variables; producers remembers which + # call nodes are rooted in a flagged namespace so a chained + # call or an assignment can pick the taint up. Defs open + # fresh local scopes (seeded from the preceding sig); classes + # and modules open fresh ivar scopes; blocks close over the + # enclosing. + def walk(node, tainted, ivars, scan) + case node + when Prism::ClassNode, Prism::ModuleNode + registry, singleton, nesting = @registry, @singleton, @nesting + self_rule, self_defs = @self_rule, @self_defs + bound = source_rule(node.constant_path.location.slice) + @self_rule = bound&.bound ? bound : nil + @self_defs = @self_rule ? body_defs(node.body) : EMPTY_SET + @nesting = nested_scope(node) + @registry, @singleton = build_registry(node), false + each_child(node) do |child| + walk(child, {}, @registry.ivars[:instance].dup, scan) + end + @registry, @singleton, @nesting = registry, singleton, nesting + @self_rule, @self_defs = self_rule, self_defs + when Prism::SingletonClassNode + if @registry && node.expression.is_a?(Prism::SelfNode) + singleton = @singleton + @singleton = true + each_child(node) do |child| + walk(child, {}, @registry.ivars[:singleton].dup, scan) + end + @singleton = singleton + else + registry = @registry + @registry = nil + each_child(node) { |child| walk(child, {}, {}, scan) } + @registry = registry + end + when Prism::DefNode + walk_def(node, nil, ivars, scan) + when Prism::StatementsNode + sig = nil + node.body.each do |child| + if child.is_a?(Prism::DefNode) + walk_def(child, sig, ivars, scan) + else + walk(child, tainted, ivars, scan) + guard_taint(child, tainted, ivars) + end + sig = sig_node?(child) ? child : nil + end + when Prism::CaseNode + walk_case(node, tainted, ivars, scan) + when Prism::IfNode + walk_if(node, tainted, ivars, scan) + when Prism::CallNode + if (rule = reopening_rule(node)) + walk_reopening(node, rule, tainted, ivars, scan) + else + walk_call(node, tainted, ivars, scan) + end + when Prism::LocalVariableWriteNode, + Prism::LocalVariableOrWriteNode, + Prism::LocalVariableAndWriteNode, + Prism::LocalVariableOperatorWriteNode + walk(node.value, tainted, ivars, scan) + rule = value_rule(node.value, tainted, ivars, scan) + rule ? tainted[node.name] = rule : tainted.delete(node.name) + when Prism::InstanceVariableWriteNode, + Prism::InstanceVariableOrWriteNode, + Prism::InstanceVariableAndWriteNode, + Prism::InstanceVariableOperatorWriteNode + walk(node.value, tainted, ivars, scan) + rule = value_rule(node.value, tainted, ivars, scan) + rule ? ivars[node.name] = rule : ivars.delete(node.name) + when Prism::MultiWriteNode + each_child(node) { |child| walk(child, tainted, ivars, scan) } + multi_targets(node).each do |target| + case target + when Prism::LocalVariableTargetNode + tainted.delete(target.name) + when Prism::InstanceVariableTargetNode + ivars.delete(target.name) + end + end + else + each_child(node) { |child| walk(child, tainted, ivars, scan) } + end + end + + # Branches run on their own copy: a value one branch + # assigns may still be live after the branch, and one + # every branch replaces is not. + def walk_if(node, tainted, ivars, scan) + walk(node.predicate, tainted, ivars, scan) + key, rule = type_check(node.predicate) + branches = [] + if node.statements + copies = [tainted.dup, ivars.dup] + with_taint(key, rule, *copies) do + walk(node.statements, *copies, scan) + end + branches << copies + end + if node.subsequent + copies = [tainted.dup, ivars.dup] + walk(node.subsequent, *copies, scan) + branches << copies + end + merge_branches(tainted, ivars, branches, + node.statements && node.subsequent) + end + + def merge_branches(tainted, ivars, branches, total) + branches.each do |branch_tainted, branch_ivars| + branch_tainted.each { |k, v| tainted[k] ||= v } + branch_ivars.each { |k, v| ivars[k] ||= v } + end + return unless total + + tainted.delete_if do |k, _| + branches.none? { |t, _| t.key?(k) } + end + ivars.delete_if do |k, _| + branches.none? { |_, i| i.key?(k) } + end + end + + def walk_def(node, sig, ivars, scan) + singleton_def = node.receiver.is_a?(Prism::SelfNode) + side = (@singleton || singleton_def) ? :singleton : :instance + tainted = param_taints(node, sig, side) + if singleton_def && !@singleton + singleton = @singleton + @singleton = true + ivars = @registry ? @registry.ivars[:singleton].dup : {} + each_child(node) { |child| walk(child, tainted, ivars, scan) } + @singleton = singleton + else + each_child(node) { |child| walk(child, tainted, ivars, scan) } + end + note_return_taint(node, side, scan) + end + + # Sig taints seed the def's scope, then call sites already + # walked add argument taints for params the sig leaves + # untyped or erased; a plain constant type keeps the param + # clean. + def param_taints(node, sig, side) + taints = sig ? sig_taints(sig) : {} + seeds = seeds_for(side, node.name) + return taints unless seeds + + plain = sig ? plain_params(sig) : EMPTY_SET + seeds.each do |key, rule| + name = seed_param_name(node.parameters, key) + next if name.nil? || taints.key?(name) || + plain.include?(name) + + taints[name] = rule + end + taints + end + + # A call through the module's own name reaches an instance + # method the module extended itself with. + def seeds_for(side, name) + key = [seed_scope, side, name] + note_def_key(key) + seeds = @param_seeds[key] + return seeds if seeds || side != :instance || + !@registry&.self_extended + + fallback = [seed_scope, :singleton, name] + note_def_key(fallback) + @param_seeds[fallback] + end + + # What this file's definitions would consume, so a seed + # learned later can call the file back. + def note_def_key(key) + @def_keys[@path]&.add(key) + end + + def seed_param_name(params, key) + return nil unless params + + if key.is_a?(Integer) + requireds = params.requireds + param = if key < requireds.size + requireds[key] + else + params.optionals[key - requireds.size] + end + case param + when Prism::RequiredParameterNode, + Prism::OptionalParameterNode + param.name + end + else + params.keywords.find do |kw| + kw.respond_to?(:name) && kw.name == key + end&.name + end + end + + # A def whose final expression produces taint hands it to + # callers even when its sig says nothing; predicates stay + # plain. Learned during one pass, applied on the next. + def note_return_taint(node, side, scan) + return if node.name.end_with?("?") + + expr = def_return_expr(node) + rule = expr && scan.producers[expr.object_id] + @return_taints[[seed_scope, side, node.name]] = rule if rule + end + + def seed_scope + @registry&.class_name + end + + # A bare or self call names a method in the scope being + # walked. A constant receiver names the scope itself, by its + # last segment: a call site and a definition rarely spell + # the path the same way. + def seed_key(node) + case node.receiver + when nil, Prism::SelfNode + [seed_scope, @singleton ? :singleton : :instance, + node.name] + when Prism::ConstantReadNode, Prism::ConstantPathNode + name = node.receiver.location.slice + .delete_prefix("::").split("::").last + [name, :singleton, node.name] + end + end + + # The block walks after the call resolves: a flagged call's + # block iterates values living in the same extension, so its + # element parameters carry the taint. + def walk_call(node, tainted, ivars, scan) + block = node.block + each_child(node) do |child| + walk(child, tainted, ivars, scan) unless child.equal?(block) + end + rule = visit_call(node, tainted, ivars, scan) || + yielded_self_rule(node, tainted, ivars, scan) + note_argument_taints(node, tainted, ivars, scan) + return unless block + + if rule && block.is_a?(Prism::BlockNode) + walk_block(node, rule, tainted, ivars, scan) + else + walk(block, tainted, ivars, scan) + end + end + + # A tainted argument seeds the named method's parameter for + # the passes that follow. + def note_argument_taints(node, tainted, ivars, scan) + key = seed_key(node) + return unless key + + args = node.arguments&.arguments + return unless args + + args.each_with_index do |arg, i| + if arg.is_a?(Prism::KeywordHashNode) + arg.elements.each do |assoc| + next unless assoc.is_a?(Prism::AssocNode) && + assoc.key.is_a?(Prism::SymbolNode) + + note_seed(key, assoc.key.unescaped.to_sym, + value_rule(assoc.value, tainted, ivars, scan)) + end + else + note_seed(key, i, + value_rule(arg, tainted, ivars, scan)) + end + end + end + + def note_seed(key, param, rule) + return unless rule + + seeds = (@param_seeds[key] ||= {}) + fresh = !seeds.key?(param) + seeds[param] = rule + @seed_at[key] = (@seed_clock += 1) if fresh + end + + # then and yield_self pass the receiver straight in, so + # the block parameter stands for the receiver. + def yielded_self_rule(node, tainted, ivars, scan) + return nil unless YIELD_SELF.include?(node.name) + + taint_source(node, tainted, ivars, scan) || + receiver_rule(node.receiver) + end + + # What an expression hands over: a variable read carries + # whatever the variable holds, self carries the body's own + # binding, anything else carries what the walk recorded. + def value_rule(node, tainted, ivars, scan) + case node + when Prism::LocalVariableReadNode then tainted[node.name] + when Prism::InstanceVariableReadNode then ivars[node.name] + when Prism::SelfNode then @singleton ? nil : @self_rule + else scan.producers[node.object_id] + end + end + + def walk_block(node, rule, tainted, ivars, scan) + names = element_params(node) + saved = names.map { |n| [n, tainted.key?(n), tainted[n]] } + names.each { |n| tainted[n] = rule } + each_child(node.block) do |child| + walk(child, tainted, ivars, scan) + end + saved.each do |name, had, prev| + had ? tainted[name] = prev : tainted.delete(name) + end + end + + def element_params(node) + params = node.block.parameters + skip = BLOCK_MEMO_POSITIONS[node.name] + case params + when Prism::NumberedParametersNode + (1..params.maximum).filter_map do |i| + :"_#{i}" unless skip == i - 1 + end + when Prism::BlockParametersNode + names = [] + (params.parameters&.requireds || []) + .each_with_index do |param, i| + collect_param_names(param, names) unless skip == i + end + names + else + [] + end + end + + def collect_param_names(param, names) + case param + when Prism::RequiredParameterNode, + Prism::LocalVariableTargetNode + names << param.name + when Prism::MultiTargetNode + [*param.lefts, param.rest, *param.rights].compact + .each { |part| collect_param_names(part, names) } + end + end + + def each_child(node, &block) + node.child_nodes.compact.each(&block) + end + + def nested_scope(node) + slice = node.constant_path.location.slice + segments = slice.delete_prefix("::").split("::") + slice.start_with?("::") ? segments : [*@nesting, *segments] + end + + def multi_targets(node) + [*node.lefts, node.rest, *node.rights].compact + end + + # A when clause matching the subject against a flagged + # constant proves its type inside the branch; if every + # branch proves the same rule, the else clause sees that + # value too, and a branch handing the value back makes the + # whole case expression a producer. + def walk_case(node, tainted, ivars, scan) + walk(node.predicate, tainted, ivars, scan) if node.predicate + key = node.predicate && taint_key(node.predicate) + rules = node.conditions.map { |c| key ? when_rule(c) : nil } + produced = nil + node.conditions.each_with_index do |clause, i| + clause.conditions.each do |cond| + walk(cond, tainted, ivars, scan) unless constant_type?(cond) + end + next unless clause.statements + + with_taint(key, rules[i], tainted, ivars) do + walk(clause.statements, tainted, ivars, scan) + produced ||= branch_rule(clause.statements, key, rules[i], scan) + end + end + if node.else_clause + shared = (rules.uniq.size == 1) ? rules.first : nil + with_taint(key, shared, tainted, ivars) do + walk(node.else_clause, tainted, ivars, scan) + produced ||= + branch_rule(node.else_clause.statements, key, shared, scan) + end + end + scan.producers[node.object_id] = produced if produced + end + + # One rule only when every condition in the clause is a + # constant resolving to it: mixed or non-constant + # conditions are not a class match. + def when_rule(clause) + rules = clause.conditions.map do |cond| + constant_type?(cond) ? source_rule(cond.location.slice) : nil + end + (rules.uniq.size == 1) ? rules.first : nil + end + + # The value a branch hands back, seen through trailing + # modifier conditionals. + def branch_rule(statements, key, rule, scan) + tail = statements&.body&.last + while tail.is_a?(Prism::IfNode) || tail.is_a?(Prism::UnlessNode) + tail = tail.statements&.body&.last + end + return nil unless tail + + scan.producers[tail.object_id] || + (rule if key && taint_key(tail) == key) + end + + # Reopening a bound name through class_eval defines methods + # on the extension's own class; the eval call itself never + # enters the extension. + def reopening_rule(node) + return nil unless EVAL_REOPENINGS.include?(node.name) && + node.block.is_a?(Prism::BlockNode) + + rule = receiver_rule(node.receiver) + rule&.bound ? rule : nil + end + + def walk_reopening(node, rule, tainted, ivars, scan) + self_rule, self_defs = @self_rule, @self_defs + @self_rule = rule + @self_defs = body_defs(node.block.body) + each_child(node) { |child| walk(child, tainted, ivars, scan) } + @self_rule, @self_defs = self_rule, self_defs + end + + # Names the reopened body itself defines: self-calls on + # these stay ordinary Ruby. + def body_defs(body) + defs = Set.new + statements = + body.is_a?(Prism::StatementsNode) ? body.body : [] + statements.each do |stmt| + case stmt + when Prism::DefNode + defs << stmt.name + when Prism::CallNode + roles = ATTR_ROLES[stmt.name] + next unless roles && stmt.receiver.nil? + + args = stmt.arguments&.arguments || [] + args.grep(Prism::SymbolNode).each do |sym| + defs << sym.unescaped.to_sym + if roles.include?(:writer) + defs << :"#{sym.unescaped}=" + end + end + end + end + defs + end + + # The store slot a subject narrows to: a local or an + # instance variable; anything else cannot hold taint. + def taint_key(node) + case node + when Prism::LocalVariableReadNode then [:local, node.name] + when Prism::InstanceVariableReadNode then [:ivar, node.name] + end + end + + def with_taint(key, rule, tainted, ivars) + return yield unless key && rule + + store = (key[0] == :local) ? tainted : ivars + name = key[1] + had, prev = store.key?(name), store[name] + store[name] = rule + yield + had ? store[name] = prev : store.delete(name) + end + + # A guard that bails unless the variable is one of a + # flagged gem's classes proves its type for whatever + # follows in the surrounding sequence. + def guard_taint(child, tainted, ivars) + return unless child.is_a?(Prism::UnlessNode) && + terminates?(child.statements) + + key, rule = type_check(child.predicate) + return unless key && rule + + store = (key[0] == :local) ? tainted : ivars + store[key[1]] = rule + end + + def terminates?(statements) + last = statements&.body&.last + last.is_a?(Prism::ReturnNode) || last.is_a?(Prism::NextNode) || + last.is_a?(Prism::BreakNode) || + (last.is_a?(Prism::CallNode) && last.name == :raise) + end + + # x.is_a?(Some::Const) as a type proof for x, through the + # left side of a && chain. + def type_check(predicate) + node = predicate + node = node.left while node.is_a?(Prism::AndNode) + return unless node.is_a?(Prism::CallNode) && + TYPE_CHECKS.include?(node.name) && node.receiver + + key = taint_key(node.receiver) + return unless key + + args = node.arguments&.arguments + return unless args&.size == 1 && constant_type?(args.first) + + rule = source_rule(args.first.location.slice) + [key, rule] if rule + end + + # Classes whose instances hold extension values—an ivar or + # method rooted in a flagged namespace—become rules under + # their own qualified name: constructing one, or receiving + # one through a sig or annotation, taints in any file. A + # subclass of a promoted or flagged class is promoted too, + # and so is a constant assigned a value rooted in a flagged + # namespace, the way protobuf-style generated Ruby binds + # extension classes to its own names. + def derived_class_rules(paths, progress = Progress::SILENT) + found = {} + mixins = [] + @nesting = EMPTY_NESTING + paths.each do |path| + progress.tick + source = read_source(path) + next unless source + + result = Prism.parse(source) + next unless result.success? + + collect_classes(result.value, [], found) + collect_mixins(result.value, [], mixins) + end + settle_subclasses(found) + settle_mixins(found, mixins) + found.values.filter_map { |info| info[:rule] } + end + + # A mixin's instance methods run with the host as self: + # include and prepend bind self to a host instance, extend + # to the host itself. Either way, a module mixed into a + # name bound to an extension value hands that value out + # through self. + MIXINS = Ractor.make_shareable( + Set.new(%i[include prepend extend]) + ) + + def collect_mixins(node, nesting, out) + case node + when Prism::ClassNode, Prism::ModuleNode + path = node.constant_path.location.slice + .delete_prefix("::") + return each_child(node) do |child| + collect_mixins(child, [*nesting, path], out) + end + when Prism::CallNode + note_mixin(node, nesting, out) + end + each_child(node) { |child| collect_mixins(child, nesting, out) } + end + + # A bare call mixes into the body it sits in; a receiver + # names the host itself. + def note_mixin(node, nesting, out) + return unless MIXINS.include?(node.name) + + host = case node.receiver + when nil then nesting.last + when Prism::ConstantReadNode, Prism::ConstantPathNode + node.receiver.location.slice.delete_prefix("::") + end + return unless host + + Array(node.arguments&.arguments).each do |arg| + next unless constant_type?(arg) + + out << [host, arg.location.slice.delete_prefix("::"), + nesting] + end + end + + def settle_mixins(found, mixins) + loop do + changed = false + mixins.each do |host, mixin, nesting| + info = found[mixin] ||= {rule: nil, superclass: nil, + nesting: nesting} + next if info[:rule] + + rule = resolve_name(host, nesting, found) + next unless rule&.bound + + info[:rule] = derived_rule(rule, mixin, bound: true) + changed = true + end + break unless changed + end + end + + # A reference resolves the way Ruby would: against each + # level of the enclosing nesting, then as written. + def resolve_name(name, nesting, found) + nesting.size.downto(0) do |depth| + candidate = [*nesting.first(depth), name].join("::") + rule = namespace_rule(candidate) || + found.dig(candidate, :rule) + return rule if rule + end + nil + end + + def collect_classes(node, nesting, found) + case node + when Prism::ClassNode, Prism::ModuleNode + path = node.constant_path.location.slice + .delete_prefix("::") + note_class(node, nesting, path, found) if + node.is_a?(Prism::ClassNode) + return each_child(node) do |child| + collect_classes(child, [*nesting, path], found) + end + when Prism::ConstantWriteNode, Prism::ConstantOrWriteNode, + Prism::ConstantPathWriteNode, + Prism::ConstantPathOrWriteNode + note_constant(node, nesting, found) + end + each_child(node) { |child| collect_classes(child, nesting, found) } + end + + # A constant bound to an extension-rooted value is the value + # under a new name; the name inherits the rule. + def note_constant(node, nesting, found) + path = if node.respond_to?(:target) + node.target.location.slice.delete_prefix("::") + else + node.name.to_s + end + fqn = [*nesting, path].join("::") + return if namespace_rule(fqn) || found.dig(fqn, :rule) + + @nesting = nesting + taint = prepass_rule(node.value, :instance, EMPTY_REGISTRY) + return unless taint + + found[fqn] = {rule: derived_rule(taint, fqn, bound: true), + superclass: nil, nesting: nesting} + end + + def note_class(node, nesting, path, found) + fqn = [*nesting, path].join("::") + return if namespace_rule(fqn) + + @nesting = [*nesting, path] + registry = build_registry(node) + taint = registry.ivars[:instance].values.first || + registry.methods[:instance].values.first + superclass = node.superclass + info = found[fqn] ||= {rule: nil, superclass: nil, + nesting: nesting} + info[:rule] ||= taint && derived_rule(taint, fqn) + if info[:superclass].nil? && constant_type?(superclass) + info[:superclass] = + superclass.location.slice.delete_prefix("::") + end + end + + def derived_rule(taint, fqn, bound: false) + Rule.new(gem: taint.gem, namespace: fqn, + verified: taint.verified, bound: bound) + end + + # Promotion follows inheritance to a fixpoint: the superclass + # reference resolves the way Ruby would, against each level + # of the subclass's own nesting, then as written. + def settle_subclasses(found) + loop do + changed = false + found.each do |fqn, info| + next if info[:rule] || info[:superclass].nil? + + parent = superclass_rule(info, found) + next unless parent + + info[:rule] = derived_rule(parent, fqn, + bound: parent.bound) + changed = true + end + break unless changed + end + end + + def superclass_rule(info, found) + resolve_name(info[:superclass], info[:nesting], found) + end + + # Pre-pass results for one class or module body: methods + # whose return value is rooted in a flagged namespace and + # ivars assigned such a value, split by definition side. + Registry = Data.define(:class_name, :methods, :ivars, + :self_extended) + + EMPTY_REGISTRY = Ractor.make_shareable( + Registry.new(class_name: "", + methods: {instance: {}, singleton: {}}, + ivars: {instance: {}, singleton: {}}, + self_extended: false) + ) + + Entry = Data.define(:side, :name, :return_expr, :ivar_writes, + :writer_calls) + + ATTR_ROLES = Ractor.make_shareable( + {attr_accessor: %i[reader writer], attr_reader: %i[reader], + attr_writer: %i[writer]} + ) + + WRITER_NAME = /\A[a-z_]\w*=\z/ + + # Collects the body's defs, attr declarations, and ivar + # writes, then settles taint to a fixpoint, so a factory + # defined below its callers still taints them. + def build_registry(node) + registry = Registry.new( + class_name: node.constant_path.location.slice + .delete_prefix("::"), + methods: {instance: {}, singleton: {}}, + ivars: {instance: {}, singleton: {}}, + self_extended: self_extended?(node.body) + ) + entries = [] + attrs = {instance: {reader: [], writer: []}, + singleton: {reader: [], writer: []}} + body = node.body + if body.is_a?(Prism::StatementsNode) + collect_entries(body.body, :instance, entries, attrs, + registry) + end + settle(registry, entries, attrs) + registry + end + + # extend self and a bare module_function make the body's + # instance methods singleton methods too, so a call through + # the module's own name reaches them. + def self_extended?(body) + return false unless body.is_a?(Prism::StatementsNode) + + body.body.any? do |stmt| + next false unless stmt.is_a?(Prism::CallNode) && + stmt.receiver.nil? + + case stmt.name + when :extend + Array(stmt.arguments&.arguments) + .any?(Prism::SelfNode) + when :module_function then stmt.arguments.nil? + end + end + end + + def collect_entries(statements, side, entries, attrs, registry) + sig = nil + statements.each do |stmt| + case stmt + when Prism::DefNode + def_side = + stmt.receiver.is_a?(Prism::SelfNode) ? :singleton : side + entries << def_entry(stmt, def_side) + note_sig_return(sig, stmt, def_side, registry) + when Prism::SingletonClassNode + if stmt.expression.is_a?(Prism::SelfNode) && + stmt.body.is_a?(Prism::StatementsNode) + collect_entries(stmt.body.body, :singleton, entries, + attrs, registry) + end + when Prism::CallNode + collect_attr(stmt, side, attrs) + when Prism::InstanceVariableWriteNode, + Prism::InstanceVariableOrWriteNode + entries << Entry.new(side: :singleton, name: nil, + return_expr: nil, + ivar_writes: [[stmt.name, stmt.value]], + writer_calls: []) + end + sig = sig_node?(stmt) ? stmt : nil + end + end + + # A sig return type under a flagged namespace marks the + # method as handing out extension values, body regardless. + def note_sig_return(sig, def_node, side, registry) + return unless sig + + returns = sig_chain_call(sig, :returns) + args = returns&.arguments&.arguments + rule = args && args.size == 1 && type_rule(args[0]) + registry.methods[side][def_node.name] = rule if rule + end + + def collect_attr(call, side, attrs) + roles = ATTR_ROLES[call.name] + return unless roles && call.receiver.nil? + + args = call.arguments&.arguments || [] + args.grep(Prism::SymbolNode).each do |sym| + name = sym.unescaped.to_sym + roles.each { |role| attrs[side][role] << name } + end + end + + def def_entry(node, side) + ivar_writes = [] + writer_calls = [] + scan_def_body(node.body, ivar_writes, writer_calls) + Entry.new(side: side, name: node.name, + return_expr: def_return_expr(node), + ivar_writes: ivar_writes, writer_calls: writer_calls) + end + + def scan_def_body(node, ivar_writes, writer_calls) + return if node.nil? || node.is_a?(Prism::ClassNode) || + node.is_a?(Prism::ModuleNode) || + node.is_a?(Prism::SingletonClassNode) || + node.is_a?(Prism::DefNode) + + case node + when Prism::InstanceVariableWriteNode, + Prism::InstanceVariableOrWriteNode + ivar_writes << [node.name, node.value] + when Prism::CallNode + args = node.arguments&.arguments + if node.receiver && args && args.size == 1 && + node.name.match?(WRITER_NAME) + writer_calls << [node.name.to_s.chomp("=").to_sym, + args.first] + end + end + node.child_nodes.compact.each do |child| + scan_def_body(child, ivar_writes, writer_calls) + end + end + + def def_return_expr(node) + body = node.body + body = body.statements if body.is_a?(Prism::BeginNode) + body.body.last if body.is_a?(Prism::StatementsNode) + end + + def settle(registry, entries, attrs) + loop do + changed = false + entries.each do |entry| + changed = true if settle_entry(entry, registry, attrs) + end + changed = true if settle_readers(registry, attrs) + break unless changed + end + end + + # An attr reader over a tainted ivar hands the taint out + # like a method returning it would. + def settle_readers(registry, attrs) + changed = false + %i[instance singleton].each do |side| + attrs[side][:reader].each do |name| + next if registry.methods[side][name] + + rule = registry.ivars[side][:"@#{name}"] + if rule + registry.methods[side][name] = rule + changed = true + end + end + end + changed + end + + def settle_entry(entry, registry, attrs) + changed = false + if entry.name && !registry.methods[entry.side][entry.name] && + (rule = return_rule(entry.return_expr, entry.side, registry)) + registry.methods[entry.side][entry.name] = rule + changed = true + end + entry.ivar_writes.each do |ivar, value| + next if registry.ivars[entry.side][ivar] + + rule = prepass_rule(value, entry.side, registry) + if rule + registry.ivars[entry.side][ivar] = rule + changed = true + end + end + entry.writer_calls.each do |name, value| + side = writer_side(name, entry.side, attrs) + next unless side + next if registry.ivars[side][:"@#{name}"] + + rule = prepass_rule(value, entry.side, registry) + if rule + registry.ivars[side][:"@#{name}"] = rule + changed = true + end + end + changed + end + + # A writer call taints the ivar behind the attr on the side + # that declares it, wherever the call sits. + def writer_side(name, caller_side, attrs) + %i[instance singleton] + .sort_by { |side| (side == caller_side) ? 0 : 1 } + .find { |side| attrs[side][:writer].include?(name) } + end + + def return_rule(expr, side, registry) + case expr + when Prism::InstanceVariableWriteNode, + Prism::InstanceVariableOrWriteNode + prepass_rule(expr.value, side, registry) + else + prepass_rule(expr, side, registry) + end + end + + # The pre-pass mirror of visit_call's taint sources, over + # syntax alone: a chain is tainted when its root is a + # flagged constant, a tainted ivar, or a call to a method + # the registry already holds. + def prepass_rule(node, side, registry) + case node + when Prism::InstanceVariableReadNode + registry.ivars[side][node.name] + when Prism::CallNode + if CORE_METHODS.include?(node.name) + return nil unless CHAIN_METHODS.include?(node.name) + + return prepass_rule(node.receiver, side, registry) + end + return nil if node.name.end_with?("?") + + if t_call?(node, :let) || t_call?(node, :cast) + args = node.arguments&.arguments + return (args && args.size >= 2) ? type_rule(args[1]) : nil + end + prepass_receiver_rule(node, side, registry) + end + end + + def prepass_receiver_rule(node, side, registry) + case (receiver = node.receiver) + when Prism::ConstantReadNode, Prism::ConstantPathNode + receiver_rule(receiver) || + (registry.methods[:singleton][node.name] if + names_class?(receiver, registry.class_name)) + when Prism::CallNode + if receiver.name == :class && + receiver.receiver.is_a?(Prism::SelfNode) + registry.methods[:singleton][node.name] + else + prepass_rule(receiver, side, registry) + end + when nil, Prism::SelfNode + registry.methods[side][node.name] + when Prism::InstanceVariableReadNode + registry.ivars[side][receiver.name] + end + end + + # The class's own name in receiver position reaches its + # singleton: the full path, or the trailing segments an + # inner reference would use. + def names_class?(receiver, class_name) + path = receiver.location.slice.delete_prefix("::") + path == class_name || class_name.end_with?("::#{path}") || + path == class_name.split("::").last + end + + # Returns the matched rule so the caller can taint the + # call's block even when the return value stays clean. + def visit_call(node, tainted, ivars, scan) + if CORE_METHODS.include?(node.name) + return chain_through(node, tainted, ivars, scan) + end + + if (rule = receiver_rule(node.receiver)) + scan.findings << finding_for(rule, node, scan.path) + scan.producers[node.object_id] = rule + elsif (rule = annotation_rule(node, scan)) + scan.producers[node.object_id] = rule + elsif (rule = taint_source(node, tainted, ivars, scan)) + scan.findings << derived_finding(rule, node, scan.path) + # A predicate returns a plain boolean and ends the + # chain; anything else is presumed to still live in the + # extension. + unless node.name.end_with?("?") + scan.producers[node.object_id] = rule + end + rule + elsif (rule = registry_rule(node)) + scan.producers[node.object_id] = rule + elsif (rule = self_call_rule(node)) + scan.findings << derived_finding(rule, node, scan.path) + unless node.name.end_with?("?") + scan.producers[node.object_id] = rule + end + rule + end + end + + # Inside a reopened bound class, self is the extension's + # own object: a self-call the body does not define lands in + # the extension, and so does a bare argumentless call that + # is not plain Ruby. + def self_call_rule(node) + return nil unless @self_rule + + if node.receiver.is_a?(Prism::SelfNode) + return nil if @self_defs.include?(node.name) + + @self_rule + elsif node.receiver.nil? && node.arguments.nil? && !node.block + return nil if @self_defs.include?(node.name) || + RUBY_METHODS.include?(node.name) + + @self_rule + end + end + + def chain_through(node, tainted, ivars, scan) + return unless CHAIN_METHODS.include?(node.name) + + rule = taint_source(node, tainted, ivars, scan) || + receiver_rule(node.receiver) + scan.producers[node.object_id] = rule if rule + end + + # Calls to methods the pre-pass proved to hand out extension + # values produce taint but no finding of their own: the + # finding lands where the value is used. + def registry_rule(node) + return nil unless @registry + + case (receiver = node.receiver) + when nil, Prism::SelfNode + method_rule(@singleton ? :singleton : :instance, node.name) + when Prism::CallNode + if receiver.name == :class && + receiver.receiver.is_a?(Prism::SelfNode) + method_rule(:singleton, node.name) + end + when Prism::ConstantReadNode, Prism::ConstantPathNode + if names_class?(receiver, @registry.class_name) + method_rule(:singleton, node.name) + end + end + end + + def method_rule(side, name) + @registry.methods[side][name] || + @return_taints[[seed_scope, side, name]] + end + + def taint_source(node, tainted, ivars, scan) + case (receiver = node.receiver) + when Prism::LocalVariableReadNode + tainted[receiver.name] + when Prism::InstanceVariableReadNode + ivars[receiver.name] + when Prism::CallNode + scan.producers[receiver.object_id] + end + end + + # T.let and T.cast assert the value's type: a type under a + # flagged namespace taints, any other constant type clears + # whatever the value expression suggested. + def annotation_rule(node, scan) + return nil unless t_call?(node, :let) || t_call?(node, :cast) + + args = node.arguments&.arguments + return nil unless args && args.size >= 2 + + rule = type_rule(args[1]) + return rule if rule + + constant_type?(args[1]) ? nil : scan.producers[args[0].object_id] + end + + def constant_type?(node) + node.is_a?(Prism::ConstantReadNode) || + node.is_a?(Prism::ConstantPathNode) + end + + def sig_node?(node) + node.is_a?(Prism::CallNode) && node.name == :sig && node.block + end + + # Params typed with a constant under a flagged namespace seed + # the def's taint scope. + def sig_taints(sig) + params = sig_chain_call(sig, :params) + args = params&.arguments&.arguments + return {} unless args + + taints = {} + args.grep(Prism::KeywordHashNode).each do |kw| + kw.elements.each do |assoc| + next unless assoc.is_a?(Prism::AssocNode) && + assoc.key.is_a?(Prism::SymbolNode) + + rule = type_rule(assoc.value) + taints[assoc.key.unescaped.to_sym] = rule if rule + end + end + taints + end + + # Param names the sig types with a constant outside every + # flagged namespace: proven plain, immune to seeding. + def plain_params(sig) + params = sig_chain_call(sig, :params) + args = params&.arguments&.arguments + return EMPTY_SET unless args + + plain = Set.new + args.grep(Prism::KeywordHashNode).each do |kw| + kw.elements.each do |assoc| + next unless assoc.is_a?(Prism::AssocNode) && + assoc.key.is_a?(Prism::SymbolNode) + next unless plain_type?(assoc.value) + + plain << assoc.key.unescaped.to_sym + end + end + plain + end + + # A bare unflagged constant, T.nilable of one, or a T:: + # container holding only such constants; T.untyped inside + # a container keeps the erasure. + def plain_type?(node) + case node + when Prism::ConstantReadNode, Prism::ConstantPathNode + source_rule(node.location.slice).nil? + when Prism::CallNode + args = node.arguments&.arguments + if t_call?(node, :nilable) + args&.size == 1 && plain_type?(args.first) + elsif t_container?(node) + !args.nil? && args.all? { |arg| plain_type?(arg) } + else + false + end + else + false + end + end + + def t_container?(node) + return false unless node.name == :[] && + constant_type?(node.receiver) + + node.receiver.location.slice.delete_prefix("::") + .start_with?("T::") + end + + def sig_chain_call(sig, name) + body = sig.block.body + node = body.is_a?(Prism::StatementsNode) ? body.body.first : body + while node.is_a?(Prism::CallNode) + return node if node.name == name + + node = node.receiver + end + nil + end + + def type_rule(node) + case node + when Prism::ConstantReadNode, Prism::ConstantPathNode + source_rule(node.location.slice) + when Prism::CallNode + args = node.arguments&.arguments + if t_call?(node, :nilable) && args&.size == 1 + type_rule(args.first) + end + end + end + + def t_call?(node, name) + node.name == name && + node.receiver.is_a?(Prism::ConstantReadNode) && + node.receiver.name == :T + end + + def index_rules + @rule_index = @rules.to_h { |rule| [rule.namespace, rule] } + end + + # Cumulative :: prefixes of the path against the namespace + # index, shortest first, tried under each level of the given + # nesting before the path as written—flat cost however + # many classes the tree promotes to rules. + def namespace_rule(path, nesting = EMPTY_NESTING) + segments = path.split("::") + nesting.size.downto(1) do |depth| + rule = prefix_rule(nesting.first(depth), segments) + return rule if rule + end + prefix_rule(EMPTY_NESTING, segments) + end + + def prefix_rule(base, segments) + prefix = base.empty? ? nil : base.join("::") + segments.each do |seg| + prefix = prefix ? "#{prefix}::#{seg}" : seg + rule = @rule_index[prefix] + return rule if rule + end + nil + end + + # An anchored path resolves from the root; anything else the + # way Ruby would look it up, innermost scope first. + def source_rule(slice) + if slice.start_with?("::") + namespace_rule(slice.delete_prefix("::")) + else + namespace_rule(slice, @nesting) + end + end + + def receiver_rule(receiver) + return nil unless constant_type?(receiver) + + source_rule(receiver.location.slice) + end + + def finding_for(rule, node, path) + interp = { + method: node.name, gem: rule.gem, + receiver: node.receiver.location.slice.delete_prefix("::") + } + message = rule.verified ? MESSAGE : UNVERIFIED_MESSAGE + # In a chained call the node spans its whole receiver; + # the method-name line is where the reader looks. + location = node.message_loc || node.location + Finding.new( + check: CHECK, + severity: :warning, + message: format(message, interp), + why: format(rule.verified ? UNSAFE_WHY : UNVERIFIED_WHY, interp), + fix: format(FIX, interp), + path: path, + line: location.start_line, + source: node.location.slice.lines.first&.strip + ) + end + + def derived_finding(rule, node, path) + interp = {method: node.name, gem: rule.gem} + location = node.message_loc || node.location + Finding.new( + check: CHECK, + severity: :warning, + message: format(DERIVED_MESSAGE, interp), + why: format(rule.verified ? UNSAFE_WHY : UNVERIFIED_WHY, interp), + fix: format(FIX, interp), + path: path, + line: location.start_line, + source: node.location.slice.lines.first&.strip + ) + end + end + end +end diff --git a/spec/audition/static/checks/dependency_class_state_spec.rb b/spec/audition/static/checks/dependency_class_state_spec.rb new file mode 100644 index 0000000..92bc38a --- /dev/null +++ b/spec/audition/static/checks/dependency_class_state_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require "tmpdir" + +RSpec.describe Audition::Static::Checks::DependencyClassState do + around do |example| + previous = described_class.attributes + example.run + described_class.attributes = previous.flat_map do |owner, names| + names.map { |name| [owner, name] } + end + end + + def learn(stub) + Dir.mktmpdir do |dir| + path = File.join(dir, "kit@1.0.0.rbi") + File.write(path, stub) + described_class.learn([path]) + yield + end + end + + def findings_for(source) + Audition::Static::Analyzer.new(checks: [described_class]) + .analyze_source(source, path: "a.rb") + end + + # The generator's own marker for a method it wrote from an + # attribute, alongside one that computes its answer. + let(:attribute_stub) do + <<~RBI + # typed: true + + module Kit + class << self + # Returns the value of attribute registry. + # + # source://kit//lib/kit.rb#9 + def registry; end + + # source://kit//lib/kit.rb#11 + def registry=(value); end + + # Builds a fresh client from the configuration. + # + # source://kit//lib/kit.rb#20 + def client; end + end + end + RBI + end + + it "flags a read of a documented singleton attribute" do + learn(attribute_stub) do + findings = findings_for("Kit.registry\n") + + expect(findings.size).to eq(1) + expect(findings.first).to have_attributes( + check: "dependency-class-state", line: 1, severity: :warning + ) + expect(findings.first.message).to include("Kit.registry") + end + end + + it "flags a write as an error" do + learn(attribute_stub) do + findings = findings_for("Kit.registry = {}\n") + + expect(findings.size).to eq(1) + expect(findings.first).to have_attributes( + line: 1, severity: :error + ) + expect(findings.first.message).to include("Kit.registry=") + end + end + + it "leaves a method that computes its answer alone" do + learn(attribute_stub) do + expect(findings_for("Kit.client\n")).to be_empty + end + end + + it "ignores an attribute on the instance side" do + stub = <<~RBI + # typed: true + + module Kit + # Returns the value of attribute registry. + # + # source://kit//lib/kit.rb#9 + def registry; end + end + RBI + + learn(stub) do + expect(findings_for("Kit.registry\n")).to be_empty + end + end + + it "matches a nested owner through its full path" do + stub = <<~RBI + # typed: true + + module Kit + class Vault + class << self + # Returns the value of attribute store. + # + # source://kit//lib/kit/vault.rb#4 + def store; end + end + end + end + RBI + + learn(stub) do + findings = findings_for("Kit::Vault.store\nVault.store\n") + + expect(findings.map(&:line)).to contain_exactly(1) + end + end + + it "reads nothing from sources that are not stubs" do + Dir.mktmpdir do |dir| + path = File.join(dir, "kit.rb") + File.write(path, attribute_stub) + described_class.learn([path]) + + expect(findings_for("Kit.registry\n")).to be_empty + end + end +end diff --git a/spec/audition/static/checks/unshareable_reads_spec.rb b/spec/audition/static/checks/unshareable_reads_spec.rb new file mode 100644 index 0000000..2c3580b --- /dev/null +++ b/spec/audition/static/checks/unshareable_reads_spec.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +require "tmpdir" + +RSpec.describe Audition::Static::Checks::UnshareableReads do + def findings_for(code) + analyzer = + Audition::Static::Analyzer.new(checks: [described_class]) + analyzer.analyze_source(code, path: "test.rb") + end + + it "flags every read site of T::Boolean" do + findings = findings_for(<<~RUBY) + class Widget + sig { returns(T::Boolean) } + def ready? + @ready = T.let(compute, T::Boolean) + end + end + RUBY + + expect(findings.size).to eq(2) + expect(findings.map(&:line)).to eq([2, 4]) + expect(findings).to all(have_attributes(severity: :warning)) + expect(findings.first.message).to include("T::Boolean") + expect(findings.first.why).to include("sig blocks") + end + + it "matches the fully qualified form" do + findings = findings_for("x = ::T::Boolean\n") + + expect(findings.size).to eq(1) + end + + it "leaves other Sorbet constants alone" do + findings = findings_for(<<~RUBY) + sig { params(items: T::Array[String]).void } + def go(items); end + RUBY + + expect(findings).to be_empty + end + + describe "dynamic constants" do + after { described_class.dynamic = [] } + + it "flags value reads under a learned namespace" do + described_class.dynamic = [%w[Kit Vault]] + findings = findings_for(<<~RUBY) + token = Kit::Vault::API_TOKEN + client = Kit::Vault::Client.new + RUBY + + expect(findings.map(&:line)).to eq([1]) + expect(findings.first.message).to include("API_TOKEN") + expect(findings.first.why).to include("const_set") + end + + it "learns namespaces that const_set computed names" do + Dir.mktmpdir do |dir| + File.write(File.join(dir, "vault.rb"), <<~RUBY) + module Vault + class << self + def load(data) + data.each { |name, value| const_set(name, value) } + end + end + end + RUBY + + described_class.learn(Dir.glob(File.join(dir, "*"))) + + expect(described_class.dynamic).to eq([%w[Vault]]) + end + end + + it "ignores const_set with a literal name" do + Dir.mktmpdir do |dir| + File.write(File.join(dir, "kit.rb"), <<~RUBY) + module Kit + const_set(:VERSION, "1.0") + end + RUBY + + described_class.learn(Dir.glob(File.join(dir, "*"))) + + expect(described_class.dynamic).to be_empty + end + end + end + + describe "learned aliases" do + after { described_class.learned = [] } + + it "flags learned aliases read in type positions" do + described_class.learned = [%w[Kit Widget RowType]] + findings = findings_for(<<~RUBY) + class Builder + sig { params(row: RowType).void } + def go(row) + T.cast(fetch, Kit::Widget::RowType) + end + end + RUBY + + expect(findings.map(&:line)).to eq([2, 4]) + expect(findings.first.message).to include("RowType") + end + + it "leaves a same-named constant outside type positions alone" do + described_class.learned = [%w[Kit RowType]] + findings = findings_for(<<~RUBY) + row = RowType.new + T.let(RowType.parse(raw), String) + RUBY + + expect(findings).to be_empty + end + + it "requires the read to line up with the definition's tail" do + described_class.learned = [%w[Kit RowType]] + findings = findings_for(<<~RUBY) + sig { returns(Other::RowType) } + def row; end + RUBY + + expect(findings).to be_empty + end + + it "learns definitions from swept files" do + Dir.mktmpdir do |dir| + File.write(File.join(dir, "kit.rbi"), <<~RUBY) + Kit::RowType = T.type_alias { T::Hash[Symbol, Symbol] } + RUBY + File.write(File.join(dir, "widget.rb"), <<~RUBY) + module Kit + ColType = T.type_alias do + T::Array[String] + end + end + RUBY + + described_class.learn(Dir.glob(File.join(dir, "*"))) + findings = findings_for(<<~RUBY) + sig { params(row: Kit::RowType, col: ColType).void } + def go(row, col); end + RUBY + + expect(findings.size).to eq(2) + end + end + end +end diff --git a/spec/audition/static/gem_calls_spec.rb b/spec/audition/static/gem_calls_spec.rb new file mode 100644 index 0000000..fb7c450 --- /dev/null +++ b/spec/audition/static/gem_calls_spec.rb @@ -0,0 +1,1364 @@ +# frozen_string_literal: true + +require "tmpdir" +require "fileutils" + +RSpec.describe Audition::Static::GemCalls do + def rule(gem: "fast_widget", namespace: "FastWidget", verified: true) + described_class::Rule.new( + gem: gem, namespace: namespace, verified: verified + ) + end + + def scan(code, rules: [rule]) + Dir.mktmpdir do |dir| + path = File.join(dir, "a.rb") + File.write(path, code) + return described_class.new(root: dir, rules: rules) + .analyze_paths([path]) + end + end + + # Stubs reach the check from the target, which finds them by + # extension rather than in a fixed directory. + def scanner(root) + described_class.new(root: root, + stubs: Audition::Target.stubs(root)) + end + + def lockfile(dir, pins) + specs = pins.map { |p| " #{p}\n" }.join + File.write(File.join(dir, "Gemfile.lock"), + "GEM\n specs:\n#{specs}") + end + + def install_fake_gem(base, name:, binary: nil, version: "1.0.0", + entry: nil, extensions: []) + full = "#{name}-#{version}" + lib = File.join(base, "gems", full, "lib") + entry_path = File.join(lib, "#{name.tr("-", "/")}.rb") + FileUtils.mkdir_p(File.dirname(entry_path)) + File.write(entry_path, + entry || "module FastWidget\nend\n") + File.binwrite(File.join(lib, "#{name}.bundle"), binary) if binary + spec_dir = File.join(base, "specifications") + FileUtils.mkdir_p(spec_dir) + File.write(File.join(spec_dir, "#{full}.gemspec"), <<~RUBY) + Gem::Specification.new do |s| + s.name = #{name.inspect} + s.version = #{version.inspect} + s.summary = "fake" + s.authors = ["spec"] + s.extensions = #{extensions.inspect} + end + RUBY + end + + # One entry per method, each with its own source line, unless + # shared: is given—the shape a compiled extension leaves. The + # directory is arbitrary: stubs are found by extension. + def write_stub(root, name:, methods:, version: "1.0.0", + shared: nil, dir: "types/generated") + dir = File.join(root, dir) + FileUtils.mkdir_p(dir) + line = 10 + body = methods.map do |owner, names| + defs = names.map do |method| + line += 1 + at = shared || line + " # source://#{name}//lib/#{name}.rb##{at}\n" \ + " def #{method}; end\n" + end.join("\n") + "class #{owner}\n#{defs}end\n" + end.join("\n") + File.write(File.join(dir, "#{name}@#{version}.rbi"), + "# typed: true\n\n#{body}") + end + + describe "bundle resolution" do + it "derives a verified rule from an installed extension " \ + "lacking the declaration" do + Dir.mktmpdir do |base| + Dir.mktmpdir do |root| + install_fake_gem(base, name: "fast_widget", + binary: "\x00mach\x00") + lockfile(root, ["fast_widget (1.0.0)"]) + allow(Gem).to receive(:path).and_return([base]) + path = File.join(root, "a.rb") + File.write(path, "FastWidget.render(v)\n") + + findings = described_class.new(root: root) + .analyze_paths([path]) + expect(findings.size).to eq(1) + expect(findings.first).to have_attributes( + check: "native-gem-calls", severity: :warning, line: 1 + ) + expect(findings.first.message) + .to include("does not declare Ractor safety") + expect(findings.first.why).to include("Ractor::UnsafeError") + end + end + end + + it "drops gems whose extension declares safety" do + Dir.mktmpdir do |base| + Dir.mktmpdir do |root| + install_fake_gem(base, name: "fast_widget", + binary: "\x00rb_ext_ractor_safe\x00") + lockfile(root, ["fast_widget (1.0.0)"]) + allow(Gem).to receive(:path).and_return([base]) + path = File.join(root, "a.rb") + File.write(path, "FastWidget.render(v)\n") + + expect(described_class.new(root: root).analyze_paths([path])) + .to be_empty + end + end + end + + it "reads the namespace from the entry file's module nesting" do + Dir.mktmpdir do |base| + Dir.mktmpdir do |root| + install_fake_gem(base, name: "fast_widget", binary: "\x00", + entry: "require \"json\"\n" \ + "module Fast\n" \ + " module WidgetCore\n" \ + " VERSION = \"1\"\n" \ + " end\n" \ + "end\n") + lockfile(root, ["fast_widget (1.0.0)"]) + allow(Gem).to receive(:path).and_return([base]) + path = File.join(root, "a.rb") + File.write(path, "Fast::WidgetCore.render(v)\n" \ + "FastWidget.render(v)\n") + + findings = described_class.new(root: root) + .analyze_paths([path]) + expect(findings.map(&:line)).to contain_exactly(1) + end + end + end + + it "flags platform-pinned gems it cannot inspect as unverified" do + Dir.mktmpdir do |base| + Dir.mktmpdir do |root| + lockfile(root, ["fast_widget (1.0.0-arm64-darwin)", + "fast_widget (1.0.0-x86_64-linux-gnu)"]) + allow(Gem).to receive(:path).and_return([base]) + path = File.join(root, "a.rb") + File.write(path, "FastWidget.render(v)\n") + + findings = described_class.new(root: root) + .analyze_paths([path]) + expect(findings.size).to eq(1) + expect(findings.first.message).to include("could not inspect") + expect(findings.first.why).to include("install the bundle") + end + end + end + + it "settles a spec with no readable binary against Ruby's " \ + "own extension" do + Dir.mktmpdir do |base| + Dir.mktmpdir do |root| + Dir.mktmpdir do |archdir| + install_fake_gem(base, name: "fast_widget", + extensions: ["ext/fast_widget/extconf.rb"]) + File.binwrite(File.join(archdir, "fast_widget.bundle"), + "\x00rb_ext_ractor_safe\x00") + lockfile(root, ["fast_widget (1.0.0)"]) + allow(Gem).to receive(:path).and_return([base]) + allow(RbConfig::CONFIG).to receive(:[]).and_call_original + allow(RbConfig::CONFIG).to receive(:[]).with("archdir") + .and_return(archdir) + path = File.join(root, "a.rb") + File.write(path, "FastWidget.render(v)\n") + + expect(described_class.new(root: root) + .analyze_paths([path])).to be_empty + end + end + end + end + + it "clears a platform pin when Ruby's own extension declares " \ + "safety" do + Dir.mktmpdir do |base| + Dir.mktmpdir do |root| + Dir.mktmpdir do |archdir| + File.binwrite(File.join(archdir, "fast_widget.bundle"), + "\x00rb_ext_ractor_safe\x00") + lockfile(root, ["fast_widget (1.0.0-arm64-darwin)"]) + allow(Gem).to receive(:path).and_return([base]) + allow(RbConfig::CONFIG).to receive(:[]).and_call_original + allow(RbConfig::CONFIG).to receive(:[]).with("archdir") + .and_return(archdir) + path = File.join(root, "a.rb") + File.write(path, "FastWidget.render(v)\n") + + expect(described_class.new(root: root) + .analyze_paths([path])).to be_empty + end + end + end + end + + it "verifies a platform pin against Ruby's own extension when " \ + "it lacks the declaration" do + Dir.mktmpdir do |base| + Dir.mktmpdir do |root| + Dir.mktmpdir do |archdir| + File.binwrite(File.join(archdir, "fast_widget.bundle"), + "\x00mach\x00") + lockfile(root, ["fast_widget (1.0.0-arm64-darwin)"]) + allow(Gem).to receive(:path).and_return([base]) + allow(RbConfig::CONFIG).to receive(:[]).and_call_original + allow(RbConfig::CONFIG).to receive(:[]).with("archdir") + .and_return(archdir) + path = File.join(root, "a.rb") + File.write(path, "FastWidget.render(v)\n") + + findings = described_class.new(root: root) + .analyze_paths([path]) + expect(findings.size).to eq(1) + expect(findings.first.message) + .to include("does not declare Ractor safety") + end + end + end + end + + it "ignores pure-Ruby pins and gems it knows nothing about" do + Dir.mktmpdir do |base| + Dir.mktmpdir do |root| + lockfile(root, ["plainlib (1.0.0)"]) + allow(Gem).to receive(:path).and_return([base]) + path = File.join(root, "a.rb") + File.write(path, "Plainlib.run(v)\n") + + expect(described_class.new(root: root).analyze_paths([path])) + .to be_empty + end + end + end + + it "reads compiled evidence from a generated type stub" do + Dir.mktmpdir do |base| + Dir.mktmpdir do |root| + lockfile(root, ["fast_widget (1.0.0)"]) + write_stub(root, name: "fast_widget", shared: 17, + methods: {"Fast::Widget" => %w[render parse], + "Fast::Widget::Doc" => %w[items first]}) + allow(Gem).to receive(:path).and_return([base]) + path = File.join(root, "a.rb") + File.write(path, "Fast::Widget.render(v)\n" \ + "Fast::Other.render(v)\n") + + findings = scanner(root).analyze_paths([path]) + expect(findings.map(&:line)).to contain_exactly(1) + expect(findings.first.message) + .to include("could not inspect") + end + end + end + + it "ignores a stub whose methods are Ruby-defined" do + Dir.mktmpdir do |base| + Dir.mktmpdir do |root| + lockfile(root, ["fast_widget (1.0.0)"]) + write_stub(root, name: "fast_widget", + methods: {"Fast::Widget" => %w[render parse], + "Fast::Widget::Doc" => %w[items first]}) + allow(Gem).to receive(:path).and_return([base]) + path = File.join(root, "a.rb") + File.write(path, "Fast::Widget.render(v)\n") + + expect(scanner(root).analyze_paths([path])).to be_empty + end + end + end + + it "ignores a stub cluster owned by a single class" do + Dir.mktmpdir do |base| + Dir.mktmpdir do |root| + lockfile(root, ["fast_widget (1.0.0)"]) + write_stub(root, name: "fast_widget", shared: 17, + methods: {"Fast::Widget" => %w[render parse items]}) + allow(Gem).to receive(:path).and_return([base]) + path = File.join(root, "a.rb") + File.write(path, "Fast::Widget.render(v)\n") + + expect(scanner(root).analyze_paths([path])).to be_empty + end + end + end + + it "adds stub namespaces to a platform pin's own name" do + Dir.mktmpdir do |base| + Dir.mktmpdir do |root| + lockfile(root, ["fast_widget (1.0.0-arm64-darwin)"]) + write_stub(root, name: "fast_widget", shared: 17, + methods: {"Fast::Widget" => %w[render parse], + "Fast::Widget::Doc" => %w[items first]}) + allow(Gem).to receive(:path).and_return([base]) + path = File.join(root, "a.rb") + File.write(path, "FastWidget.render(v)\n" \ + "Fast::Widget::Doc.items\n") + + findings = scanner(root).analyze_paths([path]) + expect(findings.map(&:line)).to contain_exactly(1, 2) + end + end + end + + it "returns nothing without a lockfile" do + Dir.mktmpdir do |root| + path = File.join(root, "a.rb") + File.write(path, "FastWidget.render(v)\n") + + expect(described_class.new(root: root).analyze_paths([path])) + .to be_empty + end + end + end + + it "flags calls anywhere under the gem's namespace" do + findings = scan( + "FastWidget.parse(v)\n" \ + "FastWidget::HTML::Document.parse(v)\n" + ) + + expect(findings.map(&:line)).to contain_exactly(1, 2) + expect(findings.first.fix).to include("main Ractor") + end + + it "leaves lookalike receivers alone" do + findings = scan( + "FastWidgetry.parse(v)\n" \ + "Acme::FastWidget.parse(v)\n" \ + "registry.parse(v)\n" + ) + + expect(findings).to be_empty + end + + it "skips core Object methods on the namespace" do + findings = scan("FastWidget.respond_to?(:parse)\n") + + expect(findings).to be_empty + end + + it "follows a gem object through the local it is assigned to" do + findings = scan( + "doc = FastWidget.parse(markup)\n" \ + "doc.items\n" \ + "doc.nil?\n" \ + "doc if doc.frozen?\n" + ) + + expect(findings.map(&:line)).to contain_exactly(1, 2) + derived = findings.find { |f| f.line == 2 } + expect(derived.message).to include("items") + expect(derived.message).to include("handed out") + end + + it "drops the taint when the local is reassigned" do + findings = scan( + "doc = FastWidget.parse(markup)\n" \ + "doc = plain\n" \ + "doc.items\n" + ) + + expect(findings.map(&:line)).to contain_exactly(1) + end + + it "keeps taint out of other method scopes" do + findings = scan( + "doc = FastWidget.parse(markup)\n" \ + "def render(doc)\n" \ + " doc.items\n" \ + "end\n" + ) + + expect(findings.map(&:line)).to contain_exactly(1) + end + + it "follows a syntactic chain rooted in the namespace" do + findings = scan( + "klass = FastWidget.pool\n" \ + " .lookup(\"w\").msgclass\n" \ + "klass.decode(bytes)\n" + ) + + methods = findings.map { |f| f.message[/\A\w+/] } + expect(methods).to include("lookup", "msgclass", "decode") + decode = findings.find { |f| f.message.start_with?("decode") } + expect(decode.line).to eq(3) + end + + it "follows the taint through a local hop" do + findings = scan( + "doc = FastWidget.parse(markup)\n" \ + "items = doc.items\n" \ + "items.push(v)\n" + ) + + expect(findings.map(&:line)).to contain_exactly(1, 2, 3) + end + + it "stops the chain at a predicate" do + findings = scan( + "doc = FastWidget.parse(markup)\n" \ + "ok = doc.valid?\n" \ + "ok.inspect\n" + ) + + expect(findings.map(&:line)).to contain_exactly(1, 2) + end + + it "passes the taint through class, freeze, and tap silently" do + findings = scan( + "doc = FastWidget.parse(markup)\n" \ + "doc.class.descriptor\n" \ + "doc.freeze.items\n" + ) + + expect(findings.map(&:line)).to contain_exactly(1, 2, 3) + methods = findings.map { |f| f.message[/\A\w+/] } + expect(methods).to include("descriptor", "items") + expect(methods).not_to include("class", "freeze") + end + + it "flags the chained call on its own line" do + findings = scan( + "class Widget\n" \ + " def initialize\n" \ + " @raw = FastWidget::Codec.decode(data)\n" \ + " end\n" \ + " def names\n" \ + " @raw.fetch(:names)\n" \ + " .map { |v| v.to_sym }\n" \ + " end\n" \ + "end\n" + ) + + expect(findings.map(&:line).uniq).to contain_exactly(3, 6, 7) + end + + it "taints params typed under the namespace in a sig" do + findings = scan( + "sig { params(doc: FastWidget::Document, n: Integer).void }\n" \ + "def render(doc, n)\n" \ + " doc.items\n" \ + " n.to_s\n" \ + "end\n" + ) + + expect(findings.map(&:line)).to contain_exactly(3) + end + + it "taints locals annotated with T.let under the namespace" do + findings = scan( + "doc = T.let(fetch_doc, T.nilable(FastWidget::Document))\n" \ + "doc.items\n" + ) + + expect(findings.map(&:line)).to contain_exactly(2) + end + + it "clears the taint when T.let names a plain type" do + findings = scan( + "s = T.let(FastWidget.render(v), String)\n" \ + "s.length\n" + ) + + expect(findings.map(&:line)).to contain_exactly(1) + end + + it "carries taint through ivars across methods" do + findings = scan( + "class Renderer\n" \ + " def initialize(markup)\n" \ + " @doc = FastWidget.parse(markup)\n" \ + " end\n" \ + "\n" \ + " def links\n" \ + " @doc.items\n" \ + " end\n" \ + "end\n" + ) + + expect(findings.map(&:line)).to contain_exactly(3, 7) + end + + it "carries taint into a local copied from an ivar" do + findings = scan( + "class Renderer\n" \ + " def initialize(markup)\n" \ + " @doc = FastWidget.parse(markup)\n" \ + " end\n" \ + "\n" \ + " def links\n" \ + " doc = @doc\n" \ + " doc.items\n" \ + " end\n" \ + "end\n" + ) + + expect(findings.map(&:line)).to contain_exactly(3, 8) + end + + it "hands a tainted receiver to a then block" do + findings = scan(<<~RUBY) + doc = FastWidget.parse(markup) + doc.then { |d| d.items } + RUBY + + expect(findings.map(&:line)).to contain_exactly(1, 2) + end + + it "keeps taint one branch introduces past the branch" do + findings = scan(<<~RUBY) + if native? + doc = FastWidget.parse(markup) + else + doc = plain_parse(markup) + end + doc.items + RUBY + + expect(findings.map(&:line)).to contain_exactly(2, 6) + end + + it "drops taint every branch replaces" do + findings = scan(<<~RUBY) + doc = FastWidget.parse(markup) + if native? + doc = plain_parse(markup) + else + doc = other_parse(markup) + end + doc.items + RUBY + + expect(findings.map(&:line)).to contain_exactly(1) + end + + it "skips unparseable files" do + findings = scan("def broken(\n") + + expect(findings).to be_empty + end + + describe "block parameters" do + it "taints block parameters of a call on a tainted receiver" do + findings = scan(<<~RUBY) + doc = FastWidget.parse(markup) + names = doc.items.map do |item| + item.label + end + names.first + RUBY + + label = findings.find { |f| f.message.start_with?("label") } + expect(label&.line).to eq(3) + end + + it "drops the block taint after the block" do + findings = scan(<<~RUBY) + item = plain + doc = FastWidget.parse(markup) + doc.items.each do |item| + item.label + end + item.label + RUBY + + expect(findings.map(&:line).uniq).to contain_exactly(2, 3, 4) + end + + it "keeps the memo parameter of each_with_object clean" do + findings = scan(<<~RUBY) + doc = FastWidget.parse(markup) + doc.fields.each_with_object({}) do |field, acc| + acc.store(field.key, 1) + end + RUBY + + methods = findings.map { |f| f.message[/\A[\w?]+/] } + expect(methods).to include("key") + expect(methods).not_to include("store") + end + + it "taints block parameters of a predicate iterator" do + findings = scan(<<~RUBY) + doc = FastWidget.parse(markup) + ok = doc.items.any? { |item| item.valid? } + ok.inspect + RUBY + + expect(findings.map(&:line).uniq).to contain_exactly(1, 2) + methods = findings.map { |f| f.message[/\A[\w?]+/] } + expect(methods).to include("valid?") + end + + it "taints numbered block parameters" do + findings = scan( + "doc = FastWidget.parse(markup)\n" \ + "doc.items.each { _1.label }\n" + ) + + methods = findings.map { |f| f.message[/\A[\w?]+/] } + expect(methods).to include("label") + end + + it "taints destructured block parameters" do + findings = scan(<<~RUBY) + doc = FastWidget.parse(markup) + doc.entries.each do |(key, val)| + val.pack + end + RUBY + + methods = findings.map { |f| f.message[/\A[\w?]+/] } + expect(methods).to include("pack") + end + + it "leaves blocks on plain calls alone" do + findings = scan("rows.each { |row| row.save }\n") + + expect(findings).to be_empty + end + end + + describe "argument taint" do + it "seeds a callee parameter from a tainted argument" do + findings = scan(<<~RUBY) + class Report + sig { params(widget: FastWidget::Row).void } + def handle(widget) + errors = widget.errors + log(errors) + end + + sig { params(errors: T::Array[T.untyped]).void } + def log(errors) + errors.each do |error| + error.message + end + end + end + RUBY + + expect(findings.map(&:line).uniq).to include(10, 11) + end + + it "keeps a plainly typed parameter clean" do + findings = scan(<<~RUBY) + class Report + sig { params(widget: FastWidget::Row).void } + def handle(widget) + errors = widget.errors + log(errors) + end + + sig { params(errors: String).void } + def log(errors) + errors.chars + end + end + RUBY + + expect(findings.map(&:line).uniq).to contain_exactly(4) + end + + it "carries a computed return into callers above the def" do + findings = scan(<<~RUBY) + class Report + sig { params(widget: FastWidget::Row).void } + def handle(widget) + errors = collect(widget) + errors.first.message + end + + def collect(widget) + rows = widget.rows + rows + extra + end + end + RUBY + + expect(findings.map(&:line).uniq).to include(5, 9, 10) + end + + it "seeds keyword parameters" do + findings = scan(<<~RUBY) + class Report + sig { params(widget: FastWidget::Row).void } + def handle(widget) + log(errors: widget.errors) + end + + def log(errors: nil) + errors.message + end + end + RUBY + + expect(findings.map(&:line).uniq).to include(4, 8) + end + + it "keeps seeds inside their own class" do + findings = scan(<<~RUBY) + class Alpha + sig { params(widget: FastWidget::Row).void } + def handle(widget) + log(widget.errors) + end + + def log(errors) + errors.message + end + end + + class Beta + def log(errors) + errors.message + end + end + RUBY + + expect(findings.map(&:line).uniq).to contain_exactly(4, 8) + end + + it "seeds through a call on the defining module's name" do + findings = scan(<<~RUBY) + module Packer + def self.pack(widget) + widget.errors + end + end + + Packer.pack(FastWidget::Row.new) + RUBY + + expect(findings.map(&:line).uniq).to contain_exactly(3, 7) + end + + it "reaches an instance method a module extended itself with" do + findings = scan(<<~RUBY) + module Packer + extend self + + def pack(widget) + widget.errors + end + end + + Packer.pack(FastWidget::Row.new) + RUBY + + expect(findings.map(&:line).uniq).to contain_exactly(5, 9) + end + + it "keeps an instance method the module never exposed clean" do + findings = scan(<<~RUBY) + module Packer + def pack(widget) + widget.errors + end + end + + Packer.pack(FastWidget::Row.new) + RUBY + + expect(findings.map(&:line).uniq).to contain_exactly(7) + end + end + + describe "type narrowing" do + it "taints the subject inside a matching when branch" do + findings = scan(<<~RUBY) + def visit(node) + case node + when FastWidget::Row + node.cells + when Integer + node.succ + end + end + RUBY + + expect(findings.map(&:line)).to contain_exactly(4) + end + + it "covers the else clause when every branch matches the rule" do + findings = scan(<<~RUBY) + def visit(node) + case node + when FastWidget::Row + node.cells + when FastWidget::Col + node.rows + else + node.val + end + end + RUBY + + expect(findings.map(&:line)).to contain_exactly(4, 6, 8) + end + + it "leaves the else clause alone on mixed branches" do + findings = scan(<<~RUBY) + def visit(node) + case node + when FastWidget::Row + node.cells + when Integer + node.succ + else + node.val + end + end + RUBY + + expect(findings.map(&:line)).to contain_exactly(4) + end + + it "makes a case handing the subject back a producer" do + findings = scan(<<~RUBY) + def unwrap(node) + ref = case node + when FastWidget::Row then node + when FastWidget::Node then node.row if deep + end + ref.cells + end + RUBY + + expect(findings.map(&:line)).to contain_exactly(4, 6) + end + + it "taints a variable proven by an is_a? guard" do + findings = scan(<<~RUBY) + def unwrap(node) + return node unless node.is_a?(FastWidget::Node) + + node.fields + end + RUBY + + expect(findings.map(&:line)).to contain_exactly(4) + end + + it "taints inside an is_a? conditional body only" do + findings = scan(<<~RUBY) + def visit(node) + if node.is_a?(FastWidget::Node) && node.val + node.fields + else + node.entries + end + node.items + end + RUBY + + expect(findings.map(&:line)).to contain_exactly(3) + end + + it "ignores guards that do not bail" do + findings = scan(<<~RUBY) + def visit(node) + log unless node.is_a?(FastWidget::Node) + node.fields + end + RUBY + + expect(findings).to be_empty + end + + it "ignores type checks against plain constants" do + findings = scan(<<~RUBY) + def visit(node) + return unless node.is_a?(Widget) + + node.fields + end + RUBY + + expect(findings).to be_empty + end + end + + describe "class-body registry" do + it "taints callers of a memoized factory defined below them" do + findings = scan(<<~RUBY) + class Widget + def initialize + @raw = Widget.native_class.new + end + + def id + @raw["id"] + end + + def id=(value) + @raw["id"] = value + end + + class << self + def native_class + @native_class ||= FastWidget::Pool.pool.lookup("w") + end + end + end + RUBY + + expect(findings.map(&:line)).to include(3, 7, 11, 16) + deref = findings.find { |f| f.line == 7 } + expect(deref.message).to include("handed out") + end + + it "reaches the singleton through self.class and bare calls" do + findings = scan(<<~RUBY) + class Widget + def native_class + self.class.native_class + end + + def dup_native + native_class.new + end + + def self.native_class + FastWidget::Pool.lookup("w") + end + end + RUBY + + expect(findings.map(&:line)).to include(7, 11) + end + + it "taints an ivar assigned through its attr writer" do + findings = scan(<<~RUBY) + class Widget + attr_writer :raw + + def field + @raw["f"] + end + + def self.decode(data) + widget = new + widget.raw = FastWidget::Codec.decode(data) + widget + end + end + RUBY + + expect(findings.map(&:line)).to include(5, 10) + end + + it "hands taint out through an attr reader over a tainted ivar" do + findings = scan(<<~RUBY) + class Widget + attr_reader :raw + + def initialize + @raw = FastWidget::Codec.decode("x") + end + + def field + raw.field("f") + end + end + RUBY + + expect(findings.map(&:line)).to include(5, 9) + end + + it "taints a method whose sig returns a namespace type" do + findings = scan(<<~RUBY) + class Widget + sig { returns(FastWidget::Blob) } + def blob + fetch_blob + end + + def use + blob.compress + end + end + RUBY + + expect(findings.map(&:line)).to contain_exactly(8) + end + + it "leaves plain methods and sibling classes alone" do + findings = scan(<<~RUBY) + class Widget + def self.native_class + FastWidget::Pool.lookup("w") + end + end + + class Gadget + def initialize + @raw = Gadget.native_class.new + end + + def builder + Builder.new + end + + def use + builder.build + @raw["f"] + end + end + RUBY + + expect(findings.map(&:line)).to contain_exactly(3) + end + end + + describe "cross-file promotion" do + def scan_files(files, rules: [rule]) + Dir.mktmpdir do |dir| + paths = files.map.with_index do |code, i| + path = File.join(dir, "f#{i}.rb") + File.write(path, code) + path + end + return described_class.new(root: dir, rules: rules) + .analyze_paths(paths) + .group_by { |f| File.basename(f.path) } + end + end + + it "promotes a class holding extension values to a rule" do + wrapper = <<~RUBY + module Kit + class Widget + def initialize(data) + @raw = FastWidget::Codec.decode(data) + end + end + end + RUBY + caller_file = <<~RUBY + widget = Kit::Widget.new(data) + widget.pack + RUBY + + findings = scan_files([wrapper, caller_file]) + + expect(findings["f1.rb"].map(&:line)).to contain_exactly(1, 2) + end + + it "taints params typed with a promoted class in a sig" do + wrapper = <<~RUBY + module Kit + class Widget + def initialize(data) + @raw = FastWidget::Codec.decode(data) + end + end + end + RUBY + caller_file = <<~RUBY + sig { params(widget: Kit::Widget).void } + def read(widget) + widget.fields + end + RUBY + + findings = scan_files([wrapper, caller_file]) + + expect(findings["f1.rb"].map(&:line)).to contain_exactly(3) + end + + it "promotes subclasses of a promoted class" do + wrapper = <<~RUBY + module Kit + class Widget + def initialize(data) + @raw = FastWidget::Codec.decode(data) + end + end + + class Gadget < Widget + end + end + RUBY + caller_file = "Kit::Gadget.new(data)\n" + + findings = scan_files([wrapper, caller_file]) + + expect(findings["f1.rb"].map(&:line)).to contain_exactly(1) + end + + it "promotes constants bound to extension-rooted values" do + binding_file = <<~RUBY + module Kit + Widget = FastWidget::Pool.generated.lookup("w").msgclass + Widget::Inner = FastWidget::Pool.generated.lookup("wi").msgclass + end + RUBY + caller_file = <<~RUBY + message = Kit::Widget.decode(data) + message.id + Kit::Widget::Inner.new(a: 1) + RUBY + + findings = scan_files([binding_file, caller_file]) + + expect(findings["f1.rb"].map(&:line)).to contain_exactly(1, 2, 3) + end + + it "resolves a promoted name through the lexical nesting" do + binding_file = <<~RUBY + module Kit + Widget = FastWidget::Pool.generated.lookup("w").msgclass + end + RUBY + caller_file = <<~RUBY + module Kit + class Builder + def build + Widget.new(a: 1) + end + end + end + RUBY + + findings = scan_files([binding_file, caller_file]) + + expect(findings["f1.rb"].map(&:line)).to contain_exactly(4) + end + + it "keeps an anchored path out of the nesting" do + binding_file = <<~RUBY + module Kit + Widget = FastWidget::Pool.generated.lookup("w").msgclass + end + RUBY + caller_file = <<~RUBY + module Kit + def self.build = ::Widget.new + end + RUBY + + findings = scan_files([binding_file, caller_file]) + + expect(findings["f1.rb"]).to be_nil + end + + it "flags self-calls in a class_eval reopening of a bound name" do + binding_file = <<~RUBY + module Kit + Widget = FastWidget::Pool.generated.lookup("w").msgclass + end + RUBY + wrapper_file = <<~RUBY + module Kit + Widget.class_eval do + def to_ruby + case self.kind + when :list_value then self.list_value + else self.helper + end + end + + def helper = 1 + end + end + RUBY + + findings = scan_files([binding_file, wrapper_file]) + + expect(findings["f1.rb"].map(&:line)).to contain_exactly(4, 5) + end + + it "flags self-calls when a bound class is reopened by name" do + binding_file = <<~RUBY + module Kit + Widget = FastWidget::Pool.generated.lookup("w").msgclass + end + RUBY + wrapper_file = <<~RUBY + module Kit + class Widget + def self.from_hash(hash) + self.new(hash) + end + end + end + RUBY + + findings = scan_files([binding_file, wrapper_file]) + + expect(findings["f1.rb"].map(&:line)).to contain_exactly(4) + end + + it "flags bare argumentless calls in a bound reopening" do + binding_file = <<~RUBY + module Kit + Widget = FastWidget::Pool.generated.lookup("w").msgclass + end + RUBY + wrapper_file = <<~RUBY + module Kit + Widget.class_eval do + def to_h + fields.each_with_object({}) do |field, acc| + acc[field.key] = field.val + end + end + + def helper = 1 + + def wrapped = helper + + def show = inspect + end + end + RUBY + + findings = scan_files([binding_file, wrapper_file]) + + expect(findings["f1.rb"].map(&:line).uniq).to contain_exactly(4, 5) + end + + it "leaves self-calls in merely promoted classes alone" do + holder_file = <<~RUBY + class Renderer + def initialize(markup) + @doc = FastWidget.parse(markup) + end + + def render + self.finish + end + end + RUBY + + findings = scan_files([holder_file]) + + expect(findings["f0.rb"].map(&:line)).to contain_exactly(3) + end + + it "leaves classes without extension values alone" do + plain = <<~RUBY + module Kit + class Clean + def initialize(data) + @data = data + end + end + end + RUBY + caller_file = "Kit::Clean.new(data).pack\n" + + findings = scan_files([plain, caller_file]) + + expect(findings["f1.rb"]).to be_nil + end + + it "seeds a parameter from a call in another file" do + def_file = <<~RUBY + module Packer + def self.pack(widget) + widget.errors + end + end + RUBY + caller_file = "Packer.pack(FastWidget::Row.new)\n" + + findings = scan_files([def_file, caller_file]) + + expect(findings["f0.rb"].map(&:line)).to contain_exactly(3) + end + + it "seeds a parameter from a call in an earlier file" do + caller_file = "Packer.pack(FastWidget::Row.new)\n" + def_file = <<~RUBY + module Packer + def self.pack(widget) + widget.errors + end + end + RUBY + + findings = scan_files([caller_file, def_file]) + + expect(findings["f1.rb"].map(&:line)).to contain_exactly(3) + end + + it "binds self in a module prepended to a bound class" do + binding_file = <<~RUBY + module Kit + Widget = FastWidget::Pool.generated.lookup("w").msgclass + end + RUBY + mixin_file = <<~RUBY + module Helper + def keys + fields.map(&:key) + end + end + + Kit::Widget.prepend(Helper) + RUBY + + findings = scan_files([binding_file, mixin_file]) + + expect(findings["f1.rb"].map(&:line)).to include(3) + end + + it "binds self in a module a bound class extends" do + binding_file = <<~RUBY + module Kit + Widget = FastWidget::Pool.generated.lookup("w").msgclass + end + RUBY + mixin_file = <<~RUBY + module Builder + def from_hash(hash) + Packer.pack(self) + end + end + + Kit::Widget.extend(Builder) + RUBY + def_file = <<~RUBY + module Packer + def self.pack(widget) + widget.errors + end + end + RUBY + + findings = scan_files([binding_file, mixin_file, def_file]) + + expect(findings["f2.rb"].map(&:line)).to contain_exactly(3) + end + + it "leaves a module mixed into a plain class alone" do + plain_file = <<~RUBY + module Helper + def keys + fields.map(&:key) + end + end + + Kit::Clean.include(Helper) + RUBY + + findings = scan_files([plain_file]) + + expect(findings["f0.rb"]).to be_nil + end + end +end From 4aba073df5fd222cb67f1b655e4ea3ad91f15486 Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Sun, 13 Sep 2026 18:06:03 +0400 Subject: [PATCH 06/14] Sharpen literal classification and constant reporting A freeze can cover only the surface of its value, and the classifier took the freeze at its word. `X.new.freeze`, `compute.freeze`, and frozen containers of fresh instances or unprovable call results now report as shallow_opaque: the wrapper is frozen, the value is not. A later bare `NAME.freeze` counts the same, and one unknown element no longer excuses a provably fresh sibling. Indexing another call's result is an unprovable call result too, and a constant-read key does not make an opaque value provable. Memoization frozen at the top is an info note rather than an error, since nothing can mutate through it. --- lib/audition/rewriters.rb | 5 +- .../static/checks/mutable_constants.rb | 71 +++++++ lib/audition/static/literal_classifier.rb | 136 ++++++++++-- spec/audition/rewriters_spec.rb | 5 +- .../static/checks/mutable_constants_spec.rb | 200 +++++++++++++++++- 5 files changed, 392 insertions(+), 25 deletions(-) diff --git a/lib/audition/rewriters.rb b/lib/audition/rewriters.rb index d80f01c..e1f6b09 100644 --- a/lib/audition/rewriters.rb +++ b/lib/audition/rewriters.rb @@ -81,7 +81,10 @@ def self.plan(file, findings) values = collector.values kinds = values.map { |v| classifier.classify(v) } flagged = kinds.reject do |k| - %i[shareable unknown].include?(k) + # A magic comment cannot fix opaque values, so they + # must not veto one either. + %i[shareable unknown instance_new opaque_call + shallow_opaque].include?(k) end scv_ok = values.any? && values.all? { |value| deep_literal?(value, classifier) } diff --git a/lib/audition/static/checks/mutable_constants.rb b/lib/audition/static/checks/mutable_constants.rb index a0635a5..a366b74 100644 --- a/lib/audition/static/checks/mutable_constants.rb +++ b/lib/audition/static/checks/mutable_constants.rb @@ -103,6 +103,44 @@ class MutableConstants < Base "or drop the default proc and fetch with a " \ "literal default: hash.fetch(key, [])." + explain :unshareable_instance, + severity: :warning, + message: "constant %{name} holds an unfrozen %{klass} " \ + "instance", + why: "A fresh instance is unfrozen, so non-main " \ + "Ractors raise Ractor::IsolationError reading " \ + "it. A warning, not an error: .new can be " \ + "overridden to return a shareable value.", + fix: "Freeze it when deeply immutable, wrap in " \ + "Ractor.make_shareable, or keep a per-Ractor " \ + "copy via Ractor.store_if_absent." + + explain :shallow_opaque, + severity: :warning, + message: "constant %{name} is frozen at the top level " \ + "only; what it holds is unproven", + why: "Freezing is shallow: elements and instance " \ + "variables stay as their calls returned them, and " \ + "unless those are deeply frozen a non-main Ractor " \ + "reading the constant raises " \ + "Ractor::IsolationError. The dynamic probe settles " \ + "it when the target boots.", + fix: "Build the value with Ractor.make_shareable for a " \ + "deep freeze, or silence a known-shareable value " \ + "with a disable comment." + + explain :opaque_constant, + severity: :warning, + message: "constant %{name} holds the result of " \ + "%{method}; shareability unproven", + why: "Unless the call returns a deeply frozen value, " \ + "non-main Ractors raise Ractor::IsolationError " \ + "reading the constant. The dynamic probe settles " \ + "it when the target boots.", + fix: "Freeze the result at definition time, or " \ + "silence a known-shareable value with a disable " \ + "comment." + explain :constant_mutation, severity: :warning, message: "in-place %{method} on constant %{name}", @@ -152,6 +190,10 @@ def examine(name, node, value) # finding stays, the autofix goes. fix_ok = !mutated?(name) && !customized?(name) kind = classifier.classify(value) + # A Sorbet cast returns its argument and a begin + # block its last statement: fixes, type names and + # depth checks target the value inside. + value = classifier.unwrap(value) # Build-then-freeze: a bare `NAME.freeze` later in the # same body makes the literal as good as frozen, so # only provably mutable elements remain to report. @@ -195,6 +237,20 @@ def examine(name, node, value) autofix: wrappable ? wrap_make_shareable(value) : nil) when :default_proc flag(node, :hash_default_proc, name: name) + when :shallow_opaque + flag(node, :shallow_opaque, name: name) + # Build-then-freeze leaves depth unknown: stay silent. + when :instance_new + unless frozen_later?(name) + flag(node, :unshareable_instance, name: name, + klass: + classifier.const_name(value.receiver) || "new") + end + when :opaque_call + unless frozen_later?(name) + flag(node, :opaque_constant, name: name, + method: opaque_display(value)) + end end end @@ -313,6 +369,21 @@ def call_display(call) owner ? "#{owner}.#{call.name}" : "String##{call.name}" end + # The receiver is arbitrary, often a chain, so the + # fallback names only the method. + def opaque_display(call) + owner = classifier.const_name(call.receiver) + method = + if owner + "#{owner}.#{call.name}" + elsif call.receiver + ".#{call.name}" + else + call.name.to_s + end + "a #{method} call" + end + # `.freeze` binds tighter than an operator: `"a" + "b".freeze` # freezes only "b", so operator calls get parentheses while # literals and parenthesized or argument-free calls take diff --git a/lib/audition/static/literal_classifier.rb b/lib/audition/static/literal_classifier.rb index 6113ec7..3739ec9 100644 --- a/lib/audition/static/literal_classifier.rb +++ b/lib/audition/static/literal_classifier.rb @@ -16,6 +16,10 @@ module Static # :proc lambda or proc # :default_proc Hash.new with a block; the block # survives .freeze and stays unshareable + # :instance_new unfrozen instance of an arbitrary class + # :opaque_call method call with an unprovable return + # :shallow_opaque frozen at the top, but what it holds + # is an unprovable call result # :unknown cannot tell statically class LiteralClassifier SYNC_PRIMITIVES = %w[ @@ -51,6 +55,18 @@ class LiteralClassifier ].freeze FORMATTERS = %i[format sprintf].freeze REGEXP_FACTORIES = %i[new union compile].freeze + # File methods returning a fresh path String. + FILE_PATH_METHODS = %i[ + expand_path join dirname basename absolute_path realpath + ].freeze + + # Calls returning a shareable primitive on any receiver. + SHAREABLE_RETURNS = %i[ + to_i to_int to_f to_r to_c to_sym size length count + bytesize ord hash + ].freeze + # Sorbet's inline casts, which return their value argument. + SORBET_CASTS = %i[let cast must].freeze # @param frozen_string_literal [Boolean] whether the file has # the frozen_string_literal magic comment @@ -61,6 +77,7 @@ def initialize(frozen_string_literal:) # @param node [Prism::Node] an expression node # @return [Symbol] classification, see class docs def classify(node) + node = begin_value(node) case node when Prism::IntegerNode, Prism::FloatNode, Prism::RationalNode, Prism::ImaginaryNode, @@ -94,6 +111,29 @@ def classify(node) end end + # The value an expression hands back once begin blocks + # and inline casts are peeled off. + def unwrap(node) + loop do + inner = begin_value(node) + inner = cast_value(inner) || inner + break node if inner.equal?(node) + + node = inner + end + end + + # A begin block's value is its last statement. A rescue, + # else, or ensure clause can supply a different one, so + # only the plain form resolves. + def begin_value(node) + return node unless node.is_a?(Prism::BeginNode) && + node.rescue_clause.nil? && node.else_clause.nil? && + node.ensure_clause.nil? + + node.statements&.body&.last || node + end + # `::Mutex` and `Mutex` are the same constant for matching # purposes; the leading colons are stripped. def const_name(node) @@ -105,6 +145,17 @@ def const_name(node) end end + # The value inside a Sorbet inline cast, or nil. The cast + # returns its argument, so fixes and type names belong on + # the value, not the cast. + def cast_value(node) + return nil unless node.is_a?(Prism::CallNode) + return nil unless const_name(node.receiver) == "T" && + SORBET_CASTS.include?(node.name) + + node.arguments&.arguments&.first + end + # What `value.freeze` would classify as, for the check to # choose a plain `.freeze` over a deep wrap: :shareable # when every element is provably shareable, :shallow_freeze @@ -165,9 +216,11 @@ def classify_call(node) return :mutable_container end - :unknown - when :[], :to_set + name ? :instance_new : :opaque_call + when :to_set set_kind(node) + when :[] + index_kind(node) when :define (const_name(receiver) == "Data") ? :shareable : :unknown when :make_shareable @@ -175,14 +228,46 @@ def classify_call(node) when :lambda, :proc (receiver.nil? && node.block) ? :proc : :unknown else - :unknown + opaque_kind(node) end end + # Catch-all for unrecognized calls: shareable returns pass, + # predicates stay silent, everything else is opaque. + def opaque_kind(node) + return :shareable if SHAREABLE_RETURNS.include?(node.name) + return :unknown if node.name.end_with?("?") + + if (value = cast_value(node)) + return classify(value) + end + + :opaque_call + end + + # Indexing into a constant, another call's result, or a + # fresh instance returns a value of unprovable shareability. + # Sorbet type constructors and ENV, whose values are frozen + # strings, stay silent. + def index_kind(node) + owner = const_name(node.receiver) + return set_kind(node) if owner == "Set" + if owner.nil? + receiver_kind = classify(node.receiver) + return :opaque_call if %i[opaque_call instance_new] + .include?(receiver_kind) + return :unknown + end + return :unknown if owner == "ENV" || + owner == "T" || owner.start_with?("T::") + + :opaque_call + end + def classify_freeze(node, receiver) return :unknown unless node.arguments.nil? && receiver - case receiver + case (receiver = begin_value(receiver)) when Prism::StringNode :shareable when Prism::ArrayNode, Prism::HashNode @@ -194,6 +279,7 @@ def classify_freeze(node, receiver) when :default_proc then :default_proc when :mutable_call then :shareable when :mutable_container then frozen_kind(receiver) + when :instance_new, :opaque_call then :shallow_opaque else :unknown end else @@ -214,6 +300,7 @@ def fresh_string?(node) owner = const_name(receiver) (name == :new && owner == "String") || (owner == "Kernel" && FORMATTERS.include?(name)) || + (owner == "File" && FILE_PATH_METHODS.include?(name)) || STRING_ONLY_METHODS.include?(name) else false @@ -243,12 +330,12 @@ def elements_kind(elements) end # A Set built from literals is a container of them: - # `Set.new([...])`, `Set[...]`, `%w[...].to_set`. Anything - # else, such as `Set.new(compute)` or a mapping block, - # stays unknown. + # `Set.new([...])`, `Set[...]`, `%w[...].to_set`. A Set + # built from an opaque source or a mapping block is still + # a fresh unfrozen Set, mutable no matter its contents. def set_kind(node) elements = set_elements(node) - elements ? elements_kind(elements) : :unknown + elements ? elements_kind(elements) : :mutable_container end def set_elements(node) @@ -308,20 +395,33 @@ def single_statement(statements) body && body.size == 1 && body[0] end - # Fold element classifications: everything provably shareable - # gives :shareable; anything provably mutable gives - # :shallow_freeze; a sync primitive poisons the whole - # container; anything unknowable gives :unknown (stay silent - # rather than guess). + # Fold element classifications by the strongest evidence: + # a sync primitive poisons the whole container; a provably + # mutable element makes it :shallow_freeze; an opaque call + # result makes it :shallow_opaque. A bare constant read is + # commonly a class or another frozen constant, so on its + # own it keeps the container silent (:unknown), but it + # cannot excuse a bad element elsewhere. + VERDICT_RANK = { + shareable: 0, unknown: 1, shallow_opaque: 2, + shallow_freeze: 3 + }.freeze + def deep_classify(elements) verdict = :shareable elements.each do |element| element_children(element).each do |child| - case classify(child) - when :shareable then nil - when :sync_primitive then return :sync_primitive - when :unknown then return :unknown - else verdict = :shallow_freeze + kind = + case classify(child) + when :shareable then :shareable + when :sync_primitive then return :sync_primitive + when :unknown then :unknown + when :instance_new, :opaque_call, :shallow_opaque + :shallow_opaque + else :shallow_freeze + end + if VERDICT_RANK[kind] > VERDICT_RANK[verdict] + verdict = kind end end end diff --git a/spec/audition/rewriters_spec.rb b/spec/audition/rewriters_spec.rb index 4fdab13..5f49437 100644 --- a/spec/audition/rewriters_spec.rb +++ b/spec/audition/rewriters_spec.rb @@ -160,7 +160,10 @@ def self.record(value) expect(content.lines.first) .to eq("# frozen_string_literal: true\n") expect(content).to include(%(NAME = "audition"\n)) - expect(all_findings(path)).to be_empty + # The opaque DYNAMIC warning stays; only the string is fixed. + remaining = all_findings(path) + expect(remaining.map(&:severity)).to eq([:warning]) + expect(remaining.first.message).to include("DYNAMIC") end end diff --git a/spec/audition/static/checks/mutable_constants_spec.rb b/spec/audition/static/checks/mutable_constants_spec.rb index 4063ad2..a6a125b 100644 --- a/spec/audition/static/checks/mutable_constants_spec.rb +++ b/spec/audition/static/checks/mutable_constants_spec.rb @@ -292,12 +292,54 @@ def self.record(v) = PARAMS[:k] = v expect(findings).to be_empty end - it "stays silent on values it cannot classify statically" do + it "warns on constants holding unprovable call results" do findings = findings_for("SETTINGS = YAML.load_file('config.yml')\n") + expect(findings.size).to eq(1) + finding = findings.first + expect(finding.severity).to eq(:warning) + expect(finding.message) + .to include("SETTINGS", "a YAML.load_file call") + expect(finding).not_to be_fixable + end + + it "trusts calls that return shareable primitives" do + findings = findings_for(<<~RUBY) + TTL = 5.minutes.to_i + WIDTH = Config.fetch(:width).to_f + KEY = ENV.fetch("MODE").to_sym + READY = Feature.enabled? + RUBY + expect(findings).to be_empty end + it "sees through Sorbet inline casts" do + findings = findings_for(<<~RUBY) + # frozen_string_literal: true + TYPED = T.let("x", String) + LOOSE = T.let(compute, T.untyped) + GOOD = T.must(42) + RUBY + + expect(findings.map(&:line)).to eq([3]) + expect(findings.first.message).to include("LOOSE") + end + + it "names the literal and fixes inside a Sorbet cast" do + code = "# frozen_string_literal: true\n" \ + "MAP = T.let({a: 1}, T::Hash[Symbol, Integer])\n" + findings = findings_for(code) + + expect(findings.size).to eq(1) + expect(findings.first.message).to include("Hash") + fix = findings.first.autofix + expect(fix.unsafe?).to be(false) + fixed = code.dup + fixed[fix.start_offset...fix.end_offset] = fix.replacement + expect(fixed).to include("T.let({a: 1}.freeze,") + end + it "flags writes through constant paths" do findings = findings_for("Foo::BAR = []\n") @@ -385,7 +427,7 @@ def self.record(v) = PARAMS[:k] = v expect(findings).to be_empty end - it "leaves ambiguous calls on non-literal receivers alone" do + it "warns on ambiguous calls on non-literal receivers" do findings = findings_for(<<~RUBY) # frozen_string_literal: true SUM = Totals.sum + 1 @@ -393,8 +435,100 @@ def self.record(v) = PARAMS[:k] = v PATH = Settings.dup RUBY + expect(findings.map(&:line)).to eq([2, 3, 4]) + expect(findings).to all(have_attributes(severity: :warning)) + expect(findings).to all(satisfy { |f| !f.fixable? }) + end + + it "stays quiet once the opaque result is frozen later" do + findings = findings_for(<<~RUBY) + SETTINGS = YAML.load_file("config.yml") + SETTINGS.freeze + RUBY + expect(findings).to be_empty end + + it "warns when a freeze covers only the surface of opaque values" do + findings = findings_for(<<~RUBY) + # frozen_string_literal: true + COUNTERS = T.let({cache: Stats.build("c")}.freeze, T.untyped) + FORMATS = [Mime[:xml], Mime[:json]].freeze + EPOCH = T.let(Time.at(0).freeze, Time) + CODEC = Encoder.new(0, MAP).freeze + CLASSES = [Widget, Gadget].freeze + RUBY + + expect(findings.map(&:line)).to eq([2, 3, 4, 5]) + expect(findings).to all(have_attributes(severity: :warning)) + expect(findings).to all(satisfy { |f| !f.fixable? }) + expect(findings.first.message).to include("frozen at the top") + end + + it "does not let constant-read keys excuse opaque values" do + findings = findings_for(<<~RUBY) + CONFIGS = { + Sizes::SMALL => Widget.new(a: 1), + }.freeze + RUBY + + expect(findings.size).to eq(1) + expect(findings.first.severity).to eq(:warning) + expect(findings.first.message).to include("frozen at the top") + end + + it "reports a provably mutable element despite unknown siblings" do + findings = findings_for(<<~RUBY) + REGISTRY = [Widget, [1]].freeze + RUBY + + expect(findings.size).to eq(1) + expect(findings.first.severity).to eq(:error) + expect(findings.first.message).to include("frozen only") + end + + it "warns when a container of opaque values is frozen later" do + findings = findings_for(<<~RUBY) + ROUTES = {home: Router.build} + ROUTES.freeze + RUBY + + expect(findings.size).to eq(1) + expect(findings.first.severity).to eq(:warning) + expect(findings.first.message).to include("frozen at the top") + end + + it "trusts indexing into ENV and Sorbet type constructors" do + findings = findings_for(<<~RUBY) + HOME = ENV["HOME"] + LIST = T::Array[String] + XML = Mime[:xml] + RUBY + + expect(findings.map(&:line)).to eq([3]) + expect(findings.first.severity).to eq(:warning) + expect(findings.first.message).to include("Mime.[]") + end + + it "flags indexing into another call's result" do + findings = findings_for(<<~RUBY) + KEY = Registry.by_name["WIDTH"] + RUBY + + expect(findings.map(&:line)).to eq([1]) + expect(findings.first.severity).to eq(:warning) + end + + it "treats frozen File path results as shareable" do + findings = findings_for(<<~RUBY) + ROOT = File.expand_path("..", __dir__).freeze + BARE = File.join("a", "b") + RUBY + + expect(findings.map(&:line)).to eq([2]) + expect(findings.first.severity).to eq(:error) + expect(findings.first.message).to include("File.join") + end describe "plain freeze for provably shareable containers" do it "appends .freeze when every element is shareable" do code = "# frozen_string_literal: true\nLIST = [1, \"a\", :b]\n" @@ -481,12 +615,23 @@ def NULL.to_s = "null" end it "leaves Object.new with arguments or a block alone" do + findings = findings_for("WITH_BLOCK = Object.new { }\n") + + expect(findings).to be_empty + end + + it "warns on fresh instances of arbitrary classes" do findings = findings_for(<<~RUBY) - WITH_BLOCK = Object.new { } SOMETHING = Widget.new + FROZEN = Widget.new.freeze + QUERY = Db::Query.new(name: "q") { |x| x } RUBY - expect(findings).to be_empty + expect(findings.map(&:line)).to eq([1, 2, 3]) + expect(findings).to all(have_attributes(severity: :warning)) + expect(findings.first.message) + .to include("SOMETHING", "Widget") + expect(findings[1].message).to include("frozen at the top") end end @@ -525,11 +670,21 @@ def NULL.to_s = "null" # frozen_string_literal: true IDS = %w(id id=).to_set.freeze DIRS = Set.new([:asc]).freeze + SEALED = Set.new(compute).freeze + RUBY + + expect(findings).to be_empty + end + + it "flags unfrozen Sets built from opaque sources" do + findings = findings_for(<<~RUBY) DYNAMIC = Set.new(compute) MAPPED = Set.new([1]) { |x| x.to_s } RUBY - expect(findings).to be_empty + expect(findings.map(&:line)).to eq([1, 2]) + expect(findings).to all(have_attributes(severity: :error)) + expect(findings.map(&:message)).to all(include("Set")) end end @@ -579,6 +734,41 @@ def self.finalize! = REGISTRY.freeze end end + describe "begin blocks" do + it "classifies a constant by the block's last statement" do + findings = findings_for(<<~RUBY) + TABLE = begin + {a: {b: 1}} + end.freeze + RUBY + + expect(findings.size).to eq(1) + expect(findings.first.message).to include("top level") + end + + it "accepts a block whose last statement is shareable" do + findings = findings_for(<<~RUBY) + LIST = begin + [1, 2] + end.freeze + RUBY + + expect(findings).to be_empty + end + + it "leaves a block with a rescue clause alone" do + findings = findings_for(<<~RUBY) + TABLE = begin + {a: {b: 1}} + rescue StandardError + {} + end.freeze + RUBY + + expect(findings).to be_empty + end + end + it "flags Concurrent::Map constants as never shareable" do findings = findings_for("CACHE = Concurrent::Map.new\n") From fff071d3a8624ab88b58c92a02cf7e2eba10d876 Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Sun, 13 Sep 2026 18:06:04 +0400 Subject: [PATCH 07/14] Color every output surface, and count test findings apart Color stopped at the findings list. The bundle sweep gained a titled table with severity glyphs on the verdicts, one color per row and blank cells where a count is zero; usage text, the baseline line, fix and dry-run output, `--compare` deltas and `--capabilities` follow the same palette. `--plain`, `NO_COLOR` and `TERM=dumb` strip all of it. A finding now carries whether it came from the target's tests. Test findings render with a `(tests)` mark and are summarized on their own line, because a frozen constant in a spec is not the same debt as one on a production boot path. --- lib/audition/finding.rb | 12 ++- lib/audition/report.rb | 9 +- lib/audition/report/github.rb | 7 +- lib/audition/report/json.rb | 6 +- lib/audition/report/sweep.rb | 182 ++++++++++++++++++++++++++++++++++ lib/audition/report/text.rb | 15 ++- spec/audition/report_spec.rb | 51 +++++++++- 7 files changed, 268 insertions(+), 14 deletions(-) create mode 100644 lib/audition/report/sweep.rb diff --git a/lib/audition/finding.rb b/lib/audition/finding.rb index eedab5a..13ee3fc 100644 --- a/lib/audition/finding.rb +++ b/lib/audition/finding.rb @@ -64,12 +64,14 @@ def unsafe? = safety == :unsafe # @return [Autofix, nil] machine-applicable correction # @!attribute [r] dependency # @return [Boolean] see {#dependency?} + # @!attribute [r] test + # @return [Boolean] see {#test?} Finding = Data.define( :check, :severity, :message, :why, :fix, - :path, :line, :source, :autofix, :dependency + :path, :line, :source, :autofix, :dependency, :test ) do def initialize(source: nil, autofix: nil, dependency: false, - **rest) + test: false, **rest) super end @@ -82,6 +84,12 @@ def error? = severity == :error # @return [Boolean] def dependency? = dependency + # True when the problem lives in the target's test or spec + # code: real, but never loaded by the production boot. + # + # @return [Boolean] + def test? = test + # @return [Boolean] whether an {Autofix} is attached def fixable? = !autofix.nil? diff --git a/lib/audition/report.rb b/lib/audition/report.rb index f79c76d..a2fb004 100644 --- a/lib/audition/report.rb +++ b/lib/audition/report.rb @@ -9,7 +9,7 @@ class Report not_ready: "not ractor-ready", blocked: "own code is ractor-ready; blocked by dependencies", risky: "risky: warnings only, no hard errors", - ready: "ractor-ready as far as audition can tell" + ready: "ractor-ready as far as Audition can tell" }.freeze attr_reader :target_type, :target_root, :findings, @@ -60,13 +60,19 @@ def dependency_errors? counts[:dep_error].positive? end + # Test findings count apart at every severity: they are the + # target's code, but a production boot never loads them, so + # they never touch the verdict. def counts @counts ||= begin base = {error: 0, dep_error: 0, warning: 0, info: 0, + test_error: 0, test_warning: 0, test_info: 0, fixable: 0} findings.each_with_object(base) do |f, acc| if f.error? && f.dependency? acc[:dep_error] += 1 + elsif f.test? + acc[:"test_#{f.severity}"] += 1 else acc[f.severity] += 1 end @@ -86,3 +92,4 @@ def counts require_relative "report/text" require_relative "report/json" require_relative "report/github" +require_relative "report/sweep" diff --git a/lib/audition/report/github.rb b/lib/audition/report/github.rb index 9dd18e9..0cf9b3e 100644 --- a/lib/audition/report/github.rb +++ b/lib/audition/report/github.rb @@ -19,7 +19,7 @@ def initialize(report) def render lines = @report.findings.map { |f| annotation(f) } lines << - "audition verdict: #{VERDICTS.fetch(@report.verdict)}" + "Audition verdict: #{VERDICTS.fetch(@report.verdict)}" lines.join("\n") end @@ -27,7 +27,7 @@ def render def summary c = @report.counts <<~MARKDOWN - ## audition: #{VERDICTS.fetch(@report.verdict)} + ## Audition: #{VERDICTS.fetch(@report.verdict)} | findings | count | | --- | --- | @@ -35,6 +35,7 @@ def summary | dependency errors | #{c[:dep_error]} | | warnings | #{c[:warning]} | | info | #{c[:info]} | + | test findings | #{c[:test_error] + c[:test_warning] + c[:test_info]} | | fixable | #{c[:fixable]} | MARKDOWN end @@ -48,7 +49,7 @@ def annotation(f) # Annotations anchor to workspace-relative paths; a `./` # prefix (from `audition .`) keeps them off the diff. file = property_escape(f.path.delete_prefix("./")) - title = property_escape("audition #{f.check}") + title = property_escape("Audition #{f.check}") "::#{level} file=#{file}#{location}," \ "title=#{title}::#{body}" end diff --git a/lib/audition/report/json.rb b/lib/audition/report/json.rb index 3aa8c5c..385a74e 100644 --- a/lib/audition/report/json.rb +++ b/lib/audition/report/json.rb @@ -32,6 +32,9 @@ def summary "dependency_errors" => counts[:dep_error], "warnings" => counts[:warning], "infos" => counts[:info], + "test_errors" => counts[:test_error], + "test_warnings" => counts[:test_warning], + "test_infos" => counts[:test_info], "fixable" => counts[:fixable] } end @@ -48,7 +51,8 @@ def findings "line" => f.line, "source" => f.source, "fixable" => f.fixable?, - "dependency" => f.dependency? + "dependency" => f.dependency?, + "test" => f.test? } end end diff --git a/lib/audition/report/sweep.rb b/lib/audition/report/sweep.rb new file mode 100644 index 0000000..ea62741 --- /dev/null +++ b/lib/audition/report/sweep.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +require "json" +require "table_tennis" + +module Audition + class Report + # Renders a bundle sweep. Its unit is a gem rather than a + # finding, so it gets its own renderer instead of bending + # Report around a shape it does not have. + class Sweep + CELLS = { + :not_ready => "not ready", :blocked => "blocked", + :risky => "risky", :ready => "ready", nil => "-" + }.freeze + + # The severity glyphs the text report uses, so a verdict + # reads the same in a cell as it does in a summary. + GLYPHS = { + not_ready: :error, blocked: :warning, + risky: :warning, ready: :pass + }.freeze + + # Foreground only, and applied to the whole row: a + # background fill reads as a bar across the table, and a + # painted cell would widen its column by the invisible + # length of its own escape sequence. Ready gems keep the + # default color, so what is colored is what needs reading. + PAINTS = Ractor.make_shareable({ + :not_ready => [:red], :blocked => [:magenta], + :risky => [:yellow], nil => [:faint] + }) + + TITLE = "Audition bundle sweep" + + # @param rows [Array] one per locked gem + # @param style [Style] palette for the text rendering + def initialize(rows, style) + @rows = rows + @style = style + end + + # @param table_opts [Hash] what the terminal supports; see + # the caller for why color cannot be detected here + # @return [String] the table plus a summary line + def render(**table_opts) + table = TableTennis.new(cells, title: TITLE, mark: paint, + **table_opts) + "#{table}\n#{summary}" + end + + def json + JSON.pretty_generate( + "audition" => VERSION, + "ruby" => RUBY_VERSION, + "bundle" => @rows.map { |r| json_row(r) } + ) + end + + # Sweep rows carry no file or line, so annotations land on + # the run summary rather than a diff. + # @return [String] annotation lines plus a summary line + def annotations + lines = @rows.filter_map do |row| + level = annotation_level(row) + next unless level + + "::#{level} title=Audition::gem #{row.name} " \ + "#{row.version}: #{row.errors} errors, " \ + "#{row.dep_errors} dependency errors, " \ + "#{row.warnings} warnings (#{CELLS.fetch(row.verdict)})" + end + (lines << plain_summary).join("\n") + end + + # @return [String] job summary page table for Actions + def markdown + lines = [ + "## #{TITLE}", "", + "| gem | version | verdict | errors | dep errors " \ + "| warnings | fixable |", + "| --- | --- | --- | --- | --- | --- | --- |" + ] + @rows.each do |r| + lines << "| #{r.name} | #{r.version} | " \ + "#{CELLS.fetch(r.verdict)} | #{r.errors} | " \ + "#{r.dep_errors} | #{r.warnings} | #{r.fixable} |" + end + lines.push("", plain_summary).join("\n") + end + + private + + def cells + @rows.map do |r| + { + "gem" => r.name, + "version" => r.version, + "verdict" => verdict_cell(r.verdict), + "errors" => clean_as_blank(r.errors), + "dep errors" => clean_as_blank(r.dep_errors), + "warnings" => clean_as_blank(r.warnings), + "fixable" => clean_as_blank(r.fixable), + "status" => r.status + } + end + end + + def verdict_cell(verdict) + cell = CELLS.fetch(verdict) + glyph = GLYPHS[verdict] + glyph ? "#{@style.glyph(glyph)} #{cell}" : cell + end + + # A clean count is the common case across hundreds of gems; + # leaving it to the table's placeholder keeps the eye on the + # rows that carry something. + def clean_as_blank(count) + count.positive? ? count : nil + end + + # The table gem hands the lambda back the row it was given, + # which carries the gem but not the verdict symbol. + def paint + paints = @rows.to_h do |r| + [[r.name, r.version], PAINTS[r.verdict]] + end + ->(row) { paints[[row["gem"], row["version"]]] } + end + + def summary + glyph, paint = if blockers.positive? + [:error, :red] + elsif ready == @rows.size + [:pass, :green] + else + [:warning, :yellow] + end + head = @style.public_send(paint, + "#{@style.glyph(glyph)} #{plain_summary}") + return head if blockers.zero? + + "#{head} #{@style.dim("· #{blockers} not ready")}" + end + + def plain_summary + "#{ready} of #{@rows.size} gems ractor-ready" + end + + def ready + @ready ||= @rows.count { |r| r.verdict == :ready } + end + + def blockers + @blockers ||= @rows.count { |r| r.verdict == :not_ready } + end + + def annotation_level(row) + if row.verdict == :not_ready || + (row.errors + row.dep_errors).positive? + "error" + elsif row.warnings.positive? + "warning" + end + end + + def json_row(row) + { + "gem" => row.name, + "version" => row.version, + "verdict" => row.verdict&.to_s, + "errors" => row.errors, + "dependency_errors" => row.dep_errors, + "warnings" => row.warnings, + "infos" => row.infos, + "fixable" => row.fixable, + "status" => row.status + } + end + end + end +end diff --git a/lib/audition/report/text.rb b/lib/audition/report/text.rb index 42497b1..e8bd1f5 100644 --- a/lib/audition/report/text.rb +++ b/lib/audition/report/text.rb @@ -21,7 +21,7 @@ def render def header s = @style - title = s.bold("audition #{VERSION}") + title = s.bold("Audition #{VERSION}") meta = s.dim( "ruby #{RUBY_VERSION} · #{@report.target_type} at " \ "#{@report.target_root}" @@ -45,8 +45,10 @@ def finding_lines(finding) fix_mark = finding.fixable? ? " #{s.cyan(s.glyph(:fix))}" : "" dep_mark = finding.dependency? ? " #{s.dim("(dependency)")}" : "" + test_mark = finding.test? ? " #{s.dim("(tests)")}" : "" head = " #{glyph} #{loc}#{finding.message}" \ - "#{fix_mark}#{dep_mark} #{s.dim(finding.check)}" + "#{fix_mark}#{dep_mark}#{test_mark} " \ + "#{s.dim(finding.check)}" [head, *annotation("why", finding.why), *annotation("fix", finding.fix)] @@ -113,6 +115,15 @@ def summary parts << s.yellow(pluralize(c[:warning], "warning")) end parts << s.cyan("#{c[:info]} info") if c[:info].positive? + test_total = c[:test_error] + c[:test_warning] + + c[:test_info] + if test_total.positive? + parts << s.dim( + pluralize(test_total, "test finding") + + " (#{c[:test_error]} error / " \ + "#{c[:test_warning]} warning / #{c[:test_info]} info)" + ) + end if c[:fixable].positive? parts << s.cyan( "#{c[:fixable]} fixable #{s.glyph(:fix)} " \ diff --git a/spec/audition/report_spec.rb b/spec/audition/report_spec.rb index ae5e843..bf77700 100644 --- a/spec/audition/report_spec.rb +++ b/spec/audition/report_spec.rb @@ -3,7 +3,7 @@ RSpec.describe Audition::Report do def finding(severity: :error, path: "lib/a.rb", line: 3, autofix: nil, check: "global-variables", - dependency: false) + dependency: false, test: false) Audition::Finding.new( check: check, severity: severity, @@ -14,7 +14,8 @@ def finding(severity: :error, path: "lib/a.rb", line: 3, line: line, source: "$x + 1", autofix: autofix, - dependency: dependency + dependency: dependency, + test: test ) end @@ -70,6 +71,18 @@ def render_json(report) expect(report_for([]).verdict).to eq(:ready) end + it "ignores test findings at every severity" do + findings = [finding(test: true, path: "spec/a_spec.rb"), + finding(severity: :warning, test: true, + path: "spec/a_spec.rb")] + report = report_for(findings) + + expect(report.verdict).to eq(:ready) + expect(report.counts[:error]).to eq(0) + expect(report.counts[:test_error]).to eq(1) + expect(report.counts[:test_warning]).to eq(1) + end + it "is blocked when a dynamic probe failed without findings" do failed = Audition::Dynamic::Result.new( mode: :rack, raw: {}, findings: [], passed: false @@ -167,6 +180,23 @@ def render_json(report) expect(two).to include("2 errors") end + it "marks test findings and totals them in the summary" do + style = Audition::Report::Style.new( + color: false, hyperlinks: false + ) + text = render_text(report_for([ + finding(test: true, path: "spec/a_spec.rb"), + finding(severity: :warning, test: true, + path: "spec/a_spec.rb") + ]), style) + + expect(text).to include("(tests)") + expect(text).to include( + "2 test findings (1 error / 1 warning / 0 info)" + ) + expect(text).to include("ractor-ready as far as Audition") + end + it "keeps overlong tokens intact when wrapping" do url = "https://example.com/#{"z" * 90}" style = Audition::Report::Style.new( @@ -185,7 +215,7 @@ def render_json(report) out = render_github(report_for([finding])) expect(out).to include( - "::error file=lib/a.rb,line=3,title=audition " \ + "::error file=lib/a.rb,line=3,title=Audition " \ "global-variables::" ) expect(out).to include("read of global variable $x") @@ -199,7 +229,7 @@ def render_json(report) out = render_github(report_for([weird])) expect(out).to include("file=lib/a%2Cb%25%3Ac.rb,line=3,") - expect(out).to include("title=audition global-variables::") + expect(out).to include("title=Audition global-variables::") end it "strips the ./ prefix so annotations anchor to files" do @@ -215,7 +245,7 @@ def render_json(report) finding, finding(severity: :warning) ])).summary - expect(md).to include("## audition: not ractor-ready") + expect(md).to include("## Audition: not ractor-ready") expect(md).to include("| errors | 1 |") expect(md).to include("| warnings | 1 |") end @@ -242,5 +272,16 @@ def render_json(report) expect(json["summary"]["errors"]).to eq(0) expect(json["summary"]["dependency_errors"]).to eq(1) end + + it "splits test findings out in the summary" do + json = JSON.parse(render_json(report_for([ + finding(test: true, path: "spec/a_spec.rb") + ]))) + + expect(json["verdict"]).to eq("ready") + expect(json["summary"]["errors"]).to eq(0) + expect(json["summary"]["test_errors"]).to eq(1) + expect(json["findings"].first["test"]).to be(true) + end end end From 11f0855ce94b1400d1aa5b30299bed055e6a8ef1 Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Sun, 13 Sep 2026 18:06:04 +0400 Subject: [PATCH 08/14] Make the test directories configurable `test`, `spec` and `features` were a constant, so a project that files its tests anywhere else had every test finding rated as production code. `test_dirs` in .audition.yml replaces that list; absent, the default stands. It replaces rather than extends, an explicit empty list is honored as "no test directories", and the `_test.rb` / `_spec.rb` suffixes count whatever it says. Entries are validated as single path segments, since each is matched against one path component and a nested path would silently match nothing. --- lib/audition/config.rb | 34 ++++++++++++++++++++++++++++++---- spec/audition/config_spec.rb | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/lib/audition/config.rb b/lib/audition/config.rb index 5a46d58..96937cd 100644 --- a/lib/audition/config.rb +++ b/lib/audition/config.rb @@ -10,6 +10,8 @@ module Audition # exclude: # - legacy/** # - db/schema.rb + # test_dirs: + # - qa # checks: # disable: # - at-exit @@ -20,7 +22,7 @@ class Config EMPTY = Ractor.make_shareable( {fail_on: nil, timeout: nil, exclude: [], - disabled_checks: []} + disabled_checks: [], test_dirs: nil} ) FAIL_ON_LEVELS = %w[error warning info never].freeze @@ -42,7 +44,8 @@ def self.load(root) timeout: data["timeout"], exclude: Array(data["exclude"]).map(&:to_s), disabled_checks: - Array(data.dig("checks", "disable")).map(&:to_s) + Array(data.dig("checks", "disable")).map(&:to_s), + test_dirs: data["test_dirs"]&.map(&:to_s) ) rescue Psych::Exception => e raise Error, "#{path}: #{e.message}" @@ -54,6 +57,7 @@ def self.validate!(path, data) "#{path}: expected a YAML mapping, got #{data.class}" end + validate_test_dirs!(path, data["test_dirs"]) fail_on = data["fail_on"] return if fail_on.nil? || FAIL_ON_LEVELS.include?(fail_on.to_s) @@ -62,13 +66,35 @@ def self.validate!(path, data) "#{path}: fail_on must be one of error, warning, " \ "info, or never (got #{fail_on.inspect})" end - private_class_method :validate! - def initialize(fail_on:, timeout:, exclude:, disabled_checks:) + # Each name is matched against one path segment, so a nested + # path would silently match nothing. + def self.validate_test_dirs!(path, dirs) + return if dirs.nil? || (dirs.is_a?(Array) && + dirs.none? { |dir| dir.to_s.include?("/") }) + + raise Error, + "#{path}: test_dirs must be a list of directory names " \ + "without slashes (got #{dirs.inspect})" + end + private_class_method :validate!, :validate_test_dirs! + + def initialize(fail_on:, timeout:, exclude:, disabled_checks:, + test_dirs: nil) @fail_on = fail_on @timeout = timeout @exclude = exclude @disabled_checks = disabled_checks + @test_dirs = test_dirs + end + + # Directories whose findings are tagged as test code rather + # than code a production boot loads. An explicit empty list + # leaves only the `_test.rb`/`_spec.rb` suffixes. + # + # @return [Array] Target::TEST_DIRS unless configured + def test_dirs + @test_dirs || Target::TEST_DIRS end # Globs follow .gitignore-style expectations: `*` stays within diff --git a/spec/audition/config_spec.rb b/spec/audition/config_spec.rb index 28d7a72..3f1ddaa 100644 --- a/spec/audition/config_spec.rb +++ b/spec/audition/config_spec.rb @@ -39,6 +39,42 @@ def write_config(dir, content) end end end + + it "defaults test_dirs to the conventional three" do + Dir.mktmpdir do |dir| + write_config(dir, "fail_on: warning\n") + + expect(described_class.load(dir).test_dirs) + .to eq(Audition::Target::TEST_DIRS) + end + end + + it "replaces test_dirs rather than adding to them" do + Dir.mktmpdir do |dir| + write_config(dir, "test_dirs:\n - qa\n - checks\n") + + expect(described_class.load(dir).test_dirs) + .to eq(%w[qa checks]) + end + end + + it "honors an empty test_dirs list" do + Dir.mktmpdir do |dir| + write_config(dir, "test_dirs: []\n") + + expect(described_class.load(dir).test_dirs).to eq([]) + end + end + + it "rejects a test_dirs entry that is a path" do + Dir.mktmpdir do |dir| + write_config(dir, "test_dirs:\n - qa/unit\n") + + expect { described_class.load(dir) }.to raise_error( + Audition::Error, /without slashes/ + ) + end + end end describe "#excluded?" do From 74f904597b98dab9bb7a60688f67dad209994746 Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Sun, 13 Sep 2026 18:06:12 +0400 Subject: [PATCH 09/14] Find stubs and extensions without assuming directory names Generated type stubs were read from a fixed `sorbet/rbi/gems`, so a project whose generator writes them anywhere else silently lost the compiled-extension evidence those stubs carry. The extension is the convention; where the generator writes them is not. Stubs are now globbed as `*.rbi` anywhere in the target, and the gem-calls check matches `@.rbi` by basename within that set. Native extensions were globbed from `ext/*`, which is also only a habit. They are found by the build file that actually compiles them (`extconf.rb`, `Cargo.toml`, `build.zig`) wherever it sits, and a workspace manifest above a crate is dropped so the crate's sources are not reported twice. A gem checkout that keeps its code outside `lib` is scanned whole rather than not at all. Reading require_paths off the gemspec would mean evaluating it, which the static pass never does, so `lib` remains the default with the checkout as the fallback. Excluded and hidden trees are skipped throughout, as before. --- lib/audition/static/native_extensions.rb | 44 ++++--- lib/audition/target.rb | 95 ++++++++++++--- .../audition/static/native_extensions_spec.rb | 42 +++++++ spec/audition/target_spec.rb | 110 +++++++++++++++++- 4 files changed, 261 insertions(+), 30 deletions(-) diff --git a/lib/audition/static/native_extensions.rb b/lib/audition/static/native_extensions.rb index 2bdd1bd..9535065 100644 --- a/lib/audition/static/native_extensions.rb +++ b/lib/audition/static/native_extensions.rb @@ -22,9 +22,9 @@ class NativeExtensions INIT = /\bInit_\w+\s*\(/ SOURCES = "*.{c,cc,cpp,cxx,m,mm,h,hpp,rs,zig}" BUILD_FILES = %w[Cargo.toml build.zig extconf.rb].freeze - # Harness crates and test trees under ext/ are not compiled - # into the extension; a fuzz target that links the VM itself - # may call rb_ext_ractor_safe without the extension doing so. + # Harness crates and test trees are not compiled into the + # extension; a fuzz target that links the VM itself may call + # rb_ext_ractor_safe without the extension doing so. HARNESS_DIRS = %w[fuzz benches tests examples test spec].freeze SILENT_WHY = @@ -51,7 +51,7 @@ class NativeExtensions DECLARED_WHY = "rb_ext_ractor_safe(true) is the maintainer's assertion " \ "that the extension keeps no process-global mutable state; " \ - "Ruby does not verify it and neither can audition. Its " \ + "Ruby does not verify it and neither can Audition. Its " \ "methods run from any Ractor, in parallel, on the strength " \ "of that assertion alone." DECLARED_FIX = @@ -91,12 +91,16 @@ def compiled_finding(path) nil end - # One finding per extension directory (ext/), anchored + # One finding per extension directory, anchored # at the declaration when there is one, else at Init_* or the # build file, where the declaration belongs. def source_finding(dir, root) sources = sources_under(dir) - label = dir.delete_prefix("#{root}/") + label = if dir == root + File.basename(root) + else + dir.delete_prefix("#{root}/") + end path, line = locate(sources, DECLARATION) if path return finding(:info, path, line, @@ -111,18 +115,26 @@ def source_finding(dir, root) "safety", SILENT_WHY + SOURCE_TAIL, SILENT_FIX) end - # ext/ directories holding native sources; ext/ itself - # when the sources sit directly in it. + # Directories holding native sources, found by the build file + # that compiles them rather than by convention: a gem is free + # to put its extension anywhere. A manifest above another one + # is dropped, so a workspace does not report the sources of + # the crates under it a second time. def extension_dirs(root) - ext = File.join(root, "ext") - return [] unless File.directory?(ext) - - dirs = Dir[File.join(ext, "*")].sort.select do |dir| - File.directory?(dir) && !skipped?(File.basename(dir)) && - sources_under(dir).any? + dirs = build_dirs(root).select { |dir| sources_under(dir).any? } + dirs.reject do |dir| + dirs.any? { |other| other.start_with?("#{dir}/") } end - dirs << ext if Dir[File.join(ext, SOURCES)].any? - dirs + end + + def build_dirs(root) + pattern = "{#{BUILD_FILES.join(",")}}" + Dir[File.join(root, "**", pattern)].filter_map do |path| + relative = path.delete_prefix("#{root}/").split("/") + next if relative[0..-2].any? { |part| skipped?(part) } + + File.dirname(path) + end.uniq.sort end def sources_under(dir) diff --git a/lib/audition/target.rb b/lib/audition/target.rb index 5ec6c7d..40a644e 100644 --- a/lib/audition/target.rb +++ b/lib/audition/target.rb @@ -12,6 +12,12 @@ class Target vendor node_modules tmp log coverage pkg .git .bundle ].freeze + # Directories holding the target's tests rather than the code + # a production boot loads. The default; `test_dirs` in + # .audition.yml replaces it for a project that files them + # elsewhere. + TEST_DIRS = %w[test spec features].freeze + # Cargo and Zig build output: full of .so/.dylib artifacts that # are not the extension `require` loads. BUILD_DIRS = %w[target zig-out].freeze @@ -20,6 +26,11 @@ class Target # "bundle" on macOS, "so" everywhere else, Windows included). COMPILED = "*.{bundle,so}" + # Generated type stubs. The extension is the convention; where + # the generator writes them is not, so they are found by + # extension anywhere in the tree. + STUBS = "*.rbi" + # @return [Symbol] one of `:script`, `:gem`, `:rack`, `:rails`, # `:directory`, `:bundle` attr_reader :type @@ -34,6 +45,10 @@ class Target # shipped with the target; empty for scripts and file lists attr_reader :compiled_files + # @return [Array] generated type stubs (.rbi) found + # anywhere in the target's tree + attr_reader :stub_files + # @return [Hash, nil] dynamic probe entry (`:mode` plus # mode-specific keys), nil for static-only targets attr_reader :entry @@ -75,7 +90,8 @@ def self.for_files(paths) type: :files, root: Dir.pwd, ruby_files: paths, - entry: nil + entry: nil, + stub_files: stubs(Dir.pwd) ) end @@ -96,10 +112,26 @@ def self.from_file(path) type: :script, root: File.dirname(path), ruby_files: [path], - entry: {mode: :script, path: path} + # A file inside a Rails root cannot run standalone; the + # script probe would report a misleading boot failure, so + # such files stay static-only. + entry: rails_member?(path) ? nil : {mode: :script, path: path}, + stub_files: stubs(File.dirname(path)) ) end + def self.rails_member?(path) + dir = File.dirname(File.expand_path(path)) + until dir == (parent = File.dirname(dir)) + return true if File.file?( + File.join(dir, "config", "application.rb") + ) + + dir = parent + end + false + end + def self.from_directory(dir) application_rb = File.join(dir, "config", "application.rb") config_ru = File.join(dir, "config.ru") @@ -117,7 +149,8 @@ def self.from_directory(dir) root: dir, ruby_files: glob(dir), entry: nil, - compiled_files: compiled(dir) + compiled_files: compiled(dir), + stub_files: stubs(dir) ) end end @@ -131,16 +164,21 @@ def self.from_gem_name(name) glob(File.join(spec.full_gem_path, rp)) end, entry: {mode: :require, feature: name, + load_paths: spec.full_require_paths, root: spec.full_gem_path}, - compiled_files: compiled_for(spec) + compiled_files: compiled_for(spec), + stub_files: stubs(spec.full_gem_path) ) rescue Gem::MissingSpecError raise Error, "#{name} is not a file, directory, or installed gem" end + # An app's own Ruby is not confined to app/lib/config: local + # gems, engines and tests boot into the same process, so the + # whole root is scanned. def self.rails_target(dir) - files = %w[app lib config].flat_map { |d| glob(File.join(dir, d)) } + files = glob(dir) files << File.join(dir, "config.ru") new( type: :rails, @@ -151,7 +189,8 @@ def self.rails_target(dir) environment: File.join(dir, "config", "environment.rb"), root: dir }, - compiled_files: compiled(dir) + compiled_files: compiled(dir), + stub_files: stubs(dir) ) end @@ -160,24 +199,31 @@ def self.rack_target(dir, config_ru) type: :rack, root: dir, ruby_files: [config_ru] + glob(dir), - entry: {mode: :rack, config_ru: config_ru}, - compiled_files: compiled(dir) + entry: {mode: :rack, config_ru: config_ru, root: dir}, + compiled_files: compiled(dir), + stub_files: stubs(dir) ) end + # Reading require_paths off the gemspec would mean evaluating + # it, which the static pass never does; `lib` is the packaging + # default, and a gem that keeps its code elsewhere falls back to + # the whole checkout rather than scanning nothing. def self.gem_dir_target(dir, gemspec) lib = File.join(dir, "lib") + paths = File.directory?(lib) ? [lib] : [dir] new( type: :gem, root: dir, - ruby_files: glob(lib), + ruby_files: paths.flat_map { |path| glob(path) }, entry: { mode: :require, feature: File.basename(gemspec, ".gemspec"), - load_paths: [lib], + load_paths: paths, root: dir }, - compiled_files: compiled(dir) + compiled_files: compiled(dir), + stub_files: stubs(dir) ) end @@ -199,6 +245,14 @@ def self.glob(dir, pattern = "*.rb", skip: EXCLUDED_DIRS) end.sort end + # Generated type stubs anywhere under a directory. + # + # @param dir [String] + # @return [Array] + def self.stubs(dir) + glob(dir, STUBS) + end + # macOS debug-symbol bundles (x.bundle.dSYM/...) carry a file # with the extension's name that nothing ever loads. def self.compiled(dir) @@ -222,15 +276,30 @@ def self.compiled_for(spec) private_class_method :from_file, :from_directory, :from_gem_name, :rails_target, :rack_target, :gem_dir_target, - :glob, :compiled, :normalize + :rails_member?, :glob, :compiled, :normalize def initialize(type:, root:, ruby_files:, entry:, - compiled_files: []) + compiled_files: [], stub_files: []) @type = type @root = root @ruby_files = ruby_files @entry = entry @compiled_files = compiled_files + @stub_files = stub_files + end + + # Whether a path is test/spec code rather than code the + # production boot loads. + # + # @param path [String] absolute or root-relative + # @param dirs [Array] directory names that hold tests + # @return [Boolean] + def test_file?(path, dirs: TEST_DIRS) + relative = File.expand_path(path, root) + .delete_prefix("#{root}/") + parts = relative.split("/") + parts[0..-2].any? { |p| dirs.include?(p) } || + parts.last.to_s.end_with?("_test.rb", "_spec.rb") end end end diff --git a/spec/audition/static/native_extensions_spec.rb b/spec/audition/static/native_extensions_spec.rb index 4ebdb7b..4497d0f 100644 --- a/spec/audition/static/native_extensions_spec.rb +++ b/spec/audition/static/native_extensions_spec.rb @@ -141,6 +141,48 @@ def analyze(dir) expect(analyze(dir)).to eq([]) end end + it "finds an extension the gem did not put under ext" do + gem_dir do |dir| + native = File.join(dir, "native/cool_gem") + FileUtils.mkdir_p(native) + File.write(File.join(native, "extconf.rb"), "require \"mkmf\"\n") + File.write(File.join(native, "cool_gem.c"), + "void\nInit_cool_gem(void)\n{\n}\n") + + findings = analyze(dir) + + expect(findings.map(&:severity)).to eq([:warning]) + expect(findings.first.message).to include("native/cool_gem") + end + end + + it "names the checkout when the build file sits at the root" do + gem_dir do |dir| + File.write(File.join(dir, "extconf.rb"), "require \"mkmf\"\n") + File.write(File.join(dir, "cool_gem.c"), + "void\nInit_cool_gem(void)\n{\n}\n") + + findings = analyze(dir) + + expect(findings.first.message).to include(File.basename(dir)) + end + end + + it "reports a crate under a workspace manifest once" do + gem_dir do |dir| + ext = File.join(dir, "ext/cool_gem") + FileUtils.mkdir_p(File.join(ext, "src")) + File.write(File.join(dir, "Cargo.toml"), "[workspace]\n") + File.write(File.join(ext, "Cargo.toml"), "[package]\n") + File.write(File.join(ext, "src/lib.rs"), + "#[magnus::init]\nfn init() {}\n") + + findings = analyze(dir) + + expect(findings.map(&:path)).to eq([File.join(ext, "Cargo.toml")]) + end + end + it "ignores fuzz, bench, and test harness sources" do gem_dir do |dir| ext = File.join(dir, "ext/cool_gem") diff --git a/spec/audition/target_spec.rb b/spec/audition/target_spec.rb index 8690985..a79af73 100644 --- a/spec/audition/target_spec.rb +++ b/spec/audition/target_spec.rb @@ -19,6 +19,21 @@ def in_tmpdir end end + it "keeps a file inside a Rails root static-only" do + in_tmpdir do |dir| + FileUtils.mkdir_p(File.join(dir, "config")) + FileUtils.mkdir_p(File.join(dir, "app/models")) + File.write(File.join(dir, "config/application.rb"), "\n") + path = File.join(dir, "app/models/user.rb") + File.write(path, "class User; end\n") + + target = described_class.detect(path) + expect(target.type).to eq(:script) + expect(target.ruby_files).to eq([path]) + expect(target.entry).to be_nil + end + end + it "detects a directory with config.ru as a rack app" do in_tmpdir do |dir| File.write(File.join(dir, "config.ru"), "run ->(e) {}\n") @@ -30,7 +45,8 @@ def in_tmpdir File.join(dir, "config.ru"), File.join(dir, "app.rb") ) expect(target.entry) - .to eq(mode: :rack, config_ru: File.join(dir, "config.ru")) + .to eq(mode: :rack, config_ru: File.join(dir, "config.ru"), + root: dir) end end @@ -54,6 +70,68 @@ def in_tmpdir end end + it "scans a Rails root beyond app, lib, and config" do + in_tmpdir do |dir| + FileUtils.mkdir_p(File.join(dir, "config")) + FileUtils.mkdir_p(File.join(dir, "gems/toolkit/lib")) + FileUtils.mkdir_p(File.join(dir, "vendor/bundle")) + File.write(File.join(dir, "config/application.rb"), "module A;end\n") + File.write(File.join(dir, "gems/toolkit/lib/toolkit.rb"), + "module Toolkit;end\n") + File.write(File.join(dir, "vendor/bundle/dep.rb"), "module D;end\n") + + target = described_class.detect(dir) + expect(target.ruby_files) + .to include(File.join(dir, "gems/toolkit/lib/toolkit.rb")) + expect(target.ruby_files) + .not_to include(File.join(dir, "vendor/bundle/dep.rb")) + end + end + + describe "#test_file?" do + it "recognizes test directories and suffixed files" do + in_tmpdir do |dir| + File.write(File.join(dir, "thing.rb"), "X = 1\n") + target = described_class.detect(dir) + + expect(target.test_file?(File.join(dir, "spec/a_spec.rb"))) + .to be(true) + expect(target.test_file?(File.join(dir, "test/models/a.rb"))) + .to be(true) + expect(target.test_file?(File.join(dir, "features/x.rb"))) + .to be(true) + expect(target.test_file?(File.join(dir, "lib/a_test.rb"))) + .to be(true) + expect(target.test_file?(File.join(dir, "app/models/a.rb"))) + .to be(false) + expect(target.test_file?(File.join(dir, "lib/testing.rb"))) + .to be(false) + end + end + + it "takes the directory names it is given" do + in_tmpdir do |dir| + File.write(File.join(dir, "thing.rb"), "X = 1\n") + target = described_class.detect(dir) + + expect(target.test_file?(File.join(dir, "qa/a.rb"), + dirs: %w[qa])).to be(true) + expect(target.test_file?(File.join(dir, "spec/a.rb"), + dirs: %w[qa])).to be(false) + end + end + + it "matches nested test directories inside engines" do + in_tmpdir do |dir| + File.write(File.join(dir, "thing.rb"), "X = 1\n") + target = described_class.detect(dir) + + path = File.join(dir, "gems/toolkit/spec/a.rb") + expect(target.test_file?(path)).to be(true) + end + end + end + it "detects a directory with a gemspec as a gem" do in_tmpdir do |dir| FileUtils.mkdir_p(File.join(dir, "lib")) @@ -71,6 +149,36 @@ def in_tmpdir end end + it "falls back to the whole checkout when a gem has no lib" do + in_tmpdir do |dir| + FileUtils.mkdir_p(File.join(dir, "src")) + File.write(File.join(dir, "cool_gem.gemspec"), "") + File.write(File.join(dir, "src/cool_gem.rb"), "module CoolGem;end\n") + + target = described_class.detect(dir) + expect(target.ruby_files) + .to eq([File.join(dir, "src/cool_gem.rb")]) + expect(target.entry[:load_paths]).to eq([dir]) + end + end + + it "finds type stubs wherever the generator wrote them" do + in_tmpdir do |dir| + FileUtils.mkdir_p(File.join(dir, "types/gems")) + FileUtils.mkdir_p(File.join(dir, "vendor/types")) + File.write(File.join(dir, "types/gems/kit@1.0.0.rbi"), "\n") + File.write(File.join(dir, "top.rbi"), "\n") + File.write(File.join(dir, "vendor/types/dep.rbi"), "\n") + File.write(File.join(dir, "thing.rb"), "X = 1\n") + + target = described_class.detect(dir) + expect(target.stub_files).to contain_exactly( + File.join(dir, "types/gems/kit@1.0.0.rbi"), + File.join(dir, "top.rbi") + ) + end + end + it "resolves an installed gem by name" do target = described_class.detect("rubydex") From 7df0b98474e82efa2eb75485b43123128c003555 Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Sun, 13 Sep 2026 18:06:12 +0400 Subject: [PATCH 10/14] Probe dependencies' extensions and entry files on load paths The require probe's entry-file fallback globbed `lib` under the target root, which is the packaging default and nothing more. It globs the load paths the payload already carries and the harness already unshifts onto $LOAD_PATH, so a target that keeps its code elsewhere gets the same fallback. Installed-gem targets and sweep rows now carry their full require paths. The probes also extend the native-extension check to dependencies: the harness records every compiled file the load pulled in and byte-scans those too, reporting silent ones as `runtime-native-extension` warnings attributed to the gem that shipped them, and skipping what the static check already covered. --- lib/audition/dynamic/harness.rb | 239 ++++++++++++++++++++++++--- lib/audition/dynamic/prober.rb | 194 +++++++++++++++++----- spec/audition/dynamic/prober_spec.rb | 192 ++++++++++++++++++++- 3 files changed, 560 insertions(+), 65 deletions(-) diff --git a/lib/audition/dynamic/harness.rb b/lib/audition/dynamic/harness.rb index 8169a76..cf93e45 100644 --- a/lib/audition/dynamic/harness.rb +++ b/lib/audition/dynamic/harness.rb @@ -15,7 +15,11 @@ require "rbconfig" module AuditionHarness - MAX_CONSTS = 5000 + # High enough for the largest targets: the sweep is the backstop + # for everything static analysis cannot prove, so it must reach + # every constant the boot defined. A hit is reported as + # truncated, never silent. + MAX_CONSTS = 200_000 # Directories under the target root that are not the target's # own surface. Bundler's deployment mode (and bundler-cache in @@ -82,7 +86,18 @@ def main(mode, payload, out:) def describe_error(error) root = unwrap(error) {"class" => scrub(root.class.name.to_s), - "message" => scrub(root.message.to_s)[0, 500]} + "message" => scrub(root.message.to_s)[0, 500], + "backtrace" => backtrace_for(root)} + end + + # Enough frames to reach the target's code under framework + # wrappers; frames carry paths, which can carry arbitrary bytes. + def backtrace_for(error) + Array(error.backtrace).first(30).map do |frame| + scrub(frame.to_s)[0, 300] + end + rescue Exception + [] end def scrub(text) @@ -137,10 +152,15 @@ def library(payload) Array(payload["load_paths"]).each do |lp| $LOAD_PATH.unshift(lp) # audition:disable global-variables end + baseline = module_state_snapshot before = Object.constants features = $LOADED_FEATURES.dup # audition:disable global-variables - require_target(payload.fetch("feature"), payload["root"]) - scan(Object.constants - before, root: payload["root"]).merge( + require_target(payload.fetch("feature"), + Array(payload["load_paths"])) + merge_reopened( + scan(Object.constants - before, root: payload["root"], + limit: payload["max_constants"]), baseline + ).merge( "native_extensions" => native_extensions( features, payload["root"], payload["known_compiled"] ) @@ -152,17 +172,17 @@ def library(payload) # rspec/mocks), and squashed names ship snake_case files # (activesupport provides active_support). The second has no # rule to invert, so when the target ships exactly one top-level - # file under lib/, that file is the entry, required by absolute - # path. An absolute require keeps the path as given, and scan + # file on its load paths, that file is the entry, required by + # absolute path. An absolute require keeps the path as given, and scan # compares constant origins against the realpathed root, so the # candidate is built from the realpath too (a symlinked tmpdir, # macOS /var, otherwise turns own findings into dependency ones). # The error reported is the last one seen: a candidate that loads # but fails inside says more than "cannot load such file". - def require_target(feature, root) + def require_target(feature, load_paths) require feature # audition:disable runtime-require rescue LoadError => error - entry_candidates(feature, root).each do |candidate| + entry_candidates(feature, load_paths).each do |candidate| return require candidate # audition:disable runtime-require rescue LoadError => e error = e @@ -170,11 +190,13 @@ def require_target(feature, root) raise error end - def entry_candidates(feature, root) + def entry_candidates(feature, load_paths) candidates = [] slashed = feature.tr("-", "/") candidates << slashed if slashed != feature - files = root ? Dir[File.join(realpath(root), "lib", "*.rb")] : [] + files = load_paths.flat_map do |path| + Dir[File.join(realpath(path), "*.rb")] + end candidates << files.first.delete_suffix(".rb") if files.size == 1 candidates end @@ -184,23 +206,28 @@ def entry_candidates(feature, root) # are inspected for class-level ivars and class variables, then # descended into. const_get can raise (autoload failures) and # anything can lie; every step is rescued and counted. - def scan(root_names, root: nil) + def scan(root_names, root: nil, limit: nil) # Loaded features are realpathed by require; the target root # must be too, or symlinked paths (macOS /var vs /private/var) # break the own-vs-dependency comparison. root = realpath(root) + limit = (limit || MAX_CONSTS).to_i unshareable = [] class_state = [] class_vars = [] errors = 0 + truncated = false seen = {} queue = root_names.map { |name| [Object, name.to_s] } visited = 0 until queue.empty? + if visited >= limit + truncated = true + break + end owner, name = queue.shift visited += 1 - break if visited > MAX_CONSTS begin value = owner.const_get(name, false) @@ -223,8 +250,11 @@ def scan(root_names, root: nil) else begin unless Ractor.shareable?(value) + blocker, blocker_depth = blocker_for(value) unshareable << origin.merge( - "const" => full, "class" => value.class.name + "const" => full, "class" => value.class.name, + "blocker" => blocker, + "blocker_nested" => blocker_depth.positive? ) end rescue Exception @@ -237,6 +267,8 @@ def scan(root_names, root: nil) "class_state" => class_state, "class_variables" => class_vars, "scanned" => visited, + "truncated" => truncated, + "limit" => limit, "errors" => errors} end @@ -340,6 +372,125 @@ def safe_shareable?(value) false end + # Ivar/cvar state of every module defined before the boot. The + # new-constants sweep never revisits these, so state the boot + # plants on reopened core and stdlib classes is diffed against + # this snapshot instead. Autoload stubs are left untouched: + # forcing them here would load code behind the back of the + # before/after constant accounting. + def module_state_snapshot(limit = 50_000) + snap = {} + seen = {} + queue = Object.constants.map { |name| [Object, name.to_s] } + until queue.empty? || snap.size >= limit + owner, name = queue.shift + begin + next if owner.autoload?(name, false) + + value = owner.const_get(name, false) + rescue Exception + next + end + next unless value.is_a?(Module) + next if seen[value.object_id] + + seen[value.object_id] = true + begin + snap[value] = [value.instance_variables, + value.class_variables(false)] + rescue Exception + next + end + value.constants(false).each do |child| + queue << [value, child.to_s] + end + end + snap + end + + # RubyGems, Bundler, and the VM mutate their own module state on + # every require; that is probe machinery, not the target's doing. + MACHINERY = /\A(?:Gem|Bundler|RubyVM)(?:::|\z)/ + + # State the boot added to pre-existing modules, reported through + # the same channels as freshly defined class state. No source + # location exists for a reopen, and unknown origins count as own. + def merge_reopened(result, snapshot) + origin = {"path" => nil, "line" => nil, "own" => true} + snapshot.each do |mod, (ivars, cvars)| + new_ivars = mod.instance_variables - ivars + new_cvars = mod.class_variables(false) - cvars + next if new_ivars.empty? && new_cvars.empty? + + full = mod.name || mod.inspect + next if full.match?(MACHINERY) + if new_ivars.any? + unshareable = new_ivars.reject do |ivar| + safe_shareable?(mod.instance_variable_get(ivar)) + end + result["class_state"] << origin.merge( + "const" => full, + "ivars" => new_ivars.map(&:to_s), + "unshareable" => unshareable.map(&:to_s) + ) + end + if new_cvars.any? + result["class_variables"] << origin.merge( + "const" => full, "cvars" => new_cvars.map(&:to_s) + ) + end + rescue Exception + next + end + result + end + + # The innermost unshareable node of an unshareable value, with + # its depth: an unfrozen node with shareable contents just needs + # freezing, an inherently unshareable one needs replacing. + # Bounded, because shareability of each child is itself a + # recursive check. + def blocker_for(value, depth = 0) + return [describe_blocker(value), depth] if depth > 5 + + child = children_of(value).find { |c| !safe_shareable?(c) } + if child + blocker_for(child, depth + 1) + else + [describe_blocker(value), depth] + end + rescue Exception + [describe_blocker(value), depth] + end + + def children_of(value) + children = + case value + when Hash then value.keys + value.values + when Array then value + when Struct, Data then value.deconstruct + else + value.instance_variables.map do |ivar| + value.instance_variable_get(ivar) + end + end + children.first(1000) + end + + # "unfrozen X" only when freezing would actually flip the + # verdict; a Proc or IO stays unshareable frozen, and naming it + # unfrozen would send the reader to a fix that cannot work. + def describe_blocker(value) + return value.class.to_s if value.frozen? + + frozen_helps = begin + Ractor.shareable?(value.dup.freeze) + rescue Exception + false + end + frozen_helps ? "unfrozen #{value.class}" : value.class.to_s + end + # -- rack -------------------------------------------------------- # App objects built in config.ru are almost never shareable (the @@ -349,6 +500,8 @@ def safe_shareable?(value) # and serve one request entirely inside a Ractor. def rack(payload) config_ru = payload.fetch("config_ru") + root = payload["root"] || File.dirname(config_ru) + bundler_setup begin require "rack" # audition:disable runtime-require rescue LoadError @@ -356,6 +509,9 @@ def rack(payload) end out = {"rack_available" => true} + baseline = module_state_snapshot + before = Object.constants + features = $LOADED_FEATURES.dup # audition:disable global-variables begin app = Rack::Builder.parse_file(config_ru) app = app.first if app.is_a?(Array) @@ -364,6 +520,16 @@ def rack(payload) rescue Exception => e out["main_boot_error"] = describe_error(e) end + # The main-process boot defines the app's constant graph; + # sweeping it gives rack targets the same backstop as require + # and rails targets. A failed boot still sweeps what loaded. + out.merge!(merge_reopened( + scan(Object.constants - before, root: root, + limit: payload["max_constants"]), baseline + )) + out["native_extensions"] = native_extensions( + features, root, payload["known_compiled"] + ) out["ractor_boot_call"] = rack_in_ractor(config_ru) if out["ractor_boot_call"]["ok"] @@ -445,20 +611,53 @@ def rack_in_ractor(config_ru) # -- rails ------------------------------------------------------- + # The prober sets BUNDLE_GEMFILE to the target's Gemfile; the + # target's gems must resolve before its boot files load. A + # setup failure propagates: it is the true boot failure, and + # the standard reporting captures it. Bundler.setup is called + # directly because bundler/setup exits on failure, swallowing + # the message. + def bundler_setup + return unless ENV["BUNDLE_GEMFILE"] + + require "bundler" # audition:disable runtime-require + Bundler.ui.silence { Bundler.setup } + end + + # A boot failure does not abandon the sweep: everything defined + # before the failure is still scanned, so the probe reports what + # it could reach alongside the boot error. def rails(payload) environment = payload.fetch("environment") + bundler_setup + baseline = module_state_snapshot before = Object.constants features = $LOADED_FEATURES.dup # audition:disable global-variables started = Time.now - require environment # audition:disable runtime-require + boot_error = nil begin - Rails.application.eager_load! - rescue Exception - nil + # Absolute requires keep the path as given; realpathing it + # keeps own-vs-dependency attribution honest under symlinked + # roots (macOS /var). + require realpath(environment) # audition:disable runtime-require + begin + Rails.application.eager_load! + rescue Exception + nil + end + rescue Exception => e + boot_error = describe_error(e) end - boot = {"ok" => true, - "seconds" => (Time.now - started).round(1)} - scan(Object.constants - before, root: payload["root"]).merge( + boot = + if boot_error + {"ok" => false, "error" => boot_error} + else + {"ok" => true, "seconds" => (Time.now - started).round(1)} + end + merge_reopened( + scan(Object.constants - before, root: payload["root"], + limit: payload["max_constants"]), baseline + ).merge( "boot" => boot, "native_extensions" => native_extensions( features, payload["root"], payload["known_compiled"] diff --git a/lib/audition/dynamic/prober.rb b/lib/audition/dynamic/prober.rb index 4fae063..24f4893 100644 --- a/lib/audition/dynamic/prober.rb +++ b/lib/audition/dynamic/prober.rb @@ -22,7 +22,7 @@ module Dynamic # Spawns the harness subprocess per probe mode, parses its JSON, # and converts observations into findings. class Prober - HARNESS = File.expand_path("harness.rb", __dir__) + HARNESS = File.expand_path("harness.rb", __dir__).freeze RUNTIME_WHY = "Observed on the live object graph after loading the " \ @@ -60,13 +60,13 @@ def probe(entry) def probe_script(entry) path = entry[:path] - ractor = run("script_ractor", "path" => path) + ractor = run("script_ractor", {"path" => path}) if ractor["ok"] return Result.new(mode: :script, raw: ractor, findings: [], passed: true) end - main = run("script_main", "path" => path) + main = run("script_main", {"path" => path}) finding = script_finding(path, ractor, main) Result.new(mode: :script, raw: {"ractor" => ractor, "main" => main}, @@ -76,6 +76,7 @@ def probe_script(entry) def script_finding(path, ractor, main) if main["ok"] error = describe(ractor) + site = failure_site(ractor, prefix: "#{path}:") Finding.new( check: "dynamic-script", severity: :error, @@ -84,11 +85,12 @@ def script_finding(path, ractor, main) "failed under Ractor.new; the static findings " \ "usually pinpoint the exact line.", fix: "Fix the static findings for this file, then " \ - "re-audition.", + "re-run Audition.", path: path, - line: nil + line: site&.last ) else + site = failure_site(main, prefix: "#{path}:") Finding.new( check: "dynamic-script", severity: :error, @@ -97,7 +99,7 @@ def script_finding(path, ractor, main) "Ractor, so Ractor-readiness cannot be assessed.", fix: "Make the script run standalone first.", path: path, - line: nil + line: site&.last ) end end @@ -105,38 +107,51 @@ def script_finding(path, ractor, main) def probe_require(entry) feature = entry[:feature] raw = run("require", - "feature" => feature, - "load_paths" => Array(entry[:load_paths]), - "root" => entry[:root], - "known_compiled" => Array(entry[:compiled_files])) - findings = runtime_findings(raw, feature) + {"feature" => feature, + "load_paths" => Array(entry[:load_paths]), + "root" => entry[:root], + "known_compiled" => Array(entry[:compiled_files]), + "max_constants" => entry[:max_constants]}) + findings = runtime_findings(raw, feature, + root: entry[:root]) Result.new(mode: :require, raw: raw, findings: findings, passed: own_clean?(findings)) end def probe_rails(entry) raw = run("rails", - "environment" => entry[:environment], - "root" => entry[:root], - "known_compiled" => Array(entry[:compiled_files])) + {"environment" => entry[:environment], + "root" => entry[:root], + "known_compiled" => Array(entry[:compiled_files]), + "max_constants" => entry[:max_constants]}, + root: entry[:root]) boot = raw["boot"] + findings = runtime_findings(raw, entry[:environment], + root: entry[:root]) if boot && !boot["ok"] - finding = Finding.new( + # The sweep of whatever loaded before the failure stays: + # a partial dynamic result beats none. + why = "Ractor-readiness cannot be fully assessed " \ + "until the application boots." + if raw["scanned"].to_i.positive? + why += " Findings below cover what loaded before " \ + "the failure." + end + site = failure_site(boot, prefix: own_prefix(entry[:root])) + findings.unshift(Finding.new( check: "dynamic-rails", severity: :error, message: "Rails failed to boot: #{describe(boot)}", - why: "Ractor-readiness cannot be assessed until the " \ - "application boots.", + why: why, fix: "Boot the app (bin/rails runner 1) and fix " \ - "whatever breaks, then re-audition.", - path: entry[:environment], - line: nil - ) + "whatever breaks, then re-run Audition.", + path: site&.first || entry[:environment], + line: site&.last + )) return Result.new(mode: :rails, raw: raw, - findings: [finding], passed: false) + findings: findings, passed: false) end - findings = runtime_findings(raw, entry[:environment]) Result.new(mode: :rails, raw: raw, findings: findings, passed: own_clean?(findings)) end @@ -150,10 +165,18 @@ def own_clean?(findings) def probe_rack(entry) config_ru = entry[:config_ru] - raw = run("rack", "config_ru" => config_ru) - findings = rack_findings(raw, config_ru) + root = entry[:root] || File.dirname(config_ru) + raw = run("rack", + {"config_ru" => config_ru, + "root" => root, + "known_compiled" => Array(entry[:compiled_files]), + "max_constants" => entry[:max_constants]}, + root: root) + findings = rack_findings(raw, config_ru, root) + findings += runtime_findings(raw, config_ru, root: root) unless raw["error"] passed = raw.dig("ractor_boot_call", "ok") == true && - raw.dig("concurrency", "failures").to_i.zero? + raw.dig("concurrency", "failures").to_i.zero? && + own_clean?(findings) Result.new(mode: :rack, raw: raw, findings: findings, passed: passed) end @@ -166,19 +189,28 @@ def probe_capabilities # -- findings builders --------------------------------------- - def runtime_findings(raw, label) + def runtime_findings(raw, label, root: nil) if raw["error"] - return [load_failure_finding(raw, label)] + return [load_failure_finding(raw, label, root)] end findings = [] raw.fetch("unshareable_constants", []).each do |entry| + blocker = entry["blocker"] + detail = + if blocker.nil? || blocker == entry["class"] + "" + elsif entry["blocker_nested"] + " (blocked by #{blocker} inside)" + else + " (just not frozen)" + end findings << runtime_finding( entry, label, check: "runtime-unshareable-constant", severity: :error, message: "constant #{entry["const"]} holds an " \ - "unshareable #{entry["class"]}", + "unshareable #{entry["class"]}#{detail}", why: "Reading it from a non-main Ractor raises " \ "Ractor::IsolationError. #{RUNTIME_WHY}", fix: "Freeze it deeply at definition time " \ @@ -208,6 +240,22 @@ def runtime_findings(raw, label) findings << native_finding(entry, label) end + # A truncated sweep must never read as a clean one. + if raw["truncated"] + findings.unshift(Finding.new( + check: "runtime-scan", + severity: :warning, + message: "constant sweep truncated after " \ + "#{raw["scanned"]} constants " \ + "(limit #{raw["limit"]})", + why: "Constants beyond the limit were never probed, " \ + "so their absence from the findings proves " \ + "nothing.", + fix: "Raise the probe's max_constants and re-run.", + path: label, + line: nil + )) + end findings end @@ -292,7 +340,8 @@ def runtime_finding(entry, label, check:, severity:, ) end - def load_failure_finding(raw, label) + def load_failure_finding(raw, label, root = nil) + site = failure_site(raw, prefix: own_prefix(root)) Finding.new( check: "runtime-load", severity: :error, @@ -300,14 +349,14 @@ def load_failure_finding(raw, label) why: "Ractor-readiness cannot be assessed until the " \ "target loads.", fix: "Make `require` succeed on a bare Ruby first.", - path: label, - line: nil + path: site&.first || label, + line: site&.last ) end - def rack_findings(raw, config_ru) + def rack_findings(raw, config_ru, root = nil) if raw.dig("ractor_boot_call", "ok") - return concurrency_findings(raw, config_ru) + return concurrency_findings(raw, config_ru, root) end if raw["rack_available"] == false @@ -316,7 +365,7 @@ def rack_findings(raw, config_ru) severity: :warning, message: "rack gem not available in the probe process", why: "The rack probe boots the app via Rack::Builder.", - fix: "Install rack next to audition and re-run.", + fix: "Install rack next to Audition and re-run.", path: config_ru, line: nil )] @@ -332,6 +381,10 @@ def rack_findings(raw, config_ru) "booting config.ru and serving one GET / inside a " \ "Ractor failed." end + site = failure_site(raw["ractor_boot_call"], + prefix: own_prefix(root)) || + failure_site({"error" => raw["main_boot_error"]}, + prefix: own_prefix(root)) [Finding.new( check: "dynamic-rack", severity: :error, @@ -340,16 +393,18 @@ def rack_findings(raw, config_ru) fix: "Remove global/class-level state touched during " \ "boot and request handling; keep middleware config " \ "frozen; open connections per-Ractor.", - path: config_ru, - line: nil + path: site&.first || config_ru, + line: site&.last )] end - def concurrency_findings(raw, config_ru) + def concurrency_findings(raw, config_ru, root = nil) stats = raw["concurrency"] || {} failures = stats["failures"].to_i return [] if failures.zero? + site = failure_site({"error" => stats["first_error"]}, + prefix: own_prefix(root)) [Finding.new( check: "dynamic-rack-concurrency", severity: :error, @@ -361,11 +416,43 @@ def concurrency_findings(raw, config_ru) "shared state races. #{RUNTIME_WHY}", fix: "Look for process-global state touched during " \ "request handling and boot.", - path: config_ru, - line: nil + path: site&.first || config_ru, + line: site&.last )] end + # A backtrace frame inside the target names the failing + # line; frames outside it stay unattributed rather than + # pinning a finding to a dependency's file. + BACKTRACE_FRAME = /\A(.+?):(\d+):in / + + def failure_site(hash, prefix:) + prefixes = Array(prefix) + return nil if prefixes.empty? || !hash.is_a?(Hash) + + error = hash["error"].is_a?(Hash) ? hash["error"] : hash + frame = Array(error["backtrace"]).find do |f| + f.is_a?(String) && prefixes.any? { |p| f.start_with?(p) } + end + match = frame&.match(BACKTRACE_FRAME) + match && [match[1], Integer(match[2], 10)] + end + + # require realpaths frames while eval keeps paths as given, + # so a symlinked root (macOS /var) must match both spellings. + def own_prefix(root) + return nil if root.nil? + + [root + File::SEPARATOR, + realpath(root) + File::SEPARATOR].uniq + end + + def realpath(path) + File.realpath(path) + rescue SystemCallError + path + end + def describe(hash) error = hash.is_a?(Hash) ? (hash["error"] || hash) : {} klass = error["class"] || "UnknownError" @@ -378,8 +465,8 @@ def describe(hash) # Harness output can carry arbitrary target bytes; force # valid UTF-8 before any string work or a binary exception # message crashes the whole run. - def run(mode, payload = {}) - out, err, timed_out = execute(mode, payload) + def run(mode, payload = {}, root: nil) + out, err, timed_out = execute(mode, payload, root: root) out = sanitize(out) err = sanitize(err) if timed_out @@ -405,9 +492,28 @@ def sanitize(text) # child the target spawned inherits our pipes and would # otherwise hold the read until it exits, defeating the # timeout and leaving orphans behind. - def execute(mode, payload) + def execute(mode, payload, root: nil) cmd = [@ruby, "-W0", HARNESS, mode] - Open3.popen3(*cmd, pgroup: true) do |stdin, stdout, stderr, wait| + # An app boots against its own bundle: the subprocess runs + # from the target root with the target's Gemfile. When + # Audition itself runs under bundle exec, the inherited + # Bundler environment (RUBYOPT's -rbundler/setup above all) + # would activate Audition's bundle inside the child before + # the harness starts, so it is scrubbed first. + env = {} + opts = {pgroup: true} + if root && File.directory?(root) + opts[:chdir] = root + gemfile = File.join(root, "Gemfile") + if File.file?(gemfile) + ENV.each_key do |key| + env[key] = nil if key.start_with?("BUNDLE") || + %w[RUBYOPT RUBYLIB].include?(key) + end + env["BUNDLE_GEMFILE"] = gemfile + end + end + Open3.popen3(env, *cmd, **opts) do |stdin, stdout, stderr, wait| stdin.write(JSON.generate(payload)) stdin.close out_reader = reader(stdout) diff --git a/spec/audition/dynamic/prober_spec.rb b/spec/audition/dynamic/prober_spec.rb index 03be1f2..d548812 100644 --- a/spec/audition/dynamic/prober_spec.rb +++ b/spec/audition/dynamic/prober_spec.rb @@ -102,6 +102,21 @@ module HelperDep end end + it "pins the failing line from the Ractor backtrace" do + Dir.mktmpdir do |dir| + path = write(dir, "late_fail.rb", <<~RUBY) + x = [1, 2].sum + $boom = x + RUBY + + result = prober.probe(mode: :script, path: path) + + finding = result.findings.first + expect(finding.path).to eq(path) + expect(finding.line).to eq(2) + end + end + it "passes a self-contained script" do Dir.mktmpdir do |dir| path = write(dir, "clean.rb", "x = [1, 2].sum\nraise unless x == 3\n") @@ -159,6 +174,59 @@ class Engine end end + it "catches state the boot plants on reopened core classes" do + Dir.mktmpdir do |dir| + write(dir, "lib/reopener.rb", <<~RUBY) + class String + @audition_planted = {"k" => 1} + end + module Comparable + @@audition_legacy = true + end + module Reopener + end + RUBY + + result = prober.probe( + mode: :require, + feature: "reopener", + load_paths: [File.join(dir, "lib")] + ) + + messages = result.findings.map(&:message) + expect(messages.grep(/@audition_planted on String/)) + .not_to be_empty + expect(messages.grep(/@@audition_legacy.* on Comparable/)) + .not_to be_empty + end + end + + it "names the blocker inside an unshareable constant" do + Dir.mktmpdir do |dir| + write(dir, "lib/blockers.rb", <<~RUBY) + module Blockers + JUST_UNFROZEN = [1, 2] + DEEP = {"handler" => proc { 1 }}.freeze + LEAF = [["s".dup].freeze].freeze + end + RUBY + + result = prober.probe( + mode: :require, + feature: "blockers", + load_paths: [File.join(dir, "lib")] + ) + + messages = result.findings.map(&:message) + expect(messages.grep(/JUST_UNFROZEN.*just not frozen/)) + .not_to be_empty + expect(messages.grep(/DEEP.*blocked by Proc inside/)) + .not_to be_empty + expect(messages.grep(/LEAF.*blocked by unfrozen String inside/)) + .not_to be_empty + end + end + it "falls back to the single top-level lib file of the target" do Dir.mktmpdir do |dir| write(dir, "lib/active_thing.rb", <<~RUBY) @@ -181,6 +249,28 @@ module ActiveThing end end + it "finds that fallback on a load path that is not lib" do + Dir.mktmpdir do |dir| + write(dir, "src/active_thing.rb", <<~RUBY) + # frozen_string_literal: true + + module ActiveThing + VERSION = "1.0" + end + RUBY + + result = prober.probe( + mode: :require, + feature: "activething", + load_paths: [File.join(dir, "src")], + root: dir + ) + + expect(result.raw["error"]).to be_nil + expect(result.passed).to be(true) + end + end + it "attributes constants loaded through the fallback to the target" do # The fallback requires the entry by absolute path, and an # absolute require keeps the path as given, so the candidate @@ -227,6 +317,31 @@ module Widgets end end + it "reports a truncated constant sweep instead of staying silent" do + Dir.mktmpdir do |dir| + write(dir, "lib/many_consts.rb", <<~RUBY) + module ManyConstsA; end + module ManyConstsB; end + module ManyConstsC; end + module ManyConstsD; end + RUBY + + result = prober.probe( + mode: :require, + feature: "many_consts", + load_paths: [File.join(dir, "lib")], + max_constants: 2 + ) + + expect(result.raw["truncated"]).to be(true) + expect(result.raw["scanned"]).to eq(2) + scan = result.findings.find { |f| f.check == "runtime-scan" } + expect(scan).not_to be_nil + expect(scan.severity).to eq(:warning) + expect(scan.message).to include("limit 2") + end + end + it "notes shareable class-level state at info level" do Dir.mktmpdir do |dir| write(dir, "lib/warmed_lib.rb", <<~RUBY) @@ -343,6 +458,54 @@ module VendoredDep end end + describe "rails probing" do + it "keeps the partial sweep when boot fails midway" do + Dir.mktmpdir do |dir| + environment = write(dir, "config/environment.rb", <<~RUBY) + module PartialApp + DIRTY = [1, 2] + end + raise "boot exploded after defining constants" + RUBY + + result = prober.probe( + mode: :rails, environment: environment, root: dir + ) + + expect(result.passed).to be(false) + boot = result.findings.first + expect(boot.check).to eq("dynamic-rails") + expect(boot.message).to include("boot exploded") + expect(boot.why).to include("loaded before the failure") + expect(boot.path).to end_with("config/environment.rb") + expect(boot.line).to eq(4) + dirty = result.findings.find do |f| + f.message.include?("PartialApp::DIRTY") + end + expect(dirty).not_to be_nil + expect(dirty.check).to eq("runtime-unshareable-constant") + expect(dirty.dependency?).to be(false) + end + end + + it "does not promise partial findings when nothing loaded" do + Dir.mktmpdir do |dir| + environment = write(dir, "config/environment.rb", <<~RUBY) + raise "dead on arrival" + RUBY + + result = prober.probe( + mode: :rails, environment: environment, root: dir + ) + + expect(result.passed).to be(false) + boot = result.findings.first + expect(boot.message).to include("dead on arrival") + expect(boot.why).not_to include("loaded before the failure") + end + end + end + describe "rack probing" do it "boots, serves, and hammers the app across Ractors" do Dir.mktmpdir do |dir| @@ -373,7 +536,34 @@ module VendoredDep result = prober.probe(mode: :rack, config_ru: config_ru) expect(result.passed).to be(false) - expect(result.findings.first.severity).to eq(:error) + finding = result.findings.first + expect(finding.severity).to eq(:error) + expect(finding.path).to eq(config_ru) + expect(finding.line).to eq(1) + end + end + + it "sweeps the constant graph the main-process boot defined" do + Dir.mktmpdir do |dir| + dir = File.realpath(dir) + config_ru = write(dir, "config.ru", <<~RUBY) + RACK_APP_STATE = [1, 2] + run ->(env) { [200, {}, ["ok"]] } + RUBY + + result = prober.probe( + mode: :rack, config_ru: config_ru, root: dir + ) + + expect(result.passed).to be(false) + swept = result.findings.find do |f| + f.check == "runtime-unshareable-constant" + end + expect(swept).not_to be_nil + expect(swept.message).to include("RACK_APP_STATE") + expect(swept.path).to eq(config_ru) + expect(swept.line).to eq(1) + expect(swept.dependency?).to be(false) end end end From f441e48ee2d9c692fe020d5169494655680ac8b6 Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Sun, 13 Sep 2026 18:06:13 +0400 Subject: [PATCH 11/14] Wire the new checks, progress and config through the CLI The CLI and the bundle sweep gain the pieces the preceding commits added: the gem-calls pass over the target's stubs, the graph audit's worker count, progress phases named for what a waiting reader wants to know, and the configured test directories at the one site that tags a finding as test code. Stub and compiled-file lists pass through `exclude` the same way the Ruby file list does. Per-gem scanning in a sweep stays serial: worker threads already parallelize across gems, and a Ractor storm underneath them would cost more than it buys. --- lib/audition/bundle_sweep.rb | 39 +++-- lib/audition/cli.rb | 267 ++++++++++++++--------------- spec/audition/bundle_sweep_spec.rb | 19 ++ spec/audition/cli_spec.rb | 41 ++++- 4 files changed, 208 insertions(+), 158 deletions(-) diff --git a/lib/audition/bundle_sweep.rb b/lib/audition/bundle_sweep.rb index 7d2c937..149cef3 100644 --- a/lib/audition/bundle_sweep.rb +++ b/lib/audition/bundle_sweep.rb @@ -31,10 +31,10 @@ def initialize(lockfile:, static_only: false, timeout: 30, # Audits every locked gem and ranks the results, worst first. # - # @param progress [Proc, nil] called with (row, done, total) - # as each gem finishes + # @param progress [Progress] named per gem as each finishes, + # which is the only unit a sweep has # @return [Array] - def rows(progress: nil) + def rows(progress: Progress::SILENT) gems = locked_gems queue = Thread::Queue.new gems.each { |g| queue << g } @@ -42,17 +42,19 @@ def rows(progress: nil) collected = [] mutex = Thread::Mutex.new - @concurrency.times.map do - Thread.new do - while (name, version = queue.pop) - row = audit_gem(name, version) - mutex.synchronize do - collected << row - progress&.call(row, collected.size, gems.size) + progress.phase("sweep", total: gems.size, unit: "gems") do + @concurrency.times.map do + Thread.new do + while (name, version = queue.pop) + row = audit_gem(name, version) + mutex.synchronize do + collected << row + progress.item(row.name) + end end end - end - end.each(&:join) + end.each(&:join) + end collected.sort_by do |row| [VERDICT_ORDER.fetch(row.verdict), -row.errors, row.name] @@ -115,8 +117,10 @@ def gem_target(spec) ruby_files: spec.require_paths.flat_map do |rp| ruby_files_under(File.join(root, rp)) end, - entry: {mode: :require, feature: spec.name, root: root}, - compiled_files: Target.compiled_for(spec) + entry: {mode: :require, feature: spec.name, + load_paths: spec.full_require_paths, root: root}, + compiled_files: Target.compiled_for(spec), + stub_files: Target.stubs(root) ) end @@ -148,7 +152,12 @@ def static_findings(target, config) end per_file = Static::Analyzer.new .analyze_paths(files, workers: 1) - per_file + Static::GraphAudit.new.analyze_paths(files) + + gem_calls = Static::GemCalls + .new(root: target.root, stubs: target.stub_files) + .analyze_paths(files) + per_file + gem_calls + Static::GraphAudit.new + .analyze_paths(files, constant_findings: per_file + gem_calls, + workers: 1) + Static::NativeExtensions.new.analyze(target, compiled_files: compiled) end diff --git a/lib/audition/cli.rb b/lib/audition/cli.rb index c0420be..5465589 100644 --- a/lib/audition/cli.rb +++ b/lib/audition/cli.rb @@ -34,7 +34,7 @@ def run(argv) args = options[:args] if args.empty? - @stderr.puts(USAGE) + @stderr.puts(style(options, io: @stderr).dim(USAGE)) return 2 end @@ -53,7 +53,7 @@ def run(argv) audit(target, options) end rescue Error => e - @stderr.puts("audition: #{e.message}") + complain(e.message, options) 2 end @@ -65,7 +65,7 @@ def parse(argv) dynamic_only: false, fix: false, unsafe: false, dry_run: false, capabilities: false, plain: false, timeout: 30, write_baseline: false, no_baseline: false, - deps: false, explicit: [] + deps: false, progress: nil, workers: nil, explicit: [] } parser = OptionParser.new do |o| o.banner = USAGE @@ -129,9 +129,20 @@ def parse(argv) options[:timeout] = v options[:explicit] << :timeout end + o.on("-j", "--workers COUNT", Integer, + "scan Ractors (default: cores, capped by " \ + "RUBY_MAX_CPU)") do |v| + options[:workers] = v + options[:explicit] << :workers + end o.on("--plain", "disable colors and hyperlinks") do options[:plain] = true end + o.on("--[no-]progress", + "narrate scan phases on stderr (default: on for a " \ + "large tree on a terminal)") do |v| + options[:progress] = v + end o.on("-v", "--version") do @stdout.puts(VERSION) return 0 @@ -144,11 +155,19 @@ def parse(argv) options[:args] = parser.parse(argv) options rescue OptionParser::ParseError => e - @stderr.puts("audition: #{e.message}") - @stderr.puts(USAGE) + complain(e.message) + @stderr.puts(style(nil, io: @stderr).dim(USAGE)) 2 end + def complain(message, options = nil) + s = style(options, io: @stderr) + @stderr.puts( + "#{s.red(s.glyph(:error))} #{s.bold("Audition")}: " \ + "#{message}" + ) + end + def audit(target, options) # Read the comparison report up front: a missing or broken # file should be a fast usage error, not a post-audit crash. @@ -158,7 +177,7 @@ def audit(target, options) directives = Directives.new findings = [] unless options[:dynamic_only] - findings = filter(static_findings(target, config), + findings = filter(static_findings(target, config, options), directives, config) findings = run_fix(target, findings, options) if options[:fix] end @@ -175,9 +194,11 @@ def audit(target, options) if options[:write_baseline] recorded = Baseline.write(target.root, all) + s = style(options) @stdout.puts( - "baseline written: #{recorded} finding(s) recorded in " \ - "#{Baseline.path_for(target.root)}" + "#{s.green(s.glyph(:pass))} baseline written: " \ + "#{s.bold("#{recorded} finding(s)")} recorded in " \ + "#{s.cyan(Baseline.path_for(target.root))}" ) return 0 end @@ -239,7 +260,10 @@ def emit_comparison(old, findings, target, options) "#{s.red("#{introduced.size} introduced")}" ) introduced.each do |f| - @stdout.puts(" + #{f.message} (#{f.location})") + @stdout.puts( + " #{s.red("+")} #{f.message} " \ + "#{s.cyan("(#{f.location})")}" + ) end end @@ -275,17 +299,55 @@ def apply_baseline(findings, target, options) baseline.filter(findings, root: target.root) end - def static_findings(target, config) + def static_findings(target, config, options) files = target.ruby_files.reject do |file| config.excluded?(file.delete_prefix("#{target.root}/")) end compiled = target.compiled_files.reject do |file| config.excluded?(file.delete_prefix("#{target.root}/")) end - per_file = Static::Analyzer.new.analyze_paths(files) - per_file + Static::GraphAudit.new.analyze_paths(files) + + stubs = target.stub_files.reject do |file| + config.excluded?(file.delete_prefix("#{target.root}/")) + end + progress = Progress.for(units: files.size, + wanted: options[:progress], format: options[:format], + io: @stderr, style: options[:plain] ? plain_style : nil) + scan(target, files, stubs, compiled, progress, + workers: options[:workers]).map do |f| + if target.test_file?(f.path, dirs: config.test_dirs) + f.with(test: true) + else + f + end + end + end + + # Phases are named for what a waiting reader wants to know; + # progress shares stderr with fix chatter, for the same reason. + def scan(target, files, stubs, compiled, progress, workers: nil) + progress.phase("learning", total: files.size + stubs.size) do |p| + Static::Checks::UnshareableReads.learn(files + stubs, + progress: p) + Static::Checks::DependencyClassState.learn(stubs) + end + per_file = progress.phase("checking", total: files.size) do |p| + Static::Analyzer.new.analyze_paths(files, workers: workers, + progress: p) + end + gem_calls = progress.phase("gem calls") do |p| + Static::GemCalls.new(root: target.root, stubs: stubs) + .analyze_paths(files, progress: p) + end + graph = progress.phase("graph") do |p| + Static::GraphAudit.new.analyze_paths(files, + constant_findings: per_file + gem_calls, + workers: workers, progress: p) + end + per_file + gem_calls + graph + Static::NativeExtensions.new.analyze(target, compiled_files: compiled) + ensure + progress.finish end # Fix chatter goes to stderr: stdout carries the report, which @@ -299,16 +361,19 @@ def run_fix(target, findings, options) applied = fixer.apply(findings) total = applied.values.sum + s = style(options, io: @stderr) if total.zero? - @stderr.puts("nothing to fix") + @stderr.puts(s.dim("nothing to fix")) return findings end @stderr.puts( - "fixed #{total} finding(s) in #{applied.size} file(s)" + "#{s.cyan(s.glyph(:fix))} #{s.green("fixed #{total} " \ + "finding(s)")} in #{s.bold("#{applied.size} file(s)")}" ) - filter(static_findings(target, Config.load(target.root)), - Directives.new, Config.load(target.root)) + config = Config.load(target.root) + filter(static_findings(target, config, options), + Directives.new, config) end def render_preview(previews, options) @@ -316,7 +381,7 @@ def render_preview(previews, options) previews.each do |preview| @stderr.puts(s.bold(preview[:path])) preview[:hunks].each do |hunk| - @stderr.puts(" @ line #{hunk[:line]}") + @stderr.puts(s.cyan(" @ line #{hunk[:line]}")) hunk[:old].each_line do |line| @stderr.puts(s.red(" - #{line.chomp}")) end @@ -325,7 +390,7 @@ def render_preview(previews, options) end end end - @stderr.puts("dry run: no files were changed") + @stderr.puts(s.dim("dry run: no files were changed")) end def prober(options) @@ -356,26 +421,32 @@ def append_step_summary(markdown) File.open(path, "a") { |f| f.puts(markdown) } rescue SystemCallError => e - @stderr.puts( - "audition: cannot write step summary: #{e.message}" - ) + complain("cannot write step summary: #{e.message}") end + # Diagnostics can precede option parsing, so --plain is + # honored only once there is something to honor it from. def style(options, io: @stdout) - if options[:plain] - Report::Style.new(color: false, hyperlinks: false) + if options&.fetch(:plain, false) + plain_style else Report::Style.detect(io: io) end end + def plain_style + Report::Style.new(color: false, hyperlinks: false) + end + def exit_code(report, options) return 0 if options[:fail_on] == :never threshold = SEVERITIES.fetch(options[:fail_on]) + # Test findings never load in a production boot; they are + # reported but do not fail the run, matching the verdict. failed = report.findings.any? do |f| - f.severity_rank >= threshold + f.severity_rank >= threshold && !f.test? end || report.dynamic_results.any? { |r| !r.passed } failed ? 1 : 0 end @@ -391,30 +462,41 @@ def deps_target(target) Target.detect(lockfile) end - VERDICT_CELLS = { - :not_ready => "not ready", :blocked => "blocked", - :risky => "risky", :ready => "ready", nil => "-" - }.freeze - def sweep(target, options) sweeper = BundleSweep.new( lockfile: target.entry[:lockfile], static_only: options[:static_only], timeout: options[:timeout] ) - rows = sweeper.rows(progress: sweep_progress) - - case options[:format] - when :json then emit_sweep_json(rows) - when :github then emit_sweep_github(rows) - else emit_sweep_table(rows, options) - end + rows = sweep_rows(sweeper, options) + emit_sweep(Report::Sweep.new(rows, style(options)), options) return 0 if options[:fail_on] == :never threshold = SEVERITIES.fetch(options[:fail_on]) (rows.any? { |r| row_failed?(r, threshold) }) ? 1 : 0 end + # No unit count: every gem is a scan of its own, so even a + # short lockfile runs long enough to narrate. + def sweep_rows(sweeper, options) + progress = Progress.for(wanted: options[:progress], + format: options[:format], io: @stderr, + style: options[:plain] ? plain_style : nil) + sweeper.rows(progress: progress) + ensure + progress.finish + end + + def emit_sweep(sweep, options) + case options[:format] + when :json then @stdout.puts(sweep.json) + when :github + @stdout.puts(sweep.annotations) + append_step_summary(sweep.markdown) + else @stdout.puts(sweep.render(**table_opts(options))) + end + end + # Same contract as a direct audit: a row fails when it carries # findings at or above --fail-on; not-ready rows always fail. def row_failed?(row, threshold) @@ -426,14 +508,6 @@ def row_failed?(row, threshold) hits.positive? end - def sweep_progress - return nil unless @stderr.respond_to?(:tty?) && @stderr.tty? - - lambda do |row, done, total| - @stderr.puts("audited #{row.name} (#{done}/#{total})") - end - end - # Cells arrive preformatted: coercion would render a version # like "3.2" as 3.200. Color follows the CLI's own detection # because the table gem reads the global $stdout and cannot @@ -446,116 +520,29 @@ def table_opts(options) } end - def emit_sweep_table(rows, options) - ready = rows.count { |r| r.verdict == :ready } - table = rows.map do |r| - { - "gem" => r.name, - "version" => r.version, - "verdict" => VERDICT_CELLS.fetch(r.verdict), - "errors" => r.errors, - "dep errors" => r.dep_errors, - "warnings" => r.warnings, - "fixable" => r.fixable, - "status" => r.status - } - end - @stdout.puts(TableTennis.new( - table, zebra: true, **table_opts(options) - ).to_s) - @stdout.puts( - "#{ready} of #{rows.size} gems ractor-ready" - ) - end - - # Sweep rows carry no file or line, so the annotations land on - # the run summary rather than a diff; the markdown table goes - # to the job summary page when Actions provides one. - def emit_sweep_github(rows) - ready = rows.count { |r| r.verdict == :ready } - rows.each do |row| - level = sweep_annotation_level(row) - next unless level - - @stdout.puts( - "::#{level} title=audition::gem #{row.name} " \ - "#{row.version}: #{row.errors} errors, " \ - "#{row.dep_errors} dependency errors, " \ - "#{row.warnings} warnings " \ - "(#{VERDICT_CELLS.fetch(row.verdict)})" - ) - end - @stdout.puts("#{ready} of #{rows.size} gems ractor-ready") - append_step_summary(sweep_summary_markdown(rows, ready)) - end - - def sweep_annotation_level(row) - if row.verdict == :not_ready || - (row.errors + row.dep_errors).positive? - "error" - elsif row.warnings.positive? - "warning" - end - end - - def sweep_summary_markdown(rows, ready) - lines = [ - "## audition bundle sweep", "", - "| gem | version | verdict | errors | dep errors " \ - "| warnings | fixable |", - "| --- | --- | --- | --- | --- | --- | --- |" - ] - rows.each do |r| - lines << "| #{r.name} | #{r.version} | " \ - "#{VERDICT_CELLS.fetch(r.verdict)} | #{r.errors} | " \ - "#{r.dep_errors} | #{r.warnings} | #{r.fixable} |" - end - lines << "" - lines << "#{ready} of #{rows.size} gems ractor-ready" - lines.join("\n") - end - - def emit_sweep_json(rows) - @stdout.puts(JSON.pretty_generate( - "audition" => VERSION, - "ruby" => RUBY_VERSION, - "bundle" => rows.map do |r| - { - "gem" => r.name, - "version" => r.version, - "verdict" => r.verdict&.to_s, - "errors" => r.errors, - "dependency_errors" => r.dep_errors, - "warnings" => r.warnings, - "infos" => r.infos, - "fixable" => r.fixable, - "status" => r.status - } - end - )) - end - def print_capabilities(options) result = prober(options).probe(mode: :capabilities) caps = result.raw["capabilities"] unless caps - @stderr.puts( - "audition: capabilities probe failed: #{result.raw}" - ) + complain("capabilities probe failed: #{result.raw}", + options) return 2 end + s = style(options) rows = caps.map do |probe, info| { "works in Ractor" => probe, - "ok" => info["ok"] ? "yes" : "no", - "raises" => info["error"] || "-" + "ok" => s.glyph(info["ok"] ? :pass : :error), + "raises" => info["error"] } end - @stdout.puts("ruby #{RUBY_VERSION} at #{RbConfig.ruby}") @stdout.puts( - TableTennis.new(rows, **table_opts(options)).to_s + "#{s.glyph(:section)} #{s.bold("ruby #{RUBY_VERSION}")} " \ + "#{s.dim("at #{RbConfig.ruby}")}" ) + @stdout.puts(TableTennis.new(rows, + title: "Audition capabilities", **table_opts(options)).to_s) 0 end end diff --git a/spec/audition/bundle_sweep_spec.rb b/spec/audition/bundle_sweep_spec.rb index e61fad0..ca3d068 100644 --- a/spec/audition/bundle_sweep_spec.rb +++ b/spec/audition/bundle_sweep_spec.rb @@ -46,6 +46,25 @@ def write_lockfile(dir, specs) end end + it "narrates one named unit per gem" do + Dir.mktmpdir do |dir| + lock = write_lockfile(dir, ["no-such-gem-xyz (1.0.0)"]) + io = StringIO.new + progress = Audition::Progress.new( + renderer: Audition::Progress::Log.new(io, + style: Audition::Report::Style.new(color: false, + hyperlinks: false)) + ) + + described_class.new(lockfile: lock, static_only: true) + .rows(progress: progress) + + expect(io.string) + .to include("Audition: sweep no-such-gem-xyz (1/1)") + expect(io.string).to match(/sweep: 1 gems in/) + end + end + it "audits the installed spec of the locked version" do Dir.mktmpdir do |dir| lock = write_lockfile(dir, ["mygem (1.2.3)"]) diff --git a/spec/audition/cli_spec.rb b/spec/audition/cli_spec.rb index 76d4dda..99b0c8a 100644 --- a/spec/audition/cli_spec.rb +++ b/spec/audition/cli_spec.rb @@ -182,6 +182,23 @@ def self.config = (@config ||= { "a" => 1 }) end end + it "rates findings under a configured test dir as test code" do + Dir.mktmpdir do |dir| + FileUtils.mkdir_p(File.join(dir, "qa")) + File.write(File.join(dir, "qa", "case.rb"), "$x = 1\n") + + default, plain, = run(dir, "--static-only") + File.write(File.join(dir, ".audition.yml"), + "test_dirs:\n - qa\n") + _, tagged, = run(dir, "--static-only") + + expect(plain).not_to include("(tests)") + expect(default).to eq(1) + expect(tagged).to include("(tests)") + expect(tagged).to include("1 test finding") + end + end + it "supports the baseline adoption workflow" do Dir.mktmpdir do |dir| path = File.join(dir, "bad.rb") @@ -358,13 +375,31 @@ def tty? end end - it "zebra-stripes the sweep table on interactive terminals" do + it "colors sweep rows by verdict, foreground only" do Dir.mktmpdir do |dir| - lock = stub_sweep(dir, [sweep_row, sweep_row(name: "zz")]) + lock = stub_sweep(dir, [ + sweep_row(name: "baddy", verdict: :not_ready, errors: 3), + sweep_row(name: "goody", verdict: :ready, warnings: 0) + ]) _, out, = tty_run(lock, "--static-only") - expect(out).to match(/\e\[[0-9;]*48;2;/) + expect(out).to include("\e[31m") + expect(out).not_to match(/48;2;/) + end + end + + it "blanks clean counts and glyphs the sweep verdicts" do + Dir.mktmpdir do |dir| + lock = stub_sweep(dir, [ + sweep_row(name: "goody", verdict: :ready, warnings: 0) + ]) + + _, out, = run(lock, "--static-only") + + expect(out).to include("ok ready") + expect(out).to include("Audition bundle sweep") + expect(out).not_to match(/\|\s+0\s+\|/) end end From 8fb942dbd49ea0a43e67fd371f2da369c2359d29 Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Sun, 13 Sep 2026 18:06:13 +0400 Subject: [PATCH 12/14] Document the new checks, the scanner, and the config key The design doc described two probes; there are three passes, and the Ractor mechanics the scanner depends on were written down nowhere. It now covers the fan-out, the work split, the thresholds, and the six Ruby 4.0.6 facts the scanner leans on, with the component table brought up to date and the out-of-scope list corrected where features had since shipped. The README documents `static-scan`, the stub and extension discovery, and the `test_dirs` key. The knowledge base and the agent skill drop a directory convention that is no longer assumed, and the self-audit job is named for what it does. --- .github/workflows/audit.yml | 2 +- CHANGELOG.md | 158 ++++++++++++++++++++++++++++-- README.md | 110 ++++++++++++++------- docs/design.md | 104 +++++++++++++++++--- docs/rails_core_best_practices.md | 40 ++++---- skills/ractor-readiness/SKILL.md | 10 +- 6 files changed, 343 insertions(+), 81 deletions(-) diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index 490116b..5fead69 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -10,7 +10,7 @@ permissions: jobs: audition: - name: audition audits audition + name: Audition audits itself runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 diff --git a/CHANGELOG.md b/CHANGELOG.md index a43a7d7..90d7407 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,147 @@ # Changelog +## [Unreleased] + +- Progress narration on stderr, stdout left pipeable: + `◆ Audition checking 50/919 5% (0.2s, on 8 ractors)`, + rewritten in place on a terminal, one line per phase off one. + A phase running in Ractors says how many, next to the clock + rather than the counts. On above 200 files and for every + bundle sweep, off for `--format json` and `--format github`; + `--progress` / `--no-progress` force either way. +- Files are dealt to scan Ractors largest-first, onto whichever + worker is carrying least (longest-processing-time-first). + Contiguous slices put neighbouring files—alike in size—on one + worker, which left a single Ractor still running well after + the others had finished: 2,500 files went 2.3s to 1.1s on 8 + workers. +- Worker count is now every core the Ractor pool can run, + `Etc.nprocessors` capped by `RUBY_MAX_CPU` (default 8), where + it was one less than the core count. Ractors past that cap add + no parallelism; `-j` / `--workers` overrides. +- The graph audit's four whole-tree walks now run in the same + Ractors, above 100 files. Each worker parses its slice once + and keeps the trees: one round reports the class and module + names its slice declares, and once those are merged a second + round walks the same trees against them. 3,400 files went + 2.4s to 1.2s on 8 workers, the whole phase 3.8s to 2.6s. +- New check `static-scan`, for the static pass's own blind + spots. rubydex reports every expression it could not resolve; + where the shape could hide what the class-state and constant + walks look for, the hole is now a finding instead of a clean + line: a singleton opened on a runtime receiver that writes + class-level state (`class << pick_target` around + `@cache = {}`), a runtime superclass or mixin argument + (`class Widget < base`, `include Object.const_get(name)`), + and a constant assigned through an unresolved path + (`TABLE = mod::Lookup`, info). Only the class and module + bodies are read, so an `include` matcher in a spec stays + silent, and a line another check already reported keeps its + own finding. +- New `.audition.yml` key `test_dirs`, for a project that files + its tests somewhere other than `test`, `spec` or `features`. + Findings under those directories are tagged `test` and + counted apart from production code. The list replaces the + default rather than adding to it; the `_test.rb` and + `_spec.rb` suffixes count whatever it says. +- Color on every output surface, not just the findings list. + The bundle sweep gained a titled table: severity glyphs on + the verdicts, one color per row, blank cells where a count is + zero. Usage text, the baseline line, fix and dry-run output, + `--compare` deltas and `--capabilities` too. `--plain`, + `NO_COLOR` and `TERM=dumb` strip all of it. +- New check `native-gem-calls`, for calls into a C extension + that never declared `rb_ext_ractor_safe`: they raise + `Ractor::UnsafeError`, and the extension is outside the tree, + so the call site is what gets flagged. Which gems count comes + from the target's own `Gemfile.lock`, its installed extension + binaries and its generated type stubs—never a gem list—and a + platform build Audition cannot inspect is reported as + unverified. Taint then follows the values: chained calls, + locals and ivars, `sig`/`T.let`/`T.cast` types, `case`/`when` + and `is_a?` narrowing, block element parameters, arguments + into other methods, reopened classes, mixin bodies, and + across files until nothing new is learned. +- Rails targets scan the whole root, not just `app`, `lib` and + `config`—local gems, engines and tests boot into the same + process. Vendored and scratch directories stay excluded. + Indexing another call's result (`Registry.by_name["KEY"]`) is + an unprovable call result. +- New `shallow_opaque` finding: `X.new.freeze`, + `compute.freeze`, and frozen containers of fresh instances or + unprovable call results freeze the wrapper, not the value. A + later bare `NAME.freeze` counts the same, and one unknown + element no longer excuses a provably fresh sibling, so + `T.let({KEY => Widget.new(...)}.freeze, ...)` now reports. +- Class-level state finds three more shapes: `attr_accessor` or + `attr_writer` on a singleton class (the declaration itself, + plus every assignment through one), ivars assigned by + instance methods of a module something `extend`s, and + `instance_variable_set("@x", v)` or + `remove_instance_variable(:@x)` where the receiver is + provably a class. Computed names, unprovable receivers, + `instance_variable_get`, `instance_variable_defined?` and + `instance_variables` stay quiet. +- New check `dependency-class-state`: a dependency holding its + configuration in a class-level ivar has no source in the + tree, so the call site is flagged; the target's own type + stubs say which calls are attribute reads. Reads warn, writes + error. +- New check `derived-constants`: an alias (`DEFAULT = PRIMARY`) + or a frozen container of references (`ALL = [A, B].freeze`) + inherits the referent's finding. `mutable-constants` findings + seed a graph pass that propagates along constant references, + transitively and across files, at the source's severity. +- New check `unshareable-reads`: reading a gem constant that + holds an unshareable object warns at the read site, `sig` + blocks included—Sorbet's `T::Boolean` is an unfrozen, lazily + memoizing `TypeAlias`. +- Test findings are counted apart. Anything under `test/`, + `spec/` or `features/`, or in a `_test.rb`/`_spec.rb` file, + is marked `(tests)` in text and `"test"` in JSON and never + moves the verdict or the exit code. +- `T.let`, `T.cast` and `T.must` are unwrapped everywhere: + classification, the reported container type (no more "mutable + T literal"), depth checks and autofixes all see the value + inside. The safe fix is now `T.let({...}.freeze, ...)`, not a + freeze on the cast. +- Dynamic findings pin the failing line. The harness ships a + backtrace with every error, so script, rack, Rails boot and + load failures land on the deepest frame inside the target + instead of `line: nil` on the entry file. +- Constant sweep limit 5,000 → 200,000, and hitting it is a + `runtime-scan` warning naming the count and the limit instead + of silent truncation; probes take `max_constants`. A failed + Rails boot keeps the sweep of everything loaded before it, + and the rails probe realpaths the environment file so a + symlinked root (macOS `/var`) stops turning the app's own + findings into dependency findings. +- The sweep catches state planted on classes that already + existed—a cache ivar on `String`, a class variable on a + stdlib module. Every pre-boot module's ivars and class + variables are snapshotted (without forcing autoloads) and + diffed after the boot; RubyGems, Bundler and VM internals are + excluded as probe machinery. +- Rack targets get the full sweep, not just the boot-and-serve + verdict: the same constant, class-state, class-variable and + native-extension passes require and Rails targets already + got. +- Unshareable constants name their blocker: "just not frozen" + where freezing the value fixes it, "blocked by unfrozen + String inside" or "blocked by Proc inside" where it does not. +- `begin ... end` is classified by its last statement, so a + constant built in a block is reported and fixed instead of + reading as opaque; an inline cast around it is peeled in + either order. A block with `rescue`, `else` or `ensure` stays + opaque. +- More call shapes classified: a `Set` from an opaque source or + a mapping block is a mutable container whatever it holds; + indexing a constant (`Mime[:xml]`) is an unprovable call + result, with `ENV` and Sorbet's `T` constructors silent; and + `File.expand_path`, `join`, `dirname`, `basename`, + `absolute_path` and `realpath` return fresh Strings—mutable + bare, shareable frozen. + ## [0.3.0] - 2026-09-05 - Native extensions. `audition .` in a gem checkout, `audition @@ -64,7 +206,7 @@ singleton ivars plus delegate, capture-free boot procs and the boot-in-raise-mode gate, share-a-copy and freeze-in-the-setter, main-or-local singletons with per-Ractor rebuilds, subsystem - make_shareable! hooks, and boot-time loading hygiene. audition + make_shareable! hooks, and boot-time loading hygiene. Audition was run on both sides of the 43 files the PRs touch. - Fix knowledge base: third pass, the gem dialect. i18n PR 741 (the first full gem conversion out of the Rails ractorization @@ -72,7 +214,7 @@ docs/rails_core_best_practices.md: config class variables moving to singleton-class ivars behind delegators, the opt-in `/ractorize` entry point, and frozen caches degrading to - recompute-per-call. audition was run on both sides of the PR + recompute-per-call. Audition was run on both sides of the PR to verify its checks against the conversion; it confirmed the fixes, caught a `.freeze` lost in a rebase and a class variable read that still raises from workers (reproduced on @@ -96,7 +238,7 @@ argument was silently ignored. README gained a CI and git hooks section with copy-paste lefthook, pre-commit, and GitHub Actions configs. -- Dogfooding: this repository now runs audition on itself, on +- Dogfooding: this repository now runs Audition on itself, on every commit through lefthook (staged files, static) and on every push through a non-blocking CI self-audit with PR annotations and a job summary. @@ -106,13 +248,13 @@ deployment mode and Actions' bundler-cache put every gem in `vendor/bundle`) count as dependencies, not as the target's own code. Found by the very first CI self-audit, which - attributed the vendored gems to audition itself and flipped + attributed the vendored gems to Audition itself and flipped the verdict from blocked to not_ready. - Report rendering split into one class per format (`Report::Text`, `Report::Json`, `Report::Github`); the `Report` class keeps only the data, verdict, and counts. The `Report#to_text/to_json/to_github` methods are gone, an API - change for anyone driving audition programmatically. + change for anyone driving Audition programmatically. ## [0.2.4] - 2026-08-23 @@ -129,7 +271,7 @@ main: the 61 ractorization commits that landed after the first study (through 2026-08-20) were read in full and distilled into seven new patterns in docs/rails_core_best_practices.md, with - every claim that touches audition's behavior verified on Ruby + every claim that touches Audition's behavior verified on Ruby 4.0.6. The check advice, autofix recipes, README, and agent skill below follow from that refresh. - New mutable-constants finding, with a safe `.freeze` autofix, @@ -163,11 +305,11 @@ rerun, spraying Ractor backtraces on stderr first, whenever a scanned file sends the capture scanner after local variables. Its visit methods were generated with define_method, the exact - un-shareable-Proc pattern audition's own unsafe-calls check + un-shareable-Proc pattern Audition's own unsafe-calls check flags (and a pragma silenced), so the first dispatch from a worker Ractor raised. They are plain defs now, and the check is covered by an in-Ractor regression spec. Found while - verifying rubydex 0.3.0, which audition now runs on: findings + verifying rubydex 0.3.0, which Audition now runs on: findings are byte-identical to 0.2.9 on real gems and no API we consume changed, so the dependency floor stays at 0.2. diff --git a/README.md b/README.md index af21347..9bc03a7 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Point it at a Ruby script, a gem, a Rack app, or a Rails root and it tells you whether that code can run inside Ractors, why it cannot, -and how to fix it. Unlike a linter, audition does not stop at +and how to fix it. Unlike a linter, Audition does not stop at pattern-matching your source: whole-program analysis is powered by [rubydex](https://github.com/Shopify/rubydex), Shopify's Ruby indexer, and the target is also loaded in a sandboxed subprocess @@ -47,7 +47,7 @@ core itself is being ractorized; see the state, so `globalid` is not blamed for ActiveSupport's state. - **Dogfooding.** The scanner for Ractor compatibility is built using Ractors: static analysis fans out across CPU cores on - Ractor workers, and audition passes its own audit. It runs on + Ractor workers, and Audition passes its own audit. It runs on itself on every commit (a lefthook pre-commit over the staged files) and on every push (a full self-audit in CI). - **Trained on Rails core.** Several checks and fix suggestions @@ -58,26 +58,24 @@ core itself is being ractorized; see the findings are documented in [docs/rails_core_best_practices.md](docs/rails_core_best_practices.md). - **Terminal-native output.** Colors, glyphs, and OSC 8 hyperlinks; - `path:line` is clickable in supporting terminals. JSON output for - CI. + `path:line` is clickable in supporting terminals. Long scans + narrate their phases on stderr. JSON output for CI. ```console $ audition worker.rb -* audition 0.1.0 ruby 4.0.6 · script at . +* Audition 0.3.0 ruby 4.0.6 · script at . worker.rb - x raises inside a Ractor: Ractor::IsolationError: can not - access global variable $jobs from non-main Ractor - why: The script ran fine on the main Ractor but failed under - Ractor.new; the static findings usually pinpoint the line. - x worker.rb:1 write to global variable $jobs - why: Non-main Ractors cannot access global variables; this - raises Ractor::IsolationError the moment the line executes - in a Ractor (verified on Ruby 4.0). - fix: Pass the value into the Ractor explicitly - (Ractor.new(value) { |v| ... }) or over a Ractor::Port; for - per-Ractor state use Ractor.current[:key]. - x worker.rb:4 read of global variable $jobs + x worker.rb:1 write to global variable $jobs global-variables + why: Non-main Ractors cannot access global variables; this raises + Ractor::IsolationError the moment the line executes in a Ractor + (verified on Ruby 4.0). + fix: Pass the value into the Ractor explicitly (Ractor.new(value) { + |v| ... }) or over a Ractor::Port; for per-Ractor state use + Ractor.current[:key]; do one-time process setup on the main Ractor + before spawning. + x worker.rb:1 raises inside a Ractor: Ractor::IsolationError ... + x worker.rb:4 read of global variable $jobs global-variables ... dynamic probes @@ -93,16 +91,29 @@ And the whole-bundle view: ```console $ audition Gemfile.lock --static-only -╭───────────────┬─────────┬───────────┬────────┬──────────┬─────────╮ -│ gem │ version │ verdict │ errors │ warnings │ fixable │ -├───────────────┼─────────┼───────────┼────────┼──────────┼─────────┤ -│ activesupport │ 8.1.0 │ not ready │ 157 │ 97 │ 77 │ -│ i18n │ 1.14.7 │ not ready │ 48 │ 40 │ 45 │ -│ mail │ 2.9.1 │ not ready │ 27 │ 4 │ 13 │ -│ rack │ 3.2.6 │ not ready │ 23 │ 45 │ 60 │ -│ ... │ │ │ │ │ │ -╰───────────────┴─────────┴───────────┴────────┴──────────┴─────────╯ -0 of 11 gems ractor-ready +╭───────────────────────────────────────────────────────────────────────────────────────╮ +│ Audition bundle sweep │ +├──────────┬──────────┬─────────────┬────────┬────────────┬──────────┬─────────┬────────┤ +│ gem │ version │ verdict │ errors │ dep errors │ warnings │ fixable │ status │ +├──────────┼──────────┼─────────────┼────────┼────────────┼──────────┼─────────┼────────┤ +│ rubocop │ 1.88.2 │ x not ready │ 194 │ - │ 203 │ 28 │ ok │ +│ parser │ 3.3.12.0 │ x not ready │ 154 │ - │ 26 │ 26 │ ok │ +│ rack │ 3.2.7 │ x not ready │ 31 │ - │ 61 │ 13 │ ok │ +│ rake │ 13.4.2 │ x not ready │ 30 │ - │ 24 │ 5 │ ok │ +│ prism │ 1.9.0 │ x not ready │ 16 │ - │ 88 │ 15 │ ok │ +│ json │ 2.21.2 │ x not ready │ 7 │ - │ 5 │ - │ ok │ +│ pastel │ 0.8.0 │ x not ready │ 2 │ - │ 1 │ - │ ok │ +│ tty-link │ 0.2.0 │ x not ready │ 1 │ - │ 1 │ - │ ok │ +╰──────────┴──────────┴─────────────┴────────┴────────────┴──────────┴─────────┴────────╯ + +x 0 of 8 gems ractor-ready · 8 not ready +``` + +Verdicts are colored by severity, clean counts are left blank, and +the sweep names each gem as it finishes: + +```console +◆ Audition sweep rubocop 8/8 (100%) 5.8s ``` **Requires Ruby 4.0 or newer**, strictly: the tool targets the modern @@ -172,6 +183,20 @@ Useful flags: | `--capabilities` | table of what this Ruby allows in Ractors | | `--timeout 60` | dynamic probe budget in seconds | | `--plain` | no colors or hyperlinks (also via NO_COLOR, pipes) | +| `--progress` / `--no-progress` | force / suppress the stderr narration | +| `-j 4` / `--workers 4` | scan Ractors (default: cores, capped by `RUBY_MAX_CPU`) | + +Long runs narrate themselves on stderr, so stdout stays pipeable: +a rewriting status line on a terminal, one line per phase off it. + +``` +◆ Audition checking 50/919 5% (0.2s, on 8 ractors) +``` + +A phase running in Ractors says how many. The narration turns on +by itself for a large tree or a bundle sweep and stays off for +`--format json` and `--format github`; the flags force either +way. Exit codes: `0` clean, `1` findings at or above the `--fail-on` threshold (or a failed dynamic probe), `2` usage error. @@ -210,11 +235,20 @@ timeout: 60 exclude: - legacy/** - db/schema.rb +test_dirs: + - qa checks: disable: - at-exit ``` +`test_dirs` names the directories that hold tests rather than +code a production boot loads; findings in them are tagged +`test` and rated as test code. It defaults to `test`, `spec`, +`features`, and replaces that list rather than adding to it—the +`_test.rb` and `_spec.rb` suffixes always count, whatever it +says. + ## CI and git hooks **GitHub Actions.** `--format github` turns findings into @@ -260,7 +294,7 @@ With [pre-commit](https://pre-commit.com): - repo: local hooks: - id: audition - name: audition + name: Audition language: system entry: audition --static-only --plain types: [ruby] @@ -292,7 +326,7 @@ Static, with file:line precision: `@cache ||= {}` and `return @x if defined?(@x)` memoizations. - **Constants that are not deeply shareable**: bare mutable literals, interpolated strings, the subtle shallow freeze - (`[[1], [2]].freeze` still raises; audition explains why), and + (`[[1], [2]].freeze` still raises; Audition explains why), and call results the magic comment never covers (`X.tr(":", "")`, `Regexp.new`, `Regexp.union`, `format`), the shapes Rails fixed last in its own ractorization. Honors `# frozen_string_literal:` @@ -317,11 +351,21 @@ Static, with file:line precision: scan of every compiled `.bundle`/`.so` for the `rb_ext_ractor_safe` import, which also covers precompiled platform gems that ship no sources; an unbuilt checkout is - scanned at the source level (`ext/**`, C, Rust, or Zig) instead. + scanned at the source level instead (C, Rust, or Zig sources + beside their `extconf.rb`, `Cargo.toml` or `build.zig`). A silent extension raises `Ractor::UnsafeError` on every call from a non-main Ractor, so it rates a warning; a declared one gets an info note, because the declaration is the maintainer's assertion, not a proof. +- **The static pass's own blind spots**: where rubydex reports an + expression it could not resolve and the shape could hide what + the checks above look for, the hole is reported rather than + read as a clean line — a singleton opened on a runtime receiver + that writes class-level state, a superclass or `include` + argument computed at runtime, a constant assigned through an + unresolved path. A clean report for such a class covers only + what the class itself declares; the dynamic probe reaches the + rest. Dynamic, on the live object graph: @@ -346,7 +390,7 @@ Dynamic, on the live object graph: ## Agent skill This repository ships a `ractor-readiness` skill that teaches -coding agents (Claude Code and friends) the full audition +coding agents (Claude Code and friends) the full Audition workflow: audit, fix tiers, suite-parity verification, and incremental adoption. It lives in [skills/ractor-readiness/SKILL.md](skills/ractor-readiness/SKILL.md). @@ -402,7 +446,7 @@ bundle exec exe/audition --capabilities ``` Static scanning is Ractor-parallel on large targets (one worker -per core, minus one for the main Ractor); audition's own `lib/` +per core, minus one for the main Ractor); Audition's own `lib/` passes `audition lib` clean. The design notes in `docs/design.md` include the empirically @@ -413,7 +457,7 @@ calibrated against. The whole-program checks stand on [rubydex](https://github.com/Shopify/rubydex), Shopify's -high-performance static analysis suite for Ruby: audition feeds +high-performance static analysis suite for Ruby: Audition feeds every file into its graph and reads state ownership back out. Thanks to its authors, in particular the top five contributors: [Alexandre Terrasa](https://github.com/Morriar), diff --git a/docs/design.md b/docs/design.md index db17f47..f5858ff 100644 --- a/docs/design.md +++ b/docs/design.md @@ -1,4 +1,4 @@ -# audition; design +# Audition; design Probe anything (script, gem, Rack app, Rails app, directory) for the ability to run under Ractors. Report **what** breaks, **why** it breaks, and **how to fix it**. @@ -11,12 +11,17 @@ ecosystem ships Ractor *utilities* (ractor_safe, ratomic, ractor-pool), not chec `Ractor.shareable?` is a primitive, not a diagnosis. Companion use case: verifying an app can run on Ractor-parallel web servers such as [kino](https://github.com/yaroslav/kino). -## Two probes, one report +## Three passes, one report -1. **Static analysis** (`Audition::Static`); Prism AST checks over every `.rb` file. - Finds code that *would* raise under a non-main Ractor, with file:line precision. - Zero execution, safe on any codebase. -2. **Dynamic probing** (`Audition::Dynamic`); a subprocess harness (JSON over stdout) +1. **Per-file static** (`Audition::Static::Analyzer`); one Prism visitor per check over + every `.rb` file. Expression-level: what a single file can prove alone. Zero + execution, safe on any codebase. +2. **Whole-program static** (`Audition::Static::GraphAudit`); semantic checks on the + rubydex graph, which resolves state to its true owner. An ivar written in the class + body, in `def self.x`, and inside `class << self`, across several files, unifies + into one declaration; per-file visitors cannot see that. The pass also reports where + the graph *fails* to resolve, so a hole does not read as a clean line. +3. **Dynamic probing** (`Audition::Dynamic`); a subprocess harness (JSON over stdout) that actually loads the target and interrogates it: runs scripts inside a Ractor, requires libraries and walks their namespaces checking `Ractor.shareable?` on every constant plus class-level state, calls Rack apps inside a Ractor, and micro-probes @@ -24,6 +29,8 @@ app can run on Ractor-parallel web servers such as [kino](https://github.com/yar Static finds latent hazards dynamic can't reach (code paths never executed during a probe); dynamic finds truth static can't see (metaprogramming, actual object graphs). +Neither closes the other's blind spots, so both report, and the check name says which +layer spoke (`runtime-*` and `dynamic-*` are the probe's). ## Ruby 4.0 Ractor semantics (empirically verified on 4.0.6, 2026-07-17) @@ -48,6 +55,43 @@ probe); dynamic finds truth static can't see (metaprogramming, actual object gra | Errors via `Ractor#value` | `Ractor::RemoteError`, real error in `#cause` | | `Ractor#take` / `Ractor.yield` | Removed; `Ractor::Port`, `Ractor#value` | +## Scanning in Ractors + +The scan is CPU-bound and embarrassingly parallel, so both static passes fan out: +`Etc.nprocessors` capped by `RUBY_MAX_CPU` (default 8—Ractors past the cap add no +parallelism), overridable with `-j`. `WorkSplit` deals files largest-first onto +whichever worker carries least (longest-processing-time-first, Graham 1969; byte size +stands in for parse cost). Contiguous slices would not do: neighbours in a tree are +alike in size, so one worker can draw a slice weighing several times the mean and +still be running once the rest have finished. + +The graph audit fans out differently, because parsed Prism trees cannot cross a Ractor +boundary. Its whole-tree walks may not emit until every name the scan declares is +known, so each worker parses its slice once and keeps the trees: round one reports the +names its slice declares, and once those are merged round two walks the same trees +against them. Below 100 files the spawn and source copy cost more than the walks they +divide, so it stays serial. Any `Ractor::Error` falls back to serial, and under `-w` +the fallback says why—a walk that is itself Ractor-hostile must not hide behind it. + +The bundle sweep is the exception: its per-gem static analysis runs in worker threads, +because the dynamic probes it interleaves are subprocesses and those parallelize +regardless of the GVL. + +**Audition's own code has to be Ractor-safe**, since it runs its own checks inside +Ractors. Both violations so far were found by running Audition on itself: a constant +holding a hash of unfrozen arrays, and a shallow-frozen singleton. + +### Mechanics the scanner itself depends on (verified on 4.0.6, 2026-09-13) + +| Fact | Consequence | +|---|---| +| A `Set` of compound elements cannot cross a boundary; `Marshal` can | Dedup sets travel as pair arrays, rebuilt in the worker | +| `Ractor::ClosedError` is a `StopIteration`, not a `Ractor::Error` | Rescue both, or a send to a dead worker kills the scan | +| `Ractor#close` from another Ractor raises | Workers can't be cancelled; a bailed fan-out is abandoned | +| Blocked Ractors hold no Ractor-pool native threads | Abandoning them costs no later parallelism | +| No penalty for running outside the main Ractor | Walks move into workers wholesale | +| A process's first parallel round pays warmup | The per-file pass absorbs it before the graph phase | + ## Components - `Audition::Finding`; `Data` value: check, severity (`:error/:warning/:info`), @@ -57,21 +101,53 @@ probe); dynamic finds truth static can't see (metaprogramming, actual object gra path or installed gem name; enumerates the Ruby files to scan and the dynamic entry. - `Audition::Static::SourceFile`; path + Prism parse result + magic-comment awareness (`frozen_string_literal`, `shareable_constant_value`). -- `Audition::Static::Checks::*`; one Prism visitor per check: - `GlobalVariables`, `ClassVariables`, `MutableConstants` (deep shareability, shallow - freeze detection), `ClassLevelState` (ivars on class objects incl. `@x ||=` - memoization), `RuntimeRequire` (require/autoload at runtime), `UnsafeCalls` - (knowledge-base driven: Rails class-level macros, sync primitives, removed APIs...). +- `Audition::Static::Analyzer`; runs the per-file checks over sources or paths, + serially or across Ractors, and reports unparseable files itself (`syntax`). +- `Audition::Static::Checks::*`; one Prism visitor per check: `GlobalVariables`, + `MutableConstants` (deep shareability, shallow freeze detection), `RactorIsolation` + (blocks capturing outer locals, off Prism's exact scope depths), `RuntimeRequire` + (require/autoload at runtime), `UnsafeCalls` (knowledge-base driven: Rails + class-level macros, sync primitives, removed APIs...), `UnshareableReads`, + `DependencyClassState`. Subclass `Base` and `Checks.register` to add one. +- `Audition::Static::LiteralClassifier`; classifies an expression node by shareability + (`:shareable`, `:mutable_container`, `:shallow_freeze`, `:proc`, `:default_proc`, + `:opaque_call`, `:shallow_opaque`...); the shared vocabulary under the constant + checks. +- `Audition::Static::GraphAudit`; the rubydex-graph pass: `class-variables`, + `class-level-state` (including singleton attributes and writes through them), + `derived-constants` (a flagged value followed into the constants that capture it), + and `static-scan` (the graph's own unresolved expressions, where the shape could + hide what the other walks look for). +- `Audition::Static::NativeExtensions` / `GemCalls`; byte-scan every compiled + `.bundle`/`.so` for the `rb_ext_ractor_safe` import, then flag call sites into gems + that never declared it—the extension is outside the scanned tree, so the call site + is all that is left to report. Taint follows the values from there. +- `Audition::Static::WorkSplit`; worker count and largest-first work dealing. - `Audition::Dynamic::Harness`; standalone script run via `ruby harness.rb `, stdlib-only, one JSON document out. Modes: `script_main`, `script_ractor`, `require` (namespace walk), `rack`, `capabilities`. - `Audition::Dynamic::Prober`; spawns the harness per mode with timeout, parses JSON, converts results to findings. -- `Audition::Report`; text (grouped by file, colored) and JSON; verdict roll-up. +- `Audition::Fixer` / `Audition::Rewriters`; safe inline autofixes attached to + findings, applied bottom-up so offsets hold, plus unsafe-tier multi-site rewrites + planned from a parsed file and its findings. `--dry-run` previews either. +- `Audition::Baseline` / `Config` / `Directives`; the incremental-adoption ledger + (counts per `check|relative/path`, so line drift does not resurrect a finding), + `.audition.yml`, and `# audition:disable ` pragmas. +- `Audition::BundleSweep`; audits every gem in a `Gemfile.lock` and ranks the + results; the "can my whole app move to Ractors" view. +- `Audition::Progress`; narrates the scan phase by phase on stderr, stdout left + pipeable. Shareable, so a worker can hold one. +- `Audition::Report`; `Text` (grouped by file, colored), `JSON`, `GitHub` + (annotations plus job summary), `Sweep` (the per-gem table), `Style` (color and OSC 8 + hyperlinks, decided once and stripped for pipes, `NO_COLOR`, `TERM=dumb`); verdict + roll-up over all three passes. - `Audition::CLI` / `exe/audition`; OptionParser; exit 0 clean / 1 findings ≥ `--fail-on` threshold / 2 usage error. ## Out of scope (v1) -Scoped `shareable_constant_value` regions (treated file-wide), non-literal constant -values statically (dynamic probe covers them), auto-fixing. +Scoped `shareable_constant_value` regions (treated file-wide). Constant values that +depend on runtime state: the classifier names the shapes it can (`:opaque_call`, +`:shallow_opaque`) and taint-follows them, but proving what a call actually returns +stays the dynamic probe's job. diff --git a/docs/rails_core_best_practices.md b/docs/rails_core_best_practices.md index 92b1c85..5577334 100644 --- a/docs/rails_core_best_practices.md +++ b/docs/rails_core_best_practices.md @@ -227,7 +227,7 @@ rare path, db27b67b), and Uncountables stopped subclassing Array reverted wholesale after merging (261cec84). Freezing shared state can regress behavior; expect rollbacks. -## What this means for audition +## What this means for Audition Detection already aligned: mutable and shallow-frozen constants, class-level ivar state on the graph, proc constants, global @@ -255,7 +255,7 @@ Refreshed on 2026-08-22 against rails/rails `main` at 2a2db1e8d6 with the same selection over `cba6112015..origin/main`: 52 commits by message, 48 by pickaxe, 61 unique without merges, every one read in full (`git show --stat -p`) by four parallel -readers; claims that touch audition's own behavior were then +readers; claims that touch Audition's own behavior were then re-verified on Ruby 4.0.6. The volume sits in Active Record model schema, Action View templates, Active Support notifications and callbacks, and routing. Every pattern from the @@ -352,7 +352,7 @@ populated its nested hashes (4c30e8c6cd). Verified on Ruby 4.0.6: `Regexp.new`, `Regexp.union`, `String#tr`, `+`, `*`, `%`, `format`, `String.new`, and `Symbol#to_s` all return unfrozen, unshareable objects, and reading such a constant from a Ractor -raises. This is now an audition check (see below). +raises. This is now an Audition check (see below). ### 15. define_method with a shareable lambda @@ -415,7 +415,7 @@ that the application's entry point requires (c0db4bb1eb, `ActiveSupport::Ractors.store_if_absent` joined the shim (444c16102d) with a Mutex-guarded `Ractor.current[key] ||=` -fallback, and the mechanical rewrite is exactly what audition +fallback, and the mechanical rewrite is exactly what Audition emits (bf6b888a7b: `@cache = Concurrent::Map.new` to `def self.cache = store_if_absent(:key) { Concurrent::Map.new }`, `map[k] ||= v` to `compute_if_absent`). New shapes on top of it: @@ -451,7 +451,7 @@ per-algorithm methods from closures (upstream), and `_returning_columns_for_insert` stays on `on_main` because it needs a connection. -## What changed in audition after the second pass +## What changed in Audition after the second pass - Pattern 14 became a mutable-constants finding with a safe `.freeze` autofix: String-only methods on any receiver, @@ -473,7 +473,7 @@ Declined, deliberately: - A Rails dialect emitting `ActiveSupport::Ractors.*`. The module is `:nodoc:` and documented as disposable once old Rubies drop - off; audition keeps emitting plain Ruby. + off; Audition keeps emitting plain Ruby. - An `on_main` autofix. It needs the ractor-dispatch gem and is the last rung of Rails' own ladder. - Autofixes for deleting memos or hoisting them into constants. @@ -491,7 +491,7 @@ engineer leading the Rails ractorization (open, head 58aa1dc). The first full gem-side conversion out of that effort, and the fix for the residual recorded at the end of the second pass ("I18n keeps configuration in class variables"). Method: the -diff and every review comment read in full, audition run on +diff and every review comment read in full, Audition run on both sides of the PR (static and dynamic), and every Ruby semantics claim below re-verified on Ruby 4.0.6. @@ -531,7 +531,7 @@ becomes a frozen alias of the new state plus `deprecate_constant` (`RESERVED_KEYS = @reserved_keys`), the plain-Ruby version of pattern 8's DeprecatedObjectProxy. And per-Ractor caches get a portability guard for pre-3.0 rubies: `if defined?(Ractor)` uses -`Ractor.current[key] ||=` (the shape audition emits) and the +`Ractor.current[key] ||=` (the shape Audition emits) and the else branch keeps the old class variable. Statically that fallback scans as a class-variables error; dynamically the branch never runs on 4.0, so the class variable is never even @@ -559,7 +559,7 @@ author scrapped it himself ("This was wrong. We can't have default procs in the hash."). The default-proc rule bites even the people leading the Rails effort. -### What audition says about the PR (verified) +### What Audition says about the PR (verified) On pre-PR main (547917d), the static scan flags everything the PR fixes: RESERVED_KEYS mutable Array plus in-place `<<`, @@ -573,13 +573,13 @@ a lazy `||=` class-ivar memo that is only safe after the opt-in ractorize file runs, and no static scan can see a file the application must choose to require. -Three residuals audition catches are real, two worth reporting +Three residuals Audition catches are real, two worth reporting upstream: - INTERPOLATION_PATTERN lost its `.freeze` in a rebase (the PR body claims it is frozen; the first review thread shows the freeze existed before the scrapped approach took it away). - audition marks it fixable. + Audition marks it fixable. - `@@fallbacks` survives, and the reader's comment claims class variable reads are safe from workers. Reproduced: with the fallbacks backend loaded and ractorize applied, @@ -591,7 +591,7 @@ upstream: writes) remain, consistent with the PR's declared "most of what Rails needs" scope. -### What this means for audition (third pass) +### What this means for Audition (third pass) - Four checks confirmed against a conversion written by the people who set the patterns: mutable-constants (including the @@ -617,7 +617,7 @@ had read: 10 merged after the second pass cutoff and 18 inside its window whose commit messages never say ractor and whose diffs add no `Ractor` token, because a plain `.freeze`, a deleted memo, or an eager require needs neither. All 45 commits -and every PR discussion were read in full, audition was run on +and every PR discussion were read in full, Audition was run on both sides of the 43 files they touch, and every Ruby claim below was re-verified on Ruby 4.0.6. Numbered on from the catalog. @@ -644,7 +644,7 @@ delegate cache frozen once populated, and two `@x ||= new.freeze` memos turned into frozen private constants, the origin of the shape in pattern 12. Its review also records a limit: a memo on `QueryAttribute` "can't be eagerly memoized" -and was reverted. All of this is now an audition check (see +and was reverted. All of this is now an Audition check (see below). ### 22. Deleting a memo is a benchmark decision @@ -664,7 +664,7 @@ state but assigned eagerly in `included`, `inherited`, made lazy (`@name || @klass&.table_name`) so the object is cheap to build before the table name is known, 1.1x instead of 6x; `attribute_method_patterns_cache` assigned eagerly as a -`Concurrent::Map` in `included` and `inherited`. audition flags +`Concurrent::Map` in `included` and `inherited`. Audition flags that last one as class-level state on the post-PR file, and two months later 1a59302e64 (pattern 11) deleted it for a frozen index. A reviewer also had the justification comment on the @@ -715,7 +715,7 @@ writing its class's setting was always a bug global). The values still need sharing: the railtie merges user config into the rescue tables, `Hash#merge` returns an unfrozen hash (verified), and an `after_initialize` block makes them -shareable explicitly. audition confirms the conversion (all +shareable explicitly. Audition confirms the conversion (all twelve `mattr_accessor`/`cattr_accessor` errors vanish) and then reports the seven new module ivars as class-level state, the adoption gap the i18n pass recorded: the target shape of the @@ -747,7 +747,7 @@ inside the block through `self.class`, the late-binding anchor of pattern 15. A sibling fix: `Array(options[:on])` captured by the transaction callback condition lambdas needed `.freeze` (58653), because `Array()` returns a fresh unfrozen array -(verified). None of this is visible statically; audition +(verified). None of this is visible statically; Audition reports nothing on either side of these files. The boot gate is the detector. @@ -788,7 +788,7 @@ with the accepted consequence that subscribers registered on main are not seen by workers (the test asserts exactly that; shape verified). The same PR moved a `Concurrent::Map` constant (`PREFIXED_PARTIAL_NAMES`) behind a `store_if_absent` method, -pattern 17 applied to a constant; audition was silent on that +pattern 17 applied to a constant; Audition was silent on that constant and now flags it, since `Concurrent::Map` defines no `#freeze` at all. The larger rebuild is `SchemaContext` (58578, the follow-up to ae739c3854): the context is frozen and made @@ -854,7 +854,7 @@ the hot path touches before moving it per Ractor. A style note from 57859: `shareable_lambda { |x| ... }` in block form, not `shareable_lambda(&->(x) { ... })`. -### What audition says about the 28 PRs (verified) +### What Audition says about the 28 PRs (verified) Static scan of both sides of the 43 files these PRs touch, with the checks as they stood before this pass: 195 findings before, @@ -863,7 +863,7 @@ the checks as they stood before this pass: 195 findings before, `@empty ||= new.freeze` memos clear (`WhereClause`, `Type`); `JS_ESCAPE_MAP`'s rebuild and the metadata serializer constants clear; the `Concurrent::Map` that 57642 assigned eagerly is -flagged, and was deleted later. What audition missed, and what +flagged, and was deleted later. What Audition missed, and what changed because of it: - Nine `Object.new` sentinels, one `BasicObject.new`, three diff --git a/skills/ractor-readiness/SKILL.md b/skills/ractor-readiness/SKILL.md index d857066..18a8be0 100644 --- a/skills/ractor-readiness/SKILL.md +++ b/skills/ractor-readiness/SKILL.md @@ -12,18 +12,18 @@ description: Use when asked whether Ruby code (a gem, script, license: MIT --- -# Checking and fixing Ractor readiness with audition +# Checking and fixing Ractor readiness with Audition ## Overview -audition (a Ruby gem) probes any target for Ractor readiness: +Audition (a Ruby gem) probes any target for Ractor readiness: script, gem, directory, Rack app, Rails root, or a whole Gemfile.lock. Static Prism and rubydex checks plus a dynamic probe that loads the target inside a real Ractor; every finding carries a why and a fix, and mechanical fixes are automated. Do not hand-roll Ractor analysis or re-verify Ruby 4.0 Ractor semantics; the tool encodes them, verified empirically. Written -against audition 0.3.x. +against Audition 0.3.x. ## Quick reference @@ -91,7 +91,7 @@ the natural place. ## Native extensions (C, Rust, and Zig) -audition cannot read native code, but it reports the one +Audition cannot read native code, but it reports the one fact that decides its Ractor behavior. Ractor::UnsafeError ("ractor unsafe method called from not main ractor") is not an IsolationError: it means a compiled extension never @@ -101,7 +101,7 @@ extension is C, Rust, or Zig. The `native-extension` check byte-scans every compiled file in the target (*.bundle on macOS, *.so elsewhere) for that import and warns when it is missing; an unbuilt checkout is scanned at the source level -(ext/**) instead. A declared extension gets an info note: the +instead, beside the build file that compiles it. A declared extension gets an info note: the declaration is the maintainer's assertion, not a proof, and only real calls from a Ractor (script and Rack probes, or the gem's own tests under Ractor.new) verify it. From 0b7e701bba6d5612768fbe330eb6417bfd1b0079 Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Mon, 14 Sep 2026 20:04:48 +0400 Subject: [PATCH 13/14] Read the class methods a concern puts on the class Class-level state was found in a module only where the target's own source spelled the extend. A concern never spells it: the mixin that puts its companion module on the class lives in whatever library defines the pattern, so a nested `ClassMethods` module and a `class_methods do` block both went unread, and the ivars their methods write were reported clean. Which trees reported the shape turned on an unrelated file. One literal `extend ClassMethods` anywhere in a scan seeded the name for the whole of it, so identical code was an error in a library that happened to contain one and silent in a library that did not. The name is seeded outright now: inside the target it is the only evidence there is. `extend const_get(:Name)` on self, and a module mixed into a `singleton_class`, join the extend sites the walk already reads. A mixin argument that resolves either way stops being reported as one the graph could not read, which would otherwise contradict the finding the walk emits on the same line. Report-only, as the shape already was. A lazy memo cannot be precomputed at load time, so no rewriter offers to. --- CHANGELOG.md | 13 ++++ docs/design.md | 3 +- lib/audition/static/graph_audit.rb | 62 ++++++++++++++- spec/audition/static/graph_audit_spec.rb | 98 ++++++++++++++++++++++++ 4 files changed, 173 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90d7407..42b20fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,19 @@ provably a class. Computed names, unprovable receivers, `instance_variable_get`, `instance_variable_defined?` and `instance_variables` stay quiet. +- Class-level state no longer waits to be shown the `extend`. A + concern puts its companion module on the class from inside + whatever library defines the pattern, so nothing in the target + ever spells `extend ClassMethods`, and a nested `ClassMethods` + module or a `class_methods do` block went unread. Both are now + walked as class-level scope. Which trees reported them used to + turn on an unrelated file—one literal `extend ClassMethods` + anywhere in a scan seeded the name for all of it—so the same + shape was an error in one library and silent in its neighbour. + `extend const_get(:Name)` on self and a module mixed into a + `singleton_class` now count as extend sites, and a mixin + argument that resolves this way stops being reported as one + the graph could not read. - New check `dependency-class-state`: a dependency holding its configuration in a class-level ivar has no source in the tree, so the call site is flagged; the target's own type diff --git a/docs/design.md b/docs/design.md index f5858ff..a9ff4ba 100644 --- a/docs/design.md +++ b/docs/design.md @@ -114,7 +114,8 @@ holding a hash of unfrozen arrays, and a shallow-frozen singleton. `:opaque_call`, `:shallow_opaque`...); the shared vocabulary under the constant checks. - `Audition::Static::GraphAudit`; the rubydex-graph pass: `class-variables`, - `class-level-state` (including singleton attributes and writes through them), + `class-level-state` (including singleton attributes, writes through them, and + the companion module a concern puts on the class), `derived-constants` (a flagged value followed into the constants that capture it), and `static-scan` (the graph's own unresolved expressions, where the shape could hide what the other walks look for). diff --git a/lib/audition/static/graph_audit.rb b/lib/audition/static/graph_audit.rb index 11cc387..a5f8835 100644 --- a/lib/audition/static/graph_audit.rb +++ b/lib/audition/static/graph_audit.rb @@ -422,6 +422,7 @@ def collect_body_mixins(root) def record_mixin(found, node, owner) return unless owner && node.receiver.nil? && MIXINS.include?(node.name.to_s) + return if resolved_mixin?(node) location = node.location (location.start_line..location.end_line).each do |line| @@ -429,6 +430,15 @@ def record_mixin(found, node, owner) end end + # The graph gave up on the line, but if every argument + # names a constant the walks read it anyway, and saying + # otherwise would contradict the finding they emit. + def resolved_mixin?(node) + arguments = Array(node.arguments&.arguments) + arguments.any? && + arguments.all? { |arg| constant_slice(arg) } + end + def constant_assigned_at(root, line) queue = [root] until queue.empty? @@ -1052,17 +1062,34 @@ def dynamic_owner(receiver, context, declared) end end + # The name a concern gives the module it puts on the class. + # Nothing in the concern's own source extends it: the mixin + # that does lives in whatever library defines the pattern, + # so within the target the name is the only evidence. + CLASS_METHODS = "ClassMethods" + + # The same methods declared as a block, with no module in + # the source to hang the name on: the concern synthesizes + # one under the conventional name at load time. + CLASS_METHODS_BLOCK = :class_methods + + # Mixing into a singleton class lands a module's instance + # methods on the class, the way extend does. + SINGLETON_MIXINS = Ractor.make_shareable( + Set.new(%i[prepend include]) + ) + # A module reached through its last name segment: the # extend site and the definition rarely spell the path # the same way. def extended_names - names = Set.new + names = Set.new([CLASS_METHODS]) source_roots.each_value { |root| collect_extends(root, names) } names end def collect_extends(node, names) - if node.is_a?(Prism::CallNode) && node.name == :extend + if node.is_a?(Prism::CallNode) && extend_call?(node) Array(node.arguments&.arguments).each do |arg| name = constant_slice(arg) names << name.split("::").last if name @@ -1071,10 +1098,37 @@ def collect_extends(node, names) node.compact_child_nodes.each { |c| collect_extends(c, names) } end + def extend_call?(node) + return true if node.name == :extend + return false unless SINGLETON_MIXINS.include?(node.name) + + node.receiver.is_a?(Prism::CallNode) && + node.receiver.name == :singleton_class + end + def constant_slice(node) case node when Prism::ConstantReadNode, Prism::ConstantPathNode node.slice.delete_prefix("::") + when Prism::CallNode + const_get_slice(node) + end + end + + # A constant fetched through `const_get` names it as + # plainly as the constant does, and a concern resolving + # its own companion module writes the extend that way. + # Only on self: an explicit receiver picks the scope at + # runtime, which is the blind spot the scan reports. + def const_get_slice(node) + return unless node.name == :const_get + return unless node.receiver.nil? || + node.receiver.is_a?(Prism::SelfNode) + + argument = Array(node.arguments&.arguments).first + case argument + when Prism::SymbolNode, Prism::StringNode + argument.unescaped end end @@ -1091,6 +1145,10 @@ def walk_extended(node, nesting, names, owner, out) when Prism::ClassNode nesting += [node.constant_path.slice] owner = nil + when Prism::CallNode + if node.block && node.name == CLASS_METHODS_BLOCK + owner = (nesting + [CLASS_METHODS]).join("::") + end when Prism::DefNode return if node.receiver else diff --git a/spec/audition/static/graph_audit_spec.rb b/spec/audition/static/graph_audit_spec.rb index 5d98b2d..68874e1 100644 --- a/spec/audition/static/graph_audit_spec.rb +++ b/spec/audition/static/graph_audit_spec.rb @@ -375,6 +375,104 @@ def scope(name) = (@scope = name) expect(findings).to be_empty end + + it "reads an extend resolved through const_get" do + findings = findings_for( + "a.rb" => <<~RUBY, + module Scoped + def scope(name) = (@scope = name) + end + RUBY + "b.rb" => <<~RUBY + class Vault + extend const_get(:Scoped) + end + RUBY + ) + + expect(findings.map(&:message)).to contain_exactly( + a_string_including("@scope on Scoped") + ) + end + + it "counts a module prepended to a singleton class" do + findings = findings_for( + "a.rb" => <<~RUBY, + module Scoped + def scope(name) = (@scope = name) + end + RUBY + "b.rb" => <<~RUBY + class Vault + singleton_class.prepend Scoped + end + RUBY + ) + + expect(findings.map(&:message)).to contain_exactly( + a_string_including("@scope on Scoped") + ) + end + end + + # The mixin that puts these on the class lives in the library + # defining the concern, never in the target. + describe "concern class methods" do + it "flags an ivar in a companion module no source extends" do + findings = findings_for( + "a.rb" => <<~RUBY + module Cache + module ClassMethods + def warm? + return @warm if defined?(@warm) + @warm = true + end + end + end + RUBY + ) + + expect(findings.size).to eq(1) + expect(findings.first).to have_attributes( + path: "a.rb", line: 5, severity: :error + ) + expect(findings.first.message).to include( + "@warm on Cache::ClassMethods" + ) + end + + it "flags an ivar written in a class-methods block" do + findings = findings_for( + "a.rb" => <<~RUBY + module Cache + class_methods do + def warm? + return @warm if defined?(@warm) + @warm = true + end + end + end + RUBY + ) + + expect(findings.map(&:message)).to contain_exactly( + a_string_including("@warm on Cache::ClassMethods") + ) + end + + it "leaves the instance side of a concern alone" do + findings = findings_for( + "a.rb" => <<~RUBY + module Cache + def warm! + @warm = true + end + end + RUBY + ) + + expect(findings).to be_empty + end end describe "dynamic instance variable writes" do From d11068b7f722a15f6ee0a4d101bc7bd699f6c6aa Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Tue, 15 Sep 2026 21:41:21 +0400 Subject: [PATCH 14/14] Require rubydex 0.4 for the rule objects the static scan reads The graph audit's static scan asks each diagnostic's rule for its name, and the spec that guards the rule table reads Rubydex::Rules. Both arrived with rubydex 0.4: 0.3.0 hands back a bare Symbol and ships no Rules module, so every scan raised there while the floor of 0.2 promised it would run. The floor now says what the code needs. --- audition.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/audition.gemspec b/audition.gemspec index 2d2b277..d4f5697 100644 --- a/audition.gemspec +++ b/audition.gemspec @@ -28,7 +28,7 @@ Gem::Specification.new do |spec| spec.add_dependency "pastel", ">= 0.8" spec.add_dependency "prism", ">= 1.0" - spec.add_dependency "rubydex", ">= 0.2" + spec.add_dependency "rubydex", ">= 0.4" spec.add_dependency "table_tennis", ">= 1.0" spec.add_dependency "tty-link", ">= 0.2" end