crap4ruby 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +20 -0
- data/CONTRIBUTING.md +19 -0
- data/LICENSE +21 -0
- data/README.md +191 -0
- data/exe/crap4ruby +6 -0
- data/lib/crap4ruby/analyzer.rb +90 -0
- data/lib/crap4ruby/cli.rb +104 -0
- data/lib/crap4ruby/coverage.rb +56 -0
- data/lib/crap4ruby/discovery.rb +56 -0
- data/lib/crap4ruby/report.rb +53 -0
- data/lib/crap4ruby/score.rb +11 -0
- data/lib/crap4ruby/version.rb +5 -0
- data/lib/crap4ruby.rb +17 -0
- metadata +78 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 55e5f45f41b35122c6ad4b4894e3d5e496af3842c4a4f28e8172806c45d8d620
|
|
4
|
+
data.tar.gz: '0279b34834aeba13649a0f124b6064b44a92fdffa868ad5f1a893e56a9067473'
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 555c402719177c91525c68611ee3e5e88ae0c674bf74a1f2b477857d4e97e017cfe50a2674d829e46a9fd7c16a252d69aaf97953ebb811d6a98a733596898157
|
|
7
|
+
data.tar.gz: 0e9ea5f5d64473c6547faaad0cc4126a0d7a1c0236c37c0a457cdb1e69ae5d828160619ddce843033df76c723c45440f64049b071a5bf74846ad11447700a1d4
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [0.1.0] - 2026-10-28
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Static Ruby method complexity analysis with Prism.
|
|
10
|
+
- SimpleCov line coverage, merged test suites, and CRAP scores.
|
|
11
|
+
- Source filters, Git change selection, explicit coverage commands, and optional score thresholds.
|
|
12
|
+
- Terminal reports and atomic snapshots compatible with crapper's EDN format.
|
|
13
|
+
- Permanent SimpleCov collection and a default Rake check enforcing a maximum method CRAP score of 10.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Split CLI execution, AST discovery, and coverage loading into smaller methods without changing their behavior or scoring rules.
|
|
18
|
+
|
|
19
|
+
[Unreleased]: https://github.com/svyatov/crap4ruby/compare/v0.1.0...HEAD
|
|
20
|
+
[0.1.0]: https://github.com/svyatov/crap4ruby/releases/tag/v0.1.0
|
data/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Use Ruby 3.3 or newer. Install dependencies and run the suite:
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
bundle install
|
|
7
|
+
bundle exec rake
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
The default check runs tests with fresh SimpleCov coverage and requires every library method's CRAP score to be at most 10.
|
|
11
|
+
Use `bundle exec rake test` for tests alone. Open `coverage/index.html` to inspect line coverage.
|
|
12
|
+
|
|
13
|
+
Add a regression test for a behavior change. Use temporary projects for filesystem, Git, coverage, and CLI tests.
|
|
14
|
+
Keep analyzed code out of the tool's process. Preserve the previous snapshot when analysis fails.
|
|
15
|
+
|
|
16
|
+
For defect reports, include the Ruby version, command, expected result, actual result, and a minimal source example.
|
|
17
|
+
Coverage defects also need a matching excerpt from `.resultset.json`. Remove application secrets before sharing examples.
|
|
18
|
+
|
|
19
|
+
Changes use Conventional Commits. Keep code, tests, and documentation for one behavior in a focused commit.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Leonid Svyatov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# crap4ruby
|
|
2
|
+
|
|
3
|
+
crap4ruby identifies complex, under-tested Ruby methods using the Change Risk Anti-Pattern (CRAP) metric.
|
|
4
|
+
|
|
5
|
+
- Analyzes Ruby 3.3+ source with Prism without loading application code.
|
|
6
|
+
- Reads SimpleCov reports, including legacy formats and multiple test suites.
|
|
7
|
+
- Prints method scores and writes the snapshot format used by [crapper](https://github.com/unclebob/crapper).
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
Ruby 3.3 or newer is required.
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
gem install crap4ruby
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
In the project you want to measure:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
crap4ruby --no-coverage
|
|
21
|
+
crap4ruby
|
|
22
|
+
crap4ruby --threshold 30
|
|
23
|
+
crap4ruby app/models lib/billing
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The first command reports complexity alone. The other commands require `coverage/.resultset.json` from your project's test run.
|
|
27
|
+
|
|
28
|
+
To use Bundler, add `gem "crap4ruby", require: false` to your project's `Gemfile` and run `bundle install`.
|
|
29
|
+
Run the tool with `bundle exec crap4ruby`.
|
|
30
|
+
|
|
31
|
+
## Install from source
|
|
32
|
+
|
|
33
|
+
Run these commands from this checkout with Ruby 3.3 or newer:
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
bundle install
|
|
37
|
+
gem build crap4ruby.gemspec
|
|
38
|
+
gem install ./crap4ruby-0.1.0.gem
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
To use Bundler in the measured project, add this checkout to that project's `Gemfile`:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
gem "crap4ruby", path: "/absolute/path/to/crap4ruby", require: false
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Then run `bundle install` and `bundle exec crap4ruby` in that project.
|
|
48
|
+
|
|
49
|
+
## Set up coverage
|
|
50
|
+
|
|
51
|
+
[SimpleCov](https://github.com/simplecov-ruby/simplecov) must start before the application loads. Add it to the measured project's test dependencies:
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
gem "simplecov", require: false, group: :test
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Place this at the top of `test/test_helper.rb` or `spec/spec_helper.rb`, before requiring application code:
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
require "simplecov"
|
|
61
|
+
SimpleCov.start
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Run your tests, then run `crap4ruby`. RSpec and Minitest work through the same SimpleCov report format.
|
|
65
|
+
|
|
66
|
+
You can also supply an explicit command:
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
crap4ruby --coverage-command 'bundle exec rspec'
|
|
70
|
+
crap4ruby --coverage custom/.resultset.json
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The command runs through `sh` at the project root with the caller's environment. Its output goes to stderr.
|
|
74
|
+
It must exit successfully and create or refresh the selected report. A failed command never replaces the CRAP snapshot.
|
|
75
|
+
crap4ruby does not configure SimpleCov, delete reports, install dependencies, or select a test runner.
|
|
76
|
+
|
|
77
|
+
## Commands
|
|
78
|
+
|
|
79
|
+
| Option | Behavior |
|
|
80
|
+
| --- | --- |
|
|
81
|
+
| `--root PATH` | Set the project root; default is the current directory. |
|
|
82
|
+
| `--source-root PATH` | Search this directory; repeat for multiple roots. |
|
|
83
|
+
| `--changed` | Limit analysis to staged, unstaged, and untracked Git changes. |
|
|
84
|
+
| `--coverage PATH` | Read this SimpleCov resultset; default is `coverage/.resultset.json`. |
|
|
85
|
+
| `--use-existing-coverage` | Explicitly select the default: read reports without running tests. |
|
|
86
|
+
| `--coverage-command COMMAND` | Run a shell command that refreshes the selected report. |
|
|
87
|
+
| `--no-coverage` | Report complexity with unknown coverage and CRAP. |
|
|
88
|
+
| `--threshold NUMBER` | Exit 2 when any unrounded score exceeds this finite, nonnegative value. |
|
|
89
|
+
| `-h`, `--help` | Print usage. |
|
|
90
|
+
| `-v`, `--version` | Print the gem version. |
|
|
91
|
+
|
|
92
|
+
Paths are relative to `--root`. Positional arguments select files or directories.
|
|
93
|
+
An argument that is not a path filters source paths by substring. Multiple filters use OR.
|
|
94
|
+
Explicit paths and source roots are combined; filters and `--changed` narrow that selection.
|
|
95
|
+
|
|
96
|
+
Discovery includes `.rb` files. It skips hidden directories, test/spec files, and these directories:
|
|
97
|
+
`test`, `tests`, `spec`, `features`, `vendor`, `node_modules`, `target`, `coverage`, `tmp`, `log`, `pkg`, `build`, and `dist`.
|
|
98
|
+
Test filenames are `test_*.rb`, `spec_*.rb`, `*_test.rb`, and `*_spec.rb`.
|
|
99
|
+
An explicitly named file overrides these exclusions. Directory traversal does not follow symbolic links.
|
|
100
|
+
|
|
101
|
+
Exit 0 means analysis succeeded. Exit 1 means invalid usage, unreadable or invalid input, a Git failure, or a coverage command failure.
|
|
102
|
+
Exit 2 means analysis succeeded but exceeded the threshold. No threshold applies unless you set one.
|
|
103
|
+
Every successful analysis replaces the snapshot, including filtered analyses and empty selections.
|
|
104
|
+
|
|
105
|
+
## Complexity and coverage
|
|
106
|
+
|
|
107
|
+
The formula uses a coverage fraction between 0 and 1:
|
|
108
|
+
|
|
109
|
+
```text
|
|
110
|
+
CRAP = CC^2 * (1 - coverage)^3 + CC
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Cyclomatic complexity (CC) starts at 1. Each of these adds 1:
|
|
114
|
+
|
|
115
|
+
- `if`, `elsif`, `unless`, and ternary conditions, including modifier forms.
|
|
116
|
+
- `while`, `until`, and `for` loops.
|
|
117
|
+
- Each `when` or pattern-matching `in` clause, including a separate increment for a guard condition.
|
|
118
|
+
- Each rescue clause and rescue modifier.
|
|
119
|
+
- Each `&&`, `||`, `and`, `or`, `&&=`, `||=`, and safe-navigation operation.
|
|
120
|
+
|
|
121
|
+
A `when` clause adds 1 regardless of its number of alternatives. `else` and `ensure` add nothing.
|
|
122
|
+
Decisions inside ordinary blocks and lambdas belong to their enclosing method.
|
|
123
|
+
Decisions in parameter defaults also count; coverage still measures only the body.
|
|
124
|
+
Nested methods are separate entries. Their bodies and nested class/module bodies do not increase the enclosing method's score.
|
|
125
|
+
Calling `each`, `map`, or another iterator does not itself add complexity.
|
|
126
|
+
|
|
127
|
+
Coverage is the fraction of executable method-body lines with positive hit counts.
|
|
128
|
+
Separate `def` headers are excluded: loading a definition does not prove that tests called its method.
|
|
129
|
+
Null lines do not enter the denominator. Multiple suites are merged by covered-line union, without applying an age cutoff.
|
|
130
|
+
Coverage paths must match absolute source paths or paths relative to the project root; unrelated checkout prefixes are not guessed.
|
|
131
|
+
|
|
132
|
+
A method absent from a valid report receives 0% coverage. A body with no measurable lines receives N/A.
|
|
133
|
+
Missing or malformed reports stop analysis unless `--no-coverage` is explicit.
|
|
134
|
+
Reports are accepted as supplied; generate fresh coverage after changing source files.
|
|
135
|
+
|
|
136
|
+
## Snapshot
|
|
137
|
+
|
|
138
|
+
The table lists the highest scores first, followed by entries with unknown scores.
|
|
139
|
+
The atomically replaced `.metrics/crap.edn` contains the same sorted entries, with coverage expressed as a percentage:
|
|
140
|
+
|
|
141
|
+
```clojure
|
|
142
|
+
{:entries [
|
|
143
|
+
{:name "charge", :namespace "Billing::Invoice", :complexity 4, :coverage 50.0, :crap 6.0}
|
|
144
|
+
]}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Namespaces use Ruby's `::` separator. Top-level methods use `Object`.
|
|
148
|
+
Instance method names are `charge`; singleton method names are `self.charge`.
|
|
149
|
+
`--no-coverage` writes `nil` for both `:coverage` and `:crap`.
|
|
150
|
+
|
|
151
|
+
The shape matches crapper's snapshot. This gem does not add Ruby support to crapper or its consumers.
|
|
152
|
+
A filtered run writes only its selected methods. Reopened classes and repeated definitions can produce repeated namespace/name pairs.
|
|
153
|
+
|
|
154
|
+
## Limits
|
|
155
|
+
|
|
156
|
+
Analysis covers explicit method definitions, including private and endless methods.
|
|
157
|
+
It does not execute Ruby or resolve runtime metaprogramming, `define_method`, aliases, generated accessors, or dynamic singleton receivers.
|
|
158
|
+
Namespaces derived from expressions, such as `factory::Thing`, are skipped with their contents.
|
|
159
|
+
Line coverage cannot distinguish expressions or methods sharing a source line, or separate evaluation of default parameters.
|
|
160
|
+
Branch coverage is not used. Static namespace names do not reflect runtime constant reassignment.
|
|
161
|
+
|
|
162
|
+
## Development
|
|
163
|
+
|
|
164
|
+
```sh
|
|
165
|
+
bundle install
|
|
166
|
+
bundle exec rake
|
|
167
|
+
bundle exec ruby -Ilib exe/crap4ruby --help
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
The default task runs the tests with SimpleCov, then fails if any library method's CRAP score exceeds 10.
|
|
171
|
+
`bundle exec rake crap` runs the same check. `bundle exec rake test` runs only the tests and coverage collection.
|
|
172
|
+
Each Rake test run discards the previous resultset so stale coverage cannot improve the score.
|
|
173
|
+
Reports are written to `coverage/index.html`, `coverage/.resultset.json`, and `.metrics/crap.edn`.
|
|
174
|
+
|
|
175
|
+
The suite exercises real SimpleCov output and subprocess CLI behavior, in addition to parser and scoring fixtures.
|
|
176
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for development guidance and [CHANGELOG.md](CHANGELOG.md) for changes.
|
|
177
|
+
The implementation is tested on CRuby 3.3, 3.4, and 4.0. Other Ruby implementations are unverified.
|
|
178
|
+
|
|
179
|
+
## Releases
|
|
180
|
+
|
|
181
|
+
Signed `v*` tags trigger the release workflow. The tag must match `Crap4Ruby::VERSION`.
|
|
182
|
+
Tests and the CRAP limit run before the gem is built. Publication requires approval in GitHub's `release` environment.
|
|
183
|
+
RubyGems trusted publishing authenticates the workflow. Sigstore signs the built gem, and RubyGems stores its attestation.
|
|
184
|
+
|
|
185
|
+
Report defects or ask questions through [GitHub issues](https://github.com/svyatov/crap4ruby/issues).
|
|
186
|
+
|
|
187
|
+
## License
|
|
188
|
+
|
|
189
|
+
[MIT](LICENSE), copyright Leonid Svyatov.
|
|
190
|
+
|
|
191
|
+
Inspired by [crap4clj](https://github.com/unclebob/crap4clj), [crap4java](https://github.com/unclebob/crap4java), and [crap4go](https://github.com/unclebob/crap4go).
|
data/exe/crap4ruby
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Crap4Ruby
|
|
4
|
+
class Analyzer
|
|
5
|
+
def call(source, path:)
|
|
6
|
+
result = Prism.parse(source)
|
|
7
|
+
unless result.success?
|
|
8
|
+
error = result.errors.first
|
|
9
|
+
raise Error, "#{path}:#{error.location.start_line}: #{error.message}"
|
|
10
|
+
end
|
|
11
|
+
discover(result.value, [], false, path)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def discover(node, scope, singleton, path)
|
|
17
|
+
return [] unless node
|
|
18
|
+
|
|
19
|
+
case node
|
|
20
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
21
|
+
owner = qualify(node.constant_path, scope, declaration: true)
|
|
22
|
+
owner ? discover(node.body, owner, false, path) : []
|
|
23
|
+
when Prism::SingletonClassNode
|
|
24
|
+
owner = node.expression.is_a?(Prism::SelfNode) ? scope : qualify(node.expression, scope)
|
|
25
|
+
owner ? discover(node.body, owner, true, path) : []
|
|
26
|
+
when Prism::DefNode
|
|
27
|
+
discover_method(node, scope, singleton, path)
|
|
28
|
+
else
|
|
29
|
+
node.compact_child_nodes.flat_map { |child| discover(child, scope, singleton, path) }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def discover_method(node, scope, singleton, path)
|
|
34
|
+
owner = node.receiver && !node.receiver.is_a?(Prism::SelfNode) ? qualify(node.receiver, scope) : scope
|
|
35
|
+
return [] unless owner
|
|
36
|
+
|
|
37
|
+
name = "#{'self.' if singleton || node.receiver}#{node.name}"
|
|
38
|
+
lines = body_lines(node.body)
|
|
39
|
+
complexity = 1 + decisions(node.parameters) + decisions(node.body)
|
|
40
|
+
[MethodInfo.new(name, owner.empty? ? "Object" : owner.join("::"), complexity, lines, path)] +
|
|
41
|
+
discover(node.body, scope, singleton, path)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def qualify(node, scope, declaration: false)
|
|
45
|
+
case node
|
|
46
|
+
when Prism::ParenthesesNode
|
|
47
|
+
qualify(node.body, scope)
|
|
48
|
+
when Prism::ConstantReadNode
|
|
49
|
+
name = node.name.to_s
|
|
50
|
+
ancestor = scope.rindex(name) unless declaration
|
|
51
|
+
ancestor ? scope.take(ancestor + 1) : scope + [name]
|
|
52
|
+
when Prism::ConstantPathNode
|
|
53
|
+
(node.parent ? qualify(node.parent, scope) : [])&.+([node.name.to_s])
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def decisions(node)
|
|
58
|
+
return 0 unless node
|
|
59
|
+
return 0 if boundary?(node)
|
|
60
|
+
|
|
61
|
+
decision = [:if_node, :unless_node, :while_node, :until_node, :for_node,
|
|
62
|
+
:when_node, :in_node, :rescue_node, :rescue_modifier_node, :and_node, :or_node].include?(node.type) ||
|
|
63
|
+
node.type.to_s.match?(/_(?:and|or)_write_node\z/)
|
|
64
|
+
count = decision ? 1 : 0
|
|
65
|
+
count += 1 if node.respond_to?(:safe_navigation?) && node.safe_navigation?
|
|
66
|
+
count + node.compact_child_nodes.sum { |child| decisions(child) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def boundary?(node)
|
|
70
|
+
[Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].any? { |type| node.is_a?(type) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def body_lines(node)
|
|
74
|
+
return [] unless node
|
|
75
|
+
|
|
76
|
+
lines = (node.location.start_line..node.location.end_line).to_a
|
|
77
|
+
excluded = []
|
|
78
|
+
collect_boundaries(node, excluded)
|
|
79
|
+
lines - excluded
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def collect_boundaries(node, excluded)
|
|
83
|
+
if boundary?(node)
|
|
84
|
+
excluded.concat((node.location.start_line..node.location.end_line).to_a)
|
|
85
|
+
else
|
|
86
|
+
node.compact_child_nodes.each { |child| collect_boundaries(child, excluded) }
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module Crap4Ruby
|
|
6
|
+
class CLI
|
|
7
|
+
def initialize(stdout: $stdout, stderr: $stderr)
|
|
8
|
+
@stdout = stdout
|
|
9
|
+
@stderr = stderr
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def run(arguments)
|
|
13
|
+
options, parser, paths = parse_options(arguments)
|
|
14
|
+
return @stdout.puts(parser) || 0 if options[:help]
|
|
15
|
+
return @stdout.puts(VERSION) || 0 if options[:version]
|
|
16
|
+
|
|
17
|
+
validate_options(options)
|
|
18
|
+
root = File.realpath(options[:root])
|
|
19
|
+
raise Error, "#{root}: project root must be a directory" unless File.directory?(root)
|
|
20
|
+
|
|
21
|
+
files = Discovery.call(root: root, source_roots: options[:source_roots], paths: paths, changed: options[:changed])
|
|
22
|
+
coverage = load_coverage(options, root) unless options[:no_coverage] || files.empty?
|
|
23
|
+
entries = files.flat_map do |path|
|
|
24
|
+
Analyzer.new.call(File.binread(path), path: path).map { |method| Score.call(method, coverage) }
|
|
25
|
+
end
|
|
26
|
+
entries = Report.sorted(entries)
|
|
27
|
+
path = Report.write(entries, root: root)
|
|
28
|
+
@stdout.write(Report.table(entries))
|
|
29
|
+
@stderr.puts("Wrote #{path}")
|
|
30
|
+
threshold_status(entries, options[:threshold])
|
|
31
|
+
rescue Error, OptionParser::ParseError, SystemCallError => error
|
|
32
|
+
@stderr.puts("crap4ruby: #{error.message}")
|
|
33
|
+
1
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def parse_options(arguments)
|
|
39
|
+
options = {root: Dir.pwd, source_roots: [], changed: false, coverage: "coverage/.resultset.json"}
|
|
40
|
+
parser = OptionParser.new do |cli|
|
|
41
|
+
cli.banner = "Usage: crap4ruby [options] [file-or-directory-or-filter ...]"
|
|
42
|
+
cli.on("--root PATH", "Project root (default: current directory)") { |path| options[:root] = path }
|
|
43
|
+
cli.on("--source-root PATH", "Analyze this directory; repeat for multiple roots") { |path| options[:source_roots] << path }
|
|
44
|
+
cli.on("--changed", "Analyze Git additions and edits, including untracked files") { options[:changed] = true }
|
|
45
|
+
cli.on("--coverage PATH", "SimpleCov resultset (default: coverage/.resultset.json)") { |path| options[:coverage] = path }
|
|
46
|
+
cli.on("--no-coverage", "Complexity only; coverage and CRAP are N/A") { options[:no_coverage] = true }
|
|
47
|
+
cli.on("--use-existing-coverage", "Read reports without running tests (the default)") { options[:existing] = true }
|
|
48
|
+
cli.on("--coverage-command COMMAND", "Run a shell command that refreshes the resultset") { |command| options[:command] = command }
|
|
49
|
+
cli.on("--threshold NUMBER", "Exit 2 when CRAP exceeds this value") do |value|
|
|
50
|
+
number = Float(value)
|
|
51
|
+
raise OptionParser::InvalidArgument, "threshold must be finite and nonnegative" unless number.finite? && number >= 0
|
|
52
|
+
|
|
53
|
+
options[:threshold] = number
|
|
54
|
+
rescue ArgumentError
|
|
55
|
+
raise OptionParser::InvalidArgument, "threshold must be finite and nonnegative"
|
|
56
|
+
end
|
|
57
|
+
cli.on("-h", "--help", "Show help") { options[:help] = true }
|
|
58
|
+
cli.on("-v", "--version", "Show version") { options[:version] = true }
|
|
59
|
+
end
|
|
60
|
+
[options, parser, parser.parse(arguments)]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def validate_options(options)
|
|
64
|
+
if options[:command] && (options[:no_coverage] || options[:existing])
|
|
65
|
+
raise Error, "--coverage-command cannot be combined with --no-coverage or --use-existing-coverage"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def threshold_status(entries, threshold)
|
|
70
|
+
if threshold && entries.any? { |entry| entry.crap && entry.crap > threshold }
|
|
71
|
+
@stderr.puts("CRAP threshold exceeded: #{threshold}")
|
|
72
|
+
2
|
|
73
|
+
else
|
|
74
|
+
0
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def load_coverage(options, root)
|
|
79
|
+
report = File.expand_path(options[:coverage], root)
|
|
80
|
+
run_coverage(options[:command], root, report) if options[:command]
|
|
81
|
+
Coverage.load(report, root: root)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def run_coverage(command, root, report)
|
|
85
|
+
raise Error, "--coverage-command must not be empty" if command.strip.empty?
|
|
86
|
+
|
|
87
|
+
before = report_stamp(report)
|
|
88
|
+
status = Open3.popen2e("sh", "-c", command, chdir: root) do |input, output, process|
|
|
89
|
+
input.close
|
|
90
|
+
output.each_line { |line| @stderr.write(line) }
|
|
91
|
+
process.value
|
|
92
|
+
end
|
|
93
|
+
raise Error, "coverage command failed (exit #{status.exitstatus || "signal #{status.termsig}"})" unless status.success?
|
|
94
|
+
raise Error, "coverage command did not refresh #{report}" if report_stamp(report) == before
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def report_stamp(path)
|
|
98
|
+
stat = File.stat(path)
|
|
99
|
+
[stat.ino, stat.mtime, stat.ctime, stat.size]
|
|
100
|
+
rescue Errno::ENOENT
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Crap4Ruby
|
|
6
|
+
class Coverage
|
|
7
|
+
def self.load(path, root:)
|
|
8
|
+
new(JSON.parse(File.read(path)), root: root)
|
|
9
|
+
rescue SystemCallError => error
|
|
10
|
+
raise Error, "#{path}: cannot read coverage report; generate SimpleCov coverage or use --no-coverage (#{error.message})"
|
|
11
|
+
rescue JSON::ParserError, Error => error
|
|
12
|
+
raise Error, "#{path}: invalid SimpleCov report (#{error.message})"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(suites, root:)
|
|
16
|
+
raise Error, "expected named test suites" unless suites.is_a?(Hash) && !suites.empty?
|
|
17
|
+
|
|
18
|
+
@files = {}
|
|
19
|
+
suites.each_value { |suite| add_suite(suite, root) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def fraction(method)
|
|
23
|
+
path = File.expand_path(method.path)
|
|
24
|
+
path = File.realpath(path) if File.exist?(path)
|
|
25
|
+
lines = @files[path]
|
|
26
|
+
return 0.0 unless lines
|
|
27
|
+
|
|
28
|
+
hits = method.lines.filter_map { |line| lines[line - 1] }
|
|
29
|
+
return nil if hits.empty?
|
|
30
|
+
|
|
31
|
+
hits.count(&:positive?).fdiv(hits.size)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def add_suite(suite, root)
|
|
37
|
+
unless suite.is_a?(Hash) && suite["coverage"].is_a?(Hash)
|
|
38
|
+
raise Error, "expected a coverage object for each suite"
|
|
39
|
+
end
|
|
40
|
+
suite["coverage"].each { |path, data| add_file(path, data, root) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def add_file(path, data, root)
|
|
44
|
+
key = File.expand_path(path, root)
|
|
45
|
+
key = File.realpath(key) if File.exist?(key)
|
|
46
|
+
lines = data.is_a?(Hash) ? data["lines"] : data
|
|
47
|
+
unless lines.is_a?(Array) && lines.all? { |hit| hit.nil? || (hit.is_a?(Integer) && hit >= 0) }
|
|
48
|
+
raise Error, "#{path}: expected nonnegative line counts or null"
|
|
49
|
+
end
|
|
50
|
+
previous = @files[key] || []
|
|
51
|
+
@files[key] = Array.new([previous.size, lines.size].max) do |index|
|
|
52
|
+
[previous[index], lines[index]].compact.max
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "find"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module Crap4Ruby
|
|
7
|
+
module Discovery
|
|
8
|
+
EXCLUDED = %w[test tests spec features vendor node_modules target coverage tmp log pkg build dist].freeze
|
|
9
|
+
|
|
10
|
+
def self.call(root:, source_roots:, paths:, changed:)
|
|
11
|
+
existing, filters = paths.partition { |path| File.exist?(File.expand_path(path, root)) }
|
|
12
|
+
roots = source_roots.map do |path|
|
|
13
|
+
directory = File.expand_path(path, root)
|
|
14
|
+
raise Error, "#{path}: source root must be a directory" unless File.directory?(directory)
|
|
15
|
+
|
|
16
|
+
directory
|
|
17
|
+
end
|
|
18
|
+
roots.concat(existing.map { |path| File.expand_path(path, root) })
|
|
19
|
+
roots << root if roots.empty?
|
|
20
|
+
files = roots.flat_map { |path| File.file?(path) ? [path] : walk(path) }
|
|
21
|
+
files = files.select { |path| File.extname(path) == ".rb" }.map { |path| File.realpath(path) }.uniq.sort
|
|
22
|
+
files.select! { |path| filters.any? { |filter| path.delete_prefix("#{root}/").include?(filter) } } unless filters.empty?
|
|
23
|
+
files &= changed_files(root) if changed
|
|
24
|
+
files
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.walk(root)
|
|
28
|
+
paths = []
|
|
29
|
+
Find.find(root) do |path|
|
|
30
|
+
name = File.basename(path)
|
|
31
|
+
if File.directory?(path)
|
|
32
|
+
Find.prune if path != root && (name.start_with?(".") || EXCLUDED.include?(name) || File.symlink?(path))
|
|
33
|
+
elsif !File.symlink?(path) && !name.start_with?(".") && !name.match?(/\A(?:test|spec)_|_(?:test|spec)\.rb\z/)
|
|
34
|
+
paths << path
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
paths
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.git(root, *arguments)
|
|
41
|
+
output, error, status = Open3.capture3("git", "-C", root, *arguments)
|
|
42
|
+
raise Error, "git: #{error.strip}" unless status.success?
|
|
43
|
+
|
|
44
|
+
output
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.changed_files(root)
|
|
48
|
+
top = git(root, "rev-parse", "--show-toplevel").chomp
|
|
49
|
+
git(root, "status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=all", "--", ".")
|
|
50
|
+
.split("\0").filter_map do |entry|
|
|
51
|
+
path = File.join(top, entry[3..])
|
|
52
|
+
File.realpath(path) if File.file?(path)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
|
|
6
|
+
module Crap4Ruby
|
|
7
|
+
module Report
|
|
8
|
+
def self.sorted(entries)
|
|
9
|
+
entries.sort_by { |entry| [entry.crap ? 0 : 1, -(entry.crap || 0), entry.namespace, entry.name, entry.path] }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.table(entries)
|
|
13
|
+
rows = [["Function", "Namespace", "CC", "Cov%", "CRAP"]] + entries.map do |entry|
|
|
14
|
+
[entry.name, entry.namespace, entry.complexity.to_s,
|
|
15
|
+
entry.coverage ? format("%.1f%%", entry.coverage) : "N/A",
|
|
16
|
+
entry.crap ? format("%.1f", entry.crap) : "N/A"]
|
|
17
|
+
end
|
|
18
|
+
widths = rows.transpose.map { |column| column.map(&:length).max }
|
|
19
|
+
lines = rows.map do |row|
|
|
20
|
+
row.each_with_index.map { |value, index| index < 2 ? value.ljust(widths[index]) : value.rjust(widths[index]) }.join(" ")
|
|
21
|
+
end
|
|
22
|
+
lines.insert(1, "-" * lines.first.length)
|
|
23
|
+
(["CRAP Report", "==========="] + lines).join("\n") + "\n"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.edn(entries)
|
|
27
|
+
rows = entries.map do |entry|
|
|
28
|
+
" {:name #{JSON.generate(entry.name)}, :namespace #{JSON.generate(entry.namespace)}, " \
|
|
29
|
+
":complexity #{entry.complexity}, :coverage #{number(entry.coverage)}, :crap #{number(entry.crap)}}"
|
|
30
|
+
end
|
|
31
|
+
rows.empty? ? "{:entries []}\n" : "{:entries [\n#{rows.join("\n")}\n]}\n"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.number(value)
|
|
35
|
+
return "nil" if value.nil?
|
|
36
|
+
|
|
37
|
+
text = format("%.4f", value).sub(/0+\z/, "")
|
|
38
|
+
text.end_with?(".") ? "#{text}0" : text
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.write(entries, root:)
|
|
42
|
+
directory = File.join(root, ".metrics")
|
|
43
|
+
FileUtils.mkdir_p(directory)
|
|
44
|
+
path = File.join(directory, "crap.edn")
|
|
45
|
+
Tempfile.create(["crap", ".edn"], directory) do |file|
|
|
46
|
+
file.write(edn(entries))
|
|
47
|
+
file.close
|
|
48
|
+
File.rename(file.path, path)
|
|
49
|
+
end
|
|
50
|
+
path
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Crap4Ruby
|
|
4
|
+
module Score
|
|
5
|
+
def self.call(method, coverage)
|
|
6
|
+
fraction = coverage&.fraction(method)
|
|
7
|
+
crap = method.complexity**2 * (1 - fraction)**3 + method.complexity if fraction
|
|
8
|
+
Entry.new(method.name, method.namespace, method.complexity, fraction && fraction * 100, crap, method.path)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
data/lib/crap4ruby.rb
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
require_relative "crap4ruby/version"
|
|
5
|
+
|
|
6
|
+
module Crap4Ruby
|
|
7
|
+
Error = Class.new(StandardError)
|
|
8
|
+
MethodInfo = Data.define(:name, :namespace, :complexity, :lines, :path)
|
|
9
|
+
Entry = Data.define(:name, :namespace, :complexity, :coverage, :crap, :path)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
require_relative "crap4ruby/analyzer"
|
|
13
|
+
require_relative "crap4ruby/coverage"
|
|
14
|
+
require_relative "crap4ruby/score"
|
|
15
|
+
require_relative "crap4ruby/report"
|
|
16
|
+
require_relative "crap4ruby/discovery"
|
|
17
|
+
require_relative "crap4ruby/cli"
|
metadata
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: crap4ruby
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Leonid Svyatov
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: prism
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.0'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '3'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '1.0'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '3'
|
|
32
|
+
description: Static Ruby method complexity and test coverage combined into CRAP scores
|
|
33
|
+
and crapper-compatible snapshots.
|
|
34
|
+
executables:
|
|
35
|
+
- crap4ruby
|
|
36
|
+
extensions: []
|
|
37
|
+
extra_rdoc_files: []
|
|
38
|
+
files:
|
|
39
|
+
- CHANGELOG.md
|
|
40
|
+
- CONTRIBUTING.md
|
|
41
|
+
- LICENSE
|
|
42
|
+
- README.md
|
|
43
|
+
- exe/crap4ruby
|
|
44
|
+
- lib/crap4ruby.rb
|
|
45
|
+
- lib/crap4ruby/analyzer.rb
|
|
46
|
+
- lib/crap4ruby/cli.rb
|
|
47
|
+
- lib/crap4ruby/coverage.rb
|
|
48
|
+
- lib/crap4ruby/discovery.rb
|
|
49
|
+
- lib/crap4ruby/report.rb
|
|
50
|
+
- lib/crap4ruby/score.rb
|
|
51
|
+
- lib/crap4ruby/version.rb
|
|
52
|
+
homepage: https://github.com/svyatov/crap4ruby
|
|
53
|
+
licenses:
|
|
54
|
+
- MIT
|
|
55
|
+
metadata:
|
|
56
|
+
source_code_uri: https://github.com/svyatov/crap4ruby
|
|
57
|
+
bug_tracker_uri: https://github.com/svyatov/crap4ruby/issues
|
|
58
|
+
changelog_uri: https://github.com/svyatov/crap4ruby/blob/main/CHANGELOG.md
|
|
59
|
+
rubygems_mfa_required: 'true'
|
|
60
|
+
allowed_push_host: https://rubygems.org
|
|
61
|
+
rdoc_options: []
|
|
62
|
+
require_paths:
|
|
63
|
+
- lib
|
|
64
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '3.3'
|
|
69
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
70
|
+
requirements:
|
|
71
|
+
- - ">="
|
|
72
|
+
- !ruby/object:Gem::Version
|
|
73
|
+
version: '0'
|
|
74
|
+
requirements: []
|
|
75
|
+
rubygems_version: 4.0.20
|
|
76
|
+
specification_version: 4
|
|
77
|
+
summary: CRAP scores for Ruby methods using Prism and SimpleCov reports
|
|
78
|
+
test_files: []
|