crspec 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,7 +18,25 @@ module Crspec
18
18
  include ActiveSupport::Testing::FileFixtures
19
19
  end
20
20
 
21
- ResponseStruct = Struct.new(:status, :body, :headers)
21
+ # Lightweight response wrapper that mimics the small subset of the
22
+ # Rails test response API that the specs rely on (status, body,
23
+ # headers, media_type, parsed_body, etc.).
24
+ ResponseStruct = Struct.new(:status, :body, :headers) do
25
+ def media_type
26
+ content_type_header = headers["Content-Type"] || headers["content-type"]
27
+ return nil unless content_type_header
28
+
29
+ # Strip any charset or parameters, e.g. "application/json; charset=utf-8"
30
+ content_type_header.split(";").first&.strip
31
+ end
32
+
33
+ def parsed_body
34
+ return nil unless body
35
+ JSON.parse(body)
36
+ rescue JSON::ParserError
37
+ nil
38
+ end
39
+ end
22
40
 
23
41
  def response
24
42
  execution_context[:last_response]
@@ -32,34 +50,72 @@ module Crspec
32
50
  end
33
51
 
34
52
  def process_request(method, path, params = {}, headers = {})
35
- body_string = params.is_a?(String) ? params : params.to_json
53
+ # Emulate Rails integration test semantics:
54
+ # - For GET/DELETE, params are typically carried in the query string
55
+ # - For POST/PUT/PATCH with JSON, params are in the request body
56
+ # - `format: :json` should affect the requested path / Accept header
57
+
58
+ params = params.dup if params.is_a?(Hash)
59
+
60
+ requested_format = nil
61
+ if params.is_a?(Hash) && params.key?(:format)
62
+ requested_format = params.delete(:format)&.to_s
63
+ end
64
+
65
+ path_with_format = path.dup
66
+ if requested_format && !path_with_format.end_with?(".#{requested_format}")
67
+ path_with_format = "#{path_with_format}.#{requested_format}"
68
+ end
69
+
70
+ query_string = nil
71
+ body_string = ""
72
+
73
+ if params.is_a?(Hash) && !params.empty?
74
+ if %i[get delete].include?(method.to_sym)
75
+ # Attach params as query string for idempotent verbs
76
+ query_string = URI.encode_www_form(params)
77
+ else
78
+ # Default to JSON body for non-GET verbs when params are present
79
+ body_string = params.to_json
80
+ headers = headers.merge("CONTENT_TYPE" => "application/json") unless headers["CONTENT_TYPE"]
81
+ end
82
+ elsif params.is_a?(String)
83
+ body_string = params
84
+ end
85
+
36
86
  env = {
37
87
  "REQUEST_METHOD" => method.to_s.upcase,
38
- "PATH_INFO" => path,
88
+ "PATH_INFO" => path_with_format,
89
+ "QUERY_STRING" => query_string.to_s,
39
90
  "rack.input" => StringIO.new(body_string),
40
- "CONTENT_TYPE" => headers["CONTENT_TYPE"] || "application/json",
41
- "HTTP_ACCEPT" => headers["HTTP_ACCEPT"] || "application/json",
91
+ "CONTENT_TYPE" => headers["CONTENT_TYPE"],
92
+ "HTTP_ACCEPT" => headers["HTTP_ACCEPT"] || (requested_format == "json" ? "application/json" : nil),
42
93
  "CONTENT_LENGTH" => body_string.bytesize.to_s
43
- }
94
+ }.compact
44
95
 
45
96
  headers.each do |k, v|
46
97
  env["HTTP_#{k.to_s.upcase.tr('-', '_')}"] = v unless k.to_s.start_with?("HTTP_")
47
98
  end
48
99
 
49
100
  status = 200
50
- response_headers = { "Content-Type" => "application/json" }
101
+ response_headers = { "Content-Type" => "text/plain" }
51
102
  response_body = ""
52
103
 
53
104
  begin
54
105
  if defined?(::Rails) && ::Rails.application && ::Rails.application.routes.routes.any?
55
- begin
56
- status, response_headers, body_obj = ::Rails.application.call(env)
57
- response_body = body_obj.respond_to?(:body) ? body_obj.body : body_obj.join
58
- rescue StandardError
59
- response_body = params.to_json
60
- end
106
+ status, response_headers, body_obj = ::Rails.application.call(env)
107
+ response_body = body_obj.respond_to?(:body) ? body_obj.body : body_obj.join
61
108
  else
62
- response_body = params.to_json
109
+ # No Rails app mounted; behave like a very small echo server.
110
+ if (env["HTTP_ACCEPT"] || "").to_s.include?("json") ||
111
+ (env["CONTENT_TYPE"] || "").to_s.include?("json") ||
112
+ requested_format == "json"
113
+ response_headers["Content-Type"] = "application/json"
114
+ response_body = params.is_a?(String) ? params : params.to_json
115
+ else
116
+ response_headers["Content-Type"] = "text/plain"
117
+ response_body = params.to_s
118
+ end
63
119
  end
64
120
  ensure
65
121
  if defined?(ActiveRecord::Base) && ActiveRecord::Base.respond_to?(:connection_handler)
@@ -5,7 +5,14 @@ require "etc"
5
5
  module Crspec
6
6
  module Rails
7
7
  class SystemServer
8
+ # The effective port is offset by TEST_ENV_NUMBER so concurrent
9
+ # --processes children never collide on the same listener.
10
+ def self.effective_port(port)
11
+ port + ENV.fetch("TEST_ENV_NUMBER", "").to_i
12
+ end
13
+
8
14
  def self.start_concurrent_server!(app = nil, port = 9887)
15
+ port = effective_port(port)
9
16
  @server_mutex ||= Mutex.new
10
17
  @server_mutex.synchronize do
11
18
  return if @running
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ begin
5
+ if defined?(Rails) && Rails.env.test? && defined?(Warden::Manager)
6
+ # Ensure there is always a failure app in test so that requests which
7
+ # hit Warden::Manager#call_failure_app do not raise "No Failure App
8
+ # provided" but instead respond with a simple 401/403 style response.
9
+ Rails.application.config.middleware.insert_after Rack::Head, Warden::Manager do |config|
10
+ config.failure_app ||= lambda do |_env|
11
+ [401, { "Content-Type" => "text/plain" }, ["Unauthorized"]]
12
+ end
13
+ end unless Rails.application.config.middleware.any? { |m| m.klass == Warden::Manager }
14
+ end
15
+ rescue StandardError
16
+ # If the app doesn't use Warden/Devise in a conventional fashion we
17
+ # silently skip installing the shim.
18
+ end
data/lib/crspec/runner.rb CHANGED
@@ -1,51 +1,89 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "etc"
4
+ begin
5
+ require "async"
6
+ require "async/semaphore"
7
+ rescue LoadError
8
+ # Fiber tier unavailable; threads-only execution.
9
+ end
4
10
  require_relative "execution_context"
5
11
  require_relative "formatters/progress_formatter"
12
+ require_relative "status_persistence"
6
13
 
7
14
  module Crspec
8
15
  class Runner
9
- attr_reader :concurrency, :passed_examples, :failed_examples, :total_duration, :formatter
16
+ attr_reader :concurrency, :fibers, :passed_examples, :failed_examples, :pending_examples, :total_duration, :formatter, :seed
10
17
 
11
- def initialize(concurrency: Etc.nprocessors, formatter: nil)
18
+ def initialize(concurrency: Etc.nprocessors, fibers: 1, formatter: nil, fail_fast: false,
19
+ seed: nil, only_failures: false, persistence_path: nil,
20
+ tags: nil, locations: nil)
12
21
  @concurrency = concurrency
22
+ @fibers = fibers && fibers > 1 && defined?(Async) ? fibers : 1
13
23
  @formatter = formatter || Formatters::ProgressFormatter.new
14
24
  @queue = Thread::Queue.new
15
25
  @passed_examples = []
16
26
  @failed_examples = []
17
- @mutex = Mutex.new
27
+ @pending_examples = []
18
28
  @total_duration = 0
29
+ @fail_fast = fail_fast == true ? 1 : fail_fast
30
+ @failure_count = 0
31
+ @failure_mutex = Mutex.new
32
+ @seed = seed
33
+ @only_failures = only_failures
34
+ @tags = tags
35
+ @locations = locations
36
+ @persistence = StatusPersistence.new(
37
+ persistence_path || Crspec.configuration.example_status_persistence_file_path
38
+ )
19
39
  end
20
40
 
21
41
  def run(example_groups)
42
+ Rails::AssetsShim.install! if defined?(Rails::AssetsShim)
22
43
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
23
44
  @formatter.start
24
- example_groups.each { |group| enqueue_examples(group) }
45
+ example_groups.each(&:finalize!)
46
+
47
+ examples = []
48
+ example_groups.each { |group| collect_examples(group, examples) }
49
+ examples = filter_examples(examples)
50
+ previous_statuses = @persistence.load
51
+ examples = order_examples(examples, previous_statuses)
52
+ examples.each { |example| @queue.push(example) }
53
+ @queue.close
25
54
 
26
55
  workers = Array.new(@concurrency) do |worker_idx|
27
56
  Thread.new do
28
57
  worker_number = worker_idx + 1
58
+ results = { passed: [], failed: [], pending: [] }
59
+ Thread.current[:crspec_results] = results
29
60
  Rails::Parallel.setup_worker(worker_number) if defined?(Rails::Parallel) && Rails::Parallel.enabled?
30
61
 
31
- Fiber.set_scheduler(Async::Scheduler.new) if defined?(Async::Scheduler)
32
- until @queue.empty?
33
- example = begin
34
- @queue.pop(true)
35
- rescue StandardError
36
- nil
62
+ if @fibers > 1
63
+ run_worker_fibers(results)
64
+ else
65
+ while (example = @queue.pop)
66
+ execute_example(example, results)
37
67
  end
38
- break unless example
39
-
40
- execute_example(example)
41
68
  end
42
69
  ensure
70
+ Rails::DatabaseIsolation.finish_worker if defined?(Rails::DatabaseIsolation)
43
71
  Rails::Parallel.teardown_worker(worker_number) if defined?(Rails::Parallel) && Rails::Parallel.enabled?
44
72
  end
45
73
  end
46
74
 
47
- workers.each(&:join)
75
+ workers.each do |worker|
76
+ worker.join
77
+ results = worker[:crspec_results]
78
+ next unless results
79
+
80
+ @passed_examples.concat(results[:passed])
81
+ @failed_examples.concat(results[:failed])
82
+ @pending_examples.concat(results[:pending])
83
+ end
48
84
  @total_duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
85
+ Mock::Interceptor.cleanup! if defined?(Mock::Interceptor)
86
+ @persistence.save(@passed_examples + @failed_examples)
49
87
  @formatter.finish
50
88
  self
51
89
  end
@@ -56,12 +94,85 @@ module Crspec
56
94
 
57
95
  private
58
96
 
59
- def enqueue_examples(group)
60
- group.examples.each { |example| @queue.push(example) }
61
- group.children.each { |child| enqueue_examples(child) }
97
+ # Each worker thread runs up to @fibers concurrent example-fibers via
98
+ # the Async reactor. IO-bound examples overlap within a thread. Fiber
99
+ # Storage isolation (ExecutionContext, Mock::Space, DB leases) makes
100
+ # this safe: each example fiber gets its own context, mock space and
101
+ # leased DB connection.
102
+ def run_worker_fibers(results)
103
+ Sync do |top|
104
+ semaphore = Async::Semaphore.new(@fibers, parent: top)
105
+ while (example = @queue.pop)
106
+ semaphore.async do
107
+ execute_example(example, results)
108
+ ensure
109
+ Rails::DatabaseIsolation.finish_worker if defined?(Rails::DatabaseIsolation)
110
+ end
111
+ end
112
+ end
113
+ end
114
+
115
+ def collect_examples(group, acc)
116
+ acc.concat(group.examples)
117
+ group.children.each { |child| collect_examples(child, acc) }
118
+ end
119
+
120
+ # Focus (fit/fdescribe/:focus) narrows to focused examples when any
121
+ # exist; --tag filters on metadata; line-number filters (spec.rb:42)
122
+ # select the example whose definition is closest above the line.
123
+ def filter_examples(examples)
124
+ examples = examples.select(&:focused?) if examples.any?(&:focused?)
125
+
126
+ if @tags && !@tags.empty?
127
+ examples = examples.select do |ex|
128
+ meta = ex.example_group.ancestor_metadata.merge(ex.metadata || {})
129
+ @tags.all? do |key, value|
130
+ value == true ? !!meta[key] : meta[key] == value
131
+ end
132
+ end
133
+ end
134
+
135
+ if @locations && !@locations.empty?
136
+ examples = examples.select do |ex|
137
+ @locations.any? do |file, line|
138
+ next false unless ex.file_path && File.expand_path(ex.file_path) == File.expand_path(file)
139
+ next true if line.nil?
140
+
141
+ candidates = examples.select { |e| e.file_path && File.expand_path(e.file_path) == File.expand_path(file) }
142
+ best = candidates.select { |e| e.line_number && e.line_number <= line }.max_by(&:line_number)
143
+ best ? ex.equal?(best) : false
144
+ end
145
+ end
146
+ end
147
+
148
+ examples
149
+ end
150
+
151
+ # Slowest-first (using persisted timings) shrinks the critical path of
152
+ # the parallel run; unknown examples go first (assumed potentially slow).
153
+ # --seed applies random ordering before the timing sort is skipped.
154
+ def order_examples(examples, previous_statuses)
155
+ if @only_failures
156
+ examples = examples.select do |ex|
157
+ entry = previous_statuses[ex.persistence_key]
158
+ entry.nil? || entry["status"] == "failed"
159
+ end
160
+ end
161
+
162
+ if @seed
163
+ rng = Random.new(@seed)
164
+ examples.shuffle(random: rng)
165
+ elsif previous_statuses.empty?
166
+ examples
167
+ else
168
+ examples.sort_by do |ex|
169
+ entry = previous_statuses[ex.persistence_key]
170
+ -(entry ? entry["run_time"].to_f : Float::INFINITY)
171
+ end
172
+ end
62
173
  end
63
174
 
64
- def execute_example(example)
175
+ def execute_example(example, results)
65
176
  ExecutionContext.isolate(example.id, example.metadata) do |_context|
66
177
  example.execute!
67
178
  ensure
@@ -76,20 +187,23 @@ module Crspec
76
187
  end
77
188
  end
78
189
 
79
- record_result(example)
190
+ record_result(example, results)
80
191
  end
81
192
  end
82
193
 
83
- def record_result(example)
84
- @mutex.synchronize do
85
- if example.status == :passed
86
- @passed_examples << example
87
- @formatter.example_passed(example)
88
- elsif example.status == :pending
89
- @formatter.example_pending(example)
90
- else
91
- @failed_examples << example
92
- @formatter.example_failed(example)
194
+ def record_result(example, results)
195
+ if example.status == :passed
196
+ results[:passed] << example
197
+ @formatter.example_passed(example)
198
+ elsif example.status == :pending
199
+ results[:pending] << example
200
+ @formatter.example_pending(example)
201
+ else
202
+ results[:failed] << example
203
+ @formatter.example_failed(example)
204
+ if @fail_fast
205
+ failures = @failure_mutex.synchronize { @failure_count += 1 }
206
+ @queue.clear if failures >= @fail_fast
93
207
  end
94
208
  end
95
209
  end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crspec
4
+ # Registry of shared example groups / contexts. Registration happens at
5
+ # load time (single-threaded); lookups at group-definition time. Blocks
6
+ # are re-evaluated in the including group, so no state is shared between
7
+ # examples — parallel-safe by construction.
8
+ module SharedRegistry
9
+ class << self
10
+ def registry
11
+ @registry ||= {}
12
+ end
13
+
14
+ def register(name, block)
15
+ registry[name.to_s] = block
16
+ end
17
+
18
+ def fetch(name)
19
+ registry[name.to_s] or raise ArgumentError,
20
+ "Could not find shared examples or context #{name.inspect}"
21
+ end
22
+
23
+ def reset!
24
+ @registry = {}
25
+ end
26
+ end
27
+ end
28
+
29
+ def self.shared_examples(name, &block)
30
+ SharedRegistry.register(name, block)
31
+ end
32
+
33
+ def self.shared_context(name, &block)
34
+ SharedRegistry.register(name, block)
35
+ end
36
+
37
+ class << self
38
+ alias shared_examples_for shared_examples
39
+ end
40
+
41
+ module SharedDSL
42
+ def shared_examples(name, &block)
43
+ Crspec::SharedRegistry.register(name, block)
44
+ end
45
+ alias shared_examples_for shared_examples
46
+ alias shared_context shared_examples
47
+
48
+ def include_context(name, *args)
49
+ block = Crspec::SharedRegistry.fetch(name)
50
+ if args.empty?
51
+ instance_eval(&block)
52
+ else
53
+ instance_exec(*args, &block)
54
+ end
55
+ end
56
+ alias include_examples include_context
57
+
58
+ def it_behaves_like(name, *args)
59
+ block = Crspec::SharedRegistry.fetch(name)
60
+ child = describe("behaves like #{name}") {}
61
+ if args.empty?
62
+ child.instance_eval(&block)
63
+ else
64
+ child.instance_exec(*args, &block)
65
+ end
66
+ child
67
+ end
68
+ alias it_should_behave_like it_behaves_like
69
+ end
70
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Crspec
7
+ # Persists per-example status and timing between runs. Backs
8
+ # slowest-first scheduling and --only-failures. Keyed by the example's
9
+ # stable identity (group description chain + example description).
10
+ class StatusPersistence
11
+ def initialize(path)
12
+ @path = path
13
+ end
14
+
15
+ def load
16
+ return {} unless @path && File.exist?(@path)
17
+
18
+ JSON.parse(File.read(@path))
19
+ rescue JSON::ParserError, Errno::ENOENT
20
+ {}
21
+ end
22
+
23
+ def save(examples)
24
+ return unless @path
25
+
26
+ previous = load
27
+ examples.each do |example|
28
+ next if example.status == :pending
29
+
30
+ previous[example.persistence_key] = {
31
+ "status" => example.status.to_s,
32
+ "run_time" => example.execution_time.round(6)
33
+ }
34
+ end
35
+
36
+ dir = File.dirname(@path)
37
+ FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
38
+ tmp_path = File.join(dir, ".crspec-status-#{Process.pid}-#{rand(1_000_000)}")
39
+ File.write(tmp_path, JSON.pretty_generate(previous))
40
+ File.rename(tmp_path, @path)
41
+ rescue SystemCallError
42
+ File.delete(tmp_path) if tmp_path && File.exist?(tmp_path)
43
+ nil
44
+ end
45
+ end
46
+ end
@@ -15,6 +15,8 @@ module Crspec
15
15
  @args = args
16
16
  @mode = :help
17
17
  @paths = []
18
+ @diff = false
19
+ @backup = true
18
20
  end
19
21
 
20
22
  def run
@@ -24,8 +26,11 @@ module Crspec
24
26
  analyze_paths(@paths)
25
27
  when :write
26
28
  write_paths(@paths)
29
+ when :report
30
+ report_paths(@paths)
27
31
  else
28
- Rails.logger.debug "Usage: crspec-transpile [--analyze|--write] <files or directories>"
32
+ puts "Usage: crspec-transpile [--analyze|--write|--report] [--diff] [--no-backup] <files or directories>"
33
+ true
29
34
  end
30
35
  end
31
36
 
@@ -43,6 +48,18 @@ module Crspec
43
48
  @mode = :write
44
49
  end
45
50
 
51
+ opts.on("--report", "Print a per-file migration report with safety scores") do
52
+ @mode = :report
53
+ end
54
+
55
+ opts.on("--diff", "Dry run: print unified diffs instead of writing") do
56
+ @diff = true
57
+ end
58
+
59
+ opts.on("--no-backup", "Do not write .bak backups when transpiling") do
60
+ @backup = false
61
+ end
62
+
46
63
  opts.on("-h", "--help", "Show help") do
47
64
  @mode = :help
48
65
  end
@@ -52,37 +69,46 @@ module Crspec
52
69
  @paths = leftovers.empty? ? ["spec"] : leftovers
53
70
  end
54
71
 
72
+ # Only *_spec.rb plus known helper files are candidates. The old
73
+ # fallback glob (**/*.rb when no spec files matched) could rewrite an
74
+ # entire application tree; it is gone.
55
75
  def find_files(paths)
56
76
  files = []
57
77
  paths.each do |p|
58
78
  if File.directory?(p)
59
79
  files.concat(Dir.glob(File.join(p, "**", "*_spec.rb")))
60
- files.concat(Dir.glob(File.join(p, "**", "*.rb"))) if files.empty?
80
+ files.concat(Dir.glob(File.join(p, "**", "{spec,rails}_helper.rb")))
61
81
  elsif File.file?(p)
62
82
  files << p
83
+ else
84
+ warn "warning: path not found: #{p}"
63
85
  end
64
86
  end
65
87
  files.uniq
66
88
  end
67
89
 
90
+ def log(msg = nil)
91
+ msg = yield if block_given?
92
+ puts msg
93
+ end
94
+
68
95
  def analyze_paths(paths)
69
96
  files = find_files(paths)
70
- total_warnings = 0
97
+ total = 0
71
98
 
72
99
  files.each do |file|
73
- content = File.read(file)
74
- rewriter = Rewriter.new(content)
75
- rewriter.transpile
76
- next if rewriter.warnings.empty?
100
+ rewriter = transpiled(file)
101
+ next if rewriter.lints.empty?
77
102
 
78
- Rails.logger.debug { "File: #{file}" }
79
- rewriter.warnings.each do |w|
80
- Rails.logger.debug { " #{w}" }
81
- total_warnings += 1
103
+ log { "File: #{file}" }
104
+ rewriter.lints.each do |lint|
105
+ log { format(" line %-4d %-8s [%s] %s", lint.line, lint.severity, lint.code, lint.message) }
106
+ total += 1
82
107
  end
83
108
  end
84
109
 
85
- Rails.logger.debug { "Analysis complete. Total warnings found: #{total_warnings}" }
110
+ log { "Analysis complete. Total findings: #{total}" }
111
+ true
86
112
  end
87
113
 
88
114
  def write_paths(paths)
@@ -96,12 +122,77 @@ module Crspec
96
122
 
97
123
  next unless new_code != content
98
124
 
99
- File.write(file, new_code)
100
- Rails.logger.debug { "Transpiled: #{file}" }
125
+ if @diff
126
+ print_diff(file, content, new_code)
127
+ else
128
+ File.write("#{file}.bak", content) if @backup
129
+ File.write(file, new_code)
130
+ log { "Transpiled: #{file}#{@backup ? " (backup: #{file}.bak)" : ""}" }
131
+ end
101
132
  count += 1
102
133
  end
103
134
 
104
- Rails.logger.debug { "Transpilation complete. Updated #{count} file(s)." }
135
+ log { @diff ? "Dry run complete. #{count} file(s) would change." : "Transpilation complete. Updated #{count} file(s)." }
136
+ true
137
+ end
138
+
139
+ # Per-file concurrency-safety score plus the constructs that need
140
+ # manual work, enabling incremental migration.
141
+ def report_paths(paths)
142
+ files = find_files(paths)
143
+ rows = files.map do |file|
144
+ rewriter = transpiled(file)
145
+ manual = rewriter.lints.reject { |l| l.severity == :info }
146
+ [file, rewriter.safety_score, rewriter.lints.size, manual]
147
+ end
148
+
149
+ log { format("%-60s %7s %9s", "File", "Score", "Findings") }
150
+ log { "-" * 78 }
151
+ rows.sort_by { |_, score, _, _| score }.each do |file, score, findings, manual|
152
+ log { format("%-60s %6d%% %9d", file, score, findings) }
153
+ manual.each do |lint|
154
+ log { format(" line %-4d %-8s [%s] %s", lint.line, lint.severity, lint.code, lint.message) }
155
+ end
156
+ end
157
+
158
+ avg = rows.empty? ? 100 : rows.sum { |_, s, _, _| s } / rows.size
159
+ manual_total = rows.sum { |_, _, _, m| m.size }
160
+ log { "-" * 78 }
161
+ log { "#{rows.size} file(s), average safety score #{avg}%, #{manual_total} construct(s) need manual work." }
162
+ true
163
+ end
164
+
165
+ def transpiled(file)
166
+ rewriter = Rewriter.new(File.read(file))
167
+ rewriter.transpile
168
+ rewriter
169
+ end
170
+
171
+ def print_diff(file, old_code, new_code)
172
+ log { "--- #{file}" }
173
+ log { "+++ #{file} (transpiled)" }
174
+ old_lines = old_code.lines
175
+ new_lines = new_code.lines
176
+ max = [old_lines.size, new_lines.size].max
177
+ offset = 0
178
+ old_lines.each_with_index do |line, i|
179
+ new_line = new_lines[i + offset]
180
+ next if line == new_line
181
+
182
+ if new_lines[i + offset + 1] == line
183
+ log { "+#{new_lines[i + offset]}" }
184
+ offset += 1
185
+ redo_line = new_lines[i + offset]
186
+ log { " #{redo_line}" } if redo_line == line
187
+ else
188
+ log { "-#{line}" }
189
+ log { "+#{new_line}" } if new_line
190
+ end
191
+ end
192
+ (old_lines.size + offset...new_lines.size).each do |i|
193
+ log { "+#{new_lines[i]}" }
194
+ end
195
+ max.zero? && nil
105
196
  end
106
197
  end
107
198
  end