crap4ruby 0.1.1 → 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 +4 -4
- data/CHANGELOG.md +18 -1
- data/CONTRIBUTING.md +12 -2
- data/README.md +32 -18
- data/lib/crap4ruby/analyzer.rb +177 -29
- data/lib/crap4ruby/cli.rb +39 -21
- data/lib/crap4ruby/coverage.rb +13 -9
- data/lib/crap4ruby/discovery.rb +22 -5
- data/lib/crap4ruby/report.rb +14 -4
- data/lib/crap4ruby/score.rb +5 -2
- data/lib/crap4ruby/version.rb +1 -1
- data/lib/crap4ruby.rb +4 -4
- data/oss-audit-report.before.md +159 -0
- data/oss-audit-report.md +110 -0
- data/scoring-audit.md +251 -0
- data/sig/crap4ruby.rbs +31 -15
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e5227286a59e47edaf5fe8e20c8ce9c1abe6391b4f95304f8352c6b98fdf53dd
|
|
4
|
+
data.tar.gz: 2a569cea7aa8f9172d2ce2c854dec0d62c76ae3e73f1f3618f911b3d55c6deff
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 25f98c5d9ca08faa51388483dc63735238f4dc0a20daf13a80ad104086e48c36d91fb2204a2d4e25a903ce17ccdd510cb8059be09b47ea4a582921fee1a5b649
|
|
7
|
+
data.tar.gz: b819b05ff07ec266c2316f77b317b2f45975e4ebd7ed2e4f325aea8553a2fa767033d4351086ab67979f9230523497f1d91aab3f49fad540ae911c529fd4dbce
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ Version numbers follow [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.2.0] - 2026-10-28
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- 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.
|
|
13
|
+
- Discover bare `define_method` calls with literal symbol or string names and blocks.
|
|
14
|
+
- Reject literal `define_method` names that cannot be represented in UTF-8, preserving the previous snapshot.
|
|
15
|
+
- 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.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Include executable interpolation lines when a method ends with a heredoc.
|
|
20
|
+
- Discover explicit method definitions inside parameter defaults and superclass expressions.
|
|
21
|
+
- Compare exact CRAP scores with decimal thresholds without floating-point rounding errors.
|
|
22
|
+
- Avoid attributing trailing code on a method's closing line to the method, and escape control characters in displayed method names.
|
|
23
|
+
|
|
8
24
|
## [0.1.1] - 2026-10-28
|
|
9
25
|
|
|
10
26
|
### Changed
|
|
@@ -26,6 +42,7 @@ Version numbers follow [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.
|
|
|
26
42
|
|
|
27
43
|
- Split CLI execution, AST discovery, and coverage loading into smaller methods without changing their behavior or scoring rules.
|
|
28
44
|
|
|
29
|
-
[Unreleased]: https://github.com/svyatov/crap4ruby/compare/v0.
|
|
45
|
+
[Unreleased]: https://github.com/svyatov/crap4ruby/compare/v0.2.0...HEAD
|
|
46
|
+
[0.2.0]: https://github.com/svyatov/crap4ruby/compare/v0.1.1...v0.2.0
|
|
30
47
|
[0.1.1]: https://github.com/svyatov/crap4ruby/compare/v0.1.0...v0.1.1
|
|
31
48
|
[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
|
|
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.
|
|
49
|
+
gem install ./crap4ruby-0.2.0.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,7 @@ 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` |
|
|
99
|
+
| `--threshold NUMBER` | Check this finite, nonnegative limit; exit 2 for a measured violation, or 1 if an unknown score could exceed it. |
|
|
100
100
|
| `-h`, `--help` | Print usage. |
|
|
101
101
|
| `-v`, `--version` | Print the gem version. |
|
|
102
102
|
|
|
@@ -109,9 +109,12 @@ Discovery includes `.rb` files. It skips hidden directories, test/spec files, an
|
|
|
109
109
|
Test filenames are `test_*.rb`, `spec_*.rb`, `*_test.rb`, and `*_spec.rb`.
|
|
110
110
|
An explicitly named file overrides these exclusions. Directory traversal does not follow symbolic links.
|
|
111
111
|
|
|
112
|
-
Exit 0 means analysis succeeded. Exit 1 means invalid usage, unreadable or invalid input, a Git failure,
|
|
113
|
-
Exit 2 means
|
|
114
|
-
|
|
112
|
+
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.
|
|
113
|
+
Exit 2 means a measured score exceeded the threshold. A measured violation takes precedence over uncertainty. No threshold applies unless you set one.
|
|
114
|
+
Decimal thresholds are compared exactly with the computed scores, before display rounding.
|
|
115
|
+
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`.
|
|
116
|
+
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.
|
|
117
|
+
Every completed analysis replaces the snapshot, including filtered analyses, empty selections, and failed threshold checks.
|
|
115
118
|
|
|
116
119
|
## Complexity and coverage
|
|
117
120
|
|
|
@@ -121,26 +124,34 @@ The scoring engine uses this formula, with coverage expressed as a fraction betw
|
|
|
121
124
|
CRAP = CC^2 * (1 - coverage)^3 + CC
|
|
122
125
|
```
|
|
123
126
|
|
|
124
|
-
|
|
127
|
+
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.
|
|
128
|
+
|
|
129
|
+
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
130
|
|
|
126
131
|
- `if`, `elsif`, `unless`, and ternary conditions, including modifier forms.
|
|
127
|
-
- `while`, `until`, and `for` loops.
|
|
128
|
-
- Each `when` or pattern-matching `in` clause
|
|
129
|
-
- Each rescue
|
|
130
|
-
- Each `&&`, `||`, `and`, `or`, `&&=`,
|
|
132
|
+
- `while`, `until`, and `for` loops, except post-test `begin ... end while/until` forms.
|
|
133
|
+
- Each `when` or pattern-matching `in` clause. A pattern guard's `if`/`unless` wrapper adds nothing; decisions within its condition still count.
|
|
134
|
+
- Each rescue group, regardless of its number of clauses, and each rescue modifier.
|
|
135
|
+
- Each `&&`, `||`, `and`, `or`, `&&=`, and `||=` operation.
|
|
136
|
+
- Each safe-navigation operation, except repeated use of the same local receiver until its next assignment.
|
|
137
|
+
- 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
138
|
|
|
132
139
|
A `when` clause adds 1 regardless of its number of alternatives. `else` and `ensure` add nothing.
|
|
133
140
|
Decisions inside ordinary blocks and lambdas belong to their enclosing method.
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
141
|
+
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.
|
|
142
|
+
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.
|
|
143
|
+
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.
|
|
144
|
+
Nested methods are also separate entries. Definitions inside parameter defaults and superclass expressions are discovered too.
|
|
137
145
|
|
|
138
146
|
Coverage is the fraction of executable method-body lines with positive hit counts.
|
|
139
|
-
|
|
147
|
+
Declaration lines and nested method/class/module bodies are excluded from coverage. Loading a definition does not prove that tests called its method.
|
|
148
|
+
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.
|
|
149
|
+
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.
|
|
150
|
+
Executable heredoc interpolation lines are included, even when the heredoc is the method's final expression.
|
|
140
151
|
Null lines do not enter the denominator. Multiple suites are merged by covered-line union, without applying an age cutoff.
|
|
141
152
|
Coverage paths must match absolute source paths or paths relative to the project root; unrelated checkout prefixes are not guessed.
|
|
142
153
|
|
|
143
|
-
A method absent from a valid report receives 0% coverage. A body with no measurable lines receives N/A.
|
|
154
|
+
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
155
|
Missing or malformed reports stop analysis unless `--no-coverage` is explicit.
|
|
145
156
|
Reports are accepted as supplied; generate fresh coverage after changing source files.
|
|
146
157
|
|
|
@@ -164,12 +175,15 @@ A filtered run writes only its selected methods. Reopened classes and repeated d
|
|
|
164
175
|
|
|
165
176
|
## Limits
|
|
166
177
|
|
|
167
|
-
Analysis covers explicit method definitions, including private and endless methods.
|
|
168
|
-
|
|
178
|
+
Analysis covers explicit method definitions, including private and endless methods, and bare `define_method` calls with one literal symbol/string name and a block.
|
|
179
|
+
Literal `define_method` names must be representable in UTF-8; unsupported names produce an error without replacing the previous snapshot.
|
|
180
|
+
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
181
|
Namespaces derived from expressions, such as `factory::Thing`, are skipped with their contents.
|
|
170
|
-
Line coverage cannot distinguish expressions
|
|
182
|
+
Line coverage cannot distinguish expressions sharing a source line or separate evaluation of default parameters.
|
|
171
183
|
Branch coverage is not used. Static namespace names do not reflect runtime constant reassignment.
|
|
172
184
|
|
|
185
|
+
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.
|
|
186
|
+
|
|
173
187
|
## Development
|
|
174
188
|
|
|
175
189
|
```sh
|
data/lib/crap4ruby/analyzer.rb
CHANGED
|
@@ -1,9 +1,25 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Crap4Ruby
|
|
4
|
-
# Finds explicit
|
|
4
|
+
# Finds explicit methods and literal define_method blocks without executing code.
|
|
5
5
|
class Analyzer
|
|
6
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
116
|
+
owner = method_owner(node, scope)
|
|
58
117
|
return [] unless owner
|
|
59
118
|
|
|
60
|
-
name = "#{
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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?
|
|
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
|
-
# @
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
@@ -19,7 +19,7 @@ module Crap4Ruby
|
|
|
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
|
|
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,10 +33,7 @@ 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
|
|
37
|
-
Analyzer.new.call(File.binread(path), path: path).map { |method| Score.call(method, coverage) }
|
|
38
|
-
end
|
|
39
|
-
entries = Report.sorted(entries)
|
|
36
|
+
entries = Report.sorted(analyze(files, coverage))
|
|
40
37
|
path = Report.write(entries, root: root)
|
|
41
38
|
@stdout.write(Report.table(entries))
|
|
42
39
|
@stderr.puts("Wrote #{path}")
|
|
@@ -48,6 +45,12 @@ module Crap4Ruby
|
|
|
48
45
|
|
|
49
46
|
private
|
|
50
47
|
|
|
48
|
+
def analyze(files, coverage)
|
|
49
|
+
files.flat_map do |path|
|
|
50
|
+
Analyzer.new.call(File.binread(path), path: path).map { |method| Score.call(method, coverage) }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
51
54
|
# Removes parsed options from arguments and returns the remaining path selectors.
|
|
52
55
|
#
|
|
53
56
|
# @param arguments [Array<String>] mutable command-line tokens
|
|
@@ -72,17 +75,22 @@ module Crap4Ruby
|
|
|
72
75
|
options[:coverage] = path
|
|
73
76
|
end
|
|
74
77
|
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)")
|
|
78
|
+
cli.on("--use-existing-coverage", "Read reports without running tests (the default)") do
|
|
79
|
+
options[:existing] = true
|
|
80
|
+
end
|
|
76
81
|
cli.on("--coverage-command COMMAND", "Run a shell command that refreshes the resultset") do |command|
|
|
77
82
|
# @type var command: String
|
|
78
83
|
options[:command] = command
|
|
79
84
|
end
|
|
80
|
-
cli.on("--threshold NUMBER", "Exit 2
|
|
85
|
+
cli.on("--threshold NUMBER", "Exit 2 above limit; exit 1 if unknown scores could exceed it") do |value|
|
|
81
86
|
# @type var value: String
|
|
82
87
|
number = Float(value)
|
|
83
|
-
raise
|
|
88
|
+
raise ArgumentError unless number.finite?
|
|
84
89
|
|
|
85
|
-
|
|
90
|
+
threshold = Rational(value, exception: false) || number.to_r
|
|
91
|
+
raise ArgumentError if threshold.negative?
|
|
92
|
+
|
|
93
|
+
options[:threshold] = threshold
|
|
86
94
|
rescue ArgumentError
|
|
87
95
|
raise OptionParser::InvalidArgument, "threshold must be finite and nonnegative"
|
|
88
96
|
end
|
|
@@ -98,23 +106,30 @@ module Crap4Ruby
|
|
|
98
106
|
# @return [void]
|
|
99
107
|
# @raise [Error] if coverage options conflict
|
|
100
108
|
def validate_options(options)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
109
|
+
return unless options[:command] && (options[:no_coverage] || options[:existing])
|
|
110
|
+
|
|
111
|
+
raise Error, "--coverage-command cannot be combined with --no-coverage or --use-existing-coverage"
|
|
104
112
|
end
|
|
105
113
|
|
|
106
|
-
#
|
|
114
|
+
# Checks measured scores and bounds unknown scores at zero coverage.
|
|
107
115
|
#
|
|
108
116
|
# @param entries [Array<Entry>] scored methods
|
|
109
|
-
# @param threshold [
|
|
110
|
-
# @return [Integer] 2
|
|
117
|
+
# @param threshold [Rational, nil] maximum allowed score, or nil to disable the check
|
|
118
|
+
# @return [Integer] 2 for a measured violation, 1 if uncertain, otherwise 0
|
|
111
119
|
def threshold_status(entries, threshold)
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
120
|
+
return 0 unless threshold
|
|
121
|
+
|
|
122
|
+
if entries.any? { |entry| entry.crap && entry.crap > threshold }
|
|
123
|
+
@stderr.puts("CRAP threshold exceeded")
|
|
124
|
+
return 2
|
|
117
125
|
end
|
|
126
|
+
|
|
127
|
+
uncertain = entries.select { |entry| !entry.crap && entry.complexity * (entry.complexity + 1) > threshold }
|
|
128
|
+
uncertain.each do |entry|
|
|
129
|
+
@stderr.puts("Cannot verify CRAP threshold: #{Report.label("#{entry.namespace}##{entry.name}")} " \
|
|
130
|
+
"has unknown coverage (maximum #{entry.complexity * (entry.complexity + 1)})")
|
|
131
|
+
end
|
|
132
|
+
uncertain.empty? ? 0 : 1
|
|
118
133
|
end
|
|
119
134
|
|
|
120
135
|
def load_coverage(options, root)
|
|
@@ -141,7 +156,10 @@ module Crap4Ruby
|
|
|
141
156
|
output.each_line { |line| @stderr.write(line) }
|
|
142
157
|
process.value
|
|
143
158
|
end
|
|
144
|
-
|
|
159
|
+
unless status.success?
|
|
160
|
+
raise Error,
|
|
161
|
+
"coverage command failed (exit #{status.exitstatus || "signal #{status.termsig}"})"
|
|
162
|
+
end
|
|
145
163
|
raise Error, "coverage command did not refresh #{report}" if report_stamp(report) == before
|
|
146
164
|
end
|
|
147
165
|
|
data/lib/crap4ruby/coverage.rb
CHANGED
|
@@ -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,
|
|
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 [
|
|
39
|
-
# or nil when
|
|
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
|
|
49
|
+
return Rational(0) unless lines
|
|
46
50
|
|
|
47
|
-
hits =
|
|
51
|
+
hits = body.filter_map { |line| lines[line - 1] }
|
|
48
52
|
return nil if hits.empty?
|
|
49
53
|
|
|
50
|
-
hits.count(&:positive?)
|
|
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
|
-
|
|
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] || []
|