|
| 1 | +# typed: ignore |
| 2 | +# devx opencode -s ses_14213e0f5ffeZhvMIpRnsDfCly |
| 3 | + |
| 4 | +$LOAD_PATH.unshift(File.expand_path("../lib", __FILE__)) |
| 5 | +require "spoom" |
| 6 | +require "ruby-progressbar" |
| 7 | +require "optparse" |
| 8 | +require "set" |
| 9 | + |
| 10 | +# Only keep a handful of failing-file examples per category so memory stays |
| 11 | +# bounded when testing over very large file sets. |
| 12 | +SAMPLE_LIMIT = 10 |
| 13 | + |
| 14 | +# Renders a Markdown table. `headers` is the header row (first cell is the |
| 15 | +# row-label column). `rows` is an array of cell arrays, each the same length as |
| 16 | +# `headers`. Every column is right-aligned. |
| 17 | +def render_table(headers, rows) |
| 18 | + widths = headers.each_index.map do |i| |
| 19 | + ([headers[i]] + rows.map { |row| row[i] }).map(&:length).max |
| 20 | + end |
| 21 | + |
| 22 | + render_row = ->(cells) do |
| 23 | + "| " + cells.each_with_index.map { |cell, i| cell.rjust(widths[i]) }.join(" | ") + " |" |
| 24 | + end |
| 25 | + |
| 26 | + separator = "|" + widths.each_with_index.map do |width, i| |
| 27 | + dashes = "-" * (width + 2) |
| 28 | + i.zero? ? dashes : "#{dashes[0..-2]}:" # right-align marker for value columns |
| 29 | + end.join("|") + "|" |
| 30 | + |
| 31 | + [render_row.call(headers), separator, *rows.map { |row| render_row.call(row) }].join("\n") |
| 32 | +end |
| 33 | + |
| 34 | +# Renders syntax errors the way `ruby -c` does: a line-numbered window around |
| 35 | +# each error with a caret and inline message (only the lines near the error, not |
| 36 | +# the whole file). This reuses CRuby's own Prism formatter, which surfaces via |
| 37 | +# the SyntaxError raised when compiling. Falls back to a terse line:col listing |
| 38 | +# if compiling doesn't raise (e.g. Prism.parse and the compiler disagree). |
| 39 | +def format_parse_errors(source, file_path, indent: " ") |
| 40 | + message = |
| 41 | + begin |
| 42 | + RubyVM::InstructionSequence.compile(source, file_path) |
| 43 | + nil # Compiled cleanly, so fall back below. |
| 44 | + rescue SyntaxError => e |
| 45 | + e.message |
| 46 | + end |
| 47 | + |
| 48 | + lines = if message |
| 49 | + body = message.lines |
| 50 | + body = body.drop(1) if body.first&.include?("syntax error") # drop the redundant "<file>:<line>:" header |
| 51 | + body |
| 52 | + else |
| 53 | + Prism.parse(source).errors.map do |error| |
| 54 | + location = error.location |
| 55 | + "#{location.start_line}:#{location.start_column}: #{error.message}\n" |
| 56 | + end |
| 57 | + end |
| 58 | + |
| 59 | + lines.map { |line| "#{indent}#{line}" }.join |
| 60 | +end |
| 61 | + |
| 62 | +extensions = Set["rb"] # Always search `.rb` files by default. |
| 63 | + |
| 64 | +# [label, translator class] pairs. Defaults to running both (--rewriter both). |
| 65 | +human = ["Human readable", Spoom::Sorbet::Translate::RBSCommentsToSorbetSigs::HumanReadableTranslator] |
| 66 | +line_matching = ["Line-matching", Spoom::Sorbet::Translate::RBSCommentsToSorbetSigs::LineMatchingTranslator] |
| 67 | +selected_rewriters = [human, line_matching] |
| 68 | + |
| 69 | +just_print_rewritten_output = false |
| 70 | +OptionParser.new do |opts| |
| 71 | + opts.banner = "Usage: rbs_rewriter_tester.rb [options] PATH" |
| 72 | + |
| 73 | + opts.on("--print-rewrite") do |
| 74 | + # This is for debugging: print the rewritten version of each file to STDOUT. |
| 75 | + just_print_rewritten_output = true |
| 76 | + end |
| 77 | + |
| 78 | + opts.on("--include-ext EXT", "When PATH is a directory, also process files with this extension (default: rb only). Repeatable.") do |ext| |
| 79 | + extensions << ext.delete_prefix(".") |
| 80 | + end |
| 81 | + |
| 82 | + opts.on("--rewriter NAME", ["human", "line-matching", "both"], |
| 83 | + "Which rewriter(s) to run: human, line-matching, or both (default: both)") do |name| |
| 84 | + selected_rewriters = case name |
| 85 | + when "human" then [human] |
| 86 | + when "line-matching" then [line_matching] |
| 87 | + when "both" then [human, line_matching] |
| 88 | + end |
| 89 | + end |
| 90 | +end.parse! |
| 91 | + |
| 92 | +if just_print_rewritten_output |
| 93 | + selected_rewriters = [line_matching] # line-matching is closer to the final output format, so better for debugging. |
| 94 | + rewritten = Spoom::Sorbet::Translate::RBSCommentsToSorbetSigs::LineMatchingTranslator.new(ARGF.read, file: ARGF.path).rewrite |
| 95 | + puts rewritten |
| 96 | + exit |
| 97 | +end |
| 98 | + |
| 99 | +path = File.expand_path(ARGV[0] || ".") |
| 100 | +files = if File.directory?(path) |
| 101 | + Dir["#{path}/**/*.{#{extensions.join(",")}}"] |
| 102 | +else |
| 103 | + [path] |
| 104 | +end |
| 105 | + |
| 106 | +# files = [ |
| 107 | +# "/Users/alex/world/trees/root/src/areas/core/shopify/bin/ci/lib/execution_strategy.rb", |
| 108 | +# "/Users/alex/world/trees/root/src/areas/core/shopify/components/access_and_auth/customer_authentication_provider/test/integration/access_and_auth/customer_authentication_provider/revocation_controller_test.rb", |
| 109 | +# "/Users/alex/world/trees/root/src/areas/core/shopify/components/access_and_auth/customer_authentication_provider/test/models/access_and_auth/customer_authentication_provider/provider_adapters/vendor_adapter_test.rb", |
| 110 | +# "/Users/alex/world/trees/root/src/areas/core/shopify/components/developer_dashboard/apps/test/views/developer_dashboard/apps/show_test.rb", |
| 111 | +# "/Users/alex/world/trees/root/src/areas/core/shopify/components/developer_dashboard/essentials/test/views/layouts/developer_dashboard/app_frame/app_frame_test.rb", |
| 112 | +# "/Users/alex/world/trees/root/src/areas/core/shopify/components/merchandising/test/models/graph_api/admin/variants_page_limit_test.rb", |
| 113 | +# "/Users/alex/world/trees/root/src/areas/core/shopify/components/merchandising/test/support/helpers/merchandising/dynamic_complexity_cost_test_helper.rb", |
| 114 | +# "/Users/alex/world/trees/root/src/areas/core/shopify/components/platform/essentials/lib/development_support/globaldb_primary_key_collector.rb", |
| 115 | +# "/Users/alex/world/trees/root/src/areas/core/shopify/components/platform/essentials/lib/development_support/migration_sql_compiler.rb", |
| 116 | +# "/Users/alex/world/trees/root/src/areas/core/shopify/gems/sqlite_test_compat/lib/active_record/sqlite3_semian_stub.rb", |
| 117 | +# ] |
| 118 | + |
| 119 | +progress = ProgressBar.create( |
| 120 | + title: "Rewriting", |
| 121 | + total: files.size, |
| 122 | + format: "%t |%B| %c/%C (%p%%) %e", |
| 123 | + progress_mark: "█", |
| 124 | + remainder_mark: "·", |
| 125 | +) |
| 126 | + |
| 127 | +# Shared (rewriter-independent) tallies. |
| 128 | +invalid_ruby_count = 0 |
| 129 | +invalid_ruby_samples = [] |
| 130 | +not_rbs_count = 0 |
| 131 | + |
| 132 | +# Per-rewriter result data, keyed by label. |
| 133 | +stats = selected_rewriters.to_h do |label, _| |
| 134 | + [label, { rewritten: 0, rewrite_failures: 0, parse_failures: 0, validation_failures: 0, elapsed: 0.0 }] |
| 135 | +end |
| 136 | + |
| 137 | +# Capped examples of failing files for the detail sections: samples[label][category] => [[path, detail], ...] |
| 138 | +samples = Hash.new { |h, label| h[label] = Hash.new { |hh, category| hh[category] = [] } } |
| 139 | +record_sample = ->(label, category, entry) do |
| 140 | + bucket = samples[label][category] |
| 141 | + bucket << entry if bucket.size < SAMPLE_LIMIT |
| 142 | +end |
| 143 | + |
| 144 | +files.each do |file_path| |
| 145 | + original = File.read(file_path) |
| 146 | + |
| 147 | + # Confirm the original file parses before attempting to rewrite it. |
| 148 | + if Prism.parse(original).failure? |
| 149 | + invalid_ruby_count += 1 |
| 150 | + invalid_ruby_samples << [file_path, original] if invalid_ruby_samples.size < SAMPLE_LIMIT |
| 151 | + next |
| 152 | + end |
| 153 | + |
| 154 | + # Whether the file contains RBS syntax is independent of the rewriter, so check once. |
| 155 | + unless Spoom::Sorbet::Translate::RBSCommentsToSorbetSigs.contains_rbs_syntax?(original) |
| 156 | + not_rbs_count += 1 |
| 157 | + next |
| 158 | + end |
| 159 | + |
| 160 | + # Run every selected rewriter over the same file so the read/parse cost is paid once. |
| 161 | + selected_rewriters.each do |label, translator_class| |
| 162 | + counts = stats[label] |
| 163 | + |
| 164 | + started = Process.clock_gettime(Process::CLOCK_MONOTONIC) |
| 165 | + begin |
| 166 | + rewritten = translator_class.new(original, file: file_path).rewrite |
| 167 | + rescue => e |
| 168 | + counts[:rewrite_failures] += 1 |
| 169 | + record_sample.call(label, :rewrite_failures, [file_path, e]) |
| 170 | + next |
| 171 | + ensure |
| 172 | + counts[:elapsed] += Process.clock_gettime(Process::CLOCK_MONOTONIC) - started |
| 173 | + end |
| 174 | + counts[:rewritten] += 1 |
| 175 | + |
| 176 | + if Prism.parse(rewritten).failure? |
| 177 | + counts[:parse_failures] += 1 |
| 178 | + record_sample.call(label, :parse_failures, [file_path, rewritten]) |
| 179 | + end |
| 180 | + |
| 181 | + unless (validation = Spoom::Sorbet::Translate::Validator.validate(original, rewritten)).valid? |
| 182 | + counts[:validation_failures] += 1 |
| 183 | + record_sample.call(label, :validation_failures, [file_path, validation]) |
| 184 | + end |
| 185 | + end |
| 186 | +ensure |
| 187 | + progress.increment |
| 188 | +end |
| 189 | + |
| 190 | +progress.finish |
| 191 | + |
| 192 | +unless invalid_ruby_samples.empty? |
| 193 | + puts |
| 194 | + puts "Invalid Ruby:" |
| 195 | + invalid_ruby_samples.each do |file_path, source| |
| 196 | + puts " #{file_path}" |
| 197 | + puts format_parse_errors(source, file_path) |
| 198 | + end |
| 199 | +end |
| 200 | + |
| 201 | +selected_rewriters.each do |label, translator_class| |
| 202 | + label_samples = samples[label] |
| 203 | + |
| 204 | + unless label_samples[:rewrite_failures].empty? |
| 205 | + puts |
| 206 | + puts "#{label} - rewrite errors:" |
| 207 | + label_samples[:rewrite_failures].each do |file_path, error| |
| 208 | + puts " #{file_path}: #{error.class}: #{error.message}" |
| 209 | + end |
| 210 | + end |
| 211 | + |
| 212 | + unless label_samples[:parse_failures].empty? |
| 213 | + puts |
| 214 | + puts "#{label} - parse failures:" |
| 215 | + label_samples[:parse_failures].each do |file_path, source| |
| 216 | + puts " #{file_path}" |
| 217 | + puts format_parse_errors(source, file_path) |
| 218 | + end |
| 219 | + end |
| 220 | + |
| 221 | + if Spoom::Sorbet::Translate::RBSCommentsToSorbetSigs::LineMatchingTranslator == translator_class |
| 222 | + unless label_samples[:validation_failures].empty? |
| 223 | + puts |
| 224 | + puts "#{label} - validation failures:" |
| 225 | + label_samples[:validation_failures].each do |file_path, result| |
| 226 | + puts " #{file_path}" |
| 227 | + result.errors.each { |error| puts " #{error}" } |
| 228 | + end |
| 229 | + end |
| 230 | + end |
| 231 | +end |
| 232 | + |
| 233 | + |
| 234 | +puts |
| 235 | +puts "Summary" |
| 236 | +puts "-------" |
| 237 | +printf("%-16s%8d\n", "Files processed:", files.size) |
| 238 | +printf("%-16s%8d\n", "Invalid Ruby:", invalid_ruby_count) |
| 239 | +printf("%-16s%8d\n", "Not RBS:", not_rbs_count) |
| 240 | + |
| 241 | +puts |
| 242 | +puts "Results:" |
| 243 | +puts |
| 244 | + |
| 245 | +headers = [""] + selected_rewriters.map { |label, _| label } |
| 246 | +headers << "New errors" if selected_rewriters.size > 1 |
| 247 | + |
| 248 | +rows = { |
| 249 | + "Rewritten:" => :rewritten, |
| 250 | + "Rewrite failures:" => :rewrite_failures, |
| 251 | + "Parse failures:" => :parse_failures, |
| 252 | + "Validation failures:" => :validation_failures, |
| 253 | + "Elapsed:" => :elapsed, |
| 254 | +}.map do |row_label, key| |
| 255 | + format_value = key == :elapsed ? ->(v) { format("%.2fs", v) } : ->(v) { v.to_s } |
| 256 | + values = selected_rewriters.map { |label, _| stats[label][key] } |
| 257 | + cells = [row_label, *values.map(&format_value)] |
| 258 | + cells << format_value.call(values.last - values.first) if selected_rewriters.size > 1 # line-matching - human |
| 259 | + cells |
| 260 | +end |
| 261 | + |
| 262 | +puts render_table(headers, rows) |
0 commit comments