exhale 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +7 -0
- data/README.md +55 -11
- data/exhale.gemspec +3 -3
- data/lib/exhale/cli.rb +96 -43
- data/lib/exhale/complexity/check.rb +173 -0
- data/lib/exhale/complexity/cognitive.rb +260 -0
- data/lib/exhale/complexity/ratchet.rb +181 -0
- data/lib/exhale/contract/markdown.rb +1 -1
- data/lib/exhale/contract.rb +79 -18
- data/lib/exhale/dry/check.rb +2 -26
- data/lib/exhale/report.rb +170 -5
- data/lib/exhale/units.rb +37 -0
- data/lib/exhale/version.rb +1 -1
- data/lib/exhale.rb +4 -0
- metadata +13 -9
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1bb02e1c178e586604ee60a1137eccc618adb554f4698bcfdba2eb847b419606
|
|
4
|
+
data.tar.gz: fc4f22bebe777b3466f79f477d1ab286c77eada7d897cf14c479b5f6732b46fa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bb150d270734ec90b0e16006f60b9284f7900e380d982a85de862e7d7bdfcea74cb9c8b49b3471bd634aa6ed31e86053ba71d1524162b7a70a18b23c132d1bdd
|
|
7
|
+
data.tar.gz: 3ac44ea2d46351e35d7a013a3d06dae27c9b2cbc51925a4c8d6e581f91266856313e612ff70dae16a3c4746296d654e2b374c7d05088d8569a4a95812e210d53
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
- `exhale complexity` is the second check. It scores every Ruby method and Rails DSL body with Cognitive Complexity at the head and at the merge base, and fails a pull request that raises a unit past the floor (8 by default) or adds one over it. Renamed and moved methods keep their history by identity, then by shape. `complexity.md` in a primitive sets its floor and keeps units up to a ceiling's max. `exhale complexity explain UNIT` prints every point. JSON lists every unit; EDN writes crapper's entries.
|
|
6
|
+
- The Contract loads per check: dry reads `duplication.md`, complexity reads `complexity.md`, and a block that belongs to the other check is an error.
|
|
7
|
+
- The mutation gate moves to Mutineer 1.5. `timeout: 120` in `.mutineer.yml` replaces the `test/support/mutineer_timeout.rb` patch, and 8 ignore entries for continuation lines are gone, because Mutineer now selects tests for the later lines of a multi-line call or hash. The 13 on the right side of a multi-line `&&` stay, because Mutineer still selects no tests for an operand that may never run.
|
|
8
|
+
- `bin/mutate --matrix` reports blind and redundant tests with Mutineer 1.5's kill matrix. It is a separate run, not part of the gate.
|
|
9
|
+
|
|
3
10
|
## 0.1.0
|
|
4
11
|
|
|
5
12
|
First release. `exhale dry` sweeps the whole codebase for duplicated Ruby and HTML ERB and fails while any copy isn't kept by the Contract.
|
data/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# exhale
|
|
2
2
|
|
|
3
|
-
<p align="center"><img src="assets/huff-exhale.
|
|
3
|
+
<p align="center"><img src="assets/huff-exhale.svg" alt="Huff, the Impatient Programming imp, lips pursed, blowing two duplicate cards away while he keeps one in his hand and his robot sits at his feet" width="320"></p>
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Exhale aims to be the premier contraction toolkit for Ruby, the checks for the breathe-out phase of Impatient Programming. Its first check is `exhale dry`. It fails a pull request while the codebase it leaves behind holds duplicated code the Contract doesn't keep, and it tells the agent doing the cleanup which original each copy should fold into.
|
|
6
6
|
|
|
7
7
|
Agents duplicate by default. They read the codebase, find a shape that works, and copy it. When pull requests merge without a person reading every diff, the copy reaches main unless a machine stops it, and every session after that copies it again. exhale is that machine for the exhale half of the breath: expand to learn, then contract what you learned into what already exists, in the same PR.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
`exhale dry` combines Robert Martin's [dryer](https://github.com/unclebob/dryer) and Ryan Davis's [flay](https://github.com/seattlerb/flay), rebuilt on [Prism](https://github.com/ruby/prism) and [Herb](https://herb-tools.dev) so it reads modern Ruby and ERB the way Rails writes them. The second check, `exhale complexity`, fails a pull request that leaves a method harder to follow than it found it. A leaked-guards check is planned.
|
|
10
10
|
|
|
11
11
|
## Install
|
|
12
12
|
|
|
@@ -20,10 +20,10 @@ end
|
|
|
20
20
|
```bash
|
|
21
21
|
bundle install
|
|
22
22
|
bundle binstubs exhale
|
|
23
|
-
bin/exhale
|
|
23
|
+
bin/exhale dry
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
`exhale dry` exits 0 when the codebase is clean. It exits 1 when the gate fails: there's unkept duplication, or a Contract clause is stale or names code that doesn't exist. It exits 2 when it couldn't run, usually because a file doesn't parse.
|
|
27
27
|
|
|
28
28
|
## What it compares
|
|
29
29
|
|
|
@@ -51,7 +51,7 @@ Every subtree of a normalized unit is a fingerprint. Each fingerprint is weighte
|
|
|
51
51
|
|
|
52
52
|
## The gate
|
|
53
53
|
|
|
54
|
-
Every run sweeps the whole codebase. Main passes the same gate, so anything a pull request trips over is its own doing. exhale compares against the merge base only to label findings:
|
|
54
|
+
Every run sweeps the whole codebase. Main passes the same gate, so anything a pull request trips over is its own doing. `exhale dry` compares against the merge base only to label findings:
|
|
55
55
|
|
|
56
56
|
| Label | Meaning |
|
|
57
57
|
| --- | --- |
|
|
@@ -61,7 +61,7 @@ Every run sweeps the whole codebase. Main passes the same gate, so anything a pu
|
|
|
61
61
|
|
|
62
62
|
The report also lists what the PR contracted: pairs that matched at the base and don't anymore.
|
|
63
63
|
|
|
64
|
-
An existing app won't sweep clean on its first run.
|
|
64
|
+
An existing app won't sweep clean on its first run. `exhale dry --introduced-only` gates on introduced and shifted findings and lists the rest as warnings. Once main is clean, drop the flag.
|
|
65
65
|
|
|
66
66
|
## The Contract
|
|
67
67
|
|
|
@@ -72,6 +72,7 @@ contract/
|
|
|
72
72
|
provider_adapter/
|
|
73
73
|
README.md the primitive's prose, plus a covers block
|
|
74
74
|
duplication.md duplication it keeps, why, and its settings
|
|
75
|
+
complexity.md complexity it keeps, why, and its floor
|
|
75
76
|
```
|
|
76
77
|
|
|
77
78
|
A `parallel` block declares units that stay parallel on purpose. Its reason is the section it sits in:
|
|
@@ -118,10 +119,53 @@ exhale:
|
|
|
118
119
|
bundler-cache: true
|
|
119
120
|
- name: exhale
|
|
120
121
|
shell: bash
|
|
121
|
-
run: bin/exhale --base origin/${{ github.base_ref || 'main' }} | tee -a "$GITHUB_STEP_SUMMARY"
|
|
122
|
+
run: bin/exhale dry --base origin/${{ github.base_ref || 'main' }} | tee -a "$GITHUB_STEP_SUMMARY"
|
|
122
123
|
```
|
|
123
124
|
|
|
124
|
-
`fetch-depth: 0` gives exhale the history it needs to find the merge base and run `git blame`.
|
|
125
|
+
`fetch-depth: 0` gives `exhale dry` the history it needs to find the merge base and run `git blame`. `exhale dry --format json` prints the same findings as data, and `--format edn` prints them in the shape dryer writes to `.metrics/dry.edn`.
|
|
126
|
+
|
|
127
|
+
## exhale complexity
|
|
128
|
+
|
|
129
|
+
`exhale complexity` scores every Ruby method and Rails DSL body twice, at the head and at the merge base, and fails the pull request when a score rose past the floor. Absolute scores mostly track size and swing from team to team, so the gate judges the change. Code that was already complex passes until someone makes it worse.
|
|
130
|
+
|
|
131
|
+
The score is G. Ann Campbell's Cognitive Complexity (SonarSource, 2017). A method that reads straight down scores low however long it is. Each `if`, `unless`, ternary, loop, `case`, `rescue` and iterating block costs a point plus how deeply it's nested, so a branch inside a loop inside a block costs 3. An `elsif` or `else` costs 1, and so does each run of `&&` or `||` and a recursive call. Calls like `send`, `define_method` and `instance_eval` cost 1 each and are reported apart as metaprogramming. `contract/cognitive/README.md` writes down every rule, down to which blocks count as iterating.
|
|
132
|
+
|
|
133
|
+
| Label | Meaning | Gate |
|
|
134
|
+
| --- | --- | --- |
|
|
135
|
+
| Raised | The score rose and ends over the floor | fails |
|
|
136
|
+
| Introduced | A new unit scores over the floor | fails |
|
|
137
|
+
| Kept | The score rose past the floor and stays within a ceiling | passes |
|
|
138
|
+
| Contracted | The score fell | passes |
|
|
139
|
+
| Gone | The base unit has no match at the head | passes |
|
|
140
|
+
|
|
141
|
+
A method keeps its history when it moves to another file, and when it's renamed: an unmatched new unit is compared with an unmatched old one whose normalized shape scores 0.80 or more against it. Splitting a method passes as long as the method doesn't rise and every new piece ends at or under the floor.
|
|
142
|
+
|
|
143
|
+
The floor is 8. A primitive can set its own in `complexity.md`, and a `ceiling` block lets a unit go over the floor, up to its max, for the reason in the section it sits in:
|
|
144
|
+
|
|
145
|
+
````markdown
|
|
146
|
+
```settings
|
|
147
|
+
floor: 10
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## The rate table stays one method
|
|
151
|
+
|
|
152
|
+
Splitting it scatters the regions across files.
|
|
153
|
+
|
|
154
|
+
```ceiling
|
|
155
|
+
max: 14
|
|
156
|
+
Billing::Rates#lookup
|
|
157
|
+
```
|
|
158
|
+
````
|
|
159
|
+
|
|
160
|
+
A ceiling that names no method or DSL body fails the gate, and so does one whose units all score at or under the floor again. A ceiling only loosens: a max under the floor changes nothing. `exhale complexity explain Billing::Rates#lookup` prints a unit's score with every point, its line and the construct that earned it. `--floor N` changes the report for a local run and leaves the exit code on the Contract's floors. `--format json` lists every unit with its score, metaprogramming points and label, and `--format edn` writes the entries crapper writes so uml-viewer can read them.
|
|
161
|
+
|
|
162
|
+
With no git repository or no merge base there's nothing to compare, so the run exits 0 and lists the units over the floor as warnings. In CI, run it beside `exhale dry`:
|
|
163
|
+
|
|
164
|
+
```yaml
|
|
165
|
+
- name: exhale complexity
|
|
166
|
+
shell: bash
|
|
167
|
+
run: bin/exhale complexity --base origin/${{ github.base_ref || 'main' }} | tee -a "$GITHUB_STEP_SUMMARY"
|
|
168
|
+
```
|
|
125
169
|
|
|
126
170
|
## Determinism
|
|
127
171
|
|
|
@@ -133,9 +177,9 @@ The same commit gets the same verdict on any machine on any day. The verdict rea
|
|
|
133
177
|
|
|
134
178
|
## How exhale holds itself to this
|
|
135
179
|
|
|
136
|
-
exhale has its own Contract in `contract/`:
|
|
180
|
+
exhale has its own Contract in `contract/`: 82 numbered obligations across 13 primitives (source, unit, shape, fingerprint, matcher, sweep, gate, clause, report, revision, cli, cognitive and ratchet). Every obligation has at least one test that names it with a `# Contract: <primitive>/<id>` comment, and `rake contract` publishes contract coverage and fails while any obligation lacks an executable test. CI also runs `exhale dry` and `exhale complexity` on itself, reading that Contract.
|
|
137
181
|
|
|
138
|
-
The mutation gate runs every mutant [Mutineer](https://github.com/davidteren/mutineer) can make of `lib/` against the whole suite. Each one is either killed by a test or listed in `.mutineer.yml` with the reason no test can catch it: an equivalent mutant, an infinite loop, or a line Ruby's coverage can't see. `bin/mutate` runs it under Ruby 3.4.
|
|
182
|
+
The mutation gate runs every mutant [Mutineer](https://github.com/davidteren/mutineer) can make of `lib/` against the whole suite. Each one is either killed by a test or listed in `.mutineer.yml` with the reason no test can catch it: an equivalent mutant, an infinite loop, or a line Ruby's coverage can't see. `bin/mutate` runs it under Ruby 3.4. `bin/mutate --matrix` also names the tests that kill no mutant and the tests whose every kill another test also makes; it runs every covering test per mutant, so it is a separate, slower run and not part of the gate.
|
|
139
183
|
|
|
140
184
|
## Known limits in 0.1
|
|
141
185
|
|
data/exhale.gemspec
CHANGED
|
@@ -8,10 +8,10 @@ Gem::Specification.new do |spec|
|
|
|
8
8
|
spec.authors = ["Obie Fernandez"]
|
|
9
9
|
spec.email = ["obiefernandez@gmail.com"]
|
|
10
10
|
|
|
11
|
-
spec.summary = "The contraction
|
|
11
|
+
spec.summary = "The contraction toolkit for Rails. Its first check, exhale dry, fails a PR while the codebase holds duplication the Contract doesn't keep"
|
|
12
12
|
spec.description = <<~DESC.strip.gsub(/\n/, " ")
|
|
13
|
-
exhale
|
|
14
|
-
sweeps the whole codebase for duplicated Ruby and ERB, scores near-copies by rarity-weighted
|
|
13
|
+
exhale is the contraction toolkit for the exhale of every pull request in a Rails app. Its
|
|
14
|
+
first check, exhale dry, sweeps the whole codebase for duplicated Ruby and ERB, scores near-copies by rarity-weighted
|
|
15
15
|
structural similarity, and fails the build while any copy is undeclared. Deliberate
|
|
16
16
|
duplication is declared in the Contract, next to the reason for it. Built on Prism and Herb.
|
|
17
17
|
DESC
|
data/lib/exhale/cli.rb
CHANGED
|
@@ -4,15 +4,23 @@ require "optparse"
|
|
|
4
4
|
require_relative "version"
|
|
5
5
|
require_relative "errors"
|
|
6
6
|
require_relative "report"
|
|
7
|
+
require_relative "units"
|
|
7
8
|
require_relative "dry/check"
|
|
9
|
+
require_relative "complexity/check"
|
|
8
10
|
|
|
9
11
|
module Exhale
|
|
10
12
|
# exhale [dry] [PATH...] [options]
|
|
11
13
|
# exhale dry explain A B
|
|
14
|
+
# exhale complexity [PATH...] [options]
|
|
15
|
+
# exhale complexity explain UNIT
|
|
12
16
|
#
|
|
13
17
|
# Exit codes: 0 pass, 1 the gate failed, 2 exhale couldn't run.
|
|
14
18
|
class CLI
|
|
15
|
-
CHECKS = %w[dry].freeze
|
|
19
|
+
CHECKS = %w[dry complexity].freeze
|
|
20
|
+
BANNERS = {
|
|
21
|
+
"dry" => "usage: exhale [dry] [PATH...] [options]\n exhale dry explain IDENTITY IDENTITY",
|
|
22
|
+
"complexity" => "usage: exhale complexity [PATH...] [options]\n exhale complexity explain IDENTITY"
|
|
23
|
+
}.freeze
|
|
16
24
|
|
|
17
25
|
def initialize(argv, out: $stdout, err: $stderr)
|
|
18
26
|
@argv = argv.dup
|
|
@@ -23,7 +31,7 @@ module Exhale
|
|
|
23
31
|
end
|
|
24
32
|
|
|
25
33
|
def run
|
|
26
|
-
@
|
|
34
|
+
@check = CHECKS.include?(@argv.first) ? @argv.shift : "dry"
|
|
27
35
|
return explain(@argv.drop(1)) if @argv.first == "explain"
|
|
28
36
|
|
|
29
37
|
paths = parser.parse(@argv)
|
|
@@ -32,10 +40,7 @@ module Exhale
|
|
|
32
40
|
paths = narrowing(paths)
|
|
33
41
|
return 2 unless paths
|
|
34
42
|
|
|
35
|
-
result =
|
|
36
|
-
contract_dir: @options[:contract_dir], overrides: @options[:overrides],
|
|
37
|
-
introduced_only: @options[:introduced_only], paths: paths,
|
|
38
|
-
cache_dir: @options[:cache_dir]).run
|
|
43
|
+
result = @check == "dry" ? dry(paths) : complexity(paths)
|
|
39
44
|
@out.print Report.render(result, @options[:format])
|
|
40
45
|
result.exit_code
|
|
41
46
|
rescue OptionParser::ParseError, ArgumentError => e
|
|
@@ -49,37 +54,65 @@ module Exhale
|
|
|
49
54
|
|
|
50
55
|
private
|
|
51
56
|
|
|
57
|
+
def dry(paths)
|
|
58
|
+
Dry::Check.new(root: @options[:root], base: @options[:base], include_tests: @options[:include_tests],
|
|
59
|
+
contract_dir: @options[:contract_dir], overrides: @options[:overrides],
|
|
60
|
+
introduced_only: @options[:introduced_only], paths: paths, cache_dir: @options[:cache_dir]).run
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def complexity(paths)
|
|
64
|
+
Complexity::Check.new(root: @options[:root], base: @options[:base], floor: @options[:floor],
|
|
65
|
+
include_tests: @options[:include_tests], contract_dir: @options[:contract_dir],
|
|
66
|
+
paths: paths).run
|
|
67
|
+
end
|
|
68
|
+
|
|
52
69
|
def parser
|
|
53
70
|
@parser ||= OptionParser.new do |o|
|
|
54
|
-
o.banner =
|
|
55
|
-
o.on("--base REF", "
|
|
71
|
+
o.banner = BANNERS.fetch(@check)
|
|
72
|
+
o.on("--base REF", "Compare with the merge base of REF and HEAD (default: the default branch)") do |v|
|
|
56
73
|
@options[:base] = v
|
|
57
74
|
end
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
75
|
+
@check == "dry" ? dry_options(o) : complexity_options(o)
|
|
76
|
+
common_options(o)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def complexity_options(o)
|
|
81
|
+
o.on("--floor N", Integer, "Scores at or under N never fail (overrides the Contract for this report)") do |v|
|
|
82
|
+
raise OptionParser::InvalidArgument, "--floor #{v}" if v.negative?
|
|
83
|
+
|
|
84
|
+
@options[:floor] = v
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def dry_options(o)
|
|
89
|
+
o.on("--threshold N", Float, "Minimum score, 0 to 1 (overrides the Contract; the run won't gate)") do |v|
|
|
90
|
+
@options[:overrides][:threshold] = Rational(v.to_s)
|
|
91
|
+
end
|
|
92
|
+
o.on("--min-lines N", Integer, "Minimum source lines (overrides the Contract; the run won't gate)") do |v|
|
|
93
|
+
@options[:overrides][:min_lines] = v
|
|
94
|
+
end
|
|
95
|
+
o.on("--min-nodes N", Integer, "Minimum normalized nodes (overrides the Contract; the run won't gate)") do |v|
|
|
96
|
+
@options[:overrides][:min_nodes] = v
|
|
97
|
+
end
|
|
98
|
+
o.on("--introduced-only", "On-ramp: gate only on introduced and shifted findings") do
|
|
99
|
+
@options[:introduced_only] = true
|
|
100
|
+
end
|
|
101
|
+
o.on("--cache DIR", "Cache directory (default tmp/exhale/)") { |v| @options[:cache_dir] = v }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def common_options(o)
|
|
105
|
+
o.on("--format F", "text, json or edn (default text)") { |v| @options[:format] = v }
|
|
106
|
+
o.on("--include-tests", "Include spec/ and test/") { @options[:include_tests] = true }
|
|
107
|
+
o.on("--contract DIR", "The Contract's root (default contract/)") { |v| @options[:contract_dir] = v }
|
|
108
|
+
o.on("--root DIR", "Repository root (default: the current directory)") { |v| @options[:root] = v }
|
|
109
|
+
o.on("-v", "--version", "Print the version") do
|
|
110
|
+
@out.puts "exhale #{VERSION}"
|
|
111
|
+
exit 0
|
|
112
|
+
end
|
|
113
|
+
o.on("-h", "--help", "Print this help") do
|
|
114
|
+
@out.puts o
|
|
115
|
+
exit 0
|
|
83
116
|
end
|
|
84
117
|
end
|
|
85
118
|
|
|
@@ -105,28 +138,48 @@ module Exhale
|
|
|
105
138
|
false
|
|
106
139
|
end
|
|
107
140
|
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
# would fail CI fails here too.
|
|
141
|
+
# explain checks --format, --root and --base the way a run does, so a
|
|
142
|
+
# flag that would fail CI fails here too.
|
|
111
143
|
def explain(args)
|
|
112
144
|
args = parser.parse(args)
|
|
113
|
-
|
|
114
|
-
|
|
145
|
+
arity = @check == "dry" ? 2 : 1
|
|
146
|
+
unless args.size == arity
|
|
147
|
+
@err.puts "usage: #{BANNERS.fetch(@check).lines.last.strip}"
|
|
115
148
|
return 2
|
|
116
149
|
end
|
|
117
150
|
return 2 unless valid_format?
|
|
118
151
|
|
|
152
|
+
@check == "dry" ? dry_explain(args) : complexity_explain(args.first)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Prints one unit's score and every point that earned it.
|
|
156
|
+
def complexity_explain(identity)
|
|
157
|
+
Complexity::Check.new(root: @options[:root], base: @options[:base]).validate!
|
|
158
|
+
root = File.expand_path(@options[:root])
|
|
159
|
+
git = Git.new(root)
|
|
160
|
+
units, _errors = Units.read(root, files: (git.files if git.repo?), include_tests: @options[:include_tests])
|
|
161
|
+
unit = units.find { |u| u.language == :ruby && u.identity == identity }
|
|
162
|
+
return missing(identity) unless unit
|
|
163
|
+
|
|
164
|
+
@out.print Report.explain(Complexity::Scored.of(unit))
|
|
165
|
+
0
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def missing(identity)
|
|
169
|
+
@err.puts "exhale: no unit named #{identity}"
|
|
170
|
+
2
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Prints two units' normalized trees and their score, for tuning.
|
|
174
|
+
def dry_explain(args)
|
|
119
175
|
Dry::Check.new(root: @options[:root], base: @options[:base], cache_dir: @options[:cache_dir]).validate!
|
|
120
176
|
root = File.expand_path(@options[:root])
|
|
121
177
|
git = Git.new(root)
|
|
122
178
|
sweep = Dry::Sweep.new(root, files: (git.files if git.repo?), include_tests: @options[:include_tests],
|
|
123
179
|
contract_dir: @options[:contract_dir]).run
|
|
124
180
|
a, b = args.map { |identity| sweep.index.entries.find { |e| e.unit.identity == identity } }
|
|
125
|
-
|
|
126
|
-
if
|
|
127
|
-
@err.puts "exhale: no unit named #{missing[0]}"
|
|
128
|
-
return 2
|
|
129
|
-
end
|
|
181
|
+
absent = args.zip([a, b]).find { |_, entry| entry.nil? }
|
|
182
|
+
return missing(absent[0]) if absent
|
|
130
183
|
|
|
131
184
|
[a, b].each do |entry|
|
|
132
185
|
@out.puts "#{entry.unit.identity} #{entry.unit.path}:#{entry.unit.start_line}-#{entry.unit.end_line}"
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
require_relative "../errors"
|
|
5
|
+
require_relative "../git"
|
|
6
|
+
require_relative "../source_files"
|
|
7
|
+
require_relative "../units"
|
|
8
|
+
require_relative "../contract"
|
|
9
|
+
require_relative "../dry/check"
|
|
10
|
+
require_relative "cognitive"
|
|
11
|
+
require_relative "ratchet"
|
|
12
|
+
|
|
13
|
+
module Exhale
|
|
14
|
+
module Complexity
|
|
15
|
+
DEFAULT_FLOOR = 8
|
|
16
|
+
|
|
17
|
+
# rows are narrowed to the run's paths. floor is the floor the report
|
|
18
|
+
# was judged with: the default, or --floor's. floors are the primitives
|
|
19
|
+
# that set their own, by name. contract_failing are the rows that fail
|
|
20
|
+
# under the Contract's floors and pass under --floor's.
|
|
21
|
+
Result = Struct.new(:rows, :clause_errors, :parse_errors, :base_sha, :notes, :exit_code, :floor, :floors,
|
|
22
|
+
:contract_failing, keyword_init: true)
|
|
23
|
+
|
|
24
|
+
# The complexity check: scores every Ruby unit at the head and at the
|
|
25
|
+
# merge base, and hands both to the Ratchet.
|
|
26
|
+
class Check
|
|
27
|
+
def initialize(root:, base: nil, floor: nil, include_tests: false, contract_dir: "contract", paths: [])
|
|
28
|
+
@root = File.expand_path(root)
|
|
29
|
+
@base_ref = base
|
|
30
|
+
@floor = floor
|
|
31
|
+
@include_tests = include_tests
|
|
32
|
+
@contract_dir = contract_dir
|
|
33
|
+
@paths = paths
|
|
34
|
+
@git = Git.new(@root)
|
|
35
|
+
@notes = []
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def run
|
|
39
|
+
base_sha = validate!
|
|
40
|
+
units, parse_errors = Units.read(@root, files: (@git.files if @git.repo?), include_tests: @include_tests)
|
|
41
|
+
contract = Contract.load(@root, dir: @contract_dir, check: :complexity)
|
|
42
|
+
@resolver = Contract::Resolver.new(contract, units)
|
|
43
|
+
head = units.select { |unit| unit.language == :ruby }.map { |unit| Scored.of(unit) }
|
|
44
|
+
base = base_scores(base_sha) if base_sha
|
|
45
|
+
no_base_note(base_sha)
|
|
46
|
+
ceilings = ceiling_lookup(contract)
|
|
47
|
+
errors = contract.errors + @resolver.errors + ceiling_errors(contract, head)
|
|
48
|
+
|
|
49
|
+
judge = lambda do |floor_for|
|
|
50
|
+
narrow(Ratchet.new(head: head, base: base, floor_for: floor_for, ceiling_for: ceilings).rows)
|
|
51
|
+
end
|
|
52
|
+
verdict = judge.call(method(:contract_floor))
|
|
53
|
+
rows = @floor ? judge.call(->(_unit) { @floor }) : verdict
|
|
54
|
+
Result.new(rows: rows, clause_errors: errors, parse_errors: parse_errors, base_sha: base_sha, notes: notes,
|
|
55
|
+
exit_code: exit_code(parse_errors, verdict, errors), floor: @floor || DEFAULT_FLOOR,
|
|
56
|
+
floors: floors(contract), contract_failing: contract_failing(verdict, rows))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The SHA of the merge base, or nil when there's none to compare with.
|
|
60
|
+
# A --base git can't find is an error, the same as for dry.
|
|
61
|
+
def validate!
|
|
62
|
+
Dry::Check.new(root: @root, base: @base_ref).validate!
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def contract_floor(unit)
|
|
68
|
+
@resolver.settings_for(unit, { floor: DEFAULT_FLOOR })[:floor]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def base_scores(sha)
|
|
72
|
+
Dir.mktmpdir("exhale-complexity") do |dir|
|
|
73
|
+
files = @git.files_at(sha).select { |path| SourceFiles.language(path) == :ruby }
|
|
74
|
+
@git.export_files(sha, files, dir)
|
|
75
|
+
units, errors = Units.read(dir, files: files, include_tests: @include_tests)
|
|
76
|
+
errors.each { |e| @notes << "#{e.path} doesn't parse at the base, so its units count as new" }
|
|
77
|
+
units.map { |unit| Scored.of(unit) }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def no_base_note(base_sha)
|
|
82
|
+
return if base_sha
|
|
83
|
+
|
|
84
|
+
advice = "; pass --base REF to compare" if @git.repo? && !@git.default_branch_ref
|
|
85
|
+
@notes.unshift("#{no_base_reason}, so nothing is compared; units over the floor are warnings#{advice}")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def no_base_reason
|
|
89
|
+
return "not a git repository" unless @git.repo?
|
|
90
|
+
return "no merge base found" if @git.default_branch_ref
|
|
91
|
+
|
|
92
|
+
*first, last = Git::DEFAULT_CANDIDATES
|
|
93
|
+
"no default branch found (origin/HEAD, #{first.join(', ')} or #{last})"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Both lists hold the same units in the same order.
|
|
97
|
+
def contract_failing(verdict, rows)
|
|
98
|
+
verdict.zip(rows).filter_map { |contract, shown| contract if contract.failing? && !shown.failing? }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Under --floor every primitive takes it, so none has its own.
|
|
102
|
+
def floors(contract)
|
|
103
|
+
return {} if @floor
|
|
104
|
+
|
|
105
|
+
contract.settings.filter_map { |name, settings| [name, settings[:floor]] if settings.key?(:floor) }.to_h
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def notes
|
|
109
|
+
notes = @notes.dup
|
|
110
|
+
if @floor
|
|
111
|
+
notes << "--floor #{@floor} overrides the Contract for this report only; " \
|
|
112
|
+
"the exit code keeps the Contract's floors"
|
|
113
|
+
end
|
|
114
|
+
notes << "narrowed to #{@paths.join(', ')}: only units there are reported and gated" unless @paths.empty?
|
|
115
|
+
notes
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# unit => the clause of its most specific ceiling reference. A tie
|
|
119
|
+
# takes the lower max, then the clause written first.
|
|
120
|
+
def ceiling_lookup(contract)
|
|
121
|
+
best = {}.compare_by_identity
|
|
122
|
+
ceiling_ranks(contract).sort_by(&:first).each { |_, unit, clause| best[unit] = clause }
|
|
123
|
+
->(unit) { best[unit] }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def ceiling_ranks(contract)
|
|
127
|
+
ceilings(contract).each_with_index.flat_map do |clause, index|
|
|
128
|
+
clause.references.flat_map do |ref|
|
|
129
|
+
@resolver.units_for(ref).map { |unit| [[ref.specificity, -clause.max, -index], unit, clause] }
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def ceilings(contract)
|
|
135
|
+
contract.clauses.select { |clause| clause.kind == :ceiling }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def ceiling_errors(contract, head)
|
|
139
|
+
scores = head.each_with_object({}.compare_by_identity) { |scored, map| map[scored.unit] = scored.score }
|
|
140
|
+
ceilings(contract).filter_map do |clause|
|
|
141
|
+
problem = ceiling_problem(clause, scores)
|
|
142
|
+
ContractError.new(clause.path, clause.line, problem) if problem
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# A ceiling has to name a scored unit, and is stale once nothing it
|
|
147
|
+
# names sits over the floor. A reference that names nothing at all is
|
|
148
|
+
# already the resolver's error.
|
|
149
|
+
def ceiling_problem(clause, scores)
|
|
150
|
+
named = clause.references.flat_map { |ref| @resolver.units_for(ref) }
|
|
151
|
+
return if named.empty?
|
|
152
|
+
|
|
153
|
+
scored = named.select { |unit| scores.key?(unit) }
|
|
154
|
+
return "ceiling names no scored unit; only methods and DSL bodies are scored" if scored.empty?
|
|
155
|
+
return if scored.any? { |unit| scores[unit] > contract_floor(unit) }
|
|
156
|
+
|
|
157
|
+
"stale ceiling: nothing it names scores over the floor; delete it"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def narrow(rows)
|
|
161
|
+
return rows if @paths.empty?
|
|
162
|
+
|
|
163
|
+
rows.select { |row| @paths.any? { |p| p == "." || row.unit.path == p || row.unit.path.start_with?("#{p}/") } }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def exit_code(parse_errors, verdict, errors)
|
|
167
|
+
return 2 unless parse_errors.empty?
|
|
168
|
+
|
|
169
|
+
verdict.any?(&:failing?) || !errors.empty? ? 1 : 0
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|