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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.ja.md +1052 -0
- data/README.md +529 -0
- data/bin/jitcatch +16 -0
- data/lib/jitcatch/assessor.rb +502 -0
- data/lib/jitcatch/changes.rb +1084 -0
- data/lib/jitcatch/cli.rb +201 -0
- data/lib/jitcatch/diff_parser.rb +59 -0
- data/lib/jitcatch/factories.rb +164 -0
- data/lib/jitcatch/failure.rb +86 -0
- data/lib/jitcatch/framework.rb +110 -0
- data/lib/jitcatch/git_repo.rb +180 -0
- data/lib/jitcatch/inflections.rb +142 -0
- data/lib/jitcatch/minitest_parser.rb +110 -0
- data/lib/jitcatch/minitest_report.rb +54 -0
- data/lib/jitcatch/readers.rb +907 -0
- data/lib/jitcatch/render.rb +712 -0
- data/lib/jitcatch/routes.rb +288 -0
- data/lib/jitcatch/rspec.rb +9 -0
- data/lib/jitcatch/rspec_parser.rb +122 -0
- data/lib/jitcatch/runner.rb +905 -0
- data/lib/jitcatch/source_index.rb +1382 -0
- data/lib/jitcatch/template.rb +38 -0
- data/lib/jitcatch/version.rb +5 -0
- data/lib/jitcatch.rb +96 -0
- data/skills/jitcatch/SKILL.md +244 -0
- metadata +73 -0
data/lib/jitcatch/cli.rb
ADDED
|
@@ -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
|