Skip to content

Commit 8597be4

Browse files
committed
Initial version of RBS validation script
1 parent 2d05cc8 commit 8597be4

1 file changed

Lines changed: 262 additions & 0 deletions

File tree

bin/scripts/rbs_rewriter_tester.rb

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
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

Comments
 (0)