crap4ruby 0.1.1 → 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: ef4ff5757bc98e35d4f9a59b34c2de273c8372b4b3019f0f1cd2234393900150
4
- data.tar.gz: 7d05a4a6376b345e41ccaaba2311392e22608c4712ae61bae210a8ad392f540a
3
+ metadata.gz: b2e848e88bdf6cd4f6b2f0a5477aa3a9f3565651006864d7101ad13beab82c7c
4
+ data.tar.gz: cc9a67ce5aaea92c6d7311f00526900017df5904c157d16764b77cbd27a0a9e7
5
5
  SHA512:
6
- metadata.gz: d0f0ae1b378c537b8eaac2972fa8bf866aeb3b0771fe0e66062b7e51309e2d08ae45c91a697ed948f5cb2e3837b2d0d7c21064c2fbf3542c9a764a1295bed790
7
- data.tar.gz: b9454920d722a74389ae717afaa0cf01205b16ba08aeea24a814161ebf3b519ad2388d2b82a2a78bb9e8b244b723f5f8fcfd8c7198b9e0c55bb4e93564a7c5e3
6
+ metadata.gz: 546293a428b456b48c4c22b34fe09980a2c88f1c7af0d69f8aa24f598fa5441993e8c50f1dc29dc346c233e0a8ffada8106fc13894a15a6185b1877c9823840a
7
+ data.tar.gz: ed047a7174bcce565ff2b248729f4faa61208f8a20794a6903d0fe9cfca7e1381243bd12287502dd5cfdef1087153c0720b363e098e39e32364262ade1501ddf
data/CHANGELOG.md CHANGED
@@ -5,6 +5,28 @@ Version numbers follow [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.2.1] - 2026-10-29
9
+
10
+ ### Added
11
+
12
+ - Add `--no-save` to print results and check thresholds without creating or replacing `.metrics/crap.edn`.
13
+
14
+ ## [0.2.0] - 2026-10-28
15
+
16
+ ### Changed
17
+
18
+ - Use RuboCop 1.92.1 complexity rules, including iteration blocks, safe-navigation discounts, rescue groups, and nested bodies, while excluding a method's own parameter defaults.
19
+ - Discover bare `define_method` calls with literal symbol or string names and blocks.
20
+ - Reject literal `define_method` names that cannot be represented in UTF-8, preserving the previous snapshot.
21
+ - Report N/A for coverage and CRAP when a body shares its declaration line. Unknown scores pass a requested threshold only when their maximum possible score fits it; otherwise the check returns exit 1. These scoring and exit-code changes require a minor release. Regenerate coverage and snapshots and review thresholds when upgrading.
22
+
23
+ ### Fixed
24
+
25
+ - Include executable interpolation lines when a method ends with a heredoc.
26
+ - Discover explicit method definitions inside parameter defaults and superclass expressions.
27
+ - Compare exact CRAP scores with decimal thresholds without floating-point rounding errors.
28
+ - Avoid attributing trailing code on a method's closing line to the method, and escape control characters in displayed method names.
29
+
8
30
  ## [0.1.1] - 2026-10-28
9
31
 
10
32
  ### Changed
@@ -26,6 +48,8 @@ Version numbers follow [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.
26
48
 
27
49
  - Split CLI execution, AST discovery, and coverage loading into smaller methods without changing their behavior or scoring rules.
28
50
 
29
- [Unreleased]: https://github.com/svyatov/crap4ruby/compare/v0.1.1...HEAD
51
+ [Unreleased]: https://github.com/svyatov/crap4ruby/compare/v0.2.1...HEAD
52
+ [0.2.1]: https://github.com/svyatov/crap4ruby/compare/v0.2.0...v0.2.1
53
+ [0.2.0]: https://github.com/svyatov/crap4ruby/compare/v0.1.1...v0.2.0
30
54
  [0.1.1]: https://github.com/svyatov/crap4ruby/compare/v0.1.0...v0.1.1
31
55
  [0.1.0]: https://github.com/svyatov/crap4ruby/releases/tag/v0.1.0
data/CONTRIBUTING.md CHANGED
@@ -12,15 +12,25 @@ bin/setup
12
12
  bundle exec rake
13
13
  ```
14
14
 
15
- Before each commit, hooks scan staged changes for secrets and whitespace errors.
15
+ Before each commit, hooks run Ruby lint checks and scan staged changes for secrets and whitespace errors.
16
16
  Commit messages must use Conventional Commits.
17
17
  Before each push, hooks run `bundle exec rake`.
18
18
  Put machine-specific hook settings in the ignored `lefthook-local.yml` file.
19
19
 
20
- The default check runs Steep, requires 100% typed calls across every Ruby file, runs tests with fresh SimpleCov coverage, and validates YARD documentation.
20
+ The default check, also available as `bundle exec rake check`, runs RuboCop, Steep, tests with fresh SimpleCov coverage, YARD validation, and dependency audits.
21
+ Every Ruby file must have 100% typed calls.
21
22
  Every library method's CRAP score must be at most 10.
22
23
  Use `bundle exec rake test` for tests alone. Open `coverage/index.html` to inspect line coverage.
23
24
 
25
+ Run `bundle exec rake lint` to check Ruby style, formatting, performance, Minitest assertions, and Rake tasks.
26
+ Run `bundle exec rake format` to apply RuboCop's safe corrections, then review the diff.
27
+ RuboCop targets Ruby 3.3 and preserves the project's double-quoted strings and compact hashes.
28
+ YARD and CRAP enforce documentation and complexity requirements; RuboCop's overlapping documentation and metrics checks are disabled.
29
+
30
+ Run `bundle exec rake audit` to check both the development and release lockfiles for known vulnerabilities and insecure gem sources.
31
+ The audit updates RubySec's advisory database over the network and stores it in the ignored `.cache/` directory.
32
+ The default check and pre-push hook therefore require network access.
33
+
24
34
  Run `bundle exec rake types` for type checks alone.
25
35
  Keep library signatures in `sig/` and signatures for tests and development tools in `sig-development/`.
26
36
  The type gate rejects unchecked Ruby files, untyped signatures, and any call that Steep cannot type.
data/README.md CHANGED
@@ -46,7 +46,7 @@ Run these commands from this checkout with Ruby 3.3 or newer:
46
46
  ```sh
47
47
  bundle install
48
48
  gem build crap4ruby.gemspec
49
- gem install ./crap4ruby-0.1.1.gem
49
+ gem install ./crap4ruby-0.2.1.gem
50
50
  ```
51
51
 
52
52
  To use Bundler in the measured project, add this checkout to that project's `Gemfile`:
@@ -96,7 +96,8 @@ crap4ruby does not configure SimpleCov, delete reports, install dependencies, or
96
96
  | `--use-existing-coverage` | Explicitly select the default: read reports without running tests. |
97
97
  | `--coverage-command COMMAND` | Run a shell command that refreshes the selected report. |
98
98
  | `--no-coverage` | Report complexity with unknown coverage and CRAP. |
99
- | `--threshold NUMBER` | Exit 2 when any unrounded score exceeds this finite, nonnegative value. |
99
+ | `--no-save` | Print results without creating or replacing `.metrics/crap.edn`. |
100
+ | `--threshold NUMBER` | Check this finite, nonnegative limit; exit 2 for a measured violation, or 1 if an unknown score could exceed it. |
100
101
  | `-h`, `--help` | Print usage. |
101
102
  | `-v`, `--version` | Print the gem version. |
102
103
 
@@ -109,9 +110,21 @@ Discovery includes `.rb` files. It skips hidden directories, test/spec files, an
109
110
  Test filenames are `test_*.rb`, `spec_*.rb`, `*_test.rb`, and `*_spec.rb`.
110
111
  An explicitly named file overrides these exclusions. Directory traversal does not follow symbolic links.
111
112
 
112
- Exit 0 means analysis succeeded. Exit 1 means invalid usage, unreadable or invalid input, a Git failure, or a coverage command failure.
113
- Exit 2 means analysis succeeded but exceeded the threshold. No threshold applies unless you set one.
114
- Every successful analysis replaces the snapshot, including filtered analyses and empty selections.
113
+ Exit 0 means analysis succeeded and any requested threshold was verified. Exit 1 means invalid usage, unreadable or invalid input, a Git failure, a coverage command failure, or an uncertain threshold result.
114
+ Exit 2 means a measured score exceeded the threshold. A measured violation takes precedence over uncertainty. No threshold applies unless you set one.
115
+ Decimal thresholds are compared exactly with the computed scores, before display rounding.
116
+ For unknown scores, the check uses the maximum possible score, `CC^2 + CC`, at zero coverage. If this maximum fits the threshold, the method passes; otherwise, the check returns exit 1 and names the method. Its displayed score remains N/A. This also applies with `--no-coverage`.
117
+ For example, an unknown score at CC 2 passes a threshold of 6, but cannot pass a threshold of 5. Supply fresh, measurable coverage or adjust the limit to resolve uncertainty.
118
+ By default, every completed analysis replaces the snapshot, including filtered analyses, empty selections, and failed threshold checks.
119
+
120
+ For pre-commit or pre-push hooks that check complexity without tests or coverage, use:
121
+
122
+ ```sh
123
+ bundle exec crap4ruby --no-coverage --no-save
124
+ ```
125
+
126
+ `--no-save` preserves the terminal report and threshold exit codes while leaving the snapshot unchanged.
127
+ It also works with coverage enabled.
115
128
 
116
129
  ## Complexity and coverage
117
130
 
@@ -121,26 +134,34 @@ The scoring engine uses this formula, with coverage expressed as a fraction betw
121
134
  CRAP = CC^2 * (1 - coverage)^3 + CC
122
135
  ```
123
136
 
124
- Cyclomatic complexity (CC) starts at 1. Each of these adds 1:
137
+ The formula follows the [original CRAP definition](https://www.artima.com/weblogs/viewpost.jsp?thread=210575). This tool uses line coverage as a proxy for the original definition's basis path coverage. These measurements are not equivalent.
138
+
139
+ Cyclomatic complexity (CC) follows [RuboCop 1.92.1](https://github.com/rubocop/rubocop/blob/aa228eb03094d66aca26664839ca14b10d76f702/lib/rubocop/cop/metrics/cyclomatic_complexity.rb). It starts at 1. Each of these adds 1:
125
140
 
126
141
  - `if`, `elsif`, `unless`, and ternary conditions, including modifier forms.
127
- - `while`, `until`, and `for` loops.
128
- - Each `when` or pattern-matching `in` clause, including a separate increment for a guard condition.
129
- - Each rescue clause and rescue modifier.
130
- - Each `&&`, `||`, `and`, `or`, `&&=`, `||=`, and safe-navigation operation.
142
+ - `while`, `until`, and `for` loops, except post-test `begin ... end while/until` forms.
143
+ - Each `when` or pattern-matching `in` clause. A pattern guard's `if`/`unless` wrapper adds nothing; decisions within its condition still count.
144
+ - Each rescue group, regardless of its number of clauses, and each rescue modifier.
145
+ - Each `&&`, `||`, `and`, `or`, `&&=`, and `||=` operation.
146
+ - Each safe-navigation operation, except repeated use of the same local receiver until its next assignment.
147
+ - Each ordinary block or block argument passed to a [known iteration method](https://github.com/rubocop/rubocop/blob/aa228eb03094d66aca26664839ca14b10d76f702/lib/rubocop/cop/metrics/utils/iterating_block.rb), such as `map`, `each`, or `reduce`.
131
148
 
132
149
  A `when` clause adds 1 regardless of its number of alternatives. `else` and `ensure` add nothing.
133
150
  Decisions inside ordinary blocks and lambdas belong to their enclosing method.
134
- Decisions in parameter defaults also count; coverage still measures only the body.
135
- Nested methods are separate entries. Their bodies and nested class/module bodies do not increase the enclosing method's score.
136
- Calling `each`, `map`, or another iterator does not itself add complexity.
151
+ The method's own parameter defaults do not count. Nested definitions and their defaults, including nested class/module bodies, contribute decisions to the enclosing body, as in RuboCop.
152
+ The safe-navigation discount follows local variable names across that entire body, including nested scopes, in RuboCop's AST traversal order. Assignments reset it; parameters, pattern bindings, and named regexp captures do not.
153
+ RuboCop 1.92.1 does not add an iteration point for numbered or implicit-`it` blocks. This tool preserves that version's behavior. Iterator calls without a block add nothing.
154
+ Nested methods are also separate entries. Definitions inside parameter defaults and superclass expressions are discovered too.
137
155
 
138
156
  Coverage is the fraction of executable method-body lines with positive hit counts.
139
- Separate `def` headers are excluded: loading a definition does not prove that tests called its method.
157
+ Declaration lines and nested method/class/module bodies are excluded from coverage. Loading a definition does not prove that tests called its method.
158
+ When the body shares a line with its declaration, coverage and CRAP are N/A, even if that line has hits. This includes ordinary inline methods, one-line endless methods, and inline `define_method` blocks. Put the body on separate lines to obtain measurable coverage.
159
+ Coverage is also N/A when a selected body line contains trailing code after the definition, including enclosing delimiters that cannot be separated from that line's hit count. Put the closing delimiter on its own line. Comments and empty statement separators do not trigger this check.
160
+ Executable heredoc interpolation lines are included, even when the heredoc is the method's final expression.
140
161
  Null lines do not enter the denominator. Multiple suites are merged by covered-line union, without applying an age cutoff.
141
162
  Coverage paths must match absolute source paths or paths relative to the project root; unrelated checkout prefixes are not guessed.
142
163
 
143
- A method absent from a valid report receives 0% coverage. A body with no measurable lines receives N/A.
164
+ A method with unambiguous body lines in a file absent from a valid report receives 0% coverage. A reported body with no measurable lines receives N/A.
144
165
  Missing or malformed reports stop analysis unless `--no-coverage` is explicit.
145
166
  Reports are accepted as supplied; generate fresh coverage after changing source files.
146
167
 
@@ -164,12 +185,15 @@ A filtered run writes only its selected methods. Reopened classes and repeated d
164
185
 
165
186
  ## Limits
166
187
 
167
- Analysis covers explicit method definitions, including private and endless methods.
168
- It does not execute Ruby or resolve runtime metaprogramming, `define_method`, aliases, generated accessors, or dynamic singleton receivers.
188
+ Analysis covers explicit method definitions, including private and endless methods, and bare `define_method` calls with one literal symbol/string name and a block.
189
+ Literal `define_method` names must be representable in UTF-8; unsupported names produce an error without replacing the previous snapshot.
190
+ It does not execute Ruby or resolve other runtime metaprogramming, dynamic `define_method` names, calls with explicit receivers, aliases, generated accessors, or dynamic singleton receivers.
169
191
  Namespaces derived from expressions, such as `factory::Thing`, are skipped with their contents.
170
- Line coverage cannot distinguish expressions or methods sharing a source line, or separate evaluation of default parameters.
192
+ Line coverage cannot distinguish expressions sharing a source line or separate evaluation of default parameters.
171
193
  Branch coverage is not used. Static namespace names do not reflect runtime constant reassignment.
172
194
 
195
+ These counting and threshold rules change results from 0.1.x and require a minor release under the versioning policy. Regenerate coverage and snapshots and review existing limits when upgrading.
196
+
173
197
  ## Development
174
198
 
175
199
  ```sh
@@ -1,9 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Crap4Ruby
4
- # Finds explicit Ruby method definitions without executing source code.
4
+ # Finds explicit methods and literal define_method blocks without executing code.
5
5
  class Analyzer
6
- # Analyzes method bodies and parameter defaults with Prism.
6
+ # RuboCop 1.92.1 Metrics::Utils::IteratingBlock's method-name policy.
7
+ ITERATING_METHODS = %i[
8
+ all? any? chain chunk chunk_while collect collect_concat count cycle
9
+ detect drop drop_while each each_cons each_entry each_slice
10
+ each_with_index each_with_object entries filter filter_map find
11
+ find_all find_index flat_map grep grep_v group_by inject lazy map
12
+ max max_by min min_by minmax minmax_by none? one? partition reduce
13
+ reject reverse_each select slice_after slice_before slice_when sort
14
+ sort_by sum take take_while tally to_h uniq zip with_index with_object
15
+ bsearch bsearch_index collect! combination d_permutation delete_if
16
+ each_index keep_if map! permutation product reject! repeat
17
+ repeated_combination select! sort! each_key each_pair each_value
18
+ fetch fetch_values has_key? merge merge! transform_keys transform_keys!
19
+ transform_values transform_values!
20
+ ].freeze
21
+
22
+ # Analyzes method bodies with Prism and the RuboCop 1.92.1 counting rules.
7
23
  #
8
24
  # @param source [String] Ruby source text
9
25
  # @param path [String] source label used in results and parse errors
@@ -34,19 +50,62 @@ module Crap4Ruby
34
50
 
35
51
  case node
36
52
  when Prism::ClassNode, Prism::ModuleNode
37
- owner = qualify(node.constant_path, scope, declaration: true)
38
- owner ? discover(node.body, owner, false, path) : []
53
+ discover_class(node, scope, singleton, path)
39
54
  when Prism::SingletonClassNode
40
55
  owner = node.expression.is_a?(Prism::SelfNode) ? scope : qualify(node.expression, scope)
41
56
  owner ? discover(node.body, owner, true, path) : []
42
57
  when Prism::DefNode
43
58
  discover_method(node, scope, singleton, path)
59
+ when Prism::CallNode
60
+ discover_call(node, scope, singleton, path)
61
+ else
62
+ node.compact_child_nodes.flat_map { |child| discover(child, scope, singleton, path) }
63
+ end
64
+ end
65
+
66
+ def discover_class(node, scope, singleton, path)
67
+ # @type var methods: Array[MethodInfo]
68
+ methods = node.is_a?(Prism::ClassNode) ? discover(node.superclass, scope, singleton, path) : []
69
+ owner = qualify(node.constant_path, scope, declaration: true)
70
+ methods + (owner ? discover(node.body, owner, false, path) : [])
71
+ end
72
+
73
+ def discover_call(node, scope, singleton, path)
74
+ name = defined_name(node)
75
+ block = node.block
76
+ if name && block.is_a?(Prism::BlockNode)
77
+ discover_block(block, name, scope, singleton, path)
44
78
  else
45
79
  node.compact_child_nodes.flat_map { |child| discover(child, scope, singleton, path) }
46
80
  end
47
81
  end
48
82
 
49
- # Measures a definition independently from definitions nested within it.
83
+ def discover_block(block, name, scope, singleton, path)
84
+ raise EncodingError unless name.valid_encoding?
85
+
86
+ name = name.encode(Encoding::UTF_8)
87
+ parameters = block.parameters
88
+ header = parameters.is_a?(Prism::BlockParametersNode) ? parameters.location : block.opening_loc
89
+ lines = body_lines(block.body, after: header.end_line, ending: block.location)
90
+ name = "self.#{name}" if singleton
91
+ [MethodInfo.new(name, scope.empty? ? "Object" : scope.join("::"), 1 + decisions(block.body), lines, path)] +
92
+ discover(block.parameters, scope, singleton, path) + discover(block.body, scope, singleton, path)
93
+ rescue EncodingError
94
+ raise Error, "#{path}:#{block.location.start_line}: define_method name must be representable in UTF-8"
95
+ end
96
+
97
+ def defined_name(node)
98
+ return unless node.is_a?(Prism::CallNode) && node.name == :define_method && node.receiver.nil?
99
+ return unless node.block.is_a?(Prism::BlockNode)
100
+
101
+ arguments = node.arguments&.arguments
102
+ return unless arguments && arguments.size == 1
103
+
104
+ name = arguments.first
105
+ name.unescaped if name.is_a?(Prism::SymbolNode) || name.is_a?(Prism::StringNode)
106
+ end
107
+
108
+ # Builds this method's entry and discovers nested definitions.
50
109
  #
51
110
  # @param node [Prism::DefNode] method definition
52
111
  # @param scope [Array<String>] lexical namespace components
@@ -54,16 +113,22 @@ module Crap4Ruby
54
113
  # @param path [String] source label
55
114
  # @return [Array<MethodInfo>] this method and its nested definitions
56
115
  def discover_method(node, scope, singleton, path)
57
- owner = node.receiver && !node.receiver.is_a?(Prism::SelfNode) ? qualify(node.receiver, scope) : scope
116
+ owner = method_owner(node, scope)
58
117
  return [] unless owner
59
118
 
60
- name = "#{'self.' if singleton || node.receiver}#{node.name}"
61
- lines = body_lines(node.body)
62
- complexity = 1 + decisions(node.parameters) + decisions(node.body)
119
+ name = "#{"self." if singleton || node.receiver}#{node.name}"
120
+ header = node.equal_loc || node.rparen_loc || node.parameters&.location || node.name_loc
121
+ lines = body_lines(node.body, after: header.end_line, ending: node.location)
122
+ complexity = 1 + decisions(node.body)
63
123
  [MethodInfo.new(name, owner.empty? ? "Object" : owner.join("::"), complexity, lines, path)] +
124
+ discover(node.parameters, scope, singleton, path) +
64
125
  discover(node.body, scope, singleton, path)
65
126
  end
66
127
 
128
+ def method_owner(node, scope)
129
+ node.receiver && !node.receiver.is_a?(Prism::SelfNode) ? qualify(node.receiver, scope) : scope
130
+ end
131
+
67
132
  # Resolves constant syntax without looking up runtime constants.
68
133
  # Declarations append names; references can reuse a lexical ancestor.
69
134
  #
@@ -86,50 +151,133 @@ module Crap4Ruby
86
151
  end
87
152
  end
88
153
 
89
- # Counts branch operations, including safe navigation, outside nested definitions.
154
+ # Counts the entire body, with RuboCop's per-method safe-navigation discount.
90
155
  #
91
156
  # @param node [Prism::Node, nil] subtree to measure
92
157
  # @return [Integer] number of decisions, excluding the method's base complexity
93
- def decisions(node)
158
+ # @param seen [Hash<Symbol, Boolean>] local receivers since their last assignment
159
+ # @param target [Boolean] whether a target node belongs to an assignment
160
+ def decisions(node, seen = {}, target: false)
94
161
  return 0 unless node
95
- return 0 if boundary?(node)
96
162
 
97
- decision = [:if_node, :unless_node, :while_node, :until_node, :for_node,
98
- :when_node, :in_node, :rescue_node, :rescue_modifier_node, :and_node, :or_node].include?(node.type) ||
99
- node.type.to_s.match?(/_(?:and|or)_write_node\z/)
100
- count = decision ? 1 : 0
163
+ case node
164
+ when Prism::LocalVariableWriteNode, Prism::LocalVariableAndWriteNode,
165
+ Prism::LocalVariableOrWriteNode, Prism::LocalVariableOperatorWriteNode
166
+ seen.delete(node.name)
167
+ when Prism::LocalVariableTargetNode
168
+ seen.delete(node.name) if target
169
+ end
170
+ count = decision?(node) ? 1 : 0
171
+ count += 1 if iteration?(node)
172
+ count += 1 if safe_navigation?(node, seen)
173
+ count + child_decisions(node, seen, target)
174
+ end
175
+
176
+ def child_decisions(node, seen, target)
177
+ # RuboCop visits unless branches and guarded patterns in this order.
178
+ if node.is_a?(Prism::UnlessNode)
179
+ return decisions(node.predicate, seen) + decisions(node.else_clause, seen) + decisions(node.statements, seen)
180
+ end
181
+
182
+ if node.is_a?(Prism::InNode)
183
+ guard = node.pattern
184
+ if guard.is_a?(Prism::IfNode) || guard.is_a?(Prism::UnlessNode)
185
+ return decisions(guard.statements, seen) + decisions(guard.predicate, seen) + decisions(node.statements, seen)
186
+ end
187
+ end
188
+ node.compact_child_nodes.sum { |child| decisions(child, seen, target: assignment_target?(node, child, target)) }
189
+ end
190
+
191
+ def assignment_target?(node, child, target)
192
+ case node
193
+ when Prism::MultiWriteNode then child != node.value
194
+ when Prism::MultiTargetNode, Prism::SplatNode then target
195
+ when Prism::ForNode then child == node.index
196
+ when Prism::RescueNode then child == node.reference
197
+ else false
198
+ end
199
+ end
200
+
201
+ def decision?(node)
202
+ return !node.begin_modifier? if node.is_a?(Prism::WhileNode) || node.is_a?(Prism::UntilNode)
203
+
204
+ %i[if_node unless_node for_node
205
+ when_node in_node rescue_modifier_node and_node or_node].include?(node.type) ||
206
+ node.type.to_s.match?(/_(?:and|or)_write_node\z/) ||
207
+ (node.is_a?(Prism::BeginNode) && !node.rescue_clause.nil?)
208
+ end
209
+
210
+ def safe_navigation?(node, seen)
101
211
  case node
102
212
  when Prism::CallNode, Prism::CallAndWriteNode, Prism::CallOrWriteNode,
103
213
  Prism::CallOperatorWriteNode, Prism::CallTargetNode, Prism::IndexAndWriteNode,
104
214
  Prism::IndexOrWriteNode, Prism::IndexOperatorWriteNode, Prism::IndexTargetNode
105
- count += 1 if node.safe_navigation?
215
+ return false unless node.safe_navigation?
216
+
217
+ receiver = node.receiver
218
+ return true unless receiver.is_a?(Prism::LocalVariableReadNode)
219
+ return false if seen[receiver.name]
220
+
221
+ seen[receiver.name] = true
222
+ else
223
+ false
106
224
  end
107
- count + node.compact_child_nodes.sum { |child| decisions(child) }
225
+ end
226
+
227
+ def iteration?(node)
228
+ return false unless node.is_a?(Prism::CallNode) && ITERATING_METHODS.include?(node.name)
229
+
230
+ block = node.block
231
+ return false unless block
232
+ return true unless block.is_a?(Prism::BlockNode)
233
+
234
+ parameters = block.parameters
235
+ !parameters || parameters.is_a?(Prism::BlockParametersNode)
108
236
  end
109
237
 
110
238
  def boundary?(node)
111
- [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].any? { |type| node.is_a?(type) }
239
+ [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].any? do |type|
240
+ node.is_a?(type)
241
+ end ||
242
+ !defined_name(node).nil?
112
243
  end
113
244
 
114
245
  # Selects body lines without nested methods, classes, or modules.
115
246
  #
116
247
  # @param node [Prism::Node, nil] method body
117
- # @return [Array<Integer>] one-based source lines; empty for an absent body
118
- def body_lines(node)
248
+ # @param after [Integer] last line of the method declaration
249
+ # @param ending [Prism::Location] definition location, including its closing delimiter
250
+ # @return [Array<Integer>, nil] body lines; nil when shared with the declaration
251
+ def body_lines(node, after:, ending:)
119
252
  return [] unless node
120
253
 
121
- lines = (node.location.start_line..node.location.end_line).to_a
254
+ # @type var start: Prism::Node?
255
+ start = node.is_a?(Prism::BeginNode) && !node.begin_keyword_loc ? node.compact_child_nodes.first : node
256
+ return [] unless start
257
+ return nil if start.location.start_line <= after
258
+
122
259
  excluded = [] #: Array[Integer]
123
- collect_boundaries(node, excluded)
124
- lines - excluded
260
+ last_line = body_end_line(node, excluded)
261
+ lines = (start.location.start_line..last_line).to_a - excluded
262
+ return nil if lines.include?(ending.end_line) && trailing_code?(ending)
263
+
264
+ lines
125
265
  end
126
266
 
127
- def collect_boundaries(node, excluded)
128
- if boundary?(node)
129
- excluded.concat((node.location.start_line..node.location.end_line).to_a)
130
- else
131
- node.compact_child_nodes.each { |child| collect_boundaries(child, excluded) }
267
+ def trailing_code?(location)
268
+ tail = location.slice_lines.lines.last.to_s.byteslice(location.end_column..).to_s
269
+ !tail.match?(/\A[;\s]*(?:#|\z)/)
270
+ end
271
+
272
+ # Heredoc interpolations can extend beyond their parent node's location.
273
+ def body_end_line(node, excluded)
274
+ last_line = node.location.end_line
275
+ node.compact_child_nodes.each do |child|
276
+ child_line = body_end_line(child, excluded)
277
+ last_line = child_line if child_line > last_line
132
278
  end
279
+ excluded.concat((node.location.start_line..last_line).to_a) if boundary?(node)
280
+ last_line
133
281
  end
134
282
  end
135
283
  end
data/lib/crap4ruby/cli.rb CHANGED
@@ -14,12 +14,12 @@ module Crap4Ruby
14
14
  @stderr = stderr
15
15
  end
16
16
 
17
- # Runs the requested analysis and replaces the snapshot after successful scoring.
17
+ # Runs the requested analysis and replaces the snapshot unless --no-save is set.
18
18
  # Help and version requests produce output without writing a snapshot.
19
19
  # Expected usage, input, filesystem, and command errors are printed to stderr.
20
20
  #
21
21
  # @param arguments [Array<String>] command-line tokens; option parsing mutates this array
22
- # @return [Integer] 0 on success, 1 on a handled error, or 2 when a score exceeds the threshold
22
+ # @return [Integer] 0 on success, 1 on error or an uncertain threshold, 2 on a measured violation
23
23
  # @example Analyze complexity without coverage
24
24
  # CLI.new.run(["--no-coverage", "--source-root", "lib"])
25
25
  def run(arguments)
@@ -33,13 +33,12 @@ module Crap4Ruby
33
33
 
34
34
  files = Discovery.call(root: root, source_roots: options[:source_roots], paths: paths, changed: options[:changed])
35
35
  coverage = load_coverage(options, root) unless options[:no_coverage] || files.empty?
36
- entries = files.flat_map do |path|
37
- Analyzer.new.call(File.binread(path), path: path).map { |method| Score.call(method, coverage) }
36
+ entries = Report.sorted(analyze(files, coverage))
37
+ unless options[:no_save]
38
+ path = Report.write(entries, root: root)
39
+ @stderr.puts("Wrote #{path}")
38
40
  end
39
- entries = Report.sorted(entries)
40
- path = Report.write(entries, root: root)
41
41
  @stdout.write(Report.table(entries))
42
- @stderr.puts("Wrote #{path}")
43
42
  threshold_status(entries, options[:threshold])
44
43
  rescue Error, OptionParser::ParseError, SystemCallError => error
45
44
  @stderr.puts("crap4ruby: #{error.message}")
@@ -48,6 +47,12 @@ module Crap4Ruby
48
47
 
49
48
  private
50
49
 
50
+ def analyze(files, coverage)
51
+ files.flat_map do |path|
52
+ Analyzer.new.call(File.binread(path), path: path).map { |method| Score.call(method, coverage) }
53
+ end
54
+ end
55
+
51
56
  # Removes parsed options from arguments and returns the remaining path selectors.
52
57
  #
53
58
  # @param arguments [Array<String>] mutable command-line tokens
@@ -72,17 +77,23 @@ module Crap4Ruby
72
77
  options[:coverage] = path
73
78
  end
74
79
  cli.on("--no-coverage", "Complexity only; coverage and CRAP are N/A") { options[:no_coverage] = true }
75
- cli.on("--use-existing-coverage", "Read reports without running tests (the default)") { options[:existing] = true }
80
+ cli.on("--no-save", "Print results without writing .metrics/crap.edn") { options[:no_save] = true }
81
+ cli.on("--use-existing-coverage", "Read reports without running tests (the default)") do
82
+ options[:existing] = true
83
+ end
76
84
  cli.on("--coverage-command COMMAND", "Run a shell command that refreshes the resultset") do |command|
77
85
  # @type var command: String
78
86
  options[:command] = command
79
87
  end
80
- cli.on("--threshold NUMBER", "Exit 2 when CRAP exceeds this value") do |value|
88
+ cli.on("--threshold NUMBER", "Exit 2 above limit; exit 1 if unknown scores could exceed it") do |value|
81
89
  # @type var value: String
82
90
  number = Float(value)
83
- raise OptionParser::InvalidArgument, "threshold must be finite and nonnegative" unless number.finite? && number >= 0
91
+ raise ArgumentError unless number.finite?
92
+
93
+ threshold = Rational(value, exception: false) || number.to_r
94
+ raise ArgumentError if threshold.negative?
84
95
 
85
- options[:threshold] = number
96
+ options[:threshold] = threshold
86
97
  rescue ArgumentError
87
98
  raise OptionParser::InvalidArgument, "threshold must be finite and nonnegative"
88
99
  end
@@ -98,23 +109,30 @@ module Crap4Ruby
98
109
  # @return [void]
99
110
  # @raise [Error] if coverage options conflict
100
111
  def validate_options(options)
101
- if options[:command] && (options[:no_coverage] || options[:existing])
102
- raise Error, "--coverage-command cannot be combined with --no-coverage or --use-existing-coverage"
103
- end
112
+ return unless options[:command] && (options[:no_coverage] || options[:existing])
113
+
114
+ raise Error, "--coverage-command cannot be combined with --no-coverage or --use-existing-coverage"
104
115
  end
105
116
 
106
- # Compares unrounded, known CRAP scores with a strict upper limit.
117
+ # Checks measured scores and bounds unknown scores at zero coverage.
107
118
  #
108
119
  # @param entries [Array<Entry>] scored methods
109
- # @param threshold [Float, nil] maximum allowed score, or nil to disable the check
110
- # @return [Integer] 2 after reporting a violation to stderr, otherwise 0
120
+ # @param threshold [Rational, nil] maximum allowed score, or nil to disable the check
121
+ # @return [Integer] 2 for a measured violation, 1 if uncertain, otherwise 0
111
122
  def threshold_status(entries, threshold)
112
- if threshold && entries.any? { |entry| entry.crap && entry.crap > threshold }
113
- @stderr.puts("CRAP threshold exceeded: #{threshold}")
114
- 2
115
- else
116
- 0
123
+ return 0 unless threshold
124
+
125
+ if entries.any? { |entry| entry.crap && entry.crap > threshold }
126
+ @stderr.puts("CRAP threshold exceeded")
127
+ return 2
117
128
  end
129
+
130
+ uncertain = entries.select { |entry| !entry.crap && entry.complexity * (entry.complexity + 1) > threshold }
131
+ uncertain.each do |entry|
132
+ @stderr.puts("Cannot verify CRAP threshold: #{Report.label("#{entry.namespace}##{entry.name}")} " \
133
+ "has unknown coverage (maximum #{entry.complexity * (entry.complexity + 1)})")
134
+ end
135
+ uncertain.empty? ? 0 : 1
118
136
  end
119
137
 
120
138
  def load_coverage(options, root)
@@ -141,7 +159,10 @@ module Crap4Ruby
141
159
  output.each_line { |line| @stderr.write(line) }
142
160
  process.value
143
161
  end
144
- raise Error, "coverage command failed (exit #{status.exitstatus || "signal #{status.termsig}"})" unless status.success?
162
+ unless status.success?
163
+ raise Error,
164
+ "coverage command failed (exit #{status.exitstatus || "signal #{status.termsig}"})"
165
+ end
145
166
  raise Error, "coverage command did not refresh #{report}" if report_stamp(report) == before
146
167
  end
147
168
 
@@ -14,7 +14,8 @@ module Crap4Ruby
14
14
  def self.load(path, root:)
15
15
  new(JSON.parse(File.read(path)), root: root)
16
16
  rescue SystemCallError => error
17
- raise Error, "#{path}: cannot read coverage report; generate SimpleCov coverage or use --no-coverage (#{error.message})"
17
+ raise Error,
18
+ "#{path}: cannot read coverage report; generate SimpleCov coverage or use --no-coverage (#{error.message})"
18
19
  rescue JSON::ParserError, Error => error
19
20
  raise Error, "#{path}: invalid SimpleCov report (#{error.message})"
20
21
  end
@@ -35,27 +36,29 @@ module Crap4Ruby
35
36
  # Null and out-of-range lines do not enter the denominator.
36
37
  #
37
38
  # @param method [MethodInfo] method whose path and body lines select coverage
38
- # @return [Float, nil] fraction from 0 to 1; zero for an absent file,
39
- # or nil when a reported file has no measurable body lines
39
+ # @return [Rational, nil] exact fraction from 0 to 1; zero for an absent file,
40
+ # or nil when body lines are ambiguous or no reported lines are measurable
40
41
  def fraction(method)
42
+ body = method.lines
43
+ return nil unless body
44
+
41
45
  path = File.expand_path(method.path)
42
46
  path = File.realpath(path) if File.exist?(path)
43
47
  # @type var lines: Array[Integer?]?
44
48
  lines = @files[path]
45
- return 0.0 unless lines
49
+ return Rational(0) unless lines
46
50
 
47
- hits = method.lines.filter_map { |line| lines[line - 1] }
51
+ hits = body.filter_map { |line| lines[line - 1] }
48
52
  return nil if hits.empty?
49
53
 
50
- hits.count(&:positive?).fdiv(hits.size)
54
+ Rational(hits.count(&:positive?), hits.size)
51
55
  end
52
56
 
53
57
  private
54
58
 
55
59
  def add_suite(suite, root)
56
- unless suite.is_a?(Hash) && suite["coverage"].is_a?(Hash)
57
- raise Error, "expected a coverage object for each suite"
58
- end
60
+ raise Error, "expected a coverage object for each suite" unless suite.is_a?(Hash) && suite["coverage"].is_a?(Hash)
61
+
59
62
  suite["coverage"].each { |path, data| add_file(path, data, root) }
60
63
  end
61
64
 
@@ -73,6 +76,7 @@ module Crap4Ruby
73
76
  unless lines.is_a?(Array) && lines.all? { |hit| hit.nil? || (hit.is_a?(Integer) && hit >= 0) }
74
77
  raise Error, "#{path}: expected nonnegative line counts or null"
75
78
  end
79
+
76
80
  # Every element was validated above; Steep cannot narrow an array through all?.
77
81
  lines = lines #: Array[Integer?]
78
82
  previous = @files[key] || []