scrap4rb 0.1.0 → 0.2.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9534e00ea290711ab140dcca603641ba180313ace9259a32c7b031cffaca788a
4
- data.tar.gz: cd326a737dd566c651b5c25ef133415afe38d667756fb9138ae20cfbbb951709
3
+ metadata.gz: 6e95177ddee73c4a8b9c61d2b7ba4f71da0f0809f4fd321728b4212f68e195f8
4
+ data.tar.gz: c457ee8fe9023e42b7388093da7ad75488155b3c19d82139f34a52043bbb43b6
5
5
  SHA512:
6
- metadata.gz: a372446e785fa9b0d5b915577df54a1526e77821357c7971095ab3b859b72778c23e85c9a8b984934a208a4d2a31c1c3d4f5df5f2a1904544925f21b8e8e7ed7
7
- data.tar.gz: 8af1af6f60679abb63c4e8ae1454176be74292096d6f45a78bcdbcfaa00ebe8e0013929488a408d00cb6f334888b0adc54d8f86102b474e131501f3467617a7f
6
+ metadata.gz: c3018805b73ab90fc395558602f14c43d1a6314640008de45118f8bdc8e46b6617d4174cf81b156691ebbec0ddb85bd4fe9e77e69b9c735b94bd307d0a038058
7
+ data.tar.gz: 54bf9499db12c403578b056c4eddb4a1c0de6066e7f9e32a7103dbab2c91e0ee5f979cb064446be254abf89433174b359a28c3c0090b7a8c74de3bb8c023cc47
data/CHANGELOG.md CHANGED
@@ -6,6 +6,30 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.1] - 2026-10-16
10
+
11
+ ### Fixed
12
+ - The README tells you to run scrap4rb from the project root when you run it from a checkout. From the scrap4rb folder, scrap4rb does not find the shared assertion helpers of the project, and a test that uses one reads as `no-assertions`.
13
+ - The README lists all structure errors, limits the 35% low-assertion rule to files with more than two tests, and lists three more deviations from SCRAP.
14
+
15
+ ## [0.2.0] - 2026-10-15
16
+
17
+ ### Changed
18
+ - The setup depth counts 1 for each `let`, `let!`, `subject` and instance variable in a setup block, plus 1 for a setup block with other statements. Before, it counted 1 for each statement in a setup block, and 0 for `let` and `subject`. See `docs/setup-depth.md`.
19
+ - temp-resource-work also counts file reads and writes, directories and processes: `File`, `IO` and `Dir` calls such as `read`, `open` and `mkdir`, `IO.popen`, `Open3` and `exec`. `File.join` and `file_fixture(...).read` do not count.
20
+ - A `begin` with several `rescue` clauses is one branch, as a `try` is in SCRAP. Before, each clause counted 1.
21
+ - The `--compare` verdict is `worse` for hidden helper lines only when more tests have them than the number of new tests. Before, a new test that called a helper made the verdict `worse`. See `docs/compare-verdict.md`.
22
+
23
+ ### Added
24
+ - A `why` line for each file, with the ratios and counts behind the score, as in the SCRAP report.
25
+ - `where` lines for a file with more than one describe block or class: the three blocks with the most pressure and the worst test of each. The JSON report has them as `top_blocks`.
26
+ - `--compare` shows `example_count_delta`.
27
+ - `loop do` is a branch, and `for x in` is a table branch. A `for` loop over a literal table is table-driven.
28
+ - The setup depth follows `setup :method_name` to the body of the method.
29
+
30
+ ### Fixed
31
+ - A bare `super` in `def setup` no longer counts as a setup statement.
32
+
9
33
  ## [0.1.0] - 2026-10-02
10
34
 
11
35
  ### Added
@@ -19,5 +43,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
19
43
  `describe`/`it` blocks. A `define_method` test inside a loop over a table
20
44
  (`CASES.each`) is one table-driven test that spans the loop.
21
45
 
22
- [Unreleased]: https://github.com/roberthopman/scrap4rb/compare/v0.1.0...HEAD
46
+ [Unreleased]: https://github.com/roberthopman/scrap4rb/compare/v0.2.1...HEAD
47
+ [0.2.1]: https://github.com/roberthopman/scrap4rb/compare/v0.2.0...v0.2.1
48
+ [0.2.0]: https://github.com/roberthopman/scrap4rb/compare/v0.1.0...v0.2.0
23
49
  [0.1.0]: https://github.com/roberthopman/scrap4rb/releases/tag/v0.1.0
data/README.md CHANGED
@@ -22,10 +22,11 @@ Or add it to a Gemfile:
22
22
  gem "scrap4rb", group: :development
23
23
  ```
24
24
 
25
- From a checkout you can run it without installing:
25
+ From a checkout you can run it without installing. Run it from the project root, because scrap4rb finds the shared assertion helpers from the current directory:
26
26
 
27
27
  ```bash
28
- ruby -Ilib exe/scrap4rb path/to/project/test
28
+ cd path/to/project
29
+ ruby -I path/to/scrap4rb/lib path/to/scrap4rb/exe/scrap4rb
29
30
  ```
30
31
 
31
32
  ## Usage
@@ -52,9 +53,12 @@ The comparison gives each changed file a verdict: `improved`, `worse`, `mixed` o
52
53
 
53
54
  ## Output
54
55
 
56
+ An example, with some lines left out and some lines shortened:
57
+
55
58
  ```
56
59
  test/models/smelly_test.rb
57
60
  score 48.46 HIGH mode LOCAL ai AUTO_REFACTOR tests=6 avg=12.78 max=18.26
61
+ why low-assert=0.83 branching=0.17 mocking=0.17 helper-hidden=1 harmful-dup=11 extraction=0.0 matrix=1 subject-rep=0
58
62
  HIGH Strengthen assertions in weak tests before doing structural cleanup.
59
63
  LOW Be skeptical of helper extraction that only hides setup; ...
60
64
  18.3 L59 it uses a long local helper low-assertion-density,helper-hidden-complexity
@@ -70,7 +74,12 @@ score Refactor pressure of the file
70
74
  level STABLE, LOW, MEDIUM, HIGH or CRITICAL
71
75
  mode STABLE: leave it. LOCAL: fix tests in place. SPLIT: split the file first
72
76
  ai LEAVE_ALONE, AUTO_TABLE_DRIVE, AUTO_REFACTOR, MANUAL_SPLIT or REVIEW_FIRST
77
+ why The ratios and counts behind the score: tests with 1 assertion or fewer, with
78
+ branches, with stubs and with hidden helper lines; duplication; extraction
79
+ pressure; table-drive candidates; subject repetition
73
80
  HIGH/... Recommendations, ranked by confidence, at most four
81
+ where The three describe blocks or classes with the most pressure, with the worst
82
+ test of each. Only for a file with more than one block
74
83
  tests The SCRAP score of each test, worst first, with its smells
75
84
  EXTRACT A group of tests where a shared helper pays for itself
76
85
  ```
@@ -79,8 +88,8 @@ EXTRACT A group of tests where a shared helper pays for itself
79
88
 
80
89
  Structure errors:
81
90
 
82
- - a `test` inside a `test`
83
- - a `setup`, `teardown`, `describe`, `context` or `class` inside a test
91
+ - a `test`, `it` or `specify` inside a test
92
+ - a `setup`, `teardown`, `before`, `after`, `describe`, `context`, `def setup` or `class` inside a test
84
93
  - a second `def test_x` with the same name in one class (the first one never runs)
85
94
  - parse errors
86
95
 
@@ -95,7 +104,7 @@ multiple-phases assert, act, assert again 5
95
104
  high-mocking more than 3 stubs or mocks 4
96
105
  large-example more than 20 lines 4
97
106
  helper-hidden-complexity more than 8 lines hidden in local helpers 4
98
- temp-resource-work temporary files, threads or shell calls 3
107
+ temp-resource-work files, directories, threads or processes 3
99
108
  literal-heavy-setup a string over 5 lines, a hash or array over 10 3
100
109
  ```
101
110
 
@@ -114,13 +123,15 @@ SCRAP (speclj) scrap4rb (Minitest)
114
123
  -------------- -------------------
115
124
  describe, context a test class, or a minitest/spec describe block
116
125
  it test "..." do, def test_*, it "..." do, define_method(:"test_...")
117
- before, with setup blocks and def setup, inherited by every test in the class
126
+ before, with setup blocks, setup :method and def setup, let and subject,
127
+ inherited by every test in the class (see docs/setup-depth.md)
118
128
  let, binding travel_to, freeze_time, within, using_session,
119
129
  perform_enqueued_jobs, with_*, and stub with a block
120
130
  with-redefs stub, stub_request, any_instance, expects, stubs, Minitest::Mock.new
121
131
  should* assert*, refute*, must_*, wont_*
122
- doseq, for, every? each, each_with_index, map, times, all?, ...
123
- if, when, cond, and if, unless, case, while, until, &&, ||, rescue
132
+ doseq, for, every? for, each, each_with_index, map, times, all?, ...
133
+ if, when, cond, and if, unless, case, while, until, &&, ||, rescue modifier
134
+ loop, try loop, begin/rescue (one per begin)
124
135
  ```
125
136
 
126
137
  The policy numbers (weights, thresholds, levels) are SCRAP's, unchanged.
@@ -128,15 +139,18 @@ The policy numbers (weights, thresholds, levels) are SCRAP's, unchanged.
128
139
  ## Deviations from SCRAP
129
140
 
130
141
  - A test whose whole body is one context block, such as `travel_to(...) do ... end`, is unwrapped before the phase count. In Ruby that block is test context, not arrange code.
131
- - A call to an assertion helper defined in `test/test_helper.rb`, `test/application_system_test_case.rb`, `test/test_helpers/` or `test/support/` counts as one assertion. SCRAP only reads the spec file itself. Without this rule, every test that uses a shared assertion helper would be "no-assertions".
142
+ - A call to an assertion helper defined in `test/test_helper.rb`, `test/application_system_test_case.rb`, `test/test_helpers/` or `test/support/` counts as one assertion. scrap4rb finds these files from the current directory. SCRAP only reads the spec file itself. Without this rule, every test that uses a shared assertion helper would be "no-assertions".
132
143
  - A loop over a literal hash (`{ 5 => 0, 6 => 10 }.each`) counts as table-driven, the same as a loop over an array of arrays.
144
+ - `--compare` does not call new tests that use a helper a helper regression. See `docs/compare-verdict.md`.
145
+ - The `where` lines show only for a file with more than one describe block or class. SCRAP shows them for each file.
146
+ - A `describe` inside a `describe` is not a structure error, because minitest/spec nests them. A second test with the same name is a structure error. SCRAP has no such check.
133
147
 
134
148
  ## Notes for Rails suites
135
149
 
136
150
  Two SCRAP rules can disagree with common Rails practice. Decide for your own suite.
137
151
 
138
152
  - **multiple-phases**: a system test that asserts after each click (to prove that the page changed before the next step) has several phases, and SCRAP gives it 5 points.
139
- - **Low-assertion files**: a file is not STABLE when more than 35% of its tests have one assertion or fewer. A suite with "one behavior, one assertion" tests will have many LOCAL files for this reason only.
153
+ - **Low-assertion files**: a file with more than two tests is not STABLE when more than 35% of its tests have one assertion or fewer. A suite with "one behavior, one assertion" tests will have many LOCAL files for this reason only.
140
154
 
141
155
  ## Development
142
156
 
@@ -152,7 +166,7 @@ The golden tests run the executable against the fixture suite in `test/fixtures/
152
166
 
153
167
  SCRAP is by Robert C. Martin (Uncle Bob): [github.com/unclebob/scrap](https://github.com/unclebob/scrap). The algorithm, the metrics, the smells and the policy numbers in scrap4rb come from SCRAP. scrap4rb is an independent Ruby implementation. It contains no SCRAP source code.
154
168
 
155
- scrap4rb 0.1.0 follows SCRAP at commit [`f793b28`](https://github.com/unclebob/scrap/tree/f793b28906d3c60e30b128e1ec08df6269a72b13) (2026-03-17). When SCRAP changes its rules or numbers, scrap4rb can differ until it follows again.
169
+ scrap4rb follows SCRAP at commit [`f793b28`](https://github.com/unclebob/scrap/tree/f793b28906d3c60e30b128e1ec08df6269a72b13) (2026-03-17). When SCRAP changes its rules or numbers, scrap4rb can differ until it follows again.
156
170
 
157
171
  Related tools by the same author: [crap4clj](https://github.com/unclebob/crap4clj), [crap4java](https://github.com/unclebob/crap4java), [crap4go](https://github.com/unclebob/crap4go) and [clj-mutate](https://github.com/unclebob/clj-mutate).
158
172
 
@@ -0,0 +1,55 @@
1
+ # The compare verdict
2
+
3
+ This document explains one deviation from SCRAP in `--compare`: when hidden helper lines make the verdict `worse`.
4
+
5
+ ## The rule in SCRAP
6
+
7
+ SCRAP compares the summary of each file with the baseline, and gives a verdict in this order:
8
+
9
+ ```
10
+ Verdict When
11
+ ------- ----
12
+ improved file score -5 or more, no rise in extraction pressure or in max SCRAP
13
+ worse helper regression: more tests with hidden helper lines, no fall in
14
+ extraction pressure, and no more table-drive candidates
15
+ worse any rise in extraction pressure or max SCRAP, or file score +5 or more
16
+ unchanged file score delta 0
17
+ mixed all other cases
18
+ ```
19
+
20
+ The helper regression rule looks for a refactor that moves setup into a local helper. The setup is then hidden, but the duplication does not go down. The rule uses `helper_hidden_delta`: the change in the number of tests that call a local helper.
21
+
22
+ The source is `comparison.clj` in [unclebob/scrap](https://github.com/unclebob/scrap).
23
+
24
+ ## The problem
25
+
26
+ `helper_hidden_delta` is a count, not a ratio. A new test that calls an existing helper adds 1. Thus a change that adds tests which call a helper gets the verdict `worse`, also when all scores improve. scrap4rb's own test suite shows this:
27
+
28
+ ```
29
+ Change file score max SCRAP helper_hidden tests SCRAP verdict
30
+ ------ ---------- --------- ------------- ----- -------------
31
+ 4 tests for setup depth -0.75 0.0 +2 +4 worse
32
+ 6 tests for branches and files -0.76 0.0 +6 +6 worse
33
+ ```
34
+
35
+ The advice that follows, "revert it, or simplify the helpers", is wrong for these changes. SCRAP has the same behavior.
36
+
37
+ ## The rule in scrap4rb
38
+
39
+ scrap4rb compares `helper_hidden_delta` with the number of new tests:
40
+
41
+ ```
42
+ helper regression = helper_hidden_delta > max(example_count_delta, 0)
43
+ and extraction pressure did not fall
44
+ and table-drive candidates did not rise
45
+ ```
46
+
47
+ - A new test that calls a helper is not a regression.
48
+ - An existing test that starts to call a helper is a regression, as in SCRAP.
49
+ - When tests are removed, the limit is 0, so removed tests cannot hide a regression.
50
+
51
+ `--compare` shows `example_count_delta`, so the reason for a verdict is visible.
52
+
53
+ ## The limit of the rule
54
+
55
+ The rule counts tests, not names. Suppose a change adds 2 tests without helpers and moves 2 existing tests to a helper. Then `helper_hidden_delta` is 2, `example_count_delta` is 2, and the rule does not see the regression. To see it, scrap4rb would have to match the tests in the baseline by name. The baseline does not store the tests, so scrap4rb does not do this.
@@ -0,0 +1,72 @@
1
+ # Setup depth
2
+
3
+ This document explains how scrap4rb counts the setup depth of a test, and why the count differs from a literal port of SCRAP.
4
+
5
+ ## Setup depth in SCRAP
6
+
7
+ SCRAP gives each test (`it`) a setup depth in two steps:
8
+
9
+ 1. The start value is the number of setup forms in the enclosing `describe` and `context` blocks: `before`, `before-all`, `with`, `around`, `with-stubs`, `let`, `binding` and `with-redefs`. Each form counts 1, however many lines it has.
10
+ 2. Inside the test, each nested `let`, `binding` or `with-redefs` adds 1. SCRAP keeps the deepest level, not the sum.
11
+
12
+ The setup depth has three effects:
13
+
14
+ - It goes into the complexity score: `saturating(1 + branches + setup_depth + helper_calls)`. The curve rises quickly at the start: 1 gives 1.0, 3 gives 8.26 and 5 gives 13.32.
15
+ - An API-contract test gets 2 levels free.
16
+ - A test with a setup depth above 2 cannot be a coverage-matrix candidate. Thus a file of such tests does not get the "make it table-driven" advice.
17
+
18
+ The source is `example_collect.clj`, `example_node.clj` and `example_metrics.clj` in [unclebob/scrap](https://github.com/unclebob/scrap).
19
+
20
+ ## Why a literal port does not work
21
+
22
+ speclj has two kinds of setup:
23
+
24
+ - `with` is a named, lazy binding. The Minitest equivalent is `let`, or an instance variable in `setup`.
25
+ - `before` is a block of actions. It counts 1, however many lines it has.
26
+
27
+ A Rails `setup` block mixes the two kinds:
28
+
29
+ ```ruby
30
+ setup do
31
+ @user = users(:alice)
32
+ @order = orders(:open)
33
+ sign_in @user
34
+ visit order_path(@order)
35
+ end
36
+ ```
37
+
38
+ In speclj, this is two `with` forms and one `before`, so the setup depth is 3.
39
+
40
+ ## The rule
41
+
42
+ scrap4rb counts the setup depth of a test class or a `describe` block as follows:
43
+
44
+ - Each `let`, `let!` and `subject` counts 1.
45
+ - In each `setup` block, `before` block and `def setup`, each instance variable assignment counts 1. For `setup :build_order`, scrap4rb reads the body of the method `build_order`.
46
+ - A setup block that also has other statements counts 1 more. A `super` call is not a statement for this rule.
47
+
48
+ A nested `describe` block adds its own setup depth to the setup depth of its parent. Inside a test, a context block (`travel_to`, `with_*`, `stub` with a block) adds 1, as a nested `with-redefs` does in SCRAP.
49
+
50
+ The three possible rules give different results for the setup block above:
51
+
52
+ ```
53
+ Rule Depth API test Large test Matrix candidate
54
+ ---- ----- -------- ---------- ----------------
55
+ A: 1 per setup block (+1 per let) 1 1.0 4.95 yes
56
+ B: 1 per statement (in 0.1.0) 4 8.26 13.32 no
57
+ C: 1 per @ivar or let, +1 if actions 3 4.95 11.0 no
58
+ ```
59
+
60
+ - Rule A is the literal mapping of `before`. A 20-line setup block costs 1, but SCRAP has the same weakness with a large `before`.
61
+ - Rule B counts too many. Each action line costs as much as a binding. With a 10-line setup block, the complexity score of a large test is 21.03 instead of 4.95. `let` costs nothing, so a move of the same setup from `setup` into `let` improves the score without a real change.
62
+ - Rule C gives the same number as the equivalent speclj code. scrap4rb uses rule C.
63
+
64
+ ## Locals in a test
65
+
66
+ In Clojure, a test keeps its locals in a `let`. That `let` adds 1 to the setup depth. SCRAP also gives the `let` form the setup phase, so the assertions inside it do not count as phases. Thus SCRAP never reports multiple-phases for this test:
67
+
68
+ ```clojure
69
+ (it "x" (let [x (atom 1)] (should= 1 @x) (reset! x 2) (should= 2 @x)))
70
+ ```
71
+
72
+ The same test in Ruby has flat locals. It gets a setup depth of 0 and multiple-phases (5 points). scrap4rb does not copy the Clojure behavior, because Ruby has no `let` scope for the locals of a test. The result is that a Ruby score and a speclj score of the same test can differ.
data/lib/scrap4rb/cli.rb CHANGED
@@ -41,16 +41,26 @@ module Scrap4rb
41
41
  opts
42
42
  end
43
43
 
44
- def analyze_file(path, shared_asserts)
45
- src = File.read(path)
44
+ def analyze_file(path, shared_asserts) = analyze_source(path, File.read(path), shared_asserts)
45
+
46
+ def analyze_source(path, src, shared_asserts)
46
47
  result = Prism.parse(src)
47
48
  exs = Example.from_tree(result.value, shared_assert_helpers: shared_asserts)
48
49
  summary = Summary.summarize(exs)
49
- blocks = exs.group_by { _1[:describe_path] }.reject { |p, _| p.empty? }.map { |p, b| { path: p, summary: Summary.summarize(b) } }
50
+ blocks = exs.group_by { _1[:describe_path] }.reject { |p, _| p.empty? }.map { |p, b| { path: p, summary: Summary.summarize(b), examples: b } }
50
51
  mode = Judgment.remediation_mode(summary, blocks)
51
52
  { path:, content_hash: Digest::SHA256.hexdigest(src)[0, 16], errors: Structure.errors(result), examples: exs, summary:,
52
53
  file_score: Judgment.pressure_score(summary), level: Judgment.level(summary), mode:,
53
- ai: Judgment.actionability(summary, mode), actions: Judgment.actions(summary, mode) }
54
+ ai: Judgment.actionability(summary, mode), actions: Judgment.actions(summary, mode), top_blocks: top_blocks(blocks) }
55
+ end
56
+
57
+ def top_blocks(blocks)
58
+ blocks.map do |b|
59
+ worst = b[:examples].max_by { _1[:scrap] }
60
+ { path: b[:path], score: Judgment.pressure_score(b[:summary]), level: Judgment.level(b[:summary]),
61
+ avg_scrap: b[:summary][:avg_scrap], harmful_duplication_score: b[:summary][:harmful_duplication_score],
62
+ worst: worst.slice(:name, :line, :scrap) }
63
+ end.sort_by { -_1[:score] }.first(Policy::REPORT[:top_block_count])
54
64
  end
55
65
 
56
66
  def print_report(r, verbose:)
@@ -58,9 +68,11 @@ module Scrap4rb
58
68
  puts "\n#{r[:path]}"
59
69
  r[:errors].each { puts " #{_1}" }
60
70
  puts " score #{r[:file_score]} #{r[:level]} mode #{r[:mode]} ai #{r[:ai]} tests=#{s[:example_count]} avg=#{s[:avg_scrap]} max=#{s[:max_scrap]}"
71
+ puts why_line(s)
61
72
  r[:actions].each { |c, t| puts " #{LABEL[c].ljust(6)} #{t}" }
73
+ r[:top_blocks].each { puts where_line(_1) } if r[:top_blocks].size > 1
62
74
  worst = r[:examples].sort_by { -_1[:scrap] }
63
- worst = worst.first(5) unless verbose
75
+ worst = worst.first(Policy::REPORT[:top_example_count]) unless verbose
64
76
  worst.each { puts example_line(_1, verbose) }
65
77
  s[:recommended_extractions].each do |x|
66
78
  puts " EXTRACT L#{x[:line_start]}-#{x[:line_end]} net=#{x[:net_benefit]} (I=#{x[:instances]} shared=#{x[:shared_forms]} variable=#{x[:variable_points]}): " +
@@ -68,6 +80,18 @@ module Scrap4rb
68
80
  end
69
81
  end
70
82
 
83
+ def why_line(s)
84
+ r = Judgment.ratios(s)
85
+ format(" why low-assert=%.2f branching=%.2f mocking=%.2f helper-hidden=%d harmful-dup=%d extraction=%.1f matrix=%d subject-rep=%d",
86
+ r[:low], r[:branching], r[:mocking], s[:helper_hidden_example_count], s[:harmful_duplication_score],
87
+ s[:extraction_pressure_score], s[:coverage_matrix_candidates], s[:subject_repetition_score])
88
+ end
89
+
90
+ def where_line(b)
91
+ format(" where %s %s %.1f avg=%.1f dup=%d worst: %s (%.1f)", b[:path].join(" / "), b[:level], b[:score],
92
+ b[:avg_scrap], b[:harmful_duplication_score], b[:worst][:name], b[:worst][:scrap])
93
+ end
94
+
71
95
  def example_line(e, verbose)
72
96
  extra = if verbose
73
97
  " [a=#{e[:assertions]} b=#{e[:branches]} d=#{e[:setup_depth]} stub=#{e[:with_redefs]} h=#{e[:helper_calls]} " \
@@ -92,7 +116,8 @@ module Scrap4rb
92
116
  max_scrap_delta: (a[:max_scrap] - b[:max_scrap]).round(2),
93
117
  extraction_pressure_delta: (a[:effective_duplication_score] - b[:effective_duplication_score]).round(2),
94
118
  case_matrix_delta: a[:case_matrix_repetition] - b[:case_matrix_repetition],
95
- helper_hidden_delta: a[:helper_hidden_example_count] - b[:helper_hidden_example_count] }
119
+ helper_hidden_delta: a[:helper_hidden_example_count] - b[:helper_hidden_example_count],
120
+ example_count_delta: a[:example_count] - b[:example_count] }
96
121
  [r[:path], cmp.merge(verdict: Judgment.verdict(cmp))]
97
122
  end
98
123
  end
@@ -61,8 +61,8 @@ module Scrap4rb
61
61
  expanded = helper && !stack.include?(node.name) ? expand(ctx, node.name, stack | [node.name], depth) : nil
62
62
  local = {
63
63
  assertions: assertion?(node) || shared_assert ? 1 : 0,
64
- branches: BRANCH_NODES.any? { node.is_a?(_1) } ? 1 : 0,
65
- table_branches: call?(node) && TABLE_CALLS.include?(node.name) && node.block ? 1 : 0,
64
+ branches: branch?(node) ? 1 : 0,
65
+ table_branches: table_branch?(node) ? 1 : 0,
66
66
  with_redefs: stub_call?(node) ? 1 : 0,
67
67
  helper_calls: helper ? 1 : 0,
68
68
  temp_resources: temp_resource?(node) ? 1 : 0,
@@ -121,8 +121,8 @@ module Scrap4rb
121
121
  ].select(&:first).map { { label: _2, penalty: _3 } }
122
122
  end
123
123
 
124
- def score(name:, node:, body:, ctx:, inherited_setup:, describe_path:, table_driven: false)
125
- m = combine(*body.map { analyze(_1, ctx, inherited_setup.size, Set.new) }, { table_driven: })
124
+ def score(name:, node:, body:, ctx:, inherited_setup:, inherited_depth:, describe_path:, table_driven: false)
125
+ m = combine(*body.map { analyze(_1, ctx, inherited_depth, Set.new) }, { table_driven: })
126
126
  raw_lines = lines_of(node)
127
127
  line_count = raw_lines + m[:helper_hidden_lines]
128
128
  forms = top_forms(body)
@@ -159,29 +159,42 @@ module Scrap4rb
159
159
  }
160
160
  end
161
161
 
162
- def setup_forms(scope_body)
163
- scope_body.flat_map do |n|
164
- if bare_call?(n) && SETUP_CALLS.include?(n.name) && n.block.is_a?(Prism::BlockNode) then block_body(n)
165
- elsif n.is_a?(Prism::DefNode) && n.name == :setup then statements(n.body).reject { bare_call?(_1) && _1.name == :super }
166
- else []
167
- end
162
+ def setup_body(node, helpers)
163
+ if bare_call?(node) && SETUP_CALLS.include?(node.name) && node.block.is_a?(Prism::BlockNode) then block_body(node)
164
+ elsif bare_call?(node) && SETUP_CALLS.include?(node.name) && node.block.nil? && node.arguments
165
+ node.arguments.arguments.grep(Prism::SymbolNode).flat_map { helpers.fetch(_1.unescaped.to_sym, []) }
166
+ elsif node.is_a?(Prism::DefNode) && node.name == :setup
167
+ statements(node.body).reject { _1.is_a?(Prism::ForwardingSuperNode) || _1.is_a?(Prism::SuperNode) }
168
+ end
169
+ end
170
+
171
+ def setup_forms(scope_body, helpers) = scope_body.flat_map { setup_body(_1, helpers) || [] }
172
+
173
+ def setup_depth(scope_body, helpers)
174
+ scope_body.sum do |n|
175
+ next 1 if bare_call?(n) && LET_CALLS.include?(n.name) && n.block
176
+ bindings, actions = (setup_body(n, helpers) || []).partition { _1.is_a?(Prism::InstanceVariableWriteNode) }
177
+ bindings.size + (actions.empty? ? 0 : 1)
168
178
  end
169
179
  end
170
180
 
171
181
  def test_body(node) = node.is_a?(Prism::DefNode) ? statements(node.body) : block_body(node)
172
182
 
173
- def collect(forms, ctx, path = [], inherited = [], out = [])
174
- setup = inherited + setup_forms(forms)
183
+ def collect(forms, ctx, path = [], inherited = [], inherited_depth = 0, out = [])
184
+ setup = inherited + setup_forms(forms, ctx.helpers)
185
+ depth = inherited_depth + setup_depth(forms, ctx.helpers)
175
186
  forms.each do |f|
176
187
  if (name, body = scope_children(f))
177
- collect(body, ctx, path + [name], setup, out)
188
+ collect(body, ctx, path + [name], setup, depth, out)
178
189
  elsif test_node?(f)
179
- out << score(name: test_name(f), node: f, body: test_body(f), ctx:, inherited_setup: setup, describe_path: path)
190
+ out << score(name: test_name(f), node: f, body: test_body(f), ctx:, inherited_setup: setup,
191
+ inherited_depth: depth, describe_path: path)
180
192
  elsif (inner = dynamic_test_in_loop(f))
181
193
  out << score(name: dynamic_test_name(inner), node: f, body: block_body(inner), ctx:, inherited_setup: setup,
182
- describe_path: path, table_driven: true)
194
+ inherited_depth: depth, describe_path: path, table_driven: true)
183
195
  elsif dynamic_test?(f)
184
- out << score(name: dynamic_test_name(f), node: f, body: block_body(f), ctx:, inherited_setup: setup, describe_path: path)
196
+ out << score(name: dynamic_test_name(f), node: f, body: block_body(f), ctx:, inherited_setup: setup,
197
+ inherited_depth: depth, describe_path: path)
185
198
  end
186
199
  end
187
200
  out
@@ -101,7 +101,8 @@ module Scrap4rb
101
101
 
102
102
  def verdict(c)
103
103
  if c[:file_score_delta] <= -5 && c[:extraction_pressure_delta] <= 0 && c[:max_scrap_delta] <= 0 then "improved"
104
- elsif c[:helper_hidden_delta].positive? && c[:extraction_pressure_delta] >= 0 && c[:case_matrix_delta] <= 0 then "worse"
104
+ elsif c[:helper_hidden_delta] > [c[:example_count_delta], 0].max && c[:extraction_pressure_delta] >= 0 && c[:case_matrix_delta] <= 0
105
+ "worse"
105
106
  elsif c[:extraction_pressure_delta].positive? || c[:max_scrap_delta].positive? || c[:file_score_delta] >= 5 then "worse"
106
107
  elsif c[:file_score_delta].zero? then "unchanged"
107
108
  else "mixed"
@@ -1,5 +1,6 @@
1
1
  module Scrap4rb
2
2
  module Policy
3
+ REPORT = { top_example_count: 5, top_block_count: 3 }.freeze
3
4
  DUPLICATION = { threshold: 0.5, matrix_max_scrap: 18, matrix_max_lines: 12, matrix_max_assertions: 1,
4
5
  matrix_max_branches: 0, matrix_max_setup_depth: 2, matrix_max_with_redefs: 0,
5
6
  matrix_max_temp_resources: 0, matrix_max_helper_hidden_lines: 0, matrix_max_subject_symbols: 2 }.freeze
@@ -8,10 +8,12 @@ module Scrap4rb
8
8
  STUB_CALLS = %i[stub stub_request any_instance expects stubs stub_const].to_set
9
9
  TABLE_CALLS = %i[each each_with_index each_pair each_with_object each_slice map flat_map times all? any? none?].to_set
10
10
  BRANCH_NODES = [Prism::IfNode, Prism::UnlessNode, Prism::CaseNode, Prism::CaseMatchNode, Prism::WhileNode,
11
- Prism::UntilNode, Prism::AndNode, Prism::OrNode, Prism::RescueNode, Prism::RescueModifierNode].freeze
11
+ Prism::UntilNode, Prism::AndNode, Prism::OrNode, Prism::RescueModifierNode].freeze
12
+ FILE_CALLS = %i[read readlines binread write binwrite open foreach popen mkdir].to_set
12
13
  TEST_CALLS = %i[test it specify].to_set
13
14
  BLOCK_CALLS = %i[describe context].to_set
14
15
  SETUP_CALLS = %i[setup before].to_set
16
+ LET_CALLS = %i[let let! subject].to_set
15
17
 
16
18
  extend self
17
19
 
@@ -44,6 +46,13 @@ module Scrap4rb
44
46
 
45
47
  def assertion?(node) = call?(node) && ASSERTION.match?(node.name)
46
48
 
49
+ def branch?(node)
50
+ BRANCH_NODES.any? { node.is_a?(_1) } || (node.is_a?(Prism::BeginNode) && !node.rescue_clause.nil?) ||
51
+ (bare_call?(node) && node.name == :loop && !node.block.nil?)
52
+ end
53
+
54
+ def table_branch?(node) = node.is_a?(Prism::ForNode) || (call?(node) && TABLE_CALLS.include?(node.name) && !node.block.nil?)
55
+
47
56
  def table_literal?(node)
48
57
  case node
49
58
  when Prism::ArrayNode then node.elements.size >= 2 && node.elements.all? { _1.is_a?(Prism::ArrayNode) || _1.is_a?(Prism::HashNode) }
@@ -53,6 +62,7 @@ module Scrap4rb
53
62
  end
54
63
 
55
64
  def table_driven_form?(node)
65
+ return table_literal?(node.collection) if node.is_a?(Prism::ForNode)
56
66
  return false unless call?(node)
57
67
  (TABLE_CALLS.include?(node.name) && node.block && table_literal?(node.receiver)) ||
58
68
  (assertion?(node) && (node.arguments&.arguments || []).any? { table_literal?(_1) })
@@ -62,10 +72,9 @@ module Scrap4rb
62
72
  return true if node.is_a?(Prism::XStringNode) || node.is_a?(Prism::InterpolatedXStringNode)
63
73
  return false unless call?(node)
64
74
  recv = node.receiver&.slice
65
- %i[mktmpdir tmpdir system spawn fork].include?(node.name) ||
66
- recv == "Tempfile" || recv == "Thread" ||
67
- (recv == "File" && %i[write binwrite].include?(node.name)) ||
68
- (recv == "FileUtils")
75
+ %i[mktmpdir tmpdir system spawn fork exec].include?(node.name) ||
76
+ %w[Tempfile Thread FileUtils Open3].include?(recv) ||
77
+ (%w[File IO Dir].include?(recv) && FILE_CALLS.include?(node.name))
69
78
  end
70
79
 
71
80
  def large_literal?(node)
@@ -85,7 +94,7 @@ module Scrap4rb
85
94
  end
86
95
 
87
96
  def control_call?(node)
88
- assertion?(node) || context_head?(node) || stub_call?(node) || TABLE_CALLS.include?(node.name) ||
97
+ assertion?(node) || context_head?(node) || stub_call?(node) || TABLE_CALLS.include?(node.name) || node.name == :loop ||
89
98
  TEST_CALLS.include?(node.name) || SETUP_CALLS.include?(node.name) || !node.name.match?(/\A[a-z_]/i)
90
99
  end
91
100
 
@@ -1,3 +1,3 @@
1
1
  module Scrap4rb
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.1"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: scrap4rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Robert Hopman
@@ -71,6 +71,8 @@ files:
71
71
  - CHANGELOG.md
72
72
  - LICENSE
73
73
  - README.md
74
+ - docs/compare-verdict.md
75
+ - docs/setup-depth.md
74
76
  - exe/scrap4rb
75
77
  - lib/scrap4rb.rb
76
78
  - lib/scrap4rb/cli.rb