jitcatch 0.1.0

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.
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JitCatch
4
+ class CLI
5
+ def self.start(argv, finished_in_time: nil)
6
+ new(argv, finished_in_time: finished_in_time).run
7
+ rescue UsageError => err
8
+ warn "jitcatch: #{err.message}"
9
+ 1
10
+ rescue GitError => err
11
+ warn "jitcatch: #{err.message}"
12
+ 2
13
+ end
14
+
15
+ def initialize(argv, finished_in_time: nil)
16
+ @argv = argv
17
+ @finished_in_time = finished_in_time
18
+ end
19
+
20
+ def run
21
+ if @argv.empty? || %w[-h --help help].include?(@argv.first)
22
+ $stdout.write(help_text)
23
+ return 0
24
+ end
25
+ if %w[-v --version].include?(@argv.first)
26
+ $stdout.write(version_line)
27
+ return 0
28
+ end
29
+
30
+ case @argv.first
31
+ when "context" then command_context(@argv.drop(1))
32
+ when "run" then command_run(@argv.drop(1))
33
+ when "assess" then command_assess(@argv.drop(1))
34
+ else
35
+ raise UsageError, "unknown command: #{@argv.first}\n\n#{help_text}"
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ # A copy run from its own git checkout, as a gem installed from git
42
+ # is, names its commit, so a quoted output can say which code wrote it.
43
+ # A copy inside another project's checkout names none.
44
+ def version_line
45
+ root = File.expand_path("../..", __dir__)
46
+ sha = File.exist?(File.join(root, ".git")) ? IO.popen(["git", "-C", root, "rev-parse", "--short", "HEAD"], err: File::NULL, &:read).strip : ""
47
+ sha.empty? ? "jitcatch #{VERSION}\n" : "jitcatch #{VERSION} (#{sha})\n"
48
+ rescue SystemCallError
49
+ "jitcatch #{VERSION}\n"
50
+ end
51
+
52
+ def command_context(argv)
53
+ opts, positional = parse_options(argv, %i[repo coverage all_readers])
54
+ return write_help if opts[:help]
55
+ raise UsageError, "usage: jitcatch context BASE HEAD" unless positional.size == 2
56
+
57
+ doc = Changes.between(repo_at(opts), positional[0], positional[1], coverage: opts[:coverage], readers: true)
58
+ limit = opts[:all_readers] ? nil : Render::READERS_SHOWN
59
+ $stdout.write(opts[:json] ? Render.dump(Render.context_json(doc, readers_limit: limit)) : Render.context_text(doc, readers_limit: limit))
60
+ 0
61
+ end
62
+
63
+ def command_run(argv)
64
+ opts, positional = parse_options(argv, %i[repo reruns timeout env workers serial number_database_url setup])
65
+ return write_help if opts[:help]
66
+ raise UsageError, "usage: jitcatch run BASE HEAD TESTS_DIR" unless positional.size == 3
67
+
68
+ raw = Runner.run(
69
+ repo_at(opts),
70
+ positional[0],
71
+ positional[1],
72
+ positional[2],
73
+ reruns: opts.fetch(:reruns, 2),
74
+ timeout: opts[:timeout],
75
+ env: opts.fetch(:env, []),
76
+ workers: opts.fetch(:workers, 1),
77
+ serial: opts.fetch(:serial, false),
78
+ number_database_url: opts.fetch(:number_database_url, false),
79
+ setup: opts[:setup],
80
+ finished_in_time: @finished_in_time
81
+ )
82
+ doc = Assessor.apply(raw)
83
+ $stdout.write(opts[:json] ? Render.dump(Render.run_json(doc)) : Render.run_text(doc))
84
+ Assessor.untested_run?(doc) ? 3 : 0
85
+ rescue EnvironmentError => err
86
+ # Every exit 3 is a report on stdout, so a caller reads one stream for it.
87
+ $stdout.write(opts[:json] ? Render.dump(err.doc) : Render.setup_failed_text(err.doc))
88
+ 3
89
+ end
90
+
91
+ def command_assess(argv)
92
+ opts, positional = parse_options(argv, [])
93
+ return write_help if opts[:help]
94
+ raise UsageError, "usage: jitcatch assess RUN.json" unless positional.size == 1
95
+
96
+ path = positional[0]
97
+ raise UsageError, "run json not found: #{path}" unless File.file?(path)
98
+
99
+ raw = JSON.parse(File.read(path))
100
+ raise UsageError, "run json must be an object" unless raw.is_a?(Hash)
101
+ raise UsageError, "run json is a run that stopped on a setup failure, so it has no tests to assess" if raw["error"] == "setup_failed"
102
+
103
+ doc = Assessor.apply(raw)
104
+ $stdout.write(opts[:json] ? Render.dump(Render.assess_json(doc)) : Render.assess_text(doc))
105
+ 0
106
+ rescue JSON::ParserError => err
107
+ raise UsageError, "run json is not JSON: #{err.message}"
108
+ end
109
+
110
+ def parse_options(argv, allowed)
111
+ opts = {}
112
+ positional = []
113
+ rest = argv.dup
114
+ while (arg = rest.shift)
115
+ if arg == "--help" || arg == "-h"
116
+ opts[:help] = true
117
+ elsif arg == "--json"
118
+ opts[:json] = true
119
+ elsif arg == "--all-readers" && allowed.include?(:all_readers)
120
+ opts[:all_readers] = true
121
+ elsif arg == "--serial" && allowed.include?(:serial)
122
+ opts[:serial] = true
123
+ elsif arg == "--number-database-url" && allowed.include?(:number_database_url)
124
+ opts[:number_database_url] = true
125
+ elsif arg.start_with?("--")
126
+ name = arg.delete_prefix("--").tr("-", "_").to_sym
127
+ raise UsageError, "unknown option: #{arg}" unless allowed.include?(name)
128
+
129
+ value = rest.shift
130
+ raise UsageError, "#{arg} requires a value" if value.nil? || value.start_with?("-")
131
+ next (opts[:env] ||= []) << env_option(arg, value) if name == :env
132
+
133
+ opts[name] = case name
134
+ when :reruns then integer_option(arg, value)
135
+ when :timeout, :workers then positive_option(arg, value)
136
+ else value
137
+ end
138
+ else
139
+ positional << arg
140
+ end
141
+ end
142
+ [opts, positional]
143
+ end
144
+
145
+ def integer_option(flag, value)
146
+ raise UsageError, "#{flag} requires an integer" unless value.match?(/\A\d+\z/)
147
+
148
+ value.to_i
149
+ end
150
+
151
+ def positive_option(flag, value)
152
+ number = integer_option(flag, value)
153
+ raise UsageError, "#{flag} must be >= 1" if number < 1
154
+
155
+ number
156
+ end
157
+
158
+ # A value on the command line would reach the run and no rerun line,
159
+ # which reads each name from the reader's shell, so --env takes a name.
160
+ def env_option(flag, value)
161
+ name = value[/\A([A-Za-z_][A-Za-z0-9_]*)=/, 1]
162
+ raise UsageError, "#{flag} takes a name and reads its value from your shell, as each rerun line does: run export #{name}=<value>, then pass #{flag} #{name}" if name
163
+ raise UsageError, "#{flag} needs a variable name, not #{value}" unless value.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
164
+ raise UsageError, "#{flag} cannot pass #{value}: jitcatch sets it" if Runner::RESERVED_ENV.include?(value)
165
+
166
+ value
167
+ end
168
+
169
+ def repo_at(opts)
170
+ opts[:repo] || Dir.pwd
171
+ end
172
+
173
+ def write_help
174
+ $stdout.write(help_text)
175
+ 0
176
+ end
177
+
178
+ def help_text
179
+ <<~HELP
180
+ jitcatch #{VERSION}
181
+
182
+ Deterministic Just-in-Time catching-test helper for Ruby.
183
+ This tool never calls an LLM. An agent writes the tests and judges intent.
184
+
185
+ Usage:
186
+ jitcatch context [--json] [--repo DIR] [--coverage FILE] [--all-readers] BASE HEAD
187
+ jitcatch run [--json] [--repo DIR] [--reruns N] [--timeout SEC] [--workers N | --serial] [--env NAME]... [--number-database-url] [--setup CMD] BASE HEAD TESTS_DIR
188
+ jitcatch assess [--json] RUN.json
189
+ jitcatch --version
190
+
191
+ context Short brief of changed methods, old/new source, call sites, tests, and the code that reads
192
+ each changed value without calling it by name.
193
+ run Run each candidate file on base and head. Keep pass/fail, drop flakes.
194
+ assess Re-apply the rule assessor to a run JSON file.
195
+
196
+ Exit status: 0 success, 1 usage error, 2 git error, 3 run tested nothing (setup or --setup failed, or
197
+ no row compared base with head and a row timed out or found no database or table).
198
+ HELP
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JitCatch
4
+ # Parses `git diff --unified=0` text. Line numbers are 1-based, matching Prism.
5
+ module DiffParser
6
+ module_function
7
+
8
+ def parse(text)
9
+ files = []
10
+ current = nil
11
+ text.to_s.each_line do |raw|
12
+ line = raw.delete_suffix("\n")
13
+ if line.start_with?("diff --git ")
14
+ current = { "old_path" => nil, "new_path" => nil, "hunks" => [] }
15
+ files << current
16
+ elsif current && line.start_with?("--- ")
17
+ current["old_path"] = clean_path(line[4..])
18
+ elsif current && line.start_with?("+++ ")
19
+ current["new_path"] = clean_path(line[4..])
20
+ elsif current && (match = line.match(/\A@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/))
21
+ current["hunks"] << {
22
+ "old_start" => match[1].to_i,
23
+ "old_count" => (match[2] || "1").to_i,
24
+ "new_start" => match[3].to_i,
25
+ "new_count" => (match[4] || "1").to_i
26
+ }
27
+ end
28
+ end
29
+ files
30
+ end
31
+
32
+ def name_status(text)
33
+ text.to_s.lines.filter_map do |line|
34
+ status, path = line.strip.split("\t", 2)
35
+ next if path.nil? || path.empty?
36
+
37
+ { "status" => status[0], "file" => path }
38
+ end.sort_by { |entry| [entry["file"], entry["status"]] }
39
+ end
40
+
41
+ def clean_path(raw)
42
+ path = raw.to_s.strip
43
+ return nil if path == "/dev/null"
44
+
45
+ path.sub(%r{\A[ab]/}, "")
46
+ end
47
+
48
+ def overlaps?(start_line, end_line, hunks, side)
49
+ hunks.any? do |hunk|
50
+ count = hunk["#{side}_count"]
51
+ next false if count.zero?
52
+
53
+ hunk_start = hunk["#{side}_start"]
54
+ hunk_end = hunk_start + count - 1
55
+ start_line <= hunk_end && end_line >= hunk_start
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module JitCatch
6
+ # Reads FactoryBot definitions as source, without loading them, so a test
7
+ # that calls `create(:loan)` can be traced to the class the factory
8
+ # builds. It does not know the app's inflections or any class a run
9
+ # computes: a factory whose class the source does not settle comes back
10
+ # with a reason and no class.
11
+ module Factories
12
+ # `builds` holds the classes its associations build, through their own
13
+ # associations too. `trait_builds` holds, by trait name, what a trait
14
+ # builds when a test names it.
15
+ Factory = Data.define(:name, :class_name, :file, :line, :reason, :builds, :trait_builds)
16
+ # One `factory` call as written. `outer` is the factory it nests in.
17
+ # `associations` names the factories it may build with: a bare name
18
+ # counts only when a factory of that name exists. `traits` holds the
19
+ # names of each trait's associations.
20
+ Written = Data.define(:names, :file, :line, :own_class, :parent, :outer, :reason, :associations, :traits)
21
+
22
+ # Calls in a factory block that define something other than an attribute.
23
+ DEFINITIONS = %i[factory trait transient sequence after before callback traits_for_enum initialize_with to_create skip_create].freeze
24
+
25
+ module_function
26
+
27
+ def file?(path)
28
+ path.match?(%r{(?:\A|/)(?:spec|test)/factories(?:\.rb\z|/.*\.rb\z)})
29
+ end
30
+
31
+ # A parent can be defined in another file, so every file is read before
32
+ # any class is settled.
33
+ def read(sources)
34
+ written = sources.sort.flat_map { |file, source| collect(Prism.parse(source).value, file, nil) }
35
+ by_name = written.flat_map { |item| item.names.map { |name| [name, item] } }.to_h
36
+ written.flat_map do |item|
37
+ class_name, reason = settle(item, by_name, [])
38
+ builds = classes_built(built_with(item, by_name, [item]), by_name)
39
+ trait_builds = traits_of(item, by_name, [item]).transform_values { |names|
40
+ classes_built(names.filter_map { |name| by_name[name] }.flat_map { |other| [other, *built_with(other, by_name, [other])] }, by_name)
41
+ }
42
+ names = item.names.empty? ? [nil] : item.names
43
+ names.map { |name| Factory.new(name: name, class_name: class_name, file: item.file, line: item.line, reason: reason, builds: builds, trait_builds: trait_builds) }
44
+ end
45
+ end
46
+
47
+ def classes_built(items, by_name)
48
+ items.filter_map { |other| settle(other, by_name, [])[0] }.uniq.sort
49
+ end
50
+
51
+ # A factory can use the traits of its parent and of the factory it
52
+ # nests in, and its own trait of the same name wins.
53
+ def traits_of(item, by_name, seen)
54
+ inherited = [item.outer, item.parent && by_name[item.parent]].compact.reject { |other| seen.include?(other) }
55
+ inherited.reverse.map { |other| traits_of(other, by_name, [*seen, other]) }.reduce({}, :merge).merge(item.traits).sort.to_h
56
+ end
57
+
58
+ def collect(node, file, outer)
59
+ if node.is_a?(Prism::CallNode) && node.name == :factory && node.receiver.nil?
60
+ item = written(node, file, outer)
61
+ return [item, *collect(node.block, file, item)]
62
+ end
63
+ return [] if node.nil?
64
+
65
+ node.compact_child_nodes.flat_map { |child| collect(child, file, outer) }
66
+ end
67
+
68
+ def written(node, file, outer)
69
+ first, *rest = Array(node.arguments&.arguments)
70
+ options = rest.grep(Prism::KeywordHashNode).flat_map(&:elements).grep(Prism::AssocNode)
71
+ .select { |pair| pair.key.is_a?(Prism::SymbolNode) }.to_h { |pair| [pair.key.unescaped, pair.value] }
72
+ name = literal(first)
73
+ reason = "its name is not a symbol or a string" unless name
74
+ own_class = class_named(options["class"]) if options.key?("class")
75
+ reason ||= "class: is not a constant, a string, or a symbol" if options.key?("class") && own_class.nil?
76
+ parent = options.key?("parent") ? literal(options["parent"]) : nil
77
+ reason ||= "parent: is not a symbol or a string" if options.key?("parent") && parent.nil?
78
+ aliases = options["aliases"].is_a?(Prism::ArrayNode) ? options["aliases"].elements.filter_map { |element| literal(element) } : []
79
+ Written.new(names: [name, *aliases].compact, file: file, line: node.location.start_line, own_class: own_class, parent: parent, outer: outer, reason: reason,
80
+ associations: associations_in(node.block), traits: traits_in(node.block))
81
+ end
82
+
83
+ def traits_in(block)
84
+ statements = block.is_a?(Prism::BlockNode) && block.body.is_a?(Prism::StatementsNode) ? block.body.body : []
85
+ statements.grep(Prism::CallNode).select { |call| call.receiver.nil? && call.name == :trait }.filter_map { |call|
86
+ name = literal(call.arguments&.arguments&.first)
87
+ [name, associations_in(call.block)] if name
88
+ }.to_h
89
+ end
90
+
91
+ # The statements of the factory's own block. A trait runs only when a
92
+ # test names it, and a nested factory is a factory of its own.
93
+ def associations_in(block)
94
+ statements = block.is_a?(Prism::BlockNode) && block.body.is_a?(Prism::StatementsNode) ? block.body.body : []
95
+ statements.grep(Prism::CallNode).flat_map do |call|
96
+ next [] unless call.receiver.nil? && !DEFINITIONS.include?(call.name)
97
+ next [association_name(call)].compact if call.name == :association
98
+ next [call.name.to_s] if call.arguments.nil? && call.block.nil?
99
+ next [] unless call.block.is_a?(Prism::BlockNode) && call.block.body.is_a?(Prism::StatementsNode)
100
+
101
+ call.block.body.body.grep(Prism::CallNode).select { |inner| inner.receiver.nil? && inner.name == :association }.filter_map { |inner| association_name(inner) }
102
+ end
103
+ end
104
+
105
+ # `association :author, factory: :user` builds from :user, and
106
+ # `factory: [:user, :admin]` from :user with a trait.
107
+ def association_name(call)
108
+ first, *rest = Array(call.arguments&.arguments)
109
+ factory = rest.grep(Prism::KeywordHashNode).flat_map(&:elements).grep(Prism::AssocNode)
110
+ .find { |pair| pair.key.is_a?(Prism::SymbolNode) && pair.key.unescaped == "factory" }&.value
111
+ factory = factory.elements.first if factory.is_a?(Prism::ArrayNode)
112
+ literal(factory) || literal(first)
113
+ end
114
+
115
+ # The factories an item builds with, its parent's and the one it nests
116
+ # in included, since a child factory inherits their attributes.
117
+ def built_with(item, by_name, seen)
118
+ inherited = [item.parent && by_name[item.parent], item.outer].compact
119
+ direct = item.associations.filter_map { |name| by_name[name] }
120
+ (direct + inherited).reject { |other| seen.include?(other) }.uniq.flat_map do |other|
121
+ reached = built_with(other, by_name, [*seen, other])
122
+ inherited.include?(other) ? reached : [other, *reached]
123
+ end
124
+ end
125
+
126
+ # FactoryBot takes a factory's class from its own `class:`, then from
127
+ # its parent (named by `parent:`, or the factory it nests in), then
128
+ # from its name.
129
+ def settle(item, by_name, seen)
130
+ return [nil, item.reason] if item.reason
131
+ return [item.own_class, nil] if item.own_class
132
+
133
+ if item.parent
134
+ parent = by_name[item.parent]
135
+ return [nil, "its parent :#{item.parent} is not a factory read here"] if parent.nil? || seen.include?(parent)
136
+
137
+ return settle(parent, by_name, [*seen, item])
138
+ end
139
+ return settle(item.outer, by_name, [*seen, item]) if item.outer
140
+
141
+ [camelize(item.names.first), nil]
142
+ end
143
+
144
+ # A string or a symbol that starts in upper case is a constant's name,
145
+ # and FactoryBot camelizes any other.
146
+ def class_named(node)
147
+ case node
148
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
149
+ node.location.slice.delete(" ").delete_prefix("::")
150
+ when Prism::StringNode, Prism::SymbolNode
151
+ text = node.unescaped.delete_prefix("::")
152
+ text.match?(/\A[A-Z]/) ? text : camelize(text)
153
+ end
154
+ end
155
+
156
+ def literal(node)
157
+ node.unescaped if node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
158
+ end
159
+
160
+ def camelize(name)
161
+ name.split("/").map { |part| part.split("_").map { |word| word.sub(/\A./) { |letter| letter.upcase } }.join }.join("::")
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JitCatch
4
+ # The result a parser gives each test, and the one it gives a file that
5
+ # fails before any test reports.
6
+ module Failure
7
+ module_function
8
+
9
+ FRAME = /^\s*(?:# )?(.+):(\d+):in [`']([^`']*)[`']/.freeze
10
+ # The runner writes this where a worktree root was, so a message still
11
+ # shows that a path stood there. Frames drop it to match the diff's paths.
12
+ WORKTREE = "{worktree}/"
13
+ # Where the runner copies the tests directory in each worktree.
14
+ TESTS_COPY = /#{Regexp.escape(WORKTREE)}\.jitcatch(?![\w.-])/.freeze
15
+ COPY_FRAME = %r{\A\.jitcatch/}.freeze
16
+ # Ruby names the file it was started on by the path it was given, so the
17
+ # error report of a file that fails to load starts a line with the copy's
18
+ # relative path. Elsewhere a relative path may be what a test compared.
19
+ COPY_PATH = %r{^(\s*(?:from )?)\.jitcatch/(?=\S+?:\d+:in )}.freeze
20
+ MESSAGE_LINES = 20
21
+ FRAMES = 15
22
+
23
+ # +cause+ is the error the failing one was raised from, when the
24
+ # framework printed it: {"error_class", "message"}.
25
+ def result(status, error_class, message, frames, cause: nil)
26
+ message = message.to_s.strip.lines.first(MESSAGE_LINES).join.rstrip
27
+ {
28
+ "status" => status,
29
+ "error_class" => error_class,
30
+ "message" => message,
31
+ "message_first_line" => first_line(message),
32
+ "backtrace" => frames.first(FRAMES),
33
+ "cause" => cause
34
+ }
35
+ end
36
+
37
+ # A test reads its own files from the copy, and the rerun line names
38
+ # +tests+, the tests directory from the repository root, so a message
39
+ # names it after the root, and a frame names it, where either named the
40
+ # copy. An absolute +tests+ is outside every worktree, so it stands alone.
41
+ def naming_tests(result, tests)
42
+ return nil if result.nil?
43
+
44
+ rooted = tests.start_with?("/") ? tests : "#{WORKTREE}#{tests}"
45
+ named = ->(text) { text.to_s.gsub(TESTS_COPY) { rooted }.gsub(COPY_PATH) { "#{$1}#{tests}/" } }
46
+ message = named.call(result["message"])
47
+ cause = result["cause"] && result["cause"].merge("message" => named.call(result["cause"]["message"]))
48
+ backtrace = Array(result["backtrace"]).map { |frame| frame.merge("file" => frame["file"].sub(COPY_FRAME) { "#{tests}/" }) }
49
+ result.merge("message" => message, "message_first_line" => first_line(message), "cause" => cause, "backtrace" => backtrace)
50
+ end
51
+
52
+ def passed(status)
53
+ result(status, nil, "", [])
54
+ end
55
+
56
+ def frame(file, line)
57
+ { "file" => file.delete_prefix(WORKTREE).delete_prefix("./"), "line" => line.to_i }
58
+ end
59
+
60
+ def frames_in(lines)
61
+ lines.filter_map { |line| (match = line.match(FRAME)) && frame(match[1], match[2]) }
62
+ end
63
+
64
+ # What a process printed when it stopped before its framework reported.
65
+ def from_streams(stdout, stderr)
66
+ text = stderr.strip
67
+ text = stdout.strip if text.empty?
68
+ return nil if text.empty?
69
+
70
+ error_class = if text.include?("LoadError") || text.include?("cannot load such file")
71
+ "LoadError"
72
+ elsif text.include?("SyntaxError") || text.include?("syntax error")
73
+ "SyntaxError"
74
+ elsif (match = text.match(/\(([A-Za-z0-9_:]+)\)\s*\z/))
75
+ match[1]
76
+ else
77
+ "RuntimeError"
78
+ end
79
+ result("error", error_class, text.lines.map(&:strip).reject(&:empty?).first, frames_in(text.lines))
80
+ end
81
+
82
+ def first_line(message)
83
+ message.to_s.lines.map(&:strip).reject(&:empty?).first.to_s[0, 180]
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JitCatch
4
+ # How a candidate file runs and how its output reads. A `*_spec.rb` file
5
+ # is RSpec unless it requires minitest (minitest/spec names files that way
6
+ # too); every other file is Minitest.
7
+ module Framework
8
+ MINITEST_REQUIRE = /^\s*require\s*\(?\s*["']minitest/.freeze
9
+ REPORT = ".jitcatch-report.json"
10
+
11
+ def self.for(relative, source)
12
+ RSpec.names?(File.basename(relative)) && !source.match?(MINITEST_REQUIRE) ? RSpec : Minitest
13
+ end
14
+
15
+ def self.named?(relative)
16
+ [Minitest, RSpec].any? { |framework| framework.names?(File.basename(relative)) }
17
+ end
18
+
19
+ module Minitest
20
+ module_function
21
+
22
+ BARE = /\A[\w.,:\/@\#{}=+-]+\z/
23
+
24
+ def names?(basename)
25
+ basename.match?(/\Atest_.+\.rb\z/) || basename.end_with?("_test.rb")
26
+ end
27
+
28
+ REPORTER = File.expand_path("minitest_report.rb", __dir__)
29
+
30
+ def arguments(path)
31
+ ["-r#{REPORTER}", path, "--seed", "1"]
32
+ end
33
+
34
+ def parse(report, stdout, stderr, normalizer)
35
+ MinitestParser.parse(report, stdout, stderr, normalizer: normalizer)
36
+ end
37
+
38
+ LIBRARY = "minitest"
39
+
40
+ # Minitest is a bundled gem, so under Bundler only a Gemfile that lists
41
+ # it can load it.
42
+ def missing(bundled)
43
+ bundled ? 'minitest is not in the bundle: add gem "minitest" to the Gemfile' : "minitest is not installed for this Ruby: gem install minitest"
44
+ end
45
+
46
+ def rerun(path, name, _line, pin_root: true, launcher: false)
47
+ ["ruby", "-Ilib", "-I.", path, *(["-n", name] if name)].map { |part| quote(part) }.join(" ")
48
+ end
49
+
50
+ # A word a shell reads as written stays bare, so a path and a
51
+ # `Class#test` name stay readable; any other word is single-quoted.
52
+ def quote(part)
53
+ return part if part.match?(BARE)
54
+
55
+ "'#{part.gsub("'", %q('\\\\''))}'"
56
+ end
57
+ end
58
+
59
+ module RSpec
60
+ module_function
61
+
62
+ # RSpec puts lib/ of the nearest directory that holds spec/ first on
63
+ # the load path, which under a Rails app's tmp/ is the app's lib/, not
64
+ # the checkout's. Pinning that directory to the working directory
65
+ # loads the checkout's lib/ in the run and in its rerun line alike.
66
+ # RSpec cuts an object it prints to 200 characters, which can cut
67
+ # the class and message a raise_error got out of the row.
68
+ PIN_ROOT = "RSpec::Core::RubyProject.instance_variable_set(:@project_root, Dir.pwd); "
69
+ LAUNCH = 'require "rspec/core"; require "rspec/support/object_formatter"; ' \
70
+ "#{PIN_ROOT}" \
71
+ "RSpec::Support::ObjectFormatter.default_instance.max_formatted_output_length = nil; exit(RSpec::Core::Runner.run(ARGV))"
72
+
73
+ def names?(basename)
74
+ basename.end_with?("_spec.rb")
75
+ end
76
+
77
+ # `--backtrace` keeps the gem frames that tell a gem's error apart.
78
+ # Formatters given here replace the ones a `.rspec` file names.
79
+ def arguments(path)
80
+ ["-e", LAUNCH, "--", "--format", "json", "--out", REPORT, "--seed", "1", "--backtrace", "--no-color", path]
81
+ end
82
+
83
+ def parse(report, stdout, stderr, normalizer)
84
+ RSpecParser.parse(report, stdout, stderr, normalizer: normalizer)
85
+ end
86
+
87
+ LIBRARY = "rspec/core"
88
+
89
+ def missing(bundled)
90
+ bundled ? 'rspec-core is not in the bundle: add gem "rspec" to the Gemfile' : "rspec-core is not installed for this Ruby: gem install rspec-core"
91
+ end
92
+
93
+ # What `rspec -r` loads to lift the cut, when head's Ruby can load it.
94
+ LAUNCHER = "jitcatch/rspec"
95
+
96
+ # The same load path and launch as the run, so the rerun loads what
97
+ # the row loaded. In a checkout that holds spec/ RSpec already takes
98
+ # the working directory as its root and adds its lib/, so the line
99
+ # needs no pin, the one part that sets rspec-core's private state, and
100
+ # is plain `rspec` when head's Ruby loads the launcher.
101
+ def rerun(path, _name, line, pin_root: true, launcher: false)
102
+ target = "#{path}#{":#{line}" if line}"
103
+ return ["rspec", "-I.", "-r", LAUNCHER, target].map { |part| Minitest.quote(part) }.join(" ") if launcher && !pin_root
104
+
105
+ launch = pin_root ? LAUNCH : LAUNCH.sub(PIN_ROOT, "")
106
+ ["ruby", "-Ilib", "-I.", "-e", launch, target].map { |part| Minitest.quote(part) }.join(" ")
107
+ end
108
+ end
109
+ end
110
+ end