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 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,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "crap4ruby"
5
+
6
+ exit Crap4Ruby::CLI.new.run(ARGV)
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crap4Ruby
4
+ VERSION = "0.1.0"
5
+ 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: []