samhuri.net/pressa/bin/validate-output
2026-02-07 15:30:02 -08:00

285 lines
7.5 KiB
Ruby
Executable file

#!/usr/bin/env ruby
require 'optparse'
require 'fileutils'
require 'digest'
require 'json'
require 'nokogiri'
begin
require 'htmlbeautifier'
rescue LoadError
# Optional dependency used only for nicer diffs.
end
class OutputValidator
def initialize(swift_dir:, ruby_dir:, verbose:, ignore_patterns:, show_details:, dump_dir:)
@swift_dir = swift_dir
@ruby_dir = ruby_dir
@differences = []
@missing_in_ruby = []
@missing_in_swift = []
@identical_count = 0
@verbose = verbose
@ignore_patterns = ignore_patterns
@show_details = show_details
@dump_dir = dump_dir
prepare_dump_dirs if @dump_dir
end
def validate
puts "Comparing outputs:"
puts " Swift: #{@swift_dir}"
puts " Ruby: #{@ruby_dir}"
puts ""
swift_files = find_html_files(@swift_dir)
ruby_files = find_html_files(@ruby_dir)
puts "Found #{swift_files.length} Swift output files"
puts "Found #{ruby_files.length} Ruby output files"
puts ""
compare_files(swift_files, ruby_files)
print_summary
end
private
def find_html_files(dir)
Dir.glob(File.join(dir, '**', '*.{html,xml,json,css,js,png,jpg,jpeg,gif,svg,ico,woff,woff2,ttf,eot}'))
.map { |f| f.sub("#{dir}/", '') }
.sort
end
def compare_files(swift_files, ruby_files)
all_files = (swift_files + ruby_files).uniq.sort
all_files.each do |relative_path|
next if ignored?(relative_path)
swift_path = File.join(@swift_dir, relative_path)
ruby_path = File.join(@ruby_dir, relative_path)
if !File.exist?(swift_path)
@missing_in_swift << relative_path
elsif !File.exist?(ruby_path)
@missing_in_ruby << relative_path
else
compare_file_contents(relative_path, swift_path, ruby_path)
end
end
end
def compare_file_contents(relative_path, swift_path, ruby_path)
if binary_file?(relative_path)
swift_content = File.binread(swift_path)
ruby_content = File.binread(ruby_path)
else
swift_content = normalize_content(relative_path, File.read(swift_path))
ruby_content = normalize_content(relative_path, File.read(ruby_path))
dump_normalized(relative_path, swift_content, ruby_content) if @dump_dir
end
if swift_content == ruby_content
@identical_count += 1
puts "#{relative_path}" if @verbose
else
@differences << {
path: relative_path,
swift_hash: Digest::SHA256.hexdigest(swift_content),
ruby_hash: Digest::SHA256.hexdigest(ruby_content),
swift_size: swift_content.length,
ruby_size: ruby_content.length
}
puts "#{relative_path} (differs)"
end
end
def prepare_dump_dirs
FileUtils.rm_rf(@dump_dir)
FileUtils.mkdir_p(File.join(@dump_dir, 'swift'))
FileUtils.mkdir_p(File.join(@dump_dir, 'ruby'))
end
def dump_normalized(relative_path, swift_content, ruby_content)
write_normalized(File.join(@dump_dir, 'swift', relative_path), swift_content)
write_normalized(File.join(@dump_dir, 'ruby', relative_path), ruby_content)
end
def write_normalized(path, content)
FileUtils.mkdir_p(File.dirname(path))
File.write(path, content)
end
def ignored?(path)
return false if @ignore_patterns.empty?
@ignore_patterns.any? { |pattern| File.fnmatch?(pattern, path) }
end
def binary_file?(path)
ext = File.extname(path).downcase
%w[.png .jpg .jpeg .gif .svg .ico .woff .woff2 .ttf .eot].include?(ext)
end
def normalize_content(path, content)
normalized = case File.extname(path).downcase
when '.html', '.htm'
normalize_html_dom(content)
when '.xml'
normalize_xml_dom(content)
when '.json'
normalize_json(content)
else
normalize_text(content)
end
strip_dynamic_values(normalized)
end
def normalize_html_dom(content)
doc = Nokogiri::HTML5(content)
html = doc.to_html
if defined?(HtmlBeautifier) && HtmlBeautifier.respond_to?(:beautify)
html = HtmlBeautifier.beautify(html)
end
html
rescue StandardError
normalize_text(content)
end
def normalize_xml_dom(content)
doc = Nokogiri::XML(content) { |cfg| cfg.noblanks }
doc.to_xml(indent: 2)
rescue StandardError
normalize_text(content)
end
def normalize_json(content)
JSON.pretty_generate(JSON.parse(content))
rescue StandardError
normalize_text(content)
end
def normalize_text(content)
content.gsub(/\s+/, ' ').gsub(/>\s+</, '><').strip
end
def strip_dynamic_values(content)
content.gsub(/<lastBuildDate>.*?<\/lastBuildDate>/, '')
end
# Legacy helper retained for backwards compatibility if needed elsewhere.
def html_save_options
Nokogiri::XML::Node::SaveOptions::AS_XHTML |
Nokogiri::XML::Node::SaveOptions::NO_DECLARATION |
Nokogiri::XML::Node::SaveOptions::FORMAT
end
def xml_save_options
Nokogiri::XML::Node::SaveOptions::AS_XML |
Nokogiri::XML::Node::SaveOptions::NO_DECLARATION |
Nokogiri::XML::Node::SaveOptions::FORMAT
end
def print_summary
puts ""
puts "=" * 60
puts "VALIDATION SUMMARY"
puts "=" * 60
puts ""
puts "Identical files: #{@identical_count}"
puts "Different files: #{@differences.length}"
puts "Missing in Ruby: #{@missing_in_ruby.length}"
puts "Missing in Swift: #{@missing_in_swift.length}"
puts ""
if @differences.any?
puts "Files with differences:"
return unless @show_details
@differences.each do |diff|
puts " #{diff[:path]}"
puts " Swift: #{diff[:swift_size]} bytes (#{diff[:swift_hash][0..7]}...)"
puts " Ruby: #{diff[:ruby_size]} bytes (#{diff[:ruby_hash][0..7]}...)"
end
puts ""
end
if @missing_in_ruby.any?
puts "Missing in Ruby output:"
@missing_in_ruby.each { |path| puts " #{path}" }
puts ""
end
if @missing_in_swift.any?
puts "Missing in Swift output:"
@missing_in_swift.each { |path| puts " #{path}" }
puts ""
end
success = @differences.empty? && @missing_in_ruby.empty? && @missing_in_swift.empty?
puts success ? "✅ VALIDATION PASSED" : "❌ VALIDATION FAILED"
puts ""
exit(success ? 0 : 1)
end
end
options = {
verbose: false,
ignore_patterns: [],
show_details: false,
dump_dir: nil
}
parser = OptionParser.new do |opts|
opts.banner = "Usage: validate-output [options] SWIFT_DIR RUBY_DIR"
opts.on('-v', '--verbose', 'Print a ✓ line for every identical file') do
options[:verbose] = true
end
opts.on('-iPATTERN', '--ignore=PATTERN', 'Ignore files matching the glob (may be repeated)') do |pattern|
options[:ignore_patterns] << pattern
end
opts.on('--details', 'Include byte counts and hashes for differing files') do
options[:show_details] = true
end
opts.on('--dump-normalized=DIR', 'Write normalized Swift/Ruby files to DIR/{swift,ruby}') do |dir|
options[:dump_dir] = dir
end
end
parser.parse!
if ARGV.length != 2
puts parser
exit 1
end
swift_dir = ARGV[0]
ruby_dir = ARGV[1]
unless Dir.exist?(swift_dir)
puts "ERROR: Swift directory not found: #{swift_dir}"
exit 1
end
unless Dir.exist?(ruby_dir)
puts "ERROR: Ruby directory not found: #{ruby_dir}"
exit 1
end
validator = OutputValidator.new(
swift_dir: swift_dir,
ruby_dir: ruby_dir,
verbose: options[:verbose],
ignore_patterns: options[:ignore_patterns],
show_details: options[:show_details],
dump_dir: options[:dump_dir]
)
validator.validate