scrap4rb 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9534e00ea290711ab140dcca603641ba180313ace9259a32c7b031cffaca788a
4
- data.tar.gz: cd326a737dd566c651b5c25ef133415afe38d667756fb9138ae20cfbbb951709
3
+ metadata.gz: 96286449e9afa50fcf63563ab4c24e5ea34422d4b78aef3c8ba5c5fecd0dad0a
4
+ data.tar.gz: 75e1b075bdc33a520a45c1cf95b5e20f484d6893969e5b138f1e6514e0fda703
5
5
  SHA512:
6
- metadata.gz: a372446e785fa9b0d5b915577df54a1526e77821357c7971095ab3b859b72778c23e85c9a8b984934a208a4d2a31c1c3d4f5df5f2a1904544925f21b8e8e7ed7
7
- data.tar.gz: 8af1af6f60679abb63c4e8ae1454176be74292096d6f45a78bcdbcfaa00ebe8e0013929488a408d00cb6f334888b0adc54d8f86102b474e131501f3467617a7f
6
+ metadata.gz: 76526de107521d564ae46d855eb70920b67f1029a9cf13d8f1831ba8ad2db2173a9a550926ec8e01ebb849f1559d153f8d095157e76dd632005a0e68619ca062
7
+ data.tar.gz: 0c6caaaf36b1d12058035482a7f8af88fabd089da3b8f3c54ccd08992bcadb1c80baa2fdf27204dfe866ddd378ac09a5368a623771a79057fdeeebc2a40a5daa
data/CHANGELOG.md CHANGED
@@ -6,6 +6,24 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.0] - 2026-10-15
10
+
11
+ ### Changed
12
+ - 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`.
13
+ - 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.
14
+ - A `begin` with several `rescue` clauses is one branch, as a `try` is in SCRAP. Before, each clause counted 1.
15
+ - 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`.
16
+
17
+ ### Added
18
+ - A `why` line for each file, with the ratios and counts behind the score, as in the SCRAP report.
19
+ - `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`.
20
+ - `--compare` shows `example_count_delta`.
21
+ - `loop do` is a branch, and `for x in` is a table branch. A `for` loop over a literal table is table-driven.
22
+ - The setup depth follows `setup :method_name` to the body of the method.
23
+
24
+ ### Fixed
25
+ - A bare `super` in `def setup` no longer counts as a setup statement.
26
+
9
27
  ## [0.1.0] - 2026-10-02
10
28
 
11
29
  ### Added
@@ -19,5 +37,6 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
19
37
  `describe`/`it` blocks. A `define_method` test inside a loop over a table
20
38
  (`CASES.each`) is one table-driven test that spans the loop.
21
39
 
22
- [Unreleased]: https://github.com/roberthopman/scrap4rb/compare/v0.1.0...HEAD
40
+ [Unreleased]: https://github.com/roberthopman/scrap4rb/compare/v0.2.0...HEAD
41
+ [0.2.0]: https://github.com/roberthopman/scrap4rb/compare/v0.1.0...v0.2.0
23
42
  [0.1.0]: https://github.com/roberthopman/scrap4rb/releases/tag/v0.1.0
data/README.md CHANGED
@@ -55,6 +55,7 @@ The comparison gives each changed file a verdict: `improved`, `worse`, `mixed` o
55
55
  ```
56
56
  test/models/smelly_test.rb
57
57
  score 48.46 HIGH mode LOCAL ai AUTO_REFACTOR tests=6 avg=12.78 max=18.26
58
+ 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
59
  HIGH Strengthen assertions in weak tests before doing structural cleanup.
59
60
  LOW Be skeptical of helper extraction that only hides setup; ...
60
61
  18.3 L59 it uses a long local helper low-assertion-density,helper-hidden-complexity
@@ -70,7 +71,12 @@ score Refactor pressure of the file
70
71
  level STABLE, LOW, MEDIUM, HIGH or CRITICAL
71
72
  mode STABLE: leave it. LOCAL: fix tests in place. SPLIT: split the file first
72
73
  ai LEAVE_ALONE, AUTO_TABLE_DRIVE, AUTO_REFACTOR, MANUAL_SPLIT or REVIEW_FIRST
74
+ why The ratios and counts behind the score: tests with 1 assertion or fewer, with
75
+ branches, with stubs and with hidden helper lines; duplication; extraction
76
+ pressure; table-drive candidates; subject repetition
73
77
  HIGH/... Recommendations, ranked by confidence, at most four
78
+ where The three describe blocks or classes with the most pressure, with the worst
79
+ test of each. Only for a file with more than one block
74
80
  tests The SCRAP score of each test, worst first, with its smells
75
81
  EXTRACT A group of tests where a shared helper pays for itself
76
82
  ```
@@ -95,7 +101,7 @@ multiple-phases assert, act, assert again 5
95
101
  high-mocking more than 3 stubs or mocks 4
96
102
  large-example more than 20 lines 4
97
103
  helper-hidden-complexity more than 8 lines hidden in local helpers 4
98
- temp-resource-work temporary files, threads or shell calls 3
104
+ temp-resource-work files, directories, threads or processes 3
99
105
  literal-heavy-setup a string over 5 lines, a hash or array over 10 3
100
106
  ```
101
107
 
@@ -114,13 +120,15 @@ SCRAP (speclj) scrap4rb (Minitest)
114
120
  -------------- -------------------
115
121
  describe, context a test class, or a minitest/spec describe block
116
122
  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
123
+ before, with setup blocks, setup :method and def setup, let and subject,
124
+ inherited by every test in the class (see docs/setup-depth.md)
118
125
  let, binding travel_to, freeze_time, within, using_session,
119
126
  perform_enqueued_jobs, with_*, and stub with a block
120
127
  with-redefs stub, stub_request, any_instance, expects, stubs, Minitest::Mock.new
121
128
  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
129
+ doseq, for, every? for, each, each_with_index, map, times, all?, ...
130
+ if, when, cond, and if, unless, case, while, until, &&, ||, rescue modifier
131
+ loop, try loop, begin/rescue (one per begin)
124
132
  ```
125
133
 
126
134
  The policy numbers (weights, thresholds, levels) are SCRAP's, unchanged.
@@ -130,6 +138,7 @@ The policy numbers (weights, thresholds, levels) are SCRAP's, unchanged.
130
138
  - 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
139
  - 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".
132
140
  - 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.
141
+ - `--compare` does not call new tests that use a helper a helper regression. See `docs/compare-verdict.md`.
133
142
 
134
143
  ## Notes for Rails suites
135
144
 
@@ -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.0"
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.0
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