swarf 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: c617f49a96d65792c3b8d3e2465effae7ad84b0c9b414770a4726cc0c8187165
4
+ data.tar.gz: 689601738e13957770d0a0c1a018e37507d5d563cc46688506f6162cc67d1367
5
+ SHA512:
6
+ metadata.gz: 1fdfdb2342066a65fa736103f9af0caa4472a35f444f80045ae1b88a17a1290904b1d86e610b9660cf75f2887bac4f4fbc4512c4a4ac28a09365a6fe3743545e
7
+ data.tar.gz: b2ae944731ba4966ce88b5abe117b61c8fe7563659274ab03e1834c3751501c7c8a8f53b2447959b7bb86c00061ee1338de8a1097698427d31ed840456b79da7
data/.standard.yml ADDED
@@ -0,0 +1 @@
1
+ ruby_version: 3.0
data/.swarfignore ADDED
@@ -0,0 +1,4 @@
1
+ # The probe starts Coverage itself, so it is loaded before measurement begins and can
2
+ # never appear in the store. Both spellings are here because patterns match relative to
3
+ # the directory scanned: `swarf` sees lib/swarf/probe.rb, `swarf lib/` sees swarf/probe.rb.
4
+ **/swarf/probe.rb
data/CONTEXT.md ADDED
@@ -0,0 +1,15 @@
1
+ # Domain language
2
+
3
+ Terms used in swarf's code, tests and commit messages. The README splits swarf into two halves that never talk to each other: the **probe**, which records coverage from inside your test process, and the **runner**, which scores. The terms below name the parts of the runner.
4
+
5
+ ## Scan
6
+
7
+ One pass over a set of paths, joining parsed complexity to recorded coverage to produce scores.
8
+
9
+ A scan owns no I/O policy — it is given its paths, its ignore patterns, its store and its root — and no formatting beyond naming each method's `file:line`. Scan is the part of the runner that produces scores; `CLI` and `Report` are the rest of it.
10
+
11
+ ## Measurement
12
+
13
+ What test runs recorded about one file: line hits, branch outcomes, method call counts, and the SHA-256 of the bytes those numbers were measured against.
14
+
15
+ A measurement knows whether it still describes the file on disk. It answers that question the first time it is asked and remembers the answer — asked before the source is read, it would report bytes the scan never saw, which is the confidently-wrong staleness DESIGN §4 exists to prevent.
data/README.md ADDED
@@ -0,0 +1,312 @@
1
+ # swarf
2
+
3
+ Scores every Ruby method by how complex it is against how well your tests actually exercise it.
4
+
5
+ ```
6
+ $ swarf lib/
7
+
8
+ Method CC Cov% CRAP Evidence Location
9
+ ---------------------------------------------------------------------
10
+ Cart#checkout 6 0.0% 42.00 never called lib/cart.rb:31
11
+ Cart#discount 4 50.0% 6.00 3/6 br lib/cart.rb:24
12
+ Cart#shipping 3 66.7% 3.33 2/3 br lib/cart.rb:15
13
+ Cart#subtotal 1 100.0% 1.00 1/1 ln lib/cart.rb:11
14
+ ```
15
+
16
+ ## The metric
17
+
18
+ ```
19
+ CRAP(m) = CC² · (1 − coverage)³ + CC
20
+ ```
21
+
22
+ Complexity is squared; the _uncovered_ fraction is cubed. Two identities explain the shape:
23
+
24
+ | coverage | CRAP | meaning |
25
+ | -------- | ---------- | --------------------------------------------------- |
26
+ | 100% | `CC` | fully tested code is only as risky as it is complex |
27
+ | 0% | `CC² + CC` | untested complexity grows quadratically |
28
+
29
+ The curve is nearly flat near full coverage and violently steep near zero, which is the
30
+ point: simple code and small gaps stay quiet, complex code nobody has run scores loudly.
31
+
32
+ From Alberto Savoia and Bob Evans (2007), where it stood for _Change Risk Analysis and
33
+ Prediction_; Robert C. Martin's ports expand it as _Change Risk Anti-Pattern_. Same formula.
34
+
35
+ ## Install
36
+
37
+ ```ruby
38
+ # Gemfile
39
+ gem "swarf", group: :development
40
+ ```
41
+
42
+ ```
43
+ $ bundle install
44
+ ```
45
+
46
+ ## Getting started
47
+
48
+ ### 1. Score complexity — no setup at all
49
+
50
+ Complexity is parsed straight from your source, so this works immediately:
51
+
52
+ ```
53
+ $ bundle exec swarf lib/
54
+
55
+ Method CC Cov% CRAP Evidence Location
56
+ ---------------------------------------------------------
57
+ Cart#checkout 6 — 42.00 no data lib/cart.rb:31
58
+ Cart#discount 4 — 20.00 no data lib/cart.rb:24
59
+ Cart#shipping 3 — 12.00 no data lib/cart.rb:15
60
+ Cart#subtotal 1 — 2.00 no data lib/cart.rb:11
61
+
62
+ No coverage recorded for every file — run your suite with swarf/probe loaded.
63
+ ```
64
+
65
+ `no data` means no test run has been recorded yet, so every method reports `CC² + CC`.
66
+ That is not a placeholder — it is the correct score for code nothing has run. Adding
67
+ coverage can only ever pull a number _down_, toward `CC`.
68
+
69
+ ### 2. Record coverage — one line
70
+
71
+ Load the probe **before anything else** in your test runs.
72
+
73
+ **RSpec** — add to `.rspec`, above every other line:
74
+
75
+ ```
76
+ --require swarf/probe
77
+ ```
78
+
79
+ **Minitest** — the very first line of `test/test_helper.rb`, above `minitest/autorun`:
80
+
81
+ ```ruby
82
+ require "swarf/probe"
83
+ ```
84
+
85
+ **Minitest via Rake** — in the `Rakefile`. `test_prelude` runs before the tests load:
86
+
87
+ ```ruby
88
+ Minitest::TestTask.create do |t|
89
+ t.test_prelude = 'require "swarf/probe"'
90
+ end
91
+ ```
92
+
93
+ **Rails** — in `test/test_helper.rb`, above `config/environment`, or the whole app loads
94
+ before the probe does and none of it is measured:
95
+
96
+ ```ruby
97
+ ENV["RAILS_ENV"] ||= "test"
98
+
99
+ require "bundler/setup"
100
+ require "swarf/probe"
101
+
102
+ require_relative "../config/environment"
103
+ ```
104
+
105
+ `bundler/setup` is only what `config/boot` would do anyway; the probe needs it to find the
106
+ gem this early. Rails parallelises tests by forking, which swarf handles — every worker
107
+ merges into the store under a lock.
108
+
109
+ **Anything else** — set it on the command line, no files to edit:
110
+
111
+ ```
112
+ $ RUBYOPT="-rswarf/probe" bundle exec rake test
113
+ ```
114
+
115
+ Order matters and is not negotiable: `Coverage` measures only files loaded _after_ it
116
+ starts. Put the probe under your application and the application is invisible to it.
117
+
118
+ Then ignore the store:
119
+
120
+ ```
121
+ # .gitignore
122
+ .swarf/
123
+ ```
124
+
125
+ ### 3. Run your tests, then score again
126
+
127
+ ```
128
+ $ bundle exec rspec
129
+ $ bundle exec swarf lib/
130
+
131
+ Method CC Cov% CRAP Evidence Location
132
+ ---------------------------------------------------------------------
133
+ Cart#checkout 6 0.0% 42.00 never called lib/cart.rb:31
134
+ Cart#discount 4 50.0% 6.00 3/6 br lib/cart.rb:24
135
+ Cart#shipping 3 66.7% 3.33 2/3 br lib/cart.rb:15
136
+ Cart#subtotal 1 100.0% 1.00 1/1 ln lib/cart.rb:11
137
+ ```
138
+
139
+ Every run merges into `.swarf/coverage.json`, so partial runs are fine — running one spec
140
+ file does not erase what another proved. Your runs, CI's runs and a colleague's all add up.
141
+
142
+ ## Reading the report
143
+
144
+ | column | means |
145
+ | ---------- | ----------------------------------------------------------------- |
146
+ | `CC` | cyclomatic complexity — how many decisions the method makes |
147
+ | `Cov%` | how much of it your tests exercised, or `—` when nothing is known |
148
+ | `CRAP` | the score; worst first |
149
+ | `Evidence` | where the coverage number came from, so you know what to do next |
150
+
151
+ The evidence column is the actionable half:
152
+
153
+ | evidence | what it means | what to do |
154
+ | -------------- | ------------------------------------------- | ------------------------------------ |
155
+ | `never called` | no test has ever executed this method | **write** a test |
156
+ | `3/6 br` | 3 of 6 branch outcomes were taken | **extend** a test to the other cases |
157
+ | `1/1 ln` | no branches, so lines are the whole truth | nothing, if it is 100% |
158
+ | `no data` | no test run has been recorded for this file | run your suite with the probe loaded |
159
+ | `stale` | the file changed since it was measured | re-run your suite |
160
+
161
+ Both `no data` and `stale` fall back to the `CRAP = CC² + CC` floor rather than guessing.
162
+
163
+ Those two also print under the table, counted by file, because the fix is per file rather
164
+ than per method:
165
+
166
+ ```
167
+ … 45 more (--limit 0 for all)
168
+ No coverage recorded for 3 of 11 files — run your suite with swarf/probe loaded.
169
+ 2 of 11 files changed after measurement — re-run your suite.
170
+ ```
171
+
172
+ A fully measured project prints neither line.
173
+
174
+ ## Command line
175
+
176
+ ```
177
+ $ swarf # the whole project
178
+ $ swarf lib/ app/ # directories, recursively
179
+ $ swarf lib/app/cart.rb # a single file
180
+ $ swarf --limit 50 # show 50 rows instead of 20
181
+ $ swarf --limit 0 # show everything
182
+ $ swarf --ignore "app/legacy/**" # skip a path (repeatable)
183
+ $ swarf --all # score everything, ignoring nothing
184
+ $ swarf --version
185
+ $ swarf --help
186
+ ```
187
+
188
+ Only the worst 20 rows print by default, with a count of what was held back. A 245-file
189
+ project reports 344 methods, and the tail of that list is all `CRAP 1.00` — noise that
190
+ buries the handful of rows worth acting on.
191
+
192
+ ## What gets skipped
193
+
194
+ ```
195
+ **/test/** **/spec/** **/features/** where coverage comes from, not where risk is
196
+ db/** migrations and schema
197
+ **/vendor/** **/tmp/** **/log/** **/node_modules/**
198
+ ```
199
+
200
+ Migrations matter more than they look. They are generated, run once and never tested, so
201
+ on a well-tested codebase they are the only untested code left and they take over the top
202
+ of the report — on a 20-file Rails app they ranked 3rd, 4th and 5th.
203
+
204
+ Add your own in `.swarfignore` at the project root, one glob per line:
205
+
206
+ ```
207
+ # generated
208
+ lib/api/generated_client.rb
209
+ app/legacy/**
210
+ ```
211
+
212
+ Patterns match against each file's path relative to the directory being scanned, and both
213
+ `*` and `**` cross directories. Naming a path on the command line always wins, so
214
+ `swarf db/migrate` scores migrations even though `db/**` is a default.
215
+
216
+ Directories are searched for `**/*.rb`, skipping `test/`, `spec/`, `vendor/`, `tmp/` and
217
+ `node_modules/`. Naming one of those directly still scores it.
218
+
219
+ `SWARF_DIR` moves the coverage store, which both the probe and the runner must agree on:
220
+
221
+ ```
222
+ $ SWARF_DIR=/tmp/swarf bundle exec rspec
223
+ $ SWARF_DIR=/tmp/swarf bundle exec swarf lib/
224
+ ```
225
+
226
+ ## How it works
227
+
228
+ Two halves that never talk to each other, joined by a file on disk.
229
+
230
+ ```mermaid
231
+ flowchart LR
232
+ accTitle: How swarf computes a CRAP score
233
+ accDescr: Any test run loads the probe, which records line, branch and method coverage into a JSON store. Separately, the swarf CLI parses your sources with Prism for complexity, joins the two, and prints a report worst first.
234
+ subgraph probe["PROBE — inside your test process"]
235
+ Specs["your specs"] -->|records| DB[("`.swarf/coverage.json`")]
236
+ end
237
+ subgraph runner["RUNNER — when you type swarf"]
238
+ Src["lib/**/*.rb"] -->|"Prism: complexity"| Score["CRAP per method"]
239
+ end
240
+ DB -->|"lines, branches, call counts"| Score
241
+ Score --> Report["report, worst first"]
242
+ ```
243
+
244
+ The probe is about thirty lines: `Coverage.start` plus an `at_exit` that dumps the result.
245
+ The runner never loads your application — it parses text.
246
+
247
+ ## Things worth knowing
248
+
249
+ **Coverage needs a run; complexity does not.** Nothing static can tell you whether a line
250
+ executed. Any run counts, not just specs — a rake task, booting the app, a script.
251
+
252
+ **The probe must load first.** `Coverage` only measures files loaded after it starts. Put
253
+ it ahead of your application, or the application is invisible to it.
254
+
255
+ **swarf and SimpleCov cannot both run.** Ruby permits one `Coverage.start` per process. If
256
+ SimpleCov gets there first, swarf warns and records nothing rather than killing your suite.
257
+
258
+ **Branch coverage is preferred, with a line fallback.** Ruby puts a decision on a line that
259
+ runs whichever way the decision goes, so `return 0 if x.negative?` reads 100% by line even
260
+ when the guard never fires — maximally wrong exactly where risk collects. Methods with no
261
+ branches fall back to lines, where "did it run" is the whole truth.
262
+
263
+ **`never called` is a fact, not an inference.** It comes from a VM-level call count. It
264
+ tells you to _write_ a test; `3/6 br` tells you to _extend_ one. A bare `0.0%` tells you
265
+ neither.
266
+
267
+ **Edited files report `no coverage`, not stale numbers.** Coverage is indexed by line
268
+ number, so inserting a method at the top of a file shifts every line below it while the
269
+ counters stay put. swarf stores a SHA-256 per measured file and drops entries whose bytes
270
+ changed, because stale coverage is worse than none — it is confidently wrong.
271
+
272
+ **swarf's CC will not match RuboCop's.** It counts `if`, `unless`, `while`, `until`, `for`,
273
+ each `when`, each `in`, each `rescue`, `&&`, `||` and `&.` — **not blocks**. Six chained
274
+ `add_option` blocks are not six decisions. The trade-off is that CC largely ignores
275
+ iteration, since Ruby iterates with blocks.
276
+
277
+ **There is no threshold and nothing fails.** swarf sorts worst-first and prints. Because
278
+ `CRAP = CC` at full coverage, a fixed threshold is a complexity cap in disguise — a method
279
+ at CC 9 can never score under 9 however well you test it, and the only remaining move is to
280
+ split it. Ranking is enough; capping complexity is RuboCop's job.
281
+
282
+ ## Known limitations
283
+
284
+ - Methods defined inside a `Struct.new do ... end` block take the enclosing module's name
285
+ (`Swarf#crap` rather than `Swarf::Score#crap`), because the block is not a class node.
286
+ - `define_method` and other dynamically defined methods are not seen at all — swarf reads
287
+ `def`.
288
+ - Nested `def`s get their own complexity, but the outer method's coverage still counts the
289
+ inner method's lines and branches.
290
+ - Methods inside `class << self` are named correctly; methods defined by `instance_eval` or
291
+ a reopened singleton via a variable are not.
292
+
293
+ ## Development
294
+
295
+ ```
296
+ $ bin/setup
297
+ $ bundle exec rake test
298
+ ```
299
+
300
+ To watch swarf score itself:
301
+
302
+ ```
303
+ $ RUBYOPT="-Ilib -rswarf/probe" bundle exec rake test
304
+ $ ruby -Ilib exe/swarf lib/
305
+ ```
306
+
307
+ ### Releasing
308
+
309
+ 1. Update the version in `lib/swarf/version.rb`
310
+ 2. Run `bundle install` to update the lockfile
311
+ 3. Commit: `git commit -am "Release vX.Y.Z"`
312
+ 4. Run `bundle exec rake release` (builds the gem, creates the git tag, pushes to RubyGems)
data/Rakefile ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+ require "standard/rake"
6
+
7
+ Minitest::TestTask.create do |task|
8
+ # Record swarf's coverage with swarf. The prelude runs before the tests load,
9
+ # which is the only place `Coverage.start` can still see them.
10
+ task.test_prelude = 'require "swarf/probe"'
11
+ end
12
+
13
+ task default: %i[test standard]
data/exe/swarf ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # frozen_string_literal: true
4
+
5
+ require "swarf"
6
+
7
+ exit Swarf::CLI.run(ARGV)
data/lib/swarf/cli.rb ADDED
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Swarf
6
+ class CLI
7
+ def self.run(argv, out: $stdout, err: $stderr)
8
+ new(argv).run(out)
9
+ rescue Error => e
10
+ err.puts("swarf: #{e.message}")
11
+ 1
12
+ end
13
+
14
+ def initialize(argv)
15
+ @limit = Report::DEFAULT_LIMIT
16
+ @extra_ignore = []
17
+ @all = false
18
+ @paths = parse(argv)
19
+ end
20
+
21
+ def run(out)
22
+ scores = Scan.new(paths: @paths, ignore: ignore).scores
23
+ out.print Report.new(scores, limit: @limit).to_s
24
+ 0
25
+ end
26
+
27
+ private
28
+
29
+ def ignore
30
+ return [] if @all
31
+
32
+ Sources::DEFAULT_IGNORE + Sources.ignore_file + @extra_ignore
33
+ end
34
+
35
+ def parse(argv)
36
+ parser = OptionParser.new do |opts|
37
+ opts.banner = "Usage: swarf [options] [paths]"
38
+ opts.on("-n", "--limit N", Integer, "Rows to show (0 for all, default #{Report::DEFAULT_LIMIT})") do |n|
39
+ @limit = n
40
+ end
41
+ opts.on("-i", "--ignore GLOB", "Skip paths matching GLOB (repeatable)") do |glob|
42
+ @extra_ignore << glob
43
+ end
44
+ opts.on("-a", "--all", "Score everything, including #{Sources::IGNORE_FILE} and the defaults") do
45
+ @all = true
46
+ end
47
+ opts.on("-v", "--version", "Print the version and exit") do
48
+ puts VERSION
49
+ exit 0
50
+ end
51
+ opts.on("-h", "--help", "Print this message and exit") do
52
+ puts opts
53
+ exit 0
54
+ end
55
+ end
56
+ parser.parse(argv)
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Swarf
6
+ module Complexity
7
+ Method = Struct.new(:path, :name, :cc, :start_line, :body) do
8
+ def range = start_line..(body&.last || start_line)
9
+ end
10
+
11
+ def self.analyze(source, path:)
12
+ result = Prism.parse(source)
13
+ visitor = Visitor.new(path)
14
+ result.value.accept(visitor)
15
+ visitor.methods
16
+ end
17
+
18
+ class Visitor < Prism::Visitor
19
+ attr_reader :methods
20
+
21
+ def initialize(path)
22
+ @path = path
23
+ @methods = []
24
+ @stack = []
25
+ @scope = []
26
+ @singleton = 0
27
+ super()
28
+ end
29
+
30
+ def visit_class_node(node)
31
+ in_scope(node.constant_path.slice) { super }
32
+ end
33
+
34
+ def visit_module_node(node)
35
+ in_scope(node.constant_path.slice) { super }
36
+ end
37
+
38
+ def visit_singleton_class_node(node)
39
+ @singleton += 1
40
+ super
41
+ @singleton -= 1
42
+ end
43
+
44
+ def visit_def_node(node)
45
+ method = Method.new(path: @path, name: qualify(node), cc: 1,
46
+ start_line: node.location.start_line, body: body_range(node))
47
+ @methods << method
48
+ @stack.push(method)
49
+ super
50
+ @stack.pop
51
+ end
52
+
53
+ DECISIONS = %i[
54
+ if unless while until for when in rescue rescue_modifier and or
55
+ ].freeze
56
+
57
+ DECISIONS.each do |construct|
58
+ define_method(:"visit_#{construct}_node") do |node|
59
+ decision
60
+ super(node)
61
+ end
62
+ end
63
+
64
+ def visit_call_node(node)
65
+ decision if node.safe_navigation?
66
+ super
67
+ end
68
+
69
+ private
70
+
71
+ def decision
72
+ @stack.last&.cc += 1
73
+ end
74
+
75
+ def in_scope(name)
76
+ @scope.push(name)
77
+ yield
78
+ @scope.pop
79
+ end
80
+
81
+ def qualify(node)
82
+ return node.name.to_s if @scope.empty?
83
+
84
+ separator = (node.receiver || @singleton.positive?) ? "." : "#"
85
+ "#{@scope.join("::")}#{separator}#{node.name}"
86
+ end
87
+
88
+ def body_range(node)
89
+ return nil unless node.body
90
+
91
+ first = node.body.location.start_line
92
+ last = node.body.location.end_line
93
+ return first..last if node.equal_loc
94
+
95
+ clamped = [first, node.location.start_line + 1].max..[last, node.location.end_line - 1].min
96
+ (clamped.begin > clamped.end) ? first..last : clamped
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Swarf
4
+ class CoverageMap
5
+ Result = Struct.new(:coverage, :evidence)
6
+
7
+ NO_DATA = Result.new(coverage: nil, evidence: "no data")
8
+ STALE = Result.new(coverage: nil, evidence: "stale")
9
+ NEVER_CALLED = Result.new(coverage: 0.0, evidence: "never called")
10
+ NO_BODY = Result.new(coverage: 1.0, evidence: "no body")
11
+
12
+ def initialize(data)
13
+ @data = data
14
+ end
15
+
16
+ def for(method)
17
+ measurement = @data[method.path]
18
+ return NO_DATA if measurement.nil?
19
+ return STALE unless measurement.current?
20
+ return NEVER_CALLED if measurement.calls(method.start_line)&.zero?
21
+
22
+ branch_coverage(measurement, method) || line_coverage(measurement, method) || NO_BODY
23
+ end
24
+
25
+ private
26
+
27
+ def branch_coverage(measurement, method)
28
+ outcomes = measurement.branches(method.range)
29
+ return nil if outcomes.empty?
30
+
31
+ ratio(outcomes.count(&:positive?), outcomes.size, "br")
32
+ end
33
+
34
+ def line_coverage(measurement, method)
35
+ return nil unless method.body
36
+
37
+ hits = measurement.lines(method.body)
38
+ return nil if hits.empty?
39
+
40
+ ratio(hits.count(&:positive?), hits.size, "ln")
41
+ end
42
+
43
+ def ratio(covered, total, unit)
44
+ Result.new(coverage: covered.fdiv(total), evidence: "#{covered}/#{total} #{unit}")
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Swarf
6
+ class Measurement
7
+ def initialize(path, entry)
8
+ @path = path
9
+ @entry = entry
10
+ @current = nil
11
+ end
12
+
13
+ def current?
14
+ @current = File.file?(@path) && Digest::SHA256.file(@path).hexdigest == @entry["sha"] if @current.nil?
15
+ @current
16
+ end
17
+
18
+ def calls(line)
19
+ @entry["methods"][line.to_s]
20
+ end
21
+
22
+ def branches(range)
23
+ range.flat_map { |line| outcomes_by_line.fetch(line, []) }
24
+ end
25
+
26
+ def lines(range)
27
+ range.filter_map { |line| @entry["lines"][line - 1] }
28
+ end
29
+
30
+ private
31
+
32
+ def outcomes_by_line
33
+ @outcomes_by_line ||= @entry["branches"].each_with_object({}) do |(branch, taken), by_line|
34
+ (by_line[branch.split(":")[2].to_i] ||= []).concat(taken.values)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "coverage"
4
+
5
+ module Swarf
6
+ module Probe
7
+ def self.start
8
+ ::Coverage.start(lines: true, branches: true, methods: true)
9
+ at_exit { record(::Coverage.result) }
10
+ rescue RuntimeError => e
11
+ warn "swarf: coverage is already being measured (#{e.message}); not recording. " \
12
+ "Remove SimpleCov, or drop the swarf/probe require."
13
+ end
14
+
15
+ def self.record(result)
16
+ require_relative "store"
17
+ Store.new.record(result)
18
+ end
19
+ end
20
+ end
21
+
22
+ Swarf::Probe.start
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Swarf
4
+ class Report
5
+ HEADINGS = ["Method", "CC", "Cov%", "CRAP", "Evidence", "Location"].freeze
6
+
7
+ DEFAULT_LIMIT = 20
8
+
9
+ def initialize(scores, limit: DEFAULT_LIMIT)
10
+ @scores = scores.sort_by { |score| -score.crap }
11
+ @shown = limit.zero? ? @scores : @scores.first(limit)
12
+ end
13
+
14
+ def to_s
15
+ return "No methods found.\n" if @scores.empty?
16
+
17
+ rows = @shown.map { |score| cells(score) }
18
+ widths = column_widths(rows)
19
+ lines = [heading(widths), divider(widths), *rows.map { |row| line(row, widths) }]
20
+ "#{(lines + footer).join("\n")}\n"
21
+ end
22
+
23
+ private
24
+
25
+ def held_back = @scores.size - @shown.size
26
+
27
+ # Notes the reader can act on, each named for the move it asks for. Kept out of the
28
+ # rows because the action is per file, not per method: a project nothing has run is
29
+ # one sentence, not one row per method.
30
+ def footer
31
+ notes = []
32
+ notes << "… #{held_back} more (--limit 0 for all)" if held_back.positive?
33
+ notes << unmeasured_note if unmeasured.positive?
34
+ notes << stale_note if stale.positive?
35
+ notes.empty? ? notes : [""] + notes
36
+ end
37
+
38
+ def unmeasured_note
39
+ "No coverage recorded for #{scope(unmeasured)} — run your suite with swarf/probe loaded."
40
+ end
41
+
42
+ def stale_note = "#{scope(stale).capitalize} changed after measurement — re-run your suite."
43
+
44
+ def scope(count) = (count == files.size) ? "every file" : "#{count} of #{files.size} files"
45
+
46
+ def unmeasured = @unmeasured ||= files_reporting(CoverageMap::NO_DATA.evidence)
47
+
48
+ def stale = @stale ||= files_reporting(CoverageMap::STALE.evidence)
49
+
50
+ def files_reporting(evidence)
51
+ files.count { |methods| methods.all? { |score| score.evidence == evidence } }
52
+ end
53
+
54
+ def files
55
+ @files ||= @scores.group_by { |score| score.location.to_s.rpartition(":").first }.values
56
+ end
57
+
58
+ def cells(score)
59
+ [score.name, score.cc.to_s, percentage(score.coverage), format("%.2f", score.crap),
60
+ score.evidence.to_s, score.location.to_s]
61
+ end
62
+
63
+ def percentage(coverage) = coverage.nil? ? "—" : format("%.1f%%", coverage * 100)
64
+
65
+ def column_widths(rows)
66
+ ([HEADINGS] + rows).transpose.map { |column| column.map(&:length).max }
67
+ end
68
+
69
+ def heading(widths) = line(HEADINGS, widths)
70
+
71
+ def divider(widths) = "-" * (widths.sum + 2 * (widths.size - 1))
72
+
73
+ def line(cells, widths)
74
+ cells.each_with_index.map do |cell, index|
75
+ (index.zero? || index == cells.size - 1) ? cell.ljust(widths[index]) : cell.rjust(widths[index])
76
+ end.join(" ").rstrip
77
+ end
78
+ end
79
+ end
data/lib/swarf/scan.rb ADDED
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Swarf
4
+ class Scan
5
+ def initialize(paths:, ignore: [], store: Store.new, root: Dir.pwd)
6
+ @root = File.expand_path(root)
7
+ @paths = resolve(paths)
8
+ @ignore = ignore
9
+ @store = store
10
+ end
11
+
12
+ def scores
13
+ coverage = CoverageMap.new(@store.read)
14
+ Sources.collect(@paths, ignore: @ignore).flat_map do |path|
15
+ Complexity.analyze(File.read(path), path: path).map do |method|
16
+ found = coverage.for(method)
17
+ Score.new(name: method.name, cc: method.cc, coverage: found.coverage,
18
+ evidence: found.evidence, location: locate(method))
19
+ end
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def resolve(paths)
26
+ return [@root] if paths.empty?
27
+
28
+ paths.map { |path| File.expand_path(path, @root) }
29
+ end
30
+
31
+ def locate(method)
32
+ "#{method.path.delete_prefix("#{@root}/")}:#{method.start_line}"
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Swarf
4
+ Score = Struct.new(:name, :cc, :coverage, :evidence, :location) do
5
+ def crap = Swarf.crap(cc, coverage || 0.0)
6
+ end
7
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Swarf
4
+ module Sources
5
+ IGNORE_FILE = ".swarfignore"
6
+
7
+ DEFAULT_IGNORE = [
8
+ "**/test/**", "**/spec/**", "**/features/**",
9
+ "db/**",
10
+ "**/vendor/**", "**/tmp/**", "**/log/**", "**/node_modules/**"
11
+ ].freeze
12
+
13
+ def self.collect(paths, ignore:)
14
+ patterns = ignore.flat_map { |pattern| variants(pattern) }
15
+ paths.flat_map { |path| expand(path, patterns) }.uniq.sort
16
+ end
17
+
18
+ def self.ignore_file(root = Dir.pwd)
19
+ File.readlines(File.join(root, IGNORE_FILE), chomp: true)
20
+ .map(&:strip).reject { |line| line.empty? || line.start_with?("#") }
21
+ rescue Errno::ENOENT
22
+ []
23
+ end
24
+
25
+ def self.expand(path, patterns)
26
+ return [path] if File.file?(path)
27
+ raise Error, "no such file or directory: #{path}" unless File.directory?(path)
28
+
29
+ Dir.glob(File.join(path, "**", "*.rb"))
30
+ .reject { |file| ignored?(file.delete_prefix("#{path}/"), patterns) }
31
+ end
32
+
33
+ def self.ignored?(relative, patterns)
34
+ patterns.any? do |pattern|
35
+ File.fnmatch?(pattern, relative, File::FNM_PATHNAME) || File.fnmatch?(pattern, relative)
36
+ end
37
+ end
38
+
39
+ def self.variants(pattern)
40
+ pattern.start_with?("**/") ? [pattern, pattern.delete_prefix("**/")] : [pattern]
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+ require "fileutils"
6
+
7
+ module Swarf
8
+ class Store
9
+ FILENAME = "coverage.json"
10
+
11
+ def self.default_dir = ENV.fetch("SWARF_DIR", File.join(Dir.pwd, ".swarf"))
12
+
13
+ def initialize(dir = self.class.default_dir, root: Dir.pwd)
14
+ @dir = dir
15
+ @root = File.expand_path(root)
16
+ end
17
+
18
+ def read
19
+ parse(File.read(path)).to_h { |file, entry| [file, Measurement.new(file, entry)] }
20
+ rescue Errno::ENOENT
21
+ {}
22
+ end
23
+
24
+ def record(result)
25
+ fresh = normalize(result)
26
+ return if fresh.empty?
27
+
28
+ FileUtils.mkdir_p(@dir)
29
+ File.open(path, File::RDWR | File::CREAT, 0o644) do |file|
30
+ file.flock(File::LOCK_EX)
31
+ merged = merge(parse(file.read), fresh)
32
+ file.rewind
33
+ file.write(JSON.pretty_generate(merged))
34
+ file.truncate(file.pos)
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def path = File.join(@dir, FILENAME)
41
+
42
+ def parse(json)
43
+ JSON.parse(json)
44
+ rescue JSON::ParserError
45
+ {}
46
+ end
47
+
48
+ def merge(merged, fresh)
49
+ fresh.each do |file, entry|
50
+ previous = merged[file]
51
+ merged[file] = (previous && previous["sha"] == entry["sha"]) ? combine(previous, entry) : entry
52
+ end
53
+ merged
54
+ end
55
+
56
+ def normalize(result)
57
+ result.filter_map do |file, coverage|
58
+ file = File.expand_path(file, @root)
59
+ next unless project_file?(file)
60
+
61
+ [file, {
62
+ "sha" => Digest::SHA256.file(file).hexdigest,
63
+ "lines" => coverage[:lines] || [],
64
+ "branches" => stringify_branches(coverage[:branches] || {}),
65
+ "methods" => stringify_methods(coverage[:methods] || {})
66
+ }]
67
+ end.to_h
68
+ end
69
+
70
+ def project_file?(file)
71
+ file.start_with?("#{@root}/") && File.file?(file)
72
+ end
73
+
74
+ def stringify_branches(branches)
75
+ branches.to_h do |branch, outcomes|
76
+ [branch.join(":"), outcomes.transform_keys { |outcome| outcome.join(":") }]
77
+ end
78
+ end
79
+
80
+ def stringify_methods(methods)
81
+ methods.each_with_object(Hash.new(0)) do |((_owner, _name, line, *), count), totals|
82
+ totals[line.to_s] += count
83
+ end
84
+ end
85
+
86
+ def combine(previous, fresh)
87
+ fresh.merge(
88
+ "lines" => sum_lines(previous["lines"], fresh["lines"]),
89
+ "branches" => sum_branches(previous["branches"], fresh["branches"]),
90
+ "methods" => previous["methods"].merge(fresh["methods"]) { |_, a, b| a + b }
91
+ )
92
+ end
93
+
94
+ def sum_lines(previous, fresh)
95
+ fresh.each_with_index.map do |hits, index|
96
+ hits.nil? ? nil : hits + (previous[index] || 0)
97
+ end
98
+ end
99
+
100
+ def sum_branches(previous, fresh)
101
+ fresh.merge(previous) do |_branch, a, b|
102
+ a.merge(b) { |_outcome, x, y| x + y }
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Swarf
4
+ VERSION = "0.1.0"
5
+ end
data/lib/swarf.rb ADDED
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "swarf/version"
4
+ require_relative "swarf/complexity"
5
+ require_relative "swarf/measurement"
6
+ require_relative "swarf/store"
7
+ require_relative "swarf/coverage_map"
8
+ require_relative "swarf/score"
9
+ require_relative "swarf/report"
10
+ require_relative "swarf/sources"
11
+ require_relative "swarf/scan"
12
+ require_relative "swarf/cli"
13
+
14
+ module Swarf
15
+ class Error < StandardError; end
16
+
17
+ # CRAP(m) = CC(m)^2 * (1 - coverage(m))^3 + CC(m)
18
+ #
19
+ # Complexity is squared; the *uncovered* fraction is cubed. The score is
20
+ # nearly flat near full coverage and violently steep near zero, so simple
21
+ # code and small gaps stay quiet while complex code nothing has run scores
22
+ # loudly.
23
+ def self.crap(complexity, coverage)
24
+ (complexity**2) * ((1.0 - coverage)**3) + complexity
25
+ end
26
+ end
data/swarf.gemspec ADDED
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/swarf/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "swarf"
7
+ spec.version = Swarf::VERSION
8
+ spec.authors = ["Ariel Rzezak"]
9
+ spec.email = ["arzezak@gmail.com"]
10
+
11
+ spec.summary = "Scores Ruby methods by the CRAP metric: complexity against coverage."
12
+ spec.description = "Ranks every Ruby method by complexity against test coverage, worst first."
13
+ spec.homepage = "https://github.com/arzezak/swarf"
14
+ spec.required_ruby_version = ">= 3.4.0"
15
+ spec.metadata["homepage_uri"] = spec.homepage
16
+ spec.metadata["source_code_uri"] = spec.homepage
17
+
18
+ spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls|
19
+ ls.readlines("\x0", chomp: true).reject do |f|
20
+ f.start_with?(*%w[bin/ Gemfile .gitignore test/])
21
+ end
22
+ end
23
+ spec.bindir = "exe"
24
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |file| File.basename(file) }
25
+ spec.require_paths = ["lib"]
26
+ end
metadata ADDED
@@ -0,0 +1,61 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: swarf
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ariel Rzezak
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Ranks every Ruby method by complexity against test coverage, worst first.
13
+ email:
14
+ - arzezak@gmail.com
15
+ executables:
16
+ - swarf
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - ".standard.yml"
21
+ - ".swarfignore"
22
+ - CONTEXT.md
23
+ - README.md
24
+ - Rakefile
25
+ - exe/swarf
26
+ - lib/swarf.rb
27
+ - lib/swarf/cli.rb
28
+ - lib/swarf/complexity.rb
29
+ - lib/swarf/coverage_map.rb
30
+ - lib/swarf/measurement.rb
31
+ - lib/swarf/probe.rb
32
+ - lib/swarf/report.rb
33
+ - lib/swarf/scan.rb
34
+ - lib/swarf/score.rb
35
+ - lib/swarf/sources.rb
36
+ - lib/swarf/store.rb
37
+ - lib/swarf/version.rb
38
+ - swarf.gemspec
39
+ homepage: https://github.com/arzezak/swarf
40
+ licenses: []
41
+ metadata:
42
+ homepage_uri: https://github.com/arzezak/swarf
43
+ source_code_uri: https://github.com/arzezak/swarf
44
+ rdoc_options: []
45
+ require_paths:
46
+ - lib
47
+ required_ruby_version: !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - ">="
50
+ - !ruby/object:Gem::Version
51
+ version: 3.4.0
52
+ required_rubygems_version: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: '0'
57
+ requirements: []
58
+ rubygems_version: 4.0.20
59
+ specification_version: 4
60
+ summary: 'Scores Ruby methods by the CRAP metric: complexity against coverage.'
61
+ test_files: []