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 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,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../lib/slopbouncer'
5
+
6
+ exit Slopbouncer.run(ARGV)
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slopbouncer
4
+ VERSION = '0.1.0'
5
+ end
@@ -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: []