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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cadf9f2ab741c8b3067a4b0a0fc455e850c932b9d64d1958dc4b3aa95b48e3e8
4
- data.tar.gz: af11c04726fbf8871630d55c15334789dccab422aa7993a650266715ffd9e3f8
3
+ metadata.gz: 1bb02e1c178e586604ee60a1137eccc618adb554f4698bcfdba2eb847b419606
4
+ data.tar.gz: fc4f22bebe777b3466f79f477d1ab286c77eada7d897cf14c479b5f6732b46fa
5
5
  SHA512:
6
- metadata.gz: 122094bca81d07462b84ee61d363672e6e5d3526c1e5ff634682cce17fc328355bd6db9b7949d671b492334949cb9c14d287f5bd128866842d4b7952063f9667
7
- data.tar.gz: da79c22a530eabefc86315cdf14d2a519b283efb30e244fc8dcec187613f38a7b1f694ba62e8eeb737103c9bf74e3f42b9e1fdf06a14fcc7b665ff35d530b7bf
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.png" alt="Huff, the Impatient Programming imp, blowing three identical cards into one while his robot watches from the leash" width="320"></p>
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
- exhale is the contraction gate for Rails. 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.
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
- The first check is `exhale dry`. It combines Uncle Bob'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.
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
- Exit code 0 means the codebase is clean. 1 means the gate failed: there's unkept duplication, or a Contract clause is stale or names code that doesn't exist. 2 means exhale couldn't run, usually because a file doesn't parse.
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. `--introduced-only` gates on introduced and shifted findings and lists the rest as warnings. Once main is clean, drop the flag.
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`. `--format json` prints the same findings as data, and `--format edn` prints them in the shape dryer writes to `.metrics/dry.edn`.
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/`: 64 numbered obligations across 11 primitives (source, unit, shape, fingerprint, matcher, sweep, gate, clause, report, revision and cli). 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 on itself, reading that 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 gate for Rails: no PR merges while the codebase holds duplication the Contract doesn't keep"
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 gates the exhale of every pull request in a Rails app. Its first check, exhale dry,
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
- @argv.shift if CHECKS.include?(@argv.first)
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 = Dry::Check.new(root: @options[:root], base: @options[:base], include_tests: @options[:include_tests],
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 = "usage: exhale [dry] [PATH...] [options]\n exhale dry explain IDENTITY IDENTITY"
55
- o.on("--base REF", "Label findings against the merge base of REF and HEAD (default: the default branch)") do |v|
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
- o.on("--threshold N", Float, "Minimum score, 0 to 1 (overrides the Contract; the run won't gate)") do |v|
59
- @options[:overrides][:threshold] = Rational(v.to_s)
60
- end
61
- o.on("--min-lines N", Integer, "Minimum source lines (overrides the Contract; the run won't gate)") do |v|
62
- @options[:overrides][:min_lines] = v
63
- end
64
- o.on("--min-nodes N", Integer, "Minimum normalized nodes (overrides the Contract; the run won't gate)") do |v|
65
- @options[:overrides][:min_nodes] = v
66
- end
67
- o.on("--format F", "text, json or edn (default text)") { |v| @options[:format] = v }
68
- o.on("--include-tests", "Compare spec/ and test/ too") { @options[:include_tests] = true }
69
- o.on("--introduced-only", "On-ramp: gate only on introduced and shifted findings") do
70
- @options[:introduced_only] = true
71
- end
72
- o.on("--contract DIR", "The Contract's root (default contract/)") { |v| @options[:contract_dir] = v }
73
- o.on("--cache DIR", "Cache directory (default tmp/exhale/)") { |v| @options[:cache_dir] = v }
74
- o.on("--root DIR", "Repository root (default: the current directory)") { |v| @options[:root] = v }
75
- o.on("-v", "--version", "Print the version") do
76
- @out.puts "exhale #{VERSION}"
77
- exit 0
78
- end
79
- o.on("-h", "--help", "Print this help") do
80
- @out.puts o
81
- exit 0
82
- end
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
- # Prints two units' normalized trees and their score, for tuning. It
109
- # checks --format, --root and --base the way a run does, so a flag that
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
- unless args.size == 2
114
- @err.puts "usage: exhale dry explain IDENTITY IDENTITY"
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
- missing = args.zip([a, b]).find { |_, entry| entry.nil? }
126
- if missing
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