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
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JitCatch
|
|
4
|
+
# Turns an ERB template into Ruby that Prism can parse, line for line, so
|
|
5
|
+
# a call in a template keeps the line it has there. Text outside the tags
|
|
6
|
+
# becomes blank lines, and the value of each `<%=` tag goes to a call
|
|
7
|
+
# named `__erb_out__`, so a reader can tell printed values from code.
|
|
8
|
+
module Template
|
|
9
|
+
OUTPUT = :__erb_out__
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def as_ruby(text)
|
|
14
|
+
out = +""
|
|
15
|
+
pos = 0
|
|
16
|
+
text.scan(/<%(=|-|#)?(.*?)-?%>/m) do
|
|
17
|
+
match = Regexp.last_match
|
|
18
|
+
out << ("\n" * text[pos...match.begin(0)].count("\n"))
|
|
19
|
+
kind = match[1]
|
|
20
|
+
code = match[2]
|
|
21
|
+
if kind == "#"
|
|
22
|
+
out << ("\n" * code.count("\n"))
|
|
23
|
+
elsif kind == "="
|
|
24
|
+
out << "#{OUTPUT} #{code.strip};"
|
|
25
|
+
out << ("\n" * code.count("\n"))
|
|
26
|
+
else
|
|
27
|
+
out << code << ";"
|
|
28
|
+
end
|
|
29
|
+
pos = match.end(0)
|
|
30
|
+
end
|
|
31
|
+
out
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def template?(path)
|
|
35
|
+
path.end_with?(".erb")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
data/lib/jitcatch.rb
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "rbconfig"
|
|
5
|
+
|
|
6
|
+
require_relative "jitcatch/version"
|
|
7
|
+
require_relative "jitcatch/template"
|
|
8
|
+
require_relative "jitcatch/git_repo"
|
|
9
|
+
require_relative "jitcatch/source_index"
|
|
10
|
+
require_relative "jitcatch/factories"
|
|
11
|
+
require_relative "jitcatch/inflections"
|
|
12
|
+
require_relative "jitcatch/routes"
|
|
13
|
+
require_relative "jitcatch/readers"
|
|
14
|
+
require_relative "jitcatch/diff_parser"
|
|
15
|
+
require_relative "jitcatch/changes"
|
|
16
|
+
require_relative "jitcatch/failure"
|
|
17
|
+
require_relative "jitcatch/minitest_parser"
|
|
18
|
+
require_relative "jitcatch/rspec_parser"
|
|
19
|
+
require_relative "jitcatch/framework"
|
|
20
|
+
require_relative "jitcatch/assessor"
|
|
21
|
+
require_relative "jitcatch/runner"
|
|
22
|
+
require_relative "jitcatch/render"
|
|
23
|
+
require_relative "jitcatch/cli"
|
|
24
|
+
|
|
25
|
+
module JitCatch
|
|
26
|
+
class Error < StandardError; end
|
|
27
|
+
class UsageError < Error; end
|
|
28
|
+
class GitError < Error; end
|
|
29
|
+
|
|
30
|
+
# A side whose setup check failed would turn every candidate into a load
|
|
31
|
+
# error, which reads like a test finding, so the run stops instead.
|
|
32
|
+
# `doc` is the document the CLI prints.
|
|
33
|
+
class EnvironmentError < Error
|
|
34
|
+
attr_reader :doc
|
|
35
|
+
|
|
36
|
+
def initialize(doc)
|
|
37
|
+
@doc = doc
|
|
38
|
+
super("setup failed on #{doc["setup_failed"].map { |failure| failure["side"] }.join(" and ")}")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
module Text
|
|
43
|
+
module_function
|
|
44
|
+
|
|
45
|
+
def cap_lines(text, limit: 40, head: 32, tail: 7)
|
|
46
|
+
return nil if text.nil?
|
|
47
|
+
parts = text.split("\n", -1)
|
|
48
|
+
parts.pop if parts.last&.empty?
|
|
49
|
+
return parts.join("\n") if parts.size <= limit
|
|
50
|
+
|
|
51
|
+
(parts[0, head] + ["# ..."] + parts[-tail, tail]).join("\n")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def dedent(text)
|
|
55
|
+
return nil if text.nil?
|
|
56
|
+
|
|
57
|
+
parts = text.split("\n", -1)
|
|
58
|
+
parts.pop if parts.last&.empty?
|
|
59
|
+
cut = parts.reject(&:empty?).map { |line| line[/^[ \t]*/].size }.min || 0
|
|
60
|
+
parts.map { |line| line.empty? ? "" : line[cut..] }.join("\n")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# A name a test creates under the temp directory is new on every run, so
|
|
64
|
+
# it would make one deterministic failure look like two different ones.
|
|
65
|
+
def mask_temp_names(text, tmpdir)
|
|
66
|
+
root = tmpdir.to_s.sub(%r{/+\z}, "")
|
|
67
|
+
return text if root.empty?
|
|
68
|
+
|
|
69
|
+
text.gsub(%r{(?<![\w./-])#{Regexp.escape(root)}/[^/\s"'`:]+}, "{tmp}")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# An object's inspect (`#<Class:0x00007f...>`) names its address, which
|
|
73
|
+
# is new on every run.
|
|
74
|
+
def mask_addresses(text)
|
|
75
|
+
text.gsub(/\b0x\h{6,}\b/, "0x{address}")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A record prints the time it was saved (`created_at: "2026-11-04
|
|
79
|
+
# 19:02:03.212197000 +0000"`), which is new on every run. A date alone
|
|
80
|
+
# stays: a test can claim one.
|
|
81
|
+
def mask_times(text)
|
|
82
|
+
text.gsub(/(?<!\d)\d{4}-\d\d-\d\d[ T]\d\d:\d\d:\d\d(?:\.\d+)?(?: ?(?:[+-]\d\d:?\d\d|UTC)|Z)?/, "{time}")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def message_lines(message)
|
|
86
|
+
message.to_s.lines.map(&:strip).reject(&:empty?)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def indent(text, spaces)
|
|
90
|
+
pad = " " * spaces
|
|
91
|
+
return "#{pad}(none)" if text.nil? || text.empty?
|
|
92
|
+
|
|
93
|
+
text.split("\n", -1).map { |line| "#{pad}#{line}" }.join("\n")
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: jitcatch
|
|
3
|
+
description: Use when reviewing or landing a code change and you want throwaway tests that catch regressions it introduces. Drives the jitcatch CLI (context, run, assess) for a Ruby diff. Write Minitest or RSpec files that pass on the base revision and fail on head, drop flakes, then judge each reported failure as a likely regression or an intended change. The tool never calls a model.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# jitcatch
|
|
7
|
+
|
|
8
|
+
`jitcatch` never calls a model. You write the tests. You decide whether a failure is a regression. The CLI prepares a brief, runs each test on the parent and on the change, and applies deterministic rules.
|
|
9
|
+
|
|
10
|
+
Do not commit the candidate tests unless the human asks to promote one.
|
|
11
|
+
|
|
12
|
+
## Prerequisites
|
|
13
|
+
|
|
14
|
+
Ruby 3.3 or newer, and git. No gems. `minitest` and `prism` ship with Ruby. RSpec candidates need `rspec-core`: in the project's bundle when it has a `Gemfile`, otherwise installed for the Ruby that runs jitcatch. The subject must be a git repository that can add worktrees. The tool reads commits only. Uncommitted files are not in the diff.
|
|
15
|
+
|
|
16
|
+
`gem install jitcatch` installs the `jitcatch` command, with no runtime dependency. In an app with a `Gemfile`, such as a Rails app, the gem can sit in the `:development, :test` group instead (`gem "jitcatch", require: false`); run it as `bundle exec jitcatch`. Each side still loads its own bundle, so the line does not need to be in either commit. From a checkout of the jitcatch repository, put its `bin` directory on `PATH`, or run `ruby /path/to/jitcatch/bin/jitcatch`. `jitcatch --help` prints the commands, and `jitcatch --version` prints `jitcatch 0.1.0`, with the commit after it (`jitcatch 0.1.0 (1fbcd18)`) when jitcatch runs from its own git checkout; quote that line with any output you quote. The `version` line of every report and `version` in `--json` hold the number only.
|
|
17
|
+
|
|
18
|
+
Exit status 0 is success, including when catching tests were found. Exit status 1 is a usage error. Exit status 2 is a git error. Exit status 3 means `run` tested nothing: a side failed its setup check before any test ran, no candidate file loaded on either side, or every row timed out, or found no database or no table, on a side (below).
|
|
19
|
+
|
|
20
|
+
## Choose base and head
|
|
21
|
+
|
|
22
|
+
`BASE` is the parent whose behavior should survive: the branch you would land onto, or `git merge-base` of that branch and the change. `HEAD` is the revision under review (a branch, `HEAD`, or a SHA). Both must resolve as `git rev-parse --verify REF^{commit}`. One exception: to review the first commit of a repository, pass `ROOT^` (or `ROOT~1`) as `BASE`. The parent of a root commit is read as the empty tree, so every file is added. On `run`, the base side is an empty directory, so every test is `fail_on_base`.
|
|
23
|
+
|
|
24
|
+
When `BASE` is not an ancestor of `HEAD` (for example, a main branch that moved on after the change branched off), the diff also undoes the commits only `BASE` has, and their methods show up as changed. `context` and `run` print `warning: base is not an ancestor of head` under `head`, with the count and the merge base. Pass that merge base as `BASE` and run again. When `BASE` and `HEAD` resolve to the same commit, they print `warning: base and head are the same commit`, and every row is `no_signal` or `fail_on_base`.
|
|
25
|
+
|
|
26
|
+
Pass `--repo DIR` when that repository is not the working directory.
|
|
27
|
+
|
|
28
|
+
Commit messages from `BASE` to `HEAD` are the intent the CLI can see (`git log BASE..HEAD`, oldest first, commits separated by `---`). The CLI leaves out the trailer block that git parses at the end of each message (`Co-Authored-By:`, `Signed-off-by:`, and the like). Git counts a last paragraph made only of `Word: text` lines as trailers too, so a closing line such as `Note: callers must refresh.` is also left out. When the intent looks cut short, read `git log BASE..HEAD`. Also read the pull request or the task when you have it. The tool does not.
|
|
29
|
+
|
|
30
|
+
## Context
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
jitcatch context --repo DIR BASE HEAD
|
|
34
|
+
jitcatch context --json --repo DIR BASE HEAD
|
|
35
|
+
jitcatch context --repo DIR --coverage FILE BASE HEAD
|
|
36
|
+
jitcatch context --repo DIR --all-readers BASE HEAD
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Omit `--repo` when the working directory is the repository.
|
|
40
|
+
|
|
41
|
+
Read `intent:` first. Then the changed files, `dependencies:` (each gem whose `Gemfile.lock` version changed, as `gem old -> new`, shown only when one did), constants (`k01`, …), and methods (`m01`, …). Each method has a change kind, signature, visibility, call sites, `reachable_from`, existing tests, and the old and new source. `reachable_from` goes through constructors: a caller of `Runner.new` reaches `Runner#initialize` and the methods it calls, and that hop does not count toward the four calls `reachable_from` follows. `reachable_from` keeps the eight nearest. `run` reads `reached_by` (`--json`), every caller at any depth, so a test that enters high (a request spec through a controller and a service, or a CLI test of `CLI.start`) still reaches a changed method five or more calls down; its row is usually `related:guess` with that method in the list. On an Active Record model, a class whose superclass chain in the repository reaches `ActiveRecord::Base`, so do `create`, `create!`, and `build`. On another class those three names reach nothing. A method marked `moved` left one file while head defines it in another, test files included; the line shows its new place and `moved_from=` the old file, and a call to it is not a call to a removed method. A constant that moves to another file with the same value is `moved` with `moved_from=` as well, and its readers are not `affected`; with a new value it is `modified`, with `moved_from=`. A method marked `affected` did not change its body. It reads a constant that did (`via=k01`). A class or module that only one side defines anywhere is listed as an `added` or `removed` constant, with its header line as the source. A file that reopens an existing class adds nothing. Methods defined in a block given to a constant (`Point = Struct.new(:x) do`, `Class.new do`, `Data.define do`) are listed under that constant (`Point#norm`), and the constant's source is the text before `do`. A reader or writer that `attr_reader`, `attr_writer`, `attr_accessor`, or a `Struct.new` / `Data.define` member list defines is a method too (`Point#x`, `Point#x=`), with the one-name form as its source (`attr_reader :seed`, `Struct.new(:x)`). On an Active Record model, `scope :recent, -> { ... }` is the class method `Post.recent` with the whole statement as its source, `delegate :name, to: :author` is `Post#name` (`Post#author_name` with `prefix: true`), a Grape endpoint (`post "/refresh" do` inside `resource :things do`, in a class under `Grape::API`) is `Things#POST /things/refresh`, which a request spec reaches by sending that verb to a path ending in those segments (`:id` matches any one, an interpolation in the test matches any one segment, and a string constant the file assigns reads as its string), and a request to a path helper (`post things_path`) reaches the controller action `config/routes.rb` draws for it, with the helper and controller names the app's `config/initializers/inflections.rb` gives (`person_path` for `resources :people`, `APIKeysController` with `inflect.acronym "API"`), so a changed action's `tests:` lists the files whose requests reach it (`via POST things_path`) and the controller tests that call it as `post :create`, not a grep of its name; an endpoint path the source computes otherwise is named with that part in braces and gets a `warning:`, as does a helper the routes file does not draw, and `enum :status, { draft: 0 }` gives each value `Post#draft?`, `Post#draft!`, `Post.draft`, and `Post.not_draft`, with that one value as the source. `Struct.new(*FIELDS)` and `attr_reader(*FIELDS)` count when the same file set `FIELDS` to a list of symbols earlier; a list built at run time or in another file gives no members. A `def` or a reader inside a conditional of the class body (`attr_reader :x if cond`, `if`, `unless`, `else`) counts, read as if every branch ran in order, so a bare `private` in a branch carries past the conditional. A definition counts too in an `ActiveSupport::Concern` block: `included do` and `prepended do` define instance methods of the module, `class_methods do` its class methods. A name dropped from that line is `removed`, the names left on it are unchanged, and a `NoMethodError` on the dropped name, called from the test, is `discard:signature_change`.
|
|
42
|
+
|
|
43
|
+
`visibility=` follows `private`, `protected`, and `public` (bare, with names, or before a `def`), the same inside `class << self`, and `private_class_method` / `public_class_method` (with names or before `def self.x`). A `private` method is not an entry point. Test it through a public caller in `reachable_from`. A method after `module_function`, or named by it, is listed as the public module method `Mod.name`. A method of a class or module that `private_constant` names in the same file, or of anything nested in it, is listed as `private`, because a test cannot name its receiver. Visibility set by `send`, or by `private :name` written above its `def`, is not seen.
|
|
44
|
+
|
|
45
|
+
Methods in test files (under `test/` or `spec/`, or named `test_*.rb` or `*_test.rb`) are not listed, and are not entry points. The changed test files still appear under `files:`.
|
|
46
|
+
|
|
47
|
+
`tests: (none)` means no test file names that method. Then `entry_point_tests:` lists, when it finds any, the test files that call a public `reachable_from` entry point: the file names the entry point's class (`Cart.new`, not only `Cart::LIMIT`) and calls a method of that name (`.total`). Each file shows the entry points it matched. Read those tests before you write yours. They show how the repository builds the object and which inputs already reach the changed method. Like `tests:`, this line is a grep. It misses tests that drive the code through a subprocess, and it can list a file that calls a same-named method on another object. A `tests:` grep hit on a common name such as `call` can be any file that defines or calls that name. A changed `initialize` shows `built_by:` above `tests:`: `create`, `create!`, and `build` on an Active Record model, and `build(:loan)` and `create(:loan)` for each factory under `spec/factories` or `test/factories` that builds the class. Its `tests:` lists only the test files that call `Owner.new` or one of those, as `spec/models/loan_spec.rb via create(:loan)`, and no grep hit for the word `initialize`. `factories_skipped:` lists each factory whose class the source does not settle (a `class:` computed at run time, an unknown parent, or a class no file defines, as an inflection such as `APIKey` causes). A factory that builds a Ruby value (`class: Hash`, `Array`, `Struct`, `OpenStruct`, and the like) is left out and not listed. A test that builds from one of those names no constructor. A factory also counts for the classes its associations build (`borrower`, `association :borrower`, or `association` in an attribute block), so a changed `Borrower` callback lists `create(:loan)` when `:loan` builds a borrower. An association in a trait lists the call with the trait, `create(:loan, :cosigned)`, and a test that writes `create(:loan, :cosigned)` mentions it; a global trait and a trait that includes another are not followed. A method that a model's class body names in a callback or a validation (`after_save :count_save`, or `if: :checked?` on a `validates`) shows `run_by:`: the calls that run it by Rails's rules (`Loan.create!`, `Loan#save`, a factory of the class), with the lines that name it. An `on: :create` validation lists no update, and an `on: :update` one no create. `build(:loan)` does not save, so it counts only in a test that also saves or validates (`.save!`, `be_valid`). Its `tests:` lists the test files that make those calls, next to the grep hits. A method of the app that makes one of those calls (`LoanDesk.open` calling `Loan.create!`) is in `reachable_from`, with its callers, so a test of `LoanDesk.open` names the callback. A save on a parameter, a local, or an instance variable the source does not type counts for the model it is named after: `loan.save!` for `Loan`, but `record.save!` for none. Any other call on such a value, and on a `belongs_to` or `has_one` reader (`borrower.approved_cents` inside `Loan`), is a call edge to that model's method too, so `reachable_from` follows `@loan.approve` in a service up to its controller. A callback in a Concern or a parent class is not followed.
|
|
48
|
+
|
|
49
|
+
The brief ends with one `entrypoints <Const>:` section for each class or module that holds a changed method. Each line is a public method of that class on head (on base when head has none) and its role: the change kind (`modified`, `added`, `affected`, `removed`) first, then `unchanged`, at most 12 lines. An unchanged method in a changed method's `reachable_from` comes before the other unchanged ones, and `(+3 more)` counts the methods the cap left out. The cap only shortens the list: a method left out still counts as a mention through `reachable_from`. A class that defines `initialize` on either side lists `Owner.new` first in its group instead, with the role of `Owner#initialize`: `Mismatch.new removed` means head dropped the constructor and `.new` now runs the inherited one. A class that makes `new` private (`private_class_method :new`) shows no `Owner.new`; build it through the public method that calls `new`. Pick the methods your tests call from this list. `run` counts these names as mentions too, so calling one gives a row its `touches:unchanged_caller` tag when it reaches a changed method.
|
|
50
|
+
|
|
51
|
+
Read `readers:` before you pick what to test, in any Rails change, and always when a changed method says `callsites: (none by name; 3 under readers:)`. It lists, per changed value (a method, a scope, a `default_scope`, a constant, a callback, a validation, a column default in `db/schema.rb` or `attribute`, a `config.` setting), the code at head that reads that value without calling it by name. A call in an ERB template is a call site, under `callsites:`, so it is not repeated here. A real run, on a change that adds `Branch#to_param` returning `code`:
|
|
52
|
+
|
|
53
|
+
```text
|
|
54
|
+
readers: 1 for 1 changed value
|
|
55
|
+
Branch#to_param (added) method m01 aac7821:app/models/branch.rb:4 readers=1
|
|
56
|
+
100 route_param_mismatch aac7821:app/views/branches/show.html.erb:2
|
|
57
|
+
rank: the URL value and the lookup now disagree
|
|
58
|
+
why: @branch goes through Branch#to_param (now `code`) into params[:branch_id]; ClosuresController#index reads params[:branch_id] by id (Branch.find(params[:branch_id]), app/controllers/closures_controller.rb:11)
|
|
59
|
+
test: Render app/views/branches/show.html.erb:2 with a Branch whose to_param differs from its id, follow the URL branch_closures_path(@branch) builds, and check ClosuresController#index finds that same Branch.
|
|
60
|
+
not seen: receivers named unlike their model (`x = Branch.first; link_to x`); ...
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
For each row, from the top: open the place (`git show aac7821:app/views/branches/show.html.erb`), read `why:` against the intent, and write the candidate `test:` describes. It states what the old code satisfied, so it passes on base: build the record so the old and the new value differ (a `code` that is not the `id`), and drive the entry point the row names (render the view or send the request, then follow the URL). A row whose effect the intent asks for still gets a test: its `reported` result is then `intended`. `rank:` says why a row scores what it does; a low row (`bare STATUSES is defined nowhere visible`, `in a file the change edits`) is the first to skip. `also:` lists more places of the same kind for the same value, and one test usually covers them. `note: this line is itself part of BASE..HEAD` marks a row the author edited, so it was in front of them. The 10 highest rows print, and the first line counts the rest; `--all-readers` prints every one. A row finds a record's model by the name of the variable that holds it (`@branch`, `home_branch`, `branches`). `not seen:` names what no row can show: a reader there needs a `git grep` of your own. A constant's `read_by:` lists the changed methods that read it by name, and a plain read inside one of those is not repeated under `readers:`.
|
|
64
|
+
|
|
65
|
+
`--coverage FILE` replaces the grep of existing tests. The file is JSON: `"format": "jitcatch-coverage-v1"` and a `tests` array of `{ "id", "file", "lines" }`, where `lines` maps repo paths to line numbers. Without it, the brief says `existing_tests grep`.
|
|
66
|
+
|
|
67
|
+
`--json` prints the same brief (`"command": "context"`).
|
|
68
|
+
|
|
69
|
+
## Write candidate tests
|
|
70
|
+
|
|
71
|
+
Write 3 to 6 candidate files in a scratch directory you can delete. Use the framework the project's own tests use: Minitest files, or RSpec specs named `*_spec.rb` (see RSpec candidates below). One directory can hold both.
|
|
72
|
+
|
|
73
|
+
A catching test states a behavior the old code already satisfies. Aim it at a changed or affected method, and at unchanged callers that reach that method (`reachable_from`). Prefer a boundary, a rounding case, or an error path the diff could disturb. One claim per test method. A test that only describes the new feature fails on base and is not evidence. Assert on one field, not on a whole returned Hash: a whole-Hash test (such as a branchout characterization test) fails on any added key, even one the intent asked for.
|
|
74
|
+
|
|
75
|
+
To use branchout characterization tests as candidates, run `branchout explore` on a checkout of `BASE`, not of `HEAD`: a test emitted on head pins the new behavior and is `fail_on_base`. Pass `--require-by-name`, so the emitted test requires the target through the load path (`require "shop/cart"`) and each side loads its own worktree's code. Pass `--format rspec` for an RSpec project. Without `--require-by-name`, check where the emitted `require_relative` or absolute path points. A path that resolves inside the worktree is fine as written: tests emitted under `.jitcatch/` of the base checkout keep `require_relative "../lib/legacy_register.rb"`, which each side resolves in its own worktree. A path outside the worktree makes both sides load the same file, so every row is `no_signal`; rewrite it to `require "<lib>"`. On jitcatch itself, 14 tests emitted on head with an outside path gave 14 `no_signal`; emitted on base with the require rewritten, they gave 8 `reported`.
|
|
76
|
+
|
|
77
|
+
The `mentions` and the `touches:` tags read the test method's body and the bodies of the methods in the same file that it calls, directly or through other such methods. A helper in another file (`require "test/support/..."`) is not read, so call the changed method or its public caller from the candidate file. Prefer a public entry point over `send(:private_helper)`: a private helper can move between base and head, and the test then fails on base.
|
|
78
|
+
|
|
79
|
+
Each file:
|
|
80
|
+
|
|
81
|
+
- `require "minitest/autorun"`, then require the library the way the repo loads it (`require "shop"` when `lib/` is on the load path). The worktree root is on the load path too, so a test can reuse the repository's own helpers by path from the root (`require "test/support/repo"`). When you start from a copy of a repository test, rewrite `require_relative "../test_helper"` to `require "test/test_helper"`: the copy runs from `.jitcatch/`, where the relative path points at the worktree root and fails with `LoadError` on both sides.
|
|
82
|
+
- Name it `*_test.rb` or `test_*.rb`. When any file matches, other `*.rb` files are not executed. At most 40 files run.
|
|
83
|
+
- The runner copies the directory into each worktree at `.jitcatch/` and runs one process per file: `ruby -Ilib -I. -r<jitcatch>/lib/jitcatch/minitest_report.rb .jitcatch/<file> --seed 1`. That file writes each result to `.jitcatch-report.json`, so a row names one test whatever the test prints and under `parallelize_me!`. A `*_spec.rb` file runs through RSpec instead (below).
|
|
84
|
+
- When the worktree has a `Gemfile`, the process also gets `-rbundler/setup` and `BUNDLE_GEMFILE` set to that worktree's `Gemfile`. Base and head then each load the gem versions their own `Gemfile.lock` names, so a test can catch a dependency bump. Only gems in the bundle load. When the `Gemfile` does not list `minitest`, no Minitest candidate can load it, and `run` stops with `setup base failed` and `minitest is not in the bundle`. For RSpec candidates the check asks for `rspec-core` instead. Do not add `require "bundler/setup"` to the file yourself.
|
|
85
|
+
- Do not depend on the original path, on order, or on the clock. The base run and the head run of a file run at the same time, each in its own worktree, so do not write a fixed path outside the worktree (use `Dir.mktmpdir`) or listen on a fixed port. When a candidate must, pass `--serial`, which runs one process at a time. The child keeps only `PATH`, `HOME`, `USER`, `LOGNAME`, `TMPDIR`, `TEMP`, `TMP`, `LD_LIBRARY_PATH`, `GEM_HOME`, and `GEM_PATH`, and sets `LANG` and `LC_ALL` to `C.UTF-8` and `TERM` to `dumb`. Other variables, such as `RUBYOPT` from `bundle exec` or `CI`, do not reach it. When the library reads a variable of its own (for example `QUINT`, the command it runs), pass it with `--env QUINT`. Without it, the library falls back to its default, which can time out on both sides.
|
|
86
|
+
- A failure message shows the root of the worktree as `{worktree}` (`{worktree}/lib/cart.rb`, and `{worktree}` for `Dir.pwd` alone) and Ruby's prefix as `{ruby}/`, so an absolute path is still visibly a path. Where it named the copy at `{worktree}/.jitcatch`, it names `TESTS_DIR` as the rerun line does (`{worktree}/candidates/fixtures/a.json`, or `{worktree}/../catch/tests/fixtures/a.json` outside the repository), and a backtrace frame names it without `{worktree}/` (`candidates/cart_test.rb`), so a file a test reads by `__dir__` is named where you edit it. A relative `.jitcatch/`, such as `__FILE__`, stays as printed, except at the start of a line in Ruby's report of a file that fails to load (`.jitcatch/x_test.rb:2:in`), which names the file as the rerun line does. An absolute `TESTS_DIR` gets no `{worktree}/`. The worktrees sit under `Dir.tmpdir`, so a claim such as `refute_includes File.expand_path(__FILE__), Dir.tmpdir` fails on both sides. A failure message may name a path under the temp directory. The rerun check compares the whole message. It masks the name directly under `Dir.tmpdir`, an object address (`#<Class:0x00007f...>`), and a time of day with its date (a record printed by `have_attributes` shows `created_at: "2026-11-04 19:02:03.212197000 +0000"`), so a new `Dir.mktmpdir` name, a new anonymous class, or the time a record was saved is not a disagreement. When a rerun fails the same way (same status and exception class) and only the message moved, such as a pid, a random record id, or a value that moves between runs, base runs the test `--reruns` times too. If base passes each time, the row stays a candidate with the tag `message_varies`; if not, it is `flaky`, and the rerun line reads `rerun 1: fail Minitest::Assertion (same outcome, message differs) <first line that moved>`, followed by `base rerun 1:` lines.
|
|
87
|
+
- A file that builds a repository, starts processes, or runs a slow fixture needs more than the default 10 seconds. Raise `--timeout`. A timed-out file is one row (`<file>#timeout`). A timeout on both sides, or on head beside a base that took more than half of `--timeout`, is `discard:timeout`; a timeout on base with a passing head is `fail_on_base`. A timeout on head beside a base that passed in at most half of it is reported with the tag `head_only_timeout` and the hint `hint: base passed in 0.1s; head did not finish in 5s, so head may loop or wait forever`: read the changed code for a loop or a wait whose end the test stubs, such as a deadline checked against a frozen clock. The text report prints a `hint:` under that row, such as `hint: base and head timed out after 10s; raise --timeout if the file needs longer`; when head has `config/application.rb` (`"rails_app": true` in `--json`), it adds `, as a Rails app's first run in a new checkout can (it loads db/schema.rb)`. A Rails app boots in each new checkout jitcatch makes, and `maintain_test_schema!` loads the schema into that checkout's database, so when head has `config/application.rb` the default is 60 seconds. When a row timed out, its hint first names the variables your shell sets that the child did not get and is likely to wait without: a network setting (`HTTPS_PROXY`, `NO_PROXY`, `SSL_CERT_FILE`, `NODE_EXTRA_CA_CERTS`, and the like) when head or a candidate loads an HTTP client such as `net/http` or `faraday` (by a `require` or a `Gemfile.lock` entry) or starts a command that fetches (`npx --yes ...`, `curl ...`, `bundle install`, `gem install`, `git clone`), or a name head's Ruby files or a candidate read from `ENV` with a literal (`hint: base and head timed out after 10s; jitcatch did not pass HTTPS_PROXY QUINT, so add --env HTTPS_PROXY --env QUINT if the file needs them, then raise --timeout if it needs longer`; `env_not_passed` in `--json`). A name `--env` passed that your shell does not set comes before those (`--env QUINT passed nothing, because QUINT is not set, so set it if the file needs it`). Pass the ones the file needs with `--env` before you raise `--timeout` further. A command that a script file or a computed string runs is not seen, so a file that fetches that way gets no proxy names; pass them yourself.
|
|
88
|
+
|
|
89
|
+
## RSpec candidates
|
|
90
|
+
|
|
91
|
+
A file named `*_spec.rb` runs with the project's RSpec, one process per file, from the worktree root:
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
ruby -Ilib -I. -e 'require "rspec/core"; require "rspec/support/object_formatter"; RSpec::Core::RubyProject.instance_variable_set(:@project_root, Dir.pwd); RSpec::Support::ObjectFormatter.default_instance.max_formatted_output_length = nil; exit(RSpec::Core::Runner.run(ARGV))' -- \
|
|
95
|
+
--format json --out .jitcatch-report.json --seed 1 --backtrace --no-color .jitcatch/<file>_spec.rb
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
- Start the file with `require "spec_helper"` or `require "rails_helper"`, as the project's specs do. RSpec puts `spec/` and `lib/` on the load path and reads the worktree's `.rspec`, so its `--require spec_helper` also loads. The launch pins RSpec's project root to the worktree, so those are the worktree's own `spec/` and `lib/` even when `TMPDIR` is under another project that holds `spec/`. Do not write `require_relative "spec_helper"`: the copy runs from `.jitcatch/`, where that path does not exist.
|
|
99
|
+
- A `*_spec.rb` file that requires `minitest` (minitest/spec) runs as Minitest.
|
|
100
|
+
- One example is one row, named by its full description. The text report shows the example's line after the file (`message_lines_spec.rb:31`). Two examples with the same description get ` (line N)` on the later one.
|
|
101
|
+
- `expect(...).to` failures are `fail` (tag `assertion`), any other error is `error`, `pending` is `skip`. An error while RSpec loads the file is one `<file>#load` row. `raise_error` that caught an arity `ArgumentError` counts as a signature change, as `assert_raises` does.
|
|
102
|
+
- An example's mentions come from its own block, the `let`, `subject`, and `before`/`after`/`around` blocks of the groups around it, those groups' arguments, and the same-file helpers they call. `describe "#total"` and `describe ".build"` name that method. `described_class.new` names `Owner.new`, and `described_class.parse` or `Index.parse` picks that class's `parse` among changed methods of the name. A `let` or `subject` name is the spec's own helper, not a mention. When its block builds one class, a call on it is a call on that class: with `let(:cart) { Cart.new }` or `subject(:cart) { described_class.new }`, `cart.total` names `Cart#total`. An implicit `subject` and `spec/support` helpers are not read, so call the changed method in the example or its `let`.
|
|
103
|
+
- A Rails `rails_helper` loads in each worktree. Read Rails apps below before you write candidates that touch the database.
|
|
104
|
+
|
|
105
|
+
On jitcatch itself, across the change that drops the `---Backtrace---` lines from an `assert_raises` message, three specs that `branchout explore --format rspec --require-by-name` emitted on base for `JitCatch::MinitestParser.parse`, in `candidates/backtrace/` of a clone, gave:
|
|
106
|
+
|
|
107
|
+
```text
|
|
108
|
+
tests:
|
|
109
|
+
t01 no_signal JitCatch::MinitestParser.parse returns summary: false when stdout is "" minitest_parse_spec.rb:11
|
|
110
|
+
t02 no_signal JitCatch::MinitestParser.parse returns summary: true when stdout is "T#test_a = 0.00 s = F\n..." minitest_parse_spec.rb:21
|
|
111
|
+
t03 reported JitCatch::MinitestParser.parse returns summary: true, status: "fail" when stdout is "ArTest#test_raises = 0..." minitest_parse_spec.rb:16 m02
|
|
112
|
+
counts generated=3 no_signal=2 fail_on_base=0 candidates=1 flaky=0 discarded=0 reported=1
|
|
113
|
+
assess:
|
|
114
|
+
c01 JitCatch::MinitestParser.parse returns summary: true, status: "fail" when stdout is "ArTest#test_raises = 0..." base=pass head=fail m02 assertion,report,touches:unchanged_caller
|
|
115
|
+
hash keys differ: "tests"
|
|
116
|
+
-...ror>\nMessage: <\"wrapped k\">\n---Backtrace---\n------------...
|
|
117
|
+
+...ror>\nMessage: <\"wrapped k\">", "message_first_line" => "[Ke...
|
|
118
|
+
^ column 301
|
|
119
|
+
rerun: bundle exec ruby -Ilib -I. -e 'require "rspec/core"; require "rspec/support/object_formatter"; RSpec::Core::RubyProject.instance_variable_set(:@project_root, Dir.pwd); RSpec::Support::ObjectFormatter.default_instance.max_formatted_output_length = nil; exit(RSpec::Core::Runner.run(ARGV))' candidates/backtrace/minitest_parse_spec.rb:16
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
That row is intended: the message no longer ends in the two lines that framed the frames. `m02` is the private `MinitestParser#parse_issue`, and the spec reaches it through `parse`, an unchanged caller.
|
|
123
|
+
|
|
124
|
+
Across the commit that pinned the project root, with `rspec-core` added to both sides of a clone, two hand-written specs next to two that branchout emitted for `JitCatch::Assessor.apply`, in `candidates/root/`, gave this row. The emitted example for `b_spec.rb` already fails on base, so it is `fail_on_base`:
|
|
125
|
+
|
|
126
|
+
```text
|
|
127
|
+
counts generated=4 no_signal=2 fail_on_base=1 candidates=1 flaky=0 discarded=0 reported=1
|
|
128
|
+
assess:
|
|
129
|
+
c01 JitCatch::Framework::RSpec reruns a row with rspec base=pass head=fail m02 assertion,report,touches:changed_method
|
|
130
|
+
-"rspec {tests}/a_spec.rb:7"
|
|
131
|
+
+"ruby -Ilib -I. -e 'require \"rspec/core\"; RSpec::Core::Ruby...
|
|
132
|
+
^ column 3
|
|
133
|
+
rerun: bundle exec ruby -Ilib -I. -e 'require "rspec/core"; require "rspec/support/object_formatter"; RSpec::Core::RubyProject.instance_variable_set(:@project_root, Dir.pwd); RSpec::Support::ObjectFormatter.default_instance.max_formatted_output_length = nil; exit(RSpec::Core::Runner.run(ARGV))' candidates/root/rerun_spec.rb:4
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Not supported yet: shared example groups (an example from a group in another file reads the whole candidate file for mentions), an error outside every example when some example ran (such as in `after(:suite)`). A project whose bundle has no `rspec-core` cannot run RSpec candidates: `run` stops with exit status 3 and says so.
|
|
137
|
+
|
|
138
|
+
## Rails apps
|
|
139
|
+
|
|
140
|
+
Write RSpec candidates that start with `require "rails_helper"`, as the app's specs do (or Minitest files that start with `require "test/test_helper"`). They can use the app's factories and its test database. Four things differ from a plain gem.
|
|
141
|
+
|
|
142
|
+
**The test database.** Base and head boot the app at the same time. SQLite at a relative path, the Rails default `storage/test.sqlite3`, is a file in each worktree, so the two sides never meet. A database server (PostgreSQL, MySQL) has one test database name for both sides, and each side's `maintain_test_schema!` loads its own `db/schema.rb` into it while the other side runs. On a change that removed a column, 1 to 6 of 6 candidates came back as `reported` `<file>#load` rows with `bin/rails aborted!`, and a collision left the database without its environment data, so every later run failed until the database was dropped and created again. jitcatch sets `TEST_ENV_NUMBER` in each checkout, the variable parallel_tests sets: `1` and `2` for base and head of the first worker, `3` and `4` for the second worker, and so on. When `config/database.yml` on both revisions names the test database with it, each checkout uses a database of its own:
|
|
143
|
+
|
|
144
|
+
```yaml
|
|
145
|
+
test:
|
|
146
|
+
<<: *default
|
|
147
|
+
database: app_test<%= ENV["TEST_ENV_NUMBER"] %>
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Create those databases once, two for each worker you will pass, before the first run. When the test helper calls `ActiveRecord::Migration.maintain_test_schema!`, Rails loads each revision's schema into them when it boots. Without that call, a database that `db:create` alone made has no tables, so also pass `run --setup 'bin/rails db:create db:schema:load'`, which loads each side's own schema:
|
|
151
|
+
|
|
152
|
+
```sh
|
|
153
|
+
for n in 1 2; do RAILS_ENV=test TEST_ENV_NUMBER=$n bin/rails db:create; done
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
When none exists, every file fails to load on both sides and `run` exits 3 (`ActiveRecord::NoDatabaseError`). When only head's is missing, its rows are `discard:no_database`; create it and run again. When head's has no tables, its rows are `discard:no_table`; load the schema there and run again. When `database.yml` does not use `TEST_ENV_NUMBER` on both revisions, `run` finds it before any candidate runs (it reads each side's `database.yml` as Active Record does, with that side's `TEST_ENV_NUMBER` and any `DATABASE_URL` that `--env` passes) and runs one process at a time, as `--serial` does, whatever `--workers` says; each process loads its revision's schema when it starts. The report then prints `serial` and two lines: `warning: base and head use one test database, lending_test, so jitcatch ran one process at a time; ...`, and a second `warning:` that names the `database.yml` change and the `db:create` loop above. That change helps only when BASE and HEAD both have it; for past commits, keep the run one process at a time. With `--env DATABASE_URL` and a URL that names a database on a server, the second line names `--number-database-url` instead: Rails reads a `DATABASE_URL` that names a database over `database:`, so only the URL can split it. A URL that names no database takes `database:` from `database.yml`, so the line stays the `database.yml` one. An SQLite URL at an absolute path gets `give DATABASE_URL a relative path, as sqlite3:storage/test.sqlite3, so each checkout opens a file under its own root`; `--number-database-url` refuses an SQLite URL with the same advice. That flag adds each checkout's `TEST_ENV_NUMBER` to the database name in the URL (`app_test_wt1` becomes `app_test_wt1_1` and `app_test_wt1_2`), which fits worktrees that each pass their own `DATABASE_URL`; the report prints them as `numbered_databases app_test_wt1_1 app_test_wt1_2`, and you create them once. A `database.yml` that reads only in the booted app gives no answer, so pass `--serial` yourself there. On the sample app with PostgreSQL 16 and a schema change, six files took 32 s one at a time and 9 s with numbered databases at a load average of 2. `--env` cannot pass `TEST_ENV_NUMBER`, because jitcatch sets it.
|
|
157
|
+
|
|
158
|
+
**A change to the schema.** When the diff touches `db/schema.rb`, `db/structure.sql`, or `db/migrate/`, `context` and `run` print `warning: head changes the database schema (...)`. A database made once holds one schema, so a row can fail on a missing column and read as a regression (a request spec that gets a 500 for an unknown attribute). When the warning says the helper does not call `maintain_test_schema!`, pass `--setup 'bin/rails db:create db:schema:load'`: jitcatch runs it once in each checkout, before the candidates, with that side's `TEST_ENV_NUMBER`, numbered `DATABASE_URL`, and `RAILS_ENV=test`. A failed `--setup` exits 3 with `jitcatch: --setup failed, so no test ran`. When the database refused the connection there, each side names the variables your shell sets that the run did not get, as `the database refused the connection, and jitcatch did not pass DB_PORT from your shell: add --env DB_PORT`; add those flags and run again. A reported row whose head answered 500 has `hint: head answered 500, and a Rails app writes the error behind it to log/test.log; ...`: run its rerun line and read `log/test.log` before you judge it.
|
|
159
|
+
|
|
160
|
+
**Files git ignores.** Each checkout has only what git tracks. `config/master.key` is not there, so an initializer that reads a credential at boot (`Rails.application.credentials.fetch(:secret_key_base)`) fails in every checkout with `KeyError: key not found: :secret_key_base`, while the same command passes in the repository. Pass the key:
|
|
161
|
+
|
|
162
|
+
```sh
|
|
163
|
+
RAILS_MASTER_KEY=$(cat config/master.key) jitcatch run --env RAILS_MASTER_KEY BASE HEAD TESTS_DIR
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Pass other variables the app reads at boot the same way (`--env DATABASE_URL`, `--env REDIS_URL`). `RAILS_ENV` is not passed; `rails_helper` sets it to `test`. `--env` takes a name, not `NAME=VALUE`: export the value first. `env_unset DB_HOST` is a note, not an error: the shell does not set it, so each side ran with the app's own default.
|
|
167
|
+
|
|
168
|
+
**Time.** Each file boots the app once on each side. On the sample app (4 CPUs, load average 1 to 3), six files with one example each took 9.8 s, `--workers 2` took 6.0 s, and one file with the same six examples took 1.9 s. Put the examples in few files, and raise `--timeout` above the app's boot time. bootsnap starts with an empty cache in each checkout. One file took 3.7 s with bootsnap and 2.5 s with `DISABLE_BOOTSNAP=1 jitcatch run --env DISABLE_BOOTSNAP`; six files took 8.0 s with bootsnap and 9.8 s without it, because later files reuse the cache. Disable it for one or two files.
|
|
169
|
+
|
|
170
|
+
**The brief.** The existing-test grep finds a spec that calls `loan.approve!` on a record a factory built, and `entry_point_tests` needs the class name in the file, which `create(:loan)` does not write. A changed `Loan#initialize` is different: its `tests:` lists the specs that call `create(:loan)` or `Loan.create!`. So is a changed callback or validation method: its `run_by:` names the saves that run it, and its `tests:` lists the specs that make them. An `on: :create` callback lists no update, and `build(:loan)` counts only in a spec that also saves or validates the record. Read the model's spec under `spec/models/` as well.
|
|
171
|
+
|
|
172
|
+
## Run
|
|
173
|
+
|
|
174
|
+
```sh
|
|
175
|
+
jitcatch run --repo DIR BASE HEAD TESTS_DIR
|
|
176
|
+
jitcatch run --json --repo DIR --reruns 2 --timeout 10 --workers 2 BASE HEAD TESTS_DIR
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`--reruns` defaults to 2: that many extra runs on head for a pass-on-base / fail-on-head result. `0` skips the flake check. `--timeout` defaults to 60 seconds per process when head has `config/application.rb` and to 10 seconds otherwise (minimum 1). `run` takes no `--coverage`: its report lists no existing tests. `--workers N` runs N files at the same time, each in its own pair of worktrees; the default is 1. Pass `--workers 2` when the candidate files do not write the same fixed path or listen on the same port. Each worker runs two Ruby processes at once (base and head), so workers pay only when CPUs are idle: check `uptime` first, and leave the default when the load average is at or above the CPU count. On 4 CPUs, 40 files went from 9.7 to 6.2 seconds with 2 workers, and 4 workers were no faster than 2. On 5 small files, 2 workers saved 0.03 to 0.24 s of 1.4 s at load average 3, about 0.15 s of 2.0 s at load average 6, and nothing at load average 15, and 5 workers were no faster than 1. A worker's own setup (two worktrees) costs about 0.1 s; the time goes to the test processes. N above the file count adds no worker. The report is the same for every N. `--env NAME` passes one variable from your environment, and you can repeat it. The report lists the names on an `env` line, and a name you passed that is not set in your environment on an `env_unset` line (`env_unset` in `--json`): the tests ran without it, so a fallback in the library may have run instead. The text report already includes the assess section. Save the `--json` document if you will call `assess` later.
|
|
180
|
+
|
|
181
|
+
Before any test runs, `run` checks once per side that the side loads its bundle and the framework (`minitest`, or `rspec-core` for RSpec candidates; without a `Gemfile`, from the Ruby that runs jitcatch). A run that passed the check prints `bundle base ok` before `tests:` for each side with a `Gemfile`. A side that fails the check would turn every candidate into a load error, which reads as `fail_on_base` or as a regression, so `run` prints no rows and exits 3. Stdout, the stream of every exit 3 report, names the Ruby that ran the check (`ruby 3.3.6 /opt/rbenv/versions/3.3.6/bin/ruby`; `ruby` with `version` and `path` in `--json`), each failed side, the command that failed (`setup head failed: ruby -e 'require "bundler/setup"; require "minitest"'`), and the last lines of its stderr. With `--json`, stdout holds `"error": "setup_failed"` and a `setup_failed` list of `side`, `commit`, `command`, `stderr`, and `not_passed`. Each side already gets the checkout's `.bundle/config`, with a relative `BUNDLE_PATH` kept pointing at the checkout. When a side has a `not passed: BUNDLE_WITHOUT (add --env BUNDLE_WITHOUT)` line, your shell sets a Bundler setting the test process did not get; pass it with `--env` and run again. A bundle installed for another Ruby reads as gems not installed (`... is not yet checked out. Run bundle install first.`), so first check that the `ruby` line names the Ruby your bundle is for; run jitcatch with that Ruby first on `PATH`. Otherwise run the named command with that Ruby in a checkout of that side and fix what it says: install the gems (`bundle install` at that revision), add `minitest` or `rspec` to the `Gemfile` in a copy of the repository, or install `rspec-core` for the Ruby. Then run again.
|
|
182
|
+
|
|
183
|
+
A side can load its bundle and still fail to boot what every candidate requires, such as a Rails app whose test database does not exist. When every file is a `<file>#load` row that errored on both sides, no row can tell base from head, so `run` also exits 3. Stdout starts `jitcatch: every candidate file failed to load on both sides`, and each side has a `load base failed:` line with the first file's rerun command and the error under it (`ActiveRecord::DatabaseConnectionError: There is an issue connecting with your hostname: 127.0.0.1.`). With `--json`, the document has `"stage": "load"`, and `"stage": "setup"` for the setup check. When head has `config/application.rb` (`"rails_app": true`), the advice also names `config/master.key` and `RAILS_MASTER_KEY` (see **Files git ignores**). When rows ran but every one of them timed out, or found no database or no table (an `ActiveRecord::StatementInvalid` from `PG::UndefinedTable`, MySQL's `Table '...' doesn't exist`, or SQLite's `no such table` or `Could not find table`), on a side, `run` exits 3 as well: stdout starts `jitcatch: no row ran to a result on both sides (6 of 6 timed out), so this run cannot tell base from head` (the parenthesis names only the causes the rows show: `timed out`, `found no database`, `found no table`, joined by `or`; for `found no table`, a second line names each numbered database, or each side's database, and says to run again with `--setup 'bin/rails db:create db:schema:load'`, which loads each side's schema into its own database) and then holds the usual report, and `--json` holds the usual document, whose `untested` (present in every run) equals the number of rows. Such rows beside rows that failed on base for another reason compare nothing either, so that run exits 3 with `jitcatch: no row compared base with head (6 of 7 found no table, and 1 failed on base), so this run cannot tell base from head`. When other rows did compare, the header holds `warning: 6 of 7 rows found no table on a side, so they checked nothing; load the schema into ...`, and a `fail_on_base` row whose base found no table has `hint: base found no table, so this row checked nothing; load base's schema into app_test_1`: load the schema and run again before you read the other rows. A run whose rows all failed on base with nothing hidden exits 0; fix the candidates. Never read an exit status 3 as a finding.
|
|
184
|
+
|
|
185
|
+
Judge only rows whose disposition is `reported`.
|
|
186
|
+
|
|
187
|
+
Each candidate row under `assess:` ends with `rerun:`, the command that runs that one test again from the repository root on the checkout you have (`--json` rows carry it as `rerun`). It names the candidate by its path from the repository root, outside the repository too (`../catch/tests/cart_test.rb`), so the line runs as printed and names no directory of one machine. A path that would climb further than the absolute path is long, as to a scratch directory under `/tmp`, is printed absolute (`/tmp/catch/cart_test.rb`), in the rerun line and in the warnings alike. A `require_relative` that leaves `TESTS_DIR` resolves inside the checkout during the run but beside `TESTS_DIR` from the rerun line, unless `TESTS_DIR` sits directly under the root; `run` then prints a `warning:` naming the line and both files, and the rerun line may pass where the row says head failed. Every `require_relative` counts, in a method or a block too; `File.join` of string literals is read as its path, and any other path gets a `warning:` that jitcatch cannot check that line. Load the code under test with `require` (the rerun line passes `-Ilib`). The line runs in your shell, and every variable `--env` passed came from that shell, so the line sees it too (`BUNDLE_VERSION=system`, for example); it holds no values, since the run records names only and a `DATABASE_URL` can hold a password. When `--env` passed names, a `rerun env:` line under each `rerun:` names them and the ones the run did not have set (`rerun env: reads DATABASE_URL QUINT from your shell, as jitcatch run did; QUINT was not set for the run`); run the line where the same names are set. A Minitest row reads `ruby -Ilib -I. candidates/cart_test.rb -n CartTest#test_total`, an RSpec row `ruby -Ilib -I. -e 'require "rspec/core"; require "rspec/support/object_formatter"; RSpec::Core::RubyProject.instance_variable_set(:@project_root, Dir.pwd); RSpec::Support::ObjectFormatter.default_instance.max_formatted_output_length = nil; exit(RSpec::Core::Runner.run(ARGV))' candidates/cart_spec.rb:11`, both behind `bundle exec` when head has a `Gemfile`. The RSpec line runs Ruby with the run's `-I` flags and pins RSpec's project root to the working directory, because `rspec` puts `lib/` of the nearest directory holding `spec/` first on the load path: a checkout under a Rails app's `tmp/` would load the app's `lib/`. When head holds `spec` at its root, as a Rails app does, RSpec finds that root on its own, so the line leaves out `RSpec::Core::RubyProject.instance_variable_set(:@project_root, Dir.pwd); `, the one part that sets rspec-core's private state. It also lifts RSpec's 200-character cut on a printed object, so a `raise_error` row shows the whole error it got. When head holds `spec` at its root and the checkout you ran jitcatch in can load `jitcatch/rspec` (its `Gemfile` lists jitcatch, or, with no `Gemfile`, your Ruby has the gem), an RSpec row reads `rspec -I. -r jitcatch/rspec candidates/cart_spec.rb:11` instead, behind `bundle exec` the same way; `jitcatch/rspec` only lifts that cut. Use it to rerun a row on head after you read the diff.
|
|
188
|
+
|
|
189
|
+
| Bucket | Meaning |
|
|
190
|
+
| --- | --- |
|
|
191
|
+
| `no_signal` | Passed on both sides. Not evidence. |
|
|
192
|
+
| `fail_on_base` | The parent did not satisfy the test. Often a new feature. Not a candidate. The text report prints the base outcome under the row (`base: fail RSpec::Expectations::ExpectationNotMetError expected block to output to stdout, but did not`), with the `got:` or `Actual:` line of a value comparison under it, so a test that does not run on base can be fixed. |
|
|
193
|
+
| `flaky` | Passed on base, failed on head, then a rerun disagreed. Dropped. The text prints each rerun under the row (`rerun 1: pass`), and the `--json` row keeps them in `reruns`. |
|
|
194
|
+
| `discarded` | Stable pass-on-base / fail-on-head, removed by a rule. Still listed under `assess:`. |
|
|
195
|
+
| `reported` | You must judge this one. |
|
|
196
|
+
|
|
197
|
+
`generated` is the number of test methods. `candidates` counts stable pass-on-base / fail-on-head tests before the discard rules. `reported` is what remains.
|
|
198
|
+
|
|
199
|
+
Tags are hints, not a verdict. They are sorted:
|
|
200
|
+
|
|
201
|
+
- `touches:changed_method` — the test names a method the diff changed. `Owner.new` names the `initialize` of that owner alone, by the path the test writes, so `QuintDriver::Mismatch.new` names `QuintDriver::Mismatch#initialize` and not another changed constructor, and `A::Error.new` names `A::Error#initialize` and not `B::Error#initialize`. A bare `Error.new` names both, and the row is a guess. A call in the constructor's `built_by:` (`Loan.create!`, `create(:loan)`, `build(:loan)`) names it the same way. Almost every test builds its object, so a changed constructor becomes the related id only when the test names no other changed method and reaches none through `reachable_from`.
|
|
202
|
+
- `touches:changed_dependency` — the error came from inside a gem that `dependencies:` lists: the first frame outside the candidate file is in that gem. An error whose first such frame is in any gem gets no `touches:changed_method`.
|
|
203
|
+
- `touches:changed_constant` — the test names a constant the diff changed, added, or removed. The row's `mentions` lists the bare names of changed methods, their classes' public methods, and changed constants, with `Owner.new` for a constructor, that the test method or its same-file helpers name. A call on a class the test names, or on an instance it builds with `.new`, is listed with the class too, as `Index.parse` or `Cart#total`. A name the test sends only to constants the repository does not define (`File.write`, `Dir.mktmpdir`) is not listed, so `File.write` does not name `Emitter.write`.
|
|
204
|
+
- `touches:unchanged_caller` — the test goes through an entry point the diff did not edit, and that entry point reaches a changed method.
|
|
205
|
+
- `discard:signature_change` — `NameError`, `NoMethodError`, or an arity `ArgumentError` on a symbol the diff removed or rewrote, including a removed nested class such as `Model::Unsupported`. An arity `ArgumentError` is `wrong number of arguments`, `missing keyword`, `unknown keyword`, or `struct size differs` (a `Struct` given more values than its members), raised in a method whose signature changed, or in the inherited method a removed one leaves (a dropped `initialize` makes `Owner.new("x", seed: 1)` raise in `Exception#initialize`). It counts too when `assert_raises(X)` caught it and failed with `[X] exception expected, not ArgumentError`, or when RSpec's `raise_error` failed with `expected X, got #<ArgumentError: ...>`, and the same for a `NoMethodError` they caught. An `ArgumentError` raised on purpose is kept. The rule needs the test to make the broken call itself: the frame that names the removed method, or the frame below the method whose arguments no longer fit, is in the candidate file. When code in the repository makes that call, head is broken for every caller, so the row is reported. The report keeps no frame labels, so the rule reads the caller from the frame positions. On Ruby 3.3 and 4.0 the first frame of an arity error is the callee's: its `def` line when Ruby defines it, or the caller's own line when C defines it (`Exception#initialize`, `Data` and `Struct` `new`), and the caller's frame follows. A C-defined callee whose caller's frame is missing, because the framework filtered it, reads as a Ruby callee: when the frame after it is in the candidate file, the row is discarded although unchanged code made the call. Rerun such a row by hand when the second frame does not look like the call the test wrote. A call the test writes to `new` of a `Data` or `Struct` class whose members the diff changed counts too (`missing keyword: :media` from `Record.new(...)`). The row's next line says that the candidate predates head, not that head regressed: `candidate is out of date with head: CatalogService::Record (k07) changed its definition; update the call at ../cand/stale_spec.rb:5 to head's code, or drop the test` (`out_of_date` in `--json`). Fix the call or drop the test.
|
|
206
|
+
- `caller:unchanged:lib/b.rb:5` — the broken call is on that line, which the diff did not change. For example, `class B < A` still calls the private `A#helper` that head removed. Judge it as a likely regression. `caller:changed:FILE:LINE` names a line the diff wrote, or a line inside a changed method, such as an edited lambda in a constant. A row whose error kept no frames keeps `discard:signature_change`.
|
|
207
|
+
- `discard:load_error` — `LoadError` on head. `discard:timeout` — the base run of a head that did not pass timed out, or the head run timed out beside a base that took more than half of `--timeout` (a run file from before base kept its time counts as that). Timeouts are not rerun. `discard:no_database` — head raised Active Record's `NoDatabaseError`, `ConnectionNotEstablished`, or `DatabaseConnectionError`: the database of head's checkout does not exist or cannot be reached (Rails apps, below). `discard:no_table` — head raised an `ActiveRecord::StatementInvalid` for a missing table: its database exists but has no schema loaded.
|
|
208
|
+
- The related id (`m05`, or `-` when none) is the downstream modified method when several changed methods sit on the path. When the failure's backtrace runs through changed methods, it is the innermost of them, by each method's lines on head. Read that method's old and new source before you judge.
|
|
209
|
+
- `related:guess` — the evidence that picked the related id points at several changed methods, most often because the test names only an unchanged caller (`build`, `between`) that reaches all of them. The next line names every one with its id (`method_ids` in `--json`): `related guess: Shop::Cart#volume_discount_cents (m05), ranked first of 3: Shop::Cart#coupon_discount_cents (m02), Shop::Cart#taxable_cents (m04), Shop::Cart#volume_discount_cents (m05)`. Read each of them before you judge, not only the pick. The pick prefers a method that existed on base, except an edit whose every added line only calls an added method (`forwards_to_added: true` in `--json`): then the added method is the pick. Among the methods an unchanged caller reaches, the pick is the one farthest below the entry point the test calls (`caller_depths` in `--json`), since a test that enters high passes the plumbing near its entry point on the way to the code it checks. When only the id would order the best two, there is no pick: the row shows `-` and the line reads `related guess: none; the test reaches 7 changed methods equally and no frame of head's failure is in one: Book#apply_catalog_record (m02), LoanRules.for (m03), ...` (`method_id: null`), then gives the next step: find what makes head's value in them, or in a changed file that holds no method, such as a view, from the `git diff --stat BASE HEAD` it prints, and `touches:changed_method` there means the test names one of `method_ids`. An assertion on error plumbing, such as the frames a backtrace keeps, often lands here, because the depth ranking favors the code a value comes from. A test that names the method itself (through `send` when it is private) gets a firm id, and so does an error whose backtrace runs through changed methods: the innermost of them is the id.
|
|
210
|
+
- `message_varies` — head failed the same way on every rerun with a message that moved, and base passed on every rerun. The finding holds; the value that moved is noise, so a better test compares a field it controls (`exists?`, one column) rather than a whole printed record.
|
|
211
|
+
- `related:dynamic` — no changed method is related, but the test calls a method that sends a changed method's name, or a caller's name, to a receiver the source does not name (`EMITTERS.fetch(format).render`, or `lines.sum(&:total_cents)`, which sends `total_cents` to each element). The next line names it: `related: none, Emit.looked_up sends render to a receiver the source does not name`. The test may reach the change at run time; read the method to judge. `context` lists these under `dynamic_callers:`, leaving out the names of instance methods of a class whose name ends in `Controller`, which the router calls and no code sends.
|
|
212
|
+
- A call through a name computed at run time is not seen at all: `const_get(kind.capitalize)` for the class, or `send(field)`, `public_send(field)`, and `send("#{field}")` for the method. No edge and no mention reaches the changed method, and the row shows `-` with no `touches:` tag and no `related:dynamic`. For `Shop.read("cart", "total")`, where `read` is `build(kind).public_send(field)` and `build` is `const_get(kind.capitalize).new`, a change to `Cart#total` gave `c01 ReadTest#test_reads_a_field_by_name base=pass head=fail - assertion,report`. When `context` shows `callsites: (none)` for a method that the code reaches by name, grep for `send`, `public_send`, and `const_get`, and judge the row from the method you found. A string literal passed to `send` is still a mention (below).
|
|
213
|
+
- A mention is a method call, a constant, or a symbol in the test code. Words in strings and comments are not mentions, so an expected string such as `"jitcatch run\n"` does not point the related id at a method named `run`. A method name passed as a plain string to `send`, `__send__`, `public_send`, `method`, `public_method`, or `respond_to?` is one (`send("total")`). A symbol that keys a Hash is not: an argument of `[]`, `[]=`, `fetch`, `dig`, `key?`, `has_key?`, `values_at`, or `fetch_values`, or a key in a hash literal or keyword argument. So `snap[:discount_cents]` does not point the related id at a method `discount_cents`, and `send(:discount_cents)` does. A call to `message`, `full_message`, or `detailed_message` also mentions `to_s`, so a test that reads `error.message` relates to a changed `Mismatch#to_s`. A test that starts a script (`IO.popen(["ruby", "-Ilib", "exe/tool"])`, `system("bin/x")`, or `File.expand_path("../exe/tool", __dir__)`) mentions the names in that script when the string is a relative path to a file of head, so the row can reach `touches:unchanged_caller`. A path built at run time is not read.
|
|
214
|
+
|
|
215
|
+
## Judge
|
|
216
|
+
|
|
217
|
+
For each reported row, compare the failure (the message lines, usually expected and actual, and the related id) with the intent and with the diff in the brief. When Minitest printed a diff, the two lines are its first removed line (`-`, expected) and first added line (`+`, actual). An RSpec `eq` failure shows its `expected:` and `got:` lines, `be` and `equal` their `expected` and `got` lines, and an rspec-mocks failure on unexpected arguments the call and its `expected:` and `got:` lines. A failed `assert_raises(X)` shows Minitest's `Class:` and `Message:` lines, which name the error that came instead. An error row raised from another error adds `cause: <class>: <first message line>` (`head.cause` in `--json`). RSpec prints it under `--- Caused by: ---`; for Minitest, jitcatch loads `lib/jitcatch/minitest_report.rb` into the candidate's process with `-r`, which prints the same line, so a Minitest row has it too. The `rerun:` line does not load that file, so a Minitest rerun prints no cause. When either line is longer than 100 characters and both are one Hash literal, the row prints `hash keys differ:` and one `-` and `+` line for each of the first 4 keys whose values differ. When both are one Array literal, it prints `array elements differ: [3], [7]` and the elements one side has and the other lacks, by index, so one inserted element is one `+[7]` line. Otherwise it prints 61 characters around the first column that differs, with a `^ column N` line under it. That column line shows only when the added line replaces the removed one; when the first `+` line comes from another place of the diff, both lines are cut to 61 characters with no column. A row line over 300 characters keeps its first and last 150 characters around `... (+N characters; the whole message is in --json) ...`. A cut first line with RSpec's `Diff:` under it, as `have_attributes` prints, adds the diff's removed and added lines (`-:state => "pending",`, `+:state => "draft",`): they name the attribute that differs. The whole message is `head.message` in the `run --json` document and `message` in the `assess --json` document.
|
|
218
|
+
|
|
219
|
+
- **likely-regression** — behavior changed, and the intent does not ask for that change.
|
|
220
|
+
- **intended** — the intent explicitly wants that behavior.
|
|
221
|
+
- **unclear** — the intent is not enough to tell. Say what is missing.
|
|
222
|
+
|
|
223
|
+
The same pass-on-base / fail-on-head shape can be either. The diff shows what moved. The intent says whether that move was wanted. Do not promote a tag into the label.
|
|
224
|
+
|
|
225
|
+
## Assess
|
|
226
|
+
|
|
227
|
+
```sh
|
|
228
|
+
jitcatch assess RUN.json
|
|
229
|
+
jitcatch assess --json RUN.json
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
`RUN.json` is the document from `jitcatch run --json`, not the text report. This reprints the run's warnings (such as `base is not an ancestor of head`), the counts, and the candidate rows. It does not call a model, and it does not decide intended versus regression.
|
|
233
|
+
|
|
234
|
+
## Report to the human
|
|
235
|
+
|
|
236
|
+
- The resolved base and head, and the intent in one or two lines.
|
|
237
|
+
- `generated`, `candidates`, `flaky`, and `reported`. Include `discarded` when it is not zero, with the discard tag.
|
|
238
|
+
- How many reported rows you marked likely-regression, intended, and unclear.
|
|
239
|
+
- One line per reported test: candidate id, name, judgment, and why. Cite the method id and what the intent says.
|
|
240
|
+
- When `reported` is 0, say so. A regression the candidate tests never stated does not appear. Mention `fail_on_base` only so a new-feature test is not mistaken for a regression.
|
|
241
|
+
|
|
242
|
+
## Afterward
|
|
243
|
+
|
|
244
|
+
Delete the scratch directory. The tests are throwaway. Promote one only when the human asks to keep it. A likely-regression test can join the real suite after the bug is fixed. An intended-change test encodes the old behavior, so do not keep it as written.
|
metadata
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: jitcatch
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- meganemura
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: jitcatch runs candidate Minitest or RSpec files on two git revisions,
|
|
13
|
+
drops flakes and other non-signals, and prints the tests that pass on the parent
|
|
14
|
+
and fail on the change, with the changed methods each one reaches.
|
|
15
|
+
email:
|
|
16
|
+
- meganemura@users.noreply.github.com
|
|
17
|
+
executables:
|
|
18
|
+
- jitcatch
|
|
19
|
+
extensions: []
|
|
20
|
+
extra_rdoc_files: []
|
|
21
|
+
files:
|
|
22
|
+
- LICENSE
|
|
23
|
+
- README.ja.md
|
|
24
|
+
- README.md
|
|
25
|
+
- bin/jitcatch
|
|
26
|
+
- lib/jitcatch.rb
|
|
27
|
+
- lib/jitcatch/assessor.rb
|
|
28
|
+
- lib/jitcatch/changes.rb
|
|
29
|
+
- lib/jitcatch/cli.rb
|
|
30
|
+
- lib/jitcatch/diff_parser.rb
|
|
31
|
+
- lib/jitcatch/factories.rb
|
|
32
|
+
- lib/jitcatch/failure.rb
|
|
33
|
+
- lib/jitcatch/framework.rb
|
|
34
|
+
- lib/jitcatch/git_repo.rb
|
|
35
|
+
- lib/jitcatch/inflections.rb
|
|
36
|
+
- lib/jitcatch/minitest_parser.rb
|
|
37
|
+
- lib/jitcatch/minitest_report.rb
|
|
38
|
+
- lib/jitcatch/readers.rb
|
|
39
|
+
- lib/jitcatch/render.rb
|
|
40
|
+
- lib/jitcatch/routes.rb
|
|
41
|
+
- lib/jitcatch/rspec.rb
|
|
42
|
+
- lib/jitcatch/rspec_parser.rb
|
|
43
|
+
- lib/jitcatch/runner.rb
|
|
44
|
+
- lib/jitcatch/source_index.rb
|
|
45
|
+
- lib/jitcatch/template.rb
|
|
46
|
+
- lib/jitcatch/version.rb
|
|
47
|
+
- skills/jitcatch/SKILL.md
|
|
48
|
+
homepage: https://github.com/meganemura/jitcatch
|
|
49
|
+
licenses:
|
|
50
|
+
- MIT
|
|
51
|
+
metadata:
|
|
52
|
+
source_code_uri: https://github.com/meganemura/jitcatch
|
|
53
|
+
changelog_uri: https://github.com/meganemura/jitcatch/blob/main/CHANGELOG.md
|
|
54
|
+
bug_tracker_uri: https://github.com/meganemura/jitcatch/issues
|
|
55
|
+
rubygems_mfa_required: 'true'
|
|
56
|
+
rdoc_options: []
|
|
57
|
+
require_paths:
|
|
58
|
+
- lib
|
|
59
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
60
|
+
requirements:
|
|
61
|
+
- - ">="
|
|
62
|
+
- !ruby/object:Gem::Version
|
|
63
|
+
version: '3.3'
|
|
64
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '0'
|
|
69
|
+
requirements: []
|
|
70
|
+
rubygems_version: 4.0.16
|
|
71
|
+
specification_version: 4
|
|
72
|
+
summary: Keep the Ruby tests that pass on the parent of a change and fail on the change.
|
|
73
|
+
test_files: []
|