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 +7 -0
- data/.standard.yml +1 -0
- data/.swarfignore +4 -0
- data/CONTEXT.md +15 -0
- data/README.md +312 -0
- data/Rakefile +13 -0
- data/exe/swarf +7 -0
- data/lib/swarf/cli.rb +59 -0
- data/lib/swarf/complexity.rb +100 -0
- data/lib/swarf/coverage_map.rb +47 -0
- data/lib/swarf/measurement.rb +38 -0
- data/lib/swarf/probe.rb +22 -0
- data/lib/swarf/report.rb +79 -0
- data/lib/swarf/scan.rb +35 -0
- data/lib/swarf/score.rb +7 -0
- data/lib/swarf/sources.rb +43 -0
- data/lib/swarf/store.rb +106 -0
- data/lib/swarf/version.rb +5 -0
- data/lib/swarf.rb +26 -0
- data/swarf.gemspec +26 -0
- metadata +61 -0
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
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
|
data/lib/swarf/probe.rb
ADDED
|
@@ -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
|
data/lib/swarf/report.rb
ADDED
|
@@ -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
|
data/lib/swarf/score.rb
ADDED
|
@@ -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
|
data/lib/swarf/store.rb
ADDED
|
@@ -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
|
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: []
|