slopbouncer 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.md +119 -0
- data/bin/slopbouncer +6 -0
- data/lib/slopbouncer/agents.rb +195 -0
- data/lib/slopbouncer/compare.rb +77 -0
- data/lib/slopbouncer/git.rb +43 -0
- data/lib/slopbouncer/install.rb +36 -0
- data/lib/slopbouncer/ruby_tests.rb +113 -0
- data/lib/slopbouncer/version.rb +5 -0
- data/lib/slopbouncer.rb +140 -0
- metadata +72 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ba4f63d3b0f813fcd42c469d4d4051ca2b0c93a3c12d8f34c4ebc8683831b166
|
|
4
|
+
data.tar.gz: 4504c98ddfbd09547290ab538c0b8a75d963a0b43d442788eec05c8349daf62e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a6a0e5a1f3bb6db696c32ecdf23807fcaadaa62b39d83d91341f9fbca6358cd70990c8d72ecd0aff1934a26d75da4aaa2c1925c13749603c7f7d4b42865c1856
|
|
7
|
+
data.tar.gz: '0838bd507b4124151b1d0aed78bafe574fc3c96eba3d306dbde56ff4a8cdb6896200663fcaf339b5a3ac3fc8153cd2502222ed996d01b22da13b274006603acc'
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kipple Pty Ltd
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Slopbouncer
|
|
2
|
+
|
|
3
|
+
A static check that flags tests which got weaker in a change.
|
|
4
|
+
|
|
5
|
+
A coding agent's job is to make the checks pass. One way to do that is to weaken the check. Slopbouncer compares each test before and after a change and reports the ones that lost something, so a person can decide whether that was intended. It reads code and never runs it, so it takes seconds.
|
|
6
|
+
|
|
7
|
+
Status: in development. It reads Ruby tests today (Minitest, as `it` blocks or `test_` methods).
|
|
8
|
+
|
|
9
|
+
## What it flags
|
|
10
|
+
|
|
11
|
+
| Rule | When |
|
|
12
|
+
|---|---|
|
|
13
|
+
| `assertion_removed` | An assertion is gone from a test that still exists |
|
|
14
|
+
| `expected_value_changed` | The value an assertion expects was edited |
|
|
15
|
+
| `test_deleted` | A test, or its whole file, was deleted |
|
|
16
|
+
| `skip_added` | A test was marked to be skipped or pending |
|
|
17
|
+
| `no_assertions` | A test the change touched asserts nothing |
|
|
18
|
+
|
|
19
|
+
## What it leaves alone
|
|
20
|
+
|
|
21
|
+
- New tests added with new code.
|
|
22
|
+
- Tests that gained assertions.
|
|
23
|
+
- A test that was reworded, moved to another file, or both, with its checks unchanged.
|
|
24
|
+
- Files that are not tests.
|
|
25
|
+
|
|
26
|
+
## Running it
|
|
27
|
+
|
|
28
|
+
It needs Ruby 3.4 and git. It is not on RubyGems yet, so build the gem from a clone and install that:
|
|
29
|
+
|
|
30
|
+
```shell
|
|
31
|
+
git clone https://github.com/mainline-dev/slopbouncer.git
|
|
32
|
+
cd slopbouncer
|
|
33
|
+
gem build slopbouncer.gemspec
|
|
34
|
+
gem install slopbouncer-0.1.0.gem
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Then run it from the root of the repository you want checked:
|
|
38
|
+
|
|
39
|
+
```shell
|
|
40
|
+
slopbouncer main..HEAD
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The argument is a range of commits. Give one commit instead, such as `HEAD` or `origin/main`, and it checks everything since then, committed or not. It prints one line for each finding:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
test/basket_test.rb: Basket applies a voucher: skip_added: marked to be skipped
|
|
47
|
+
test/checkout_test.rb: Checkout lets a guest pay: assertion_removed: assert_equal "paid", order.status
|
|
48
|
+
test/refund_test.rb: Refund refunds part of an order: expected_value_changed: 100 → 90
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
| Option | What it does |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `--json` | Prints the findings as JSON |
|
|
54
|
+
| `--strict` | Exits with 1 when anything is flagged |
|
|
55
|
+
| `--agent=NAME` | Runs as that coding agent's hook (see below) |
|
|
56
|
+
|
|
57
|
+
Without `--strict` it exits with 0 whatever it finds: it flags for review and does not block. It exits with 2 when it is given nothing to compare.
|
|
58
|
+
|
|
59
|
+
## Feeding it back to a coding agent
|
|
60
|
+
|
|
61
|
+
Slopbouncer is most use when the agent that made the change hears about it. The agent knows why it changed a test, and it can put the test back.
|
|
62
|
+
|
|
63
|
+
One command sets an agent up, from the root of your project:
|
|
64
|
+
|
|
65
|
+
```shell
|
|
66
|
+
slopbouncer install claude-code
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
| Agent | Command | What it adds |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| Claude Code | `slopbouncer install claude-code` | A `Stop` hook in `.claude/settings.json` |
|
|
72
|
+
| Cursor | `slopbouncer install cursor` | A `stop` hook in `.cursor/hooks.json` |
|
|
73
|
+
| Gemini CLI | `slopbouncer install gemini` | An `AfterAgent` hook in `.gemini/settings.json` |
|
|
74
|
+
| Codex | `slopbouncer install codex` | A `Stop` hook in `.codex/hooks.json` |
|
|
75
|
+
|
|
76
|
+
It adds one hook and keeps everything else in the file. Running it again changes nothing. For Claude Code, `--local` writes to `.claude/settings.local.json`, which keeps the hook to you and out of the settings your team shares.
|
|
77
|
+
|
|
78
|
+
When the agent is about to finish and has weakened an existing test, it is sent back once and told which tests, with two ways to answer: put the test back and make the code pass it, or leave the change and tell the person which behaviour changed. The second time it is let finish, so its reasons are on the record for the person to read.
|
|
79
|
+
|
|
80
|
+
The hook checks the work the agent has not committed. If your agent commits as it goes, edit the hook's command to name the commit its work started from, such as `origin/main`, in place of `HEAD`.
|
|
81
|
+
|
|
82
|
+
Codex skips a new hook until you have trusted it: after installing, open `/hooks` in Codex and review it.
|
|
83
|
+
|
|
84
|
+
Claude Code is the one this has been run under. The Cursor, Gemini CLI and Codex hooks are written to those agents' published hook contracts and tested against them, and have not yet been run inside the agents themselves. If one misbehaves, please open an issue.
|
|
85
|
+
|
|
86
|
+
An agent set on a passing build can still get round a static check, so keep running the plain command where a person will see it.
|
|
87
|
+
|
|
88
|
+
## Your own assertion helpers
|
|
89
|
+
|
|
90
|
+
Slopbouncer counts calls such as `assert_equal`, `refute_nil`, `must_equal` and `expect` as assertions. If your tests assert through helpers of your own, name them in `.slopbouncer.yml` at the root of your repository:
|
|
91
|
+
|
|
92
|
+
```yaml
|
|
93
|
+
assertions:
|
|
94
|
+
- sees_total
|
|
95
|
+
- is_refused
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
A test that only calls helpers Slopbouncer has not been told about looks like a test with no assertions.
|
|
99
|
+
|
|
100
|
+
## Its limits
|
|
101
|
+
|
|
102
|
+
- It catches tests that are visibly weaker. A test that looks thorough and checks nothing needs mutation testing, which is slower and runs the code. Use both: Slopbouncer on every change, mutation testing behind it.
|
|
103
|
+
- It cannot tell a stronger assertion from a weaker one. An assertion swapped for another kind is reported as removed.
|
|
104
|
+
- It cannot see intent. A test changed on purpose, because the behaviour changed, is flagged the same way as one weakened to get a build to pass.
|
|
105
|
+
|
|
106
|
+
On its first run, over the 706 commits in [Mainline](https://mainline.dev)'s history that touch a test, it flagged 210 of them. In a sample of those, every finding was a real change to a test and every one looked intended. So read a finding as "this test changed in a way worth a look".
|
|
107
|
+
|
|
108
|
+
## Working on it
|
|
109
|
+
|
|
110
|
+
```shell
|
|
111
|
+
bundle install
|
|
112
|
+
bundle exec rake
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The tests are scenarios. Each one builds a throwaway git repository, makes a change in it, and runs the real command over that change. `PROJECT.md` holds the plan and the rules still to come.
|
|
116
|
+
|
|
117
|
+
## Licence
|
|
118
|
+
|
|
119
|
+
MIT. See `LICENSE`.
|
data/bin/slopbouncer
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Slopbouncer
|
|
6
|
+
# The coding agents Slopbouncer can run under. Each one knows where that
|
|
7
|
+
# agent keeps its hooks, how to add Slopbouncer to them, and how to tell the
|
|
8
|
+
# agent it may not finish yet. A new agent is one more module here.
|
|
9
|
+
module Agents
|
|
10
|
+
# Claude Code runs Stop hooks when the agent is about to finish. A hook
|
|
11
|
+
# that exits with 2 stops it, and what the hook wrote to standard error is
|
|
12
|
+
# handed to the agent as the reason.
|
|
13
|
+
module ClaudeCode
|
|
14
|
+
NAME = 'claude-code'
|
|
15
|
+
SHARED = '.claude/settings.json'
|
|
16
|
+
LOCAL = '.claude/settings.local.json'
|
|
17
|
+
|
|
18
|
+
def self.settings_path(local:)
|
|
19
|
+
local ? LOCAL : SHARED
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.command(base)
|
|
23
|
+
"slopbouncer #{base} --agent=#{NAME}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.installed?(settings)
|
|
27
|
+
stop_hooks(settings).any? { |hook| hook['command'].to_s.include?('slopbouncer') }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The settings with Slopbouncer added to whatever runs at Stop already.
|
|
31
|
+
def self.add(settings, command)
|
|
32
|
+
hooks = settings.fetch('hooks', {})
|
|
33
|
+
entry = { 'hooks' => [{ 'type' => 'command', 'command' => command }] }
|
|
34
|
+
settings.merge('hooks' => hooks.merge('Stop' => hooks.fetch('Stop', []) + [entry]))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Claude Code says on the hook's input whether it has stopped the agent
|
|
38
|
+
# once already.
|
|
39
|
+
def self.stopped_before?(input)
|
|
40
|
+
input['stop_hook_active'] == true
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# What to write and exit with to send the agent back: [output, error, status].
|
|
44
|
+
def self.block(message)
|
|
45
|
+
['', message, 2]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.stop_hooks(settings)
|
|
49
|
+
settings.dig('hooks', 'Stop').to_a.flat_map { |entry| entry['hooks'].to_a }
|
|
50
|
+
end
|
|
51
|
+
private_class_method :stop_hooks
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Cursor runs stop hooks when the agent loop ends. A hook answers on
|
|
55
|
+
# standard output with JSON, and a follow-up message there is given to the
|
|
56
|
+
# agent as its next turn. Project hooks run from the project root.
|
|
57
|
+
module Cursor
|
|
58
|
+
NAME = 'cursor'
|
|
59
|
+
SHARED = '.cursor/hooks.json'
|
|
60
|
+
VERSION = 1
|
|
61
|
+
|
|
62
|
+
# Cursor's own per-person file is in the home directory and its hooks
|
|
63
|
+
# do not run from the project, so there is nothing to offer here.
|
|
64
|
+
def self.settings_path(local:)
|
|
65
|
+
raise Error, "Cursor keeps a project's hooks in #{SHARED} only. Run it without --local." if local
|
|
66
|
+
|
|
67
|
+
SHARED
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.command(base)
|
|
71
|
+
"slopbouncer #{base} --agent=#{NAME}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.installed?(settings)
|
|
75
|
+
stop_hooks(settings).any? { |hook| hook['command'].to_s.include?('slopbouncer') }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.add(settings, command)
|
|
79
|
+
hooks = settings.fetch('hooks', {})
|
|
80
|
+
{ 'version' => VERSION }.merge(settings).merge('hooks' => hooks.merge('stop' => stop_hooks(settings) + [{ 'command' => command }]))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Cursor counts the follow-ups a stop hook has already caused, and says
|
|
84
|
+
# how the loop ended. Only an agent that finished by itself is sent back.
|
|
85
|
+
def self.stopped_before?(input)
|
|
86
|
+
input['loop_count'].to_i.positive? || (input.key?('status') && input['status'] != 'completed')
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.block(message)
|
|
90
|
+
[JSON.generate('followup_message' => message), '', 0]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.stop_hooks(settings)
|
|
94
|
+
settings.dig('hooks', 'stop').to_a
|
|
95
|
+
end
|
|
96
|
+
private_class_method :stop_hooks
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Gemini CLI runs AfterAgent hooks once the agent has written its final
|
|
100
|
+
# response. A hook answers on standard output with JSON and nothing else:
|
|
101
|
+
# a decision to deny the response, with a reason that Gemini gives the
|
|
102
|
+
# agent as a new prompt.
|
|
103
|
+
module Gemini
|
|
104
|
+
NAME = 'gemini'
|
|
105
|
+
SHARED = '.gemini/settings.json'
|
|
106
|
+
|
|
107
|
+
# Gemini's per-person settings are in the home directory and apply to
|
|
108
|
+
# every project, so there is nothing to offer here.
|
|
109
|
+
def self.settings_path(local:)
|
|
110
|
+
raise Error, "Gemini CLI keeps a project's hooks in #{SHARED} only. Run it without --local." if local
|
|
111
|
+
|
|
112
|
+
SHARED
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def self.command(base)
|
|
116
|
+
"slopbouncer #{base} --agent=#{NAME}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def self.installed?(settings)
|
|
120
|
+
after_agent(settings).flat_map { |entry| entry['hooks'].to_a }.any? { |hook| hook['command'].to_s.include?('slopbouncer') }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def self.add(settings, command)
|
|
124
|
+
entry = { 'hooks' => [{ 'type' => 'command', 'name' => 'slopbouncer', 'command' => command }] }
|
|
125
|
+
settings.merge('hooks' => settings.fetch('hooks', {}).merge('AfterAgent' => after_agent(settings) + [entry]))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Gemini says on the hook's input whether this is already a retry.
|
|
129
|
+
def self.stopped_before?(input)
|
|
130
|
+
input['stop_hook_active'] == true
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def self.block(message)
|
|
134
|
+
[JSON.generate('decision' => 'deny', 'reason' => message), '', 0]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def self.after_agent(settings)
|
|
138
|
+
settings.dig('hooks', 'AfterAgent').to_a
|
|
139
|
+
end
|
|
140
|
+
private_class_method :after_agent
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Codex runs Stop hooks when a turn is about to end. A hook answers on
|
|
144
|
+
# standard output with JSON: a decision to block, whose reason Codex gives
|
|
145
|
+
# the agent as a new prompt. Codex skips a new or changed hook until a
|
|
146
|
+
# person has reviewed and trusted it.
|
|
147
|
+
module Codex
|
|
148
|
+
NAME = 'codex'
|
|
149
|
+
SHARED = '.codex/hooks.json'
|
|
150
|
+
AFTER = 'Codex skips a new hook until you trust it: open /hooks in Codex to review it.'
|
|
151
|
+
|
|
152
|
+
# Codex's per-person hooks are in the home directory and apply to every
|
|
153
|
+
# project, so there is nothing to offer here.
|
|
154
|
+
def self.settings_path(local:)
|
|
155
|
+
raise Error, "Codex keeps a project's hooks in #{SHARED} only. Run it without --local." if local
|
|
156
|
+
|
|
157
|
+
SHARED
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def self.command(base)
|
|
161
|
+
"slopbouncer #{base} --agent=#{NAME}"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def self.installed?(settings)
|
|
165
|
+
stop(settings).flat_map { |entry| entry['hooks'].to_a }.any? { |hook| hook['command'].to_s.include?('slopbouncer') }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def self.add(settings, command)
|
|
169
|
+
entry = { 'hooks' => [{ 'type' => 'command', 'command' => command }] }
|
|
170
|
+
settings.merge('hooks' => settings.fetch('hooks', {}).merge('Stop' => stop(settings) + [entry]))
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Codex says on the hook's input whether Stop already continued this turn.
|
|
174
|
+
def self.stopped_before?(input)
|
|
175
|
+
input['stop_hook_active'] == true
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def self.block(message)
|
|
179
|
+
[JSON.generate('decision' => 'block', 'reason' => message), '', 0]
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def self.stop(settings)
|
|
183
|
+
settings.dig('hooks', 'Stop').to_a
|
|
184
|
+
end
|
|
185
|
+
private_class_method :stop
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
ALL = { ClaudeCode::NAME => ClaudeCode, Cursor::NAME => Cursor, Gemini::NAME => Gemini, Codex::NAME => Codex }.freeze
|
|
189
|
+
DEFAULT = ClaudeCode::NAME
|
|
190
|
+
|
|
191
|
+
def self.named(name)
|
|
192
|
+
ALL.fetch(name) { raise Error, "Slopbouncer does not know the agent #{name.inspect}. It knows: #{ALL.keys.join(', ')}" }
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slopbouncer
|
|
4
|
+
# Compares a test file's table of tests before and after a change and says
|
|
5
|
+
# which tests got weaker. Tests that are new are never flagged. It cannot
|
|
6
|
+
# tell a stronger assertion from a weaker one, so an assertion swapped for
|
|
7
|
+
# another kind is reported as removed, for a person to judge.
|
|
8
|
+
module Compare
|
|
9
|
+
Finding = Struct.new(:file, :test, :rule, :detail, :before, :after, keyword_init: true)
|
|
10
|
+
Result = Struct.new(:findings, :gone, :fresh, keyword_init: true)
|
|
11
|
+
|
|
12
|
+
# A reworded test is the same test: one that has gone is taken to be a
|
|
13
|
+
# new one when at least this share of their body lines are the same.
|
|
14
|
+
SAME_TEST = 0.5
|
|
15
|
+
|
|
16
|
+
# gone: tests no longer in the file. fresh: tests that are new to it.
|
|
17
|
+
# The caller decides whether a gone test turned up in another file.
|
|
18
|
+
def self.call(file, before, after)
|
|
19
|
+
now = after.to_h { |test| [test.name, test] }
|
|
20
|
+
gone = before.reject { |test| now.key?(test.name) }
|
|
21
|
+
fresh = after.reject { |test| before.any? { |was| was.name == test.name } }
|
|
22
|
+
reworded = reworded(gone, fresh)
|
|
23
|
+
pairs = before.filter_map { |was| [was, now[was.name]] if now.key?(was.name) } + reworded
|
|
24
|
+
Result.new(findings: pairs.flat_map { |was, test| weaker(file, was, test) },
|
|
25
|
+
gone: gone - reworded.map(&:first), fresh: fresh - reworded.map(&:last))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.deleted(file, test)
|
|
29
|
+
Finding.new(file: file, test: test.name, rule: 'test_deleted', detail: "#{test.assertions.size} assertions")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Pairs each test that has gone with the new test most like it.
|
|
33
|
+
def self.reworded(gone, fresh)
|
|
34
|
+
left = fresh.dup
|
|
35
|
+
gone.filter_map do |was|
|
|
36
|
+
match = left.max_by { |test| likeness(was, test) }
|
|
37
|
+
next unless match && likeness(was, match) >= SAME_TEST
|
|
38
|
+
|
|
39
|
+
left.delete(match)
|
|
40
|
+
[was, match]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.likeness(was, now)
|
|
45
|
+
shared = (was.body & now.body).size
|
|
46
|
+
total = [was.body.size, now.body.size].max
|
|
47
|
+
total.zero? ? 0.0 : shared.to_f / total
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.weaker(file, was, now)
|
|
51
|
+
finding = ->(rule, **rest) { Finding.new(file: file, test: now.name, rule: rule, **rest) }
|
|
52
|
+
lost = was.assertions.reject { |assertion| now.assertions.map(&:source).include?(assertion.source) }
|
|
53
|
+
gained = now.assertions.reject { |assertion| was.assertions.map(&:source).include?(assertion.source) }
|
|
54
|
+
found = lost.map do |assertion|
|
|
55
|
+
edit = gained.find { |other| edited_value(assertion, other) }
|
|
56
|
+
next finding.call('assertion_removed', detail: assertion.source) unless edit
|
|
57
|
+
|
|
58
|
+
gained.delete(edit)
|
|
59
|
+
before, after = edited_value(assertion, edit)
|
|
60
|
+
finding.call('expected_value_changed', detail: edit.source, before: before, after: after)
|
|
61
|
+
end
|
|
62
|
+
found << finding.call('skip_added', detail: 'marked to be skipped') if now.skipped && !was.skipped
|
|
63
|
+
found << finding.call('no_assertions', detail: 'asserts nothing') if now.assertions.empty? && was.body != now.body
|
|
64
|
+
found
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The one argument that differs between two calls of the same assertion,
|
|
68
|
+
# as [before, after], when everything else about them is the same.
|
|
69
|
+
def self.edited_value(was, now)
|
|
70
|
+
return unless was.method == now.method && was.arguments.size == now.arguments.size
|
|
71
|
+
|
|
72
|
+
differing = was.arguments.zip(now.arguments).reject { |before, after| before == after }
|
|
73
|
+
differing.first if differing.size == 1
|
|
74
|
+
end
|
|
75
|
+
private_class_method :reworded, :likeness, :weaker, :edited_value
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
|
|
5
|
+
module Slopbouncer
|
|
6
|
+
# What git knows about a change: which files it touched, and what each
|
|
7
|
+
# held before and after.
|
|
8
|
+
module Git
|
|
9
|
+
Changed = Struct.new(:before_path, :after_path, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
# The files changed between two commits, or between a commit and the
|
|
12
|
+
# working tree when no second commit is given. A file that was added has
|
|
13
|
+
# no before path, and one that was deleted has no after path.
|
|
14
|
+
def self.changed(base, head = nil)
|
|
15
|
+
run('diff', '--name-status', '-M', *[base, head].compact).lines.map do |line|
|
|
16
|
+
status, first, second = line.chomp.split("\t")
|
|
17
|
+
case status[0]
|
|
18
|
+
when 'A' then Changed.new(after_path: first)
|
|
19
|
+
when 'D' then Changed.new(before_path: first)
|
|
20
|
+
when 'R' then Changed.new(before_path: first, after_path: second)
|
|
21
|
+
else Changed.new(before_path: first, after_path: first)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# What a file holds at a commit, or in the working tree when none is given.
|
|
27
|
+
def self.show(revision, path)
|
|
28
|
+
revision ? run('show', "#{revision}:#{path}") : File.read(File.join(root, path))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.root
|
|
32
|
+
@root ||= run('rev-parse', '--show-toplevel').strip
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.run(*arguments)
|
|
36
|
+
output, error, status = Open3.capture3('git', *arguments)
|
|
37
|
+
raise Error, "git #{arguments.first} failed: #{error.strip}" unless status.success?
|
|
38
|
+
|
|
39
|
+
output
|
|
40
|
+
end
|
|
41
|
+
private_class_method :run, :root
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module Slopbouncer
|
|
7
|
+
# Sets a coding agent up to run Slopbouncer when it finishes, by adding one
|
|
8
|
+
# hook to that agent's settings in the current directory. Everything else in
|
|
9
|
+
# the settings is kept, and setting up twice changes nothing.
|
|
10
|
+
module Install
|
|
11
|
+
# Says what it did, in one line.
|
|
12
|
+
def self.call(agent_name, local: false, base: 'HEAD')
|
|
13
|
+
agent = Agents.named(agent_name)
|
|
14
|
+
path = agent.settings_path(local: local)
|
|
15
|
+
settings = read(path)
|
|
16
|
+
return "#{path} already runs Slopbouncer. Nothing changed." if agent.installed?(settings)
|
|
17
|
+
|
|
18
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
19
|
+
File.write(path, "#{JSON.pretty_generate(agent.add(settings, agent.command(base)))}\n")
|
|
20
|
+
["Added Slopbouncer to #{path}. #{agent_name} will run it when it is about to finish.",
|
|
21
|
+
(agent::AFTER if agent.const_defined?(:AFTER))].compact.join("\n")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.read(path)
|
|
25
|
+
return {} unless File.exist?(path)
|
|
26
|
+
|
|
27
|
+
settings = JSON.parse(File.read(path))
|
|
28
|
+
raise Error, "#{path} does not hold settings Slopbouncer can add to. Nothing changed." unless settings.is_a?(Hash)
|
|
29
|
+
|
|
30
|
+
settings
|
|
31
|
+
rescue JSON::ParserError
|
|
32
|
+
raise Error, "#{path} could not be read as JSON. Nothing changed."
|
|
33
|
+
end
|
|
34
|
+
private_class_method :read
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'prism'
|
|
4
|
+
|
|
5
|
+
module Slopbouncer
|
|
6
|
+
# Reads a Ruby test file into a table of its tests: each test's full name,
|
|
7
|
+
# the assertions it makes, whether it is marked to be skipped, and its body.
|
|
8
|
+
# It reads the code and never runs it.
|
|
9
|
+
module RubyTests
|
|
10
|
+
Test = Struct.new(:name, :assertions, :skipped, :body, keyword_init: true)
|
|
11
|
+
Assertion = Struct.new(:method, :arguments, :source, keyword_init: true)
|
|
12
|
+
|
|
13
|
+
GROUPS = %i[describe context feature].freeze
|
|
14
|
+
SKIPPED_GROUPS = %i[xdescribe xcontext xfeature].freeze
|
|
15
|
+
TESTS = %i[it test specify scenario example].freeze
|
|
16
|
+
SKIPPED_TESTS = %i[xit xtest xspecify xscenario xexample].freeze
|
|
17
|
+
SKIPS = %i[skip pending].freeze
|
|
18
|
+
ASSERTION = /\A(assert|refute|must|wont)(_|\z)|\A(expect|flunk)\z/
|
|
19
|
+
|
|
20
|
+
# helpers: the project's own method names that count as assertions.
|
|
21
|
+
def self.in(source, helpers: [])
|
|
22
|
+
Reader.new(helpers).read(Prism.parse(source).value)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Walks the syntax tree, keeping the names of the groups it is inside.
|
|
26
|
+
class Reader
|
|
27
|
+
def initialize(helpers)
|
|
28
|
+
@helpers = helpers.map(&:to_sym)
|
|
29
|
+
@tests = []
|
|
30
|
+
@groups = []
|
|
31
|
+
@skipping = 0
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def read(node)
|
|
35
|
+
visit(node)
|
|
36
|
+
@tests
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def visit(node)
|
|
42
|
+
return unless node
|
|
43
|
+
|
|
44
|
+
case node
|
|
45
|
+
when Prism::ClassNode, Prism::ModuleNode then inside(node.constant_path.slice) { visit(node.body) }
|
|
46
|
+
when Prism::DefNode then node.name.start_with?('test_') ? test(node.name.to_s, node.body) : visit(node.body)
|
|
47
|
+
when Prism::CallNode then call(node)
|
|
48
|
+
else node.compact_child_nodes.each { |child| visit(child) }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def call(node)
|
|
53
|
+
label = label(node)
|
|
54
|
+
if !(node.block && label) then node.compact_child_nodes.each { |child| visit(child) }
|
|
55
|
+
elsif GROUPS.include?(node.name) then inside(label) { visit(node.block) }
|
|
56
|
+
elsif SKIPPED_GROUPS.include?(node.name) then inside(label, skipped: true) { visit(node.block) }
|
|
57
|
+
elsif TESTS.include?(node.name) then test(label, node.block)
|
|
58
|
+
elsif SKIPPED_TESTS.include?(node.name) then test(label, node.block, skipped: true)
|
|
59
|
+
else node.compact_child_nodes.each { |child| visit(child) }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# What a group or test is called: its first argument, as written.
|
|
64
|
+
def label(node)
|
|
65
|
+
first = node.arguments&.arguments&.first
|
|
66
|
+
return unless first
|
|
67
|
+
|
|
68
|
+
first.respond_to?(:unescaped) ? first.unescaped : first.slice
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def inside(name, skipped: false)
|
|
72
|
+
@groups.push(name)
|
|
73
|
+
@skipping += 1 if skipped
|
|
74
|
+
yield
|
|
75
|
+
ensure
|
|
76
|
+
@skipping -= 1 if skipped
|
|
77
|
+
@groups.pop
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def test(name, body, skipped: false)
|
|
81
|
+
calls = calls_in(body)
|
|
82
|
+
@tests << Test.new(
|
|
83
|
+
name: [*@groups, name].join(' '),
|
|
84
|
+
assertions: calls.select { |call| assertion?(call) }.map { |call| assertion(call) },
|
|
85
|
+
skipped: skipped || @skipping.positive? || calls.any? { |call| call.receiver.nil? && SKIPS.include?(call.name) },
|
|
86
|
+
body: body_of(body)
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def calls_in(node)
|
|
91
|
+
return [] unless node
|
|
92
|
+
|
|
93
|
+
own = node.is_a?(Prism::CallNode) ? [node] : []
|
|
94
|
+
own + node.compact_child_nodes.flat_map { |child| calls_in(child) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def assertion?(call)
|
|
98
|
+
call.name.match?(ASSERTION) || @helpers.include?(call.name)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def assertion(node)
|
|
102
|
+
arguments = node.arguments&.arguments.to_a.map { |argument| argument.slice.split.join(' ') }
|
|
103
|
+
Assertion.new(method: node.name.to_s, arguments: arguments, source: node.slice.split.join(' '))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The lines inside a test, without its opening and closing lines.
|
|
107
|
+
def body_of(node)
|
|
108
|
+
inner = node.is_a?(Prism::BlockNode) ? node.body : node
|
|
109
|
+
inner ? inner.slice.lines.map(&:strip).reject(&:empty?) : []
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
data/lib/slopbouncer.rb
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
require_relative 'slopbouncer/version'
|
|
6
|
+
require_relative 'slopbouncer/git'
|
|
7
|
+
require_relative 'slopbouncer/ruby_tests'
|
|
8
|
+
require_relative 'slopbouncer/compare'
|
|
9
|
+
require_relative 'slopbouncer/agents'
|
|
10
|
+
require_relative 'slopbouncer/install'
|
|
11
|
+
|
|
12
|
+
# A static check that flags tests which got weaker in a change, so a person
|
|
13
|
+
# can decide whether that was intended.
|
|
14
|
+
module Slopbouncer
|
|
15
|
+
class Error < StandardError; end
|
|
16
|
+
|
|
17
|
+
# A project's own settings, in the directory the command is run from:
|
|
18
|
+
# assertions: the names of its helper methods that count as assertions.
|
|
19
|
+
CONFIG = '.slopbouncer.yml'
|
|
20
|
+
|
|
21
|
+
TEST_FILE = %r{(\A|/)(test|spec)/.*\.rb\z|(\A|/)(test_[^/]*|[^/]*_(test|spec))\.rb\z}
|
|
22
|
+
|
|
23
|
+
USAGE = 'Give a range of commits, such as main..HEAD, or one commit to compare the working tree with, such as HEAD'
|
|
24
|
+
|
|
25
|
+
# The findings for the commits in a range such as main..HEAD, or for
|
|
26
|
+
# everything since one commit, committed or not, when given just that.
|
|
27
|
+
def self.check(target)
|
|
28
|
+
base, head = target.split('..', 2)
|
|
29
|
+
raise Error, USAGE if base.to_s.empty? || (target.include?('..') && head.to_s.empty?)
|
|
30
|
+
|
|
31
|
+
helpers = settings.fetch('assertions', [])
|
|
32
|
+
results = Git.changed(base, head).filter_map do |changed|
|
|
33
|
+
next unless [changed.before_path, changed.after_path].compact.all? { |path| path.match?(TEST_FILE) }
|
|
34
|
+
|
|
35
|
+
before = changed.before_path ? RubyTests.in(Git.show(base, changed.before_path), helpers: helpers) : []
|
|
36
|
+
after = changed.after_path ? RubyTests.in(Git.show(head, changed.after_path), helpers: helpers) : []
|
|
37
|
+
[changed.after_path || changed.before_path, Compare.call(changed.after_path || changed.before_path, before, after)]
|
|
38
|
+
end
|
|
39
|
+
results.flat_map { |_file, result| result.findings } + deleted(results)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# A test that has gone from one file is deleted unless the same test, body
|
|
43
|
+
# for body, is new in another file of the same change.
|
|
44
|
+
def self.deleted(results)
|
|
45
|
+
moved = results.flat_map { |_file, result| result.fresh.map(&:body) }
|
|
46
|
+
results.flat_map do |file, result|
|
|
47
|
+
result.gone.reject { |test| moved.include?(test.body) }.map { |test| Compare.deleted(file, test) }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.settings
|
|
52
|
+
File.exist?(CONFIG) ? YAML.safe_load_file(CONFIG) || {} : {}
|
|
53
|
+
rescue Psych::Exception => e
|
|
54
|
+
raise Error, "#{CONFIG} could not be read: #{e.message}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.run(arguments, input: $stdin)
|
|
58
|
+
words = arguments.reject { |argument| argument.start_with?('--') }
|
|
59
|
+
return install(words.drop(1), arguments) if words.first == 'install'
|
|
60
|
+
|
|
61
|
+
target = words.first
|
|
62
|
+
agent = arguments.find { |argument| argument == '--agent' || argument.start_with?('--agent=') }
|
|
63
|
+
return stop_hook(target.to_s, input, agent.split('=', 2)[1] || Agents::DEFAULT) if agent
|
|
64
|
+
|
|
65
|
+
findings = check(target.to_s)
|
|
66
|
+
puts arguments.include?('--json') ? JSON.pretty_generate(findings.map { |finding| finding.to_h.compact }) : report(findings)
|
|
67
|
+
arguments.include?('--strict') && findings.any? ? 1 : 0
|
|
68
|
+
rescue Error => e
|
|
69
|
+
warn e.message
|
|
70
|
+
2
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Sets a coding agent up in the current directory: slopbouncer install claude-code
|
|
74
|
+
def self.install(words, arguments)
|
|
75
|
+
raise Error, "Say which agent to set up: #{Agents::ALL.keys.join(', ')}" if words.empty?
|
|
76
|
+
|
|
77
|
+
puts Install.call(words.first, local: arguments.include?('--local'))
|
|
78
|
+
0
|
|
79
|
+
rescue Error => e
|
|
80
|
+
warn e.message
|
|
81
|
+
2
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Run as a coding agent's stop hook. The agent is stopped from finishing
|
|
85
|
+
# once and told what it weakened, in the way its own harness expects. A
|
|
86
|
+
# mistake in how the hook is set up never stops the agent: it is said on
|
|
87
|
+
# standard error and the agent is let finish.
|
|
88
|
+
def self.stop_hook(target, input, agent_name)
|
|
89
|
+
agent = Agents.named(agent_name)
|
|
90
|
+
told = hook_input(input)
|
|
91
|
+
return 0 if agent.stopped_before?(told)
|
|
92
|
+
|
|
93
|
+
findings = Dir.chdir(working_directory(told)) { check(target) }
|
|
94
|
+
return 0 if findings.empty?
|
|
95
|
+
|
|
96
|
+
output, error, status = agent.block(to_the_agent(findings))
|
|
97
|
+
print output
|
|
98
|
+
warn error unless error.empty?
|
|
99
|
+
status
|
|
100
|
+
rescue Error => e
|
|
101
|
+
warn e.message
|
|
102
|
+
0
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Where the agent says it is working, when its harness says so on the
|
|
106
|
+
# hook's input. Some harnesses run hooks from somewhere else.
|
|
107
|
+
def self.working_directory(told)
|
|
108
|
+
said = told['cwd'] || Array(told['workspace_roots']).first
|
|
109
|
+
said.is_a?(String) && File.directory?(said) ? said : Dir.pwd
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# What the agent's harness passes the hook on standard input, if anything.
|
|
113
|
+
def self.hook_input(input)
|
|
114
|
+
text = input.tty? ? '' : input.read.to_s
|
|
115
|
+
parsed = text.strip.empty? ? {} : JSON.parse(text)
|
|
116
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
117
|
+
rescue JSON::ParserError
|
|
118
|
+
{}
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def self.to_the_agent(findings)
|
|
122
|
+
<<~MESSAGE
|
|
123
|
+
Slopbouncer: this change weakens #{findings.size} existing #{findings.size == 1 ? 'test' : 'tests'}.
|
|
124
|
+
|
|
125
|
+
#{report(findings)}
|
|
126
|
+
|
|
127
|
+
For each one, do one of two things before you finish:
|
|
128
|
+
1. Put the test back as it was, and make the code pass it.
|
|
129
|
+
2. Leave the change, and tell the person which behaviour changed and why the test had to change with it.
|
|
130
|
+
MESSAGE
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def self.report(findings)
|
|
134
|
+
findings.map { |finding|
|
|
135
|
+
what = finding.before ? "#{finding.before} → #{finding.after}" : finding.detail
|
|
136
|
+
"#{finding.file}: #{finding.test}: #{finding.rule}: #{what}"
|
|
137
|
+
}.join("\n")
|
|
138
|
+
end
|
|
139
|
+
private_class_method :deleted, :settings, :install, :stop_hook, :working_directory, :hook_input, :to_the_agent
|
|
140
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: slopbouncer
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Kipple Pty Ltd
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: prism
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.5'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '3'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '1.5'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '3'
|
|
32
|
+
description: A static, diff-aware check for code written by coding agents. It compares
|
|
33
|
+
each test before and after a change and flags the ones that got weaker, and can
|
|
34
|
+
tell the agent that made the change before it finishes.
|
|
35
|
+
executables:
|
|
36
|
+
- slopbouncer
|
|
37
|
+
extensions: []
|
|
38
|
+
extra_rdoc_files: []
|
|
39
|
+
files:
|
|
40
|
+
- LICENSE
|
|
41
|
+
- README.md
|
|
42
|
+
- bin/slopbouncer
|
|
43
|
+
- lib/slopbouncer.rb
|
|
44
|
+
- lib/slopbouncer/agents.rb
|
|
45
|
+
- lib/slopbouncer/compare.rb
|
|
46
|
+
- lib/slopbouncer/git.rb
|
|
47
|
+
- lib/slopbouncer/install.rb
|
|
48
|
+
- lib/slopbouncer/ruby_tests.rb
|
|
49
|
+
- lib/slopbouncer/version.rb
|
|
50
|
+
homepage: https://github.com/mainline-dev/slopbouncer
|
|
51
|
+
licenses:
|
|
52
|
+
- MIT
|
|
53
|
+
metadata:
|
|
54
|
+
rubygems_mfa_required: 'true'
|
|
55
|
+
rdoc_options: []
|
|
56
|
+
require_paths:
|
|
57
|
+
- lib
|
|
58
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
59
|
+
requirements:
|
|
60
|
+
- - ">="
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: '3.4'
|
|
63
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0'
|
|
68
|
+
requirements: []
|
|
69
|
+
rubygems_version: 3.6.9
|
|
70
|
+
specification_version: 4
|
|
71
|
+
summary: Flags tests that got weaker in a change
|
|
72
|
+
test_files: []
|