exhale 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/.ruby-version +1 -0
- data/CHANGELOG.md +12 -0
- data/LICENSE.txt +21 -0
- data/README.md +149 -0
- data/Rakefile +96 -0
- data/exe/exhale +6 -0
- data/exhale.gemspec +43 -0
- data/lib/exhale/cli.rb +148 -0
- data/lib/exhale/contract/markdown.rb +107 -0
- data/lib/exhale/contract/reference.rb +63 -0
- data/lib/exhale/contract/resolver.rb +142 -0
- data/lib/exhale/contract.rb +194 -0
- data/lib/exhale/dry/check.rb +266 -0
- data/lib/exhale/dry/fingerprints.rb +105 -0
- data/lib/exhale/dry/gate.rb +425 -0
- data/lib/exhale/dry/index.rb +59 -0
- data/lib/exhale/dry/matcher.rb +468 -0
- data/lib/exhale/dry/normalizer/erb.rb +289 -0
- data/lib/exhale/dry/normalizer/ruby.rb +193 -0
- data/lib/exhale/dry/normalizer.rb +26 -0
- data/lib/exhale/errors.rb +27 -0
- data/lib/exhale/git.rb +279 -0
- data/lib/exhale/report.rb +203 -0
- data/lib/exhale/shape.rb +32 -0
- data/lib/exhale/source_files.rb +84 -0
- data/lib/exhale/unit.rb +27 -0
- data/lib/exhale/units/erb.rb +30 -0
- data/lib/exhale/units/ruby.rb +283 -0
- data/lib/exhale/version.rb +5 -0
- data/lib/exhale.rb +25 -0
- metadata +152 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: cadf9f2ab741c8b3067a4b0a0fc455e850c932b9d64d1958dc4b3aa95b48e3e8
|
|
4
|
+
data.tar.gz: af11c04726fbf8871630d55c15334789dccab422aa7993a650266715ffd9e3f8
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 122094bca81d07462b84ee61d363672e6e5d3526c1e5ff634682cce17fc328355bd6db9b7949d671b492334949cb9c14d287f5bd128866842d4b7952063f9667
|
|
7
|
+
data.tar.gz: da79c22a530eabefc86315cdf14d2a519b283efb30e244fc8dcec187613f38a7b1f694ba62e8eeb737103c9bf74e3f42b9e1fdf06a14fcc7b665ff35d530b7bf
|
data/.ruby-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.3.4
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
- Units from Prism (methods, Rails DSL bodies, concerns) and Herb (HTML ERB templates), normalized so the names of operations survive and the names of things become markers.
|
|
8
|
+
- Rarity-weighted Jaccard over subtree fingerprints, with exact prefix filtering for whole units, exact subtree digests for copied fragments, and statement-run seeds for code lifted out of the middle of a method.
|
|
9
|
+
- The verdict depends only on the commit. The merge base labels each finding introduced, shifted, already there, kept or contracted, and `--introduced-only` is the on-ramp for codebases that don't sweep clean yet.
|
|
10
|
+
- The Contract under `contract/<primitive>/` keeps deliberate duplication (`parallel`), maps primitives to code (`covers`) and holds settings (`settings`). Stale clauses and unknown references fail the gate.
|
|
11
|
+
- Text, JSON and EDN reports. EDN uses the shape Uncle Bob's dryer writes.
|
|
12
|
+
- exhale ships with its own Contract (64 obligations, all executable), a clean run on itself, and a mutation gate where every Mutineer mutant is killed or listed with a reason.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Obie Fernandez
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# exhale
|
|
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>
|
|
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.
|
|
6
|
+
|
|
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
|
+
|
|
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.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
# Gemfile
|
|
15
|
+
group :development, :test do
|
|
16
|
+
gem "exhale", require: false
|
|
17
|
+
end
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
bundle install
|
|
22
|
+
bundle binstubs exhale
|
|
23
|
+
bin/exhale
|
|
24
|
+
```
|
|
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.
|
|
27
|
+
|
|
28
|
+
## What it compares
|
|
29
|
+
|
|
30
|
+
Units are methods, the bodies of Rails DSL calls (`scope`, `validate`, callbacks, `before_action` blocks), and ERB templates. Inside each unit it also compares fragments: blocks, conditionals, HTML elements, and runs of three or more consecutive statements lifted out of the middle of a method.
|
|
31
|
+
|
|
32
|
+
Class-body declarations like `has_many` and `validates` never count, and neither does anything under `db/`, `config/`, `vendor/` or `tmp/`. Tests are left out unless you pass `--include-tests`, because tests should be DAMP, not DRY.
|
|
33
|
+
|
|
34
|
+
Normalization keeps the names of operations and drops the names of things. Method names at call sites survive, so do operators, HTML tags and Stimulus `data-controller` values. Locals, instance variables, constants, symbols and literals become markers. These two methods are the same shape:
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
def alpha(xs)
|
|
38
|
+
ys = xs.select(&:odd?)
|
|
39
|
+
ys.map(&:succ)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def beta(items)
|
|
43
|
+
kept = items.select(&:even?)
|
|
44
|
+
kept.map(&:pred)
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Scoring
|
|
49
|
+
|
|
50
|
+
Every subtree of a normalized unit is a fingerprint. Each fingerprint is weighted by how rare it is, `ln(1 + 1000 / count)`, and two units score by Jaccard similarity over those weights. A shape that shows up in every controller weighs close to nothing, so scaffolding doesn't drown out real copies. The default threshold is 0.80, with floors of 4 lines and 20 normalized nodes.
|
|
51
|
+
|
|
52
|
+
## The gate
|
|
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:
|
|
55
|
+
|
|
56
|
+
| Label | Meaning |
|
|
57
|
+
| --- | --- |
|
|
58
|
+
| Introduced | The PR touched one side, and the pair didn't match at the base |
|
|
59
|
+
| Shifted | Neither side was touched, but the PR's changes moved the weights enough to push the pair over |
|
|
60
|
+
| Already there | The pair matched at the base too |
|
|
61
|
+
|
|
62
|
+
The report also lists what the PR contracted: pairs that matched at the base and don't anymore.
|
|
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.
|
|
65
|
+
|
|
66
|
+
## The Contract
|
|
67
|
+
|
|
68
|
+
Deliberate duplication is a design decision, so it's declared in the Contract next to its reason. The Contract is organized by primitive, and each kind of analysis has its own file inside the primitive:
|
|
69
|
+
|
|
70
|
+
```text
|
|
71
|
+
contract/
|
|
72
|
+
provider_adapter/
|
|
73
|
+
README.md the primitive's prose, plus a covers block
|
|
74
|
+
duplication.md duplication it keeps, why, and its settings
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
A `parallel` block declares units that stay parallel on purpose. Its reason is the section it sits in:
|
|
78
|
+
|
|
79
|
+
````markdown
|
|
80
|
+
## Adapters stay independent
|
|
81
|
+
|
|
82
|
+
Each provider adapter stays independent. Providers change on their own
|
|
83
|
+
schedules, and a shared base class would couple their releases.
|
|
84
|
+
|
|
85
|
+
```parallel
|
|
86
|
+
Payments::*::Adapter
|
|
87
|
+
```
|
|
88
|
+
````
|
|
89
|
+
|
|
90
|
+
A `covers` block in the primitive's `README.md` names the code that belongs to it, and a `settings` block in `duplication.md` overrides the defaults for that code:
|
|
91
|
+
|
|
92
|
+
````markdown
|
|
93
|
+
```covers
|
|
94
|
+
Payments::*::Adapter
|
|
95
|
+
views/payments/**
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
```settings
|
|
99
|
+
threshold: 0.75
|
|
100
|
+
min-lines: 6
|
|
101
|
+
min-nodes: 30
|
|
102
|
+
```
|
|
103
|
+
````
|
|
104
|
+
|
|
105
|
+
A clause that names code that no longer exists fails the gate, and so does a clause with nothing left to keep. The Contract stays true or the build stays red.
|
|
106
|
+
|
|
107
|
+
## In CI
|
|
108
|
+
|
|
109
|
+
```yaml
|
|
110
|
+
exhale:
|
|
111
|
+
runs-on: ubuntu-latest
|
|
112
|
+
steps:
|
|
113
|
+
- uses: actions/checkout@v4
|
|
114
|
+
with:
|
|
115
|
+
fetch-depth: 0
|
|
116
|
+
- uses: ruby/setup-ruby@v1
|
|
117
|
+
with:
|
|
118
|
+
bundler-cache: true
|
|
119
|
+
- name: exhale
|
|
120
|
+
shell: bash
|
|
121
|
+
run: bin/exhale --base origin/${{ github.base_ref || 'main' }} | tee -a "$GITHUB_STEP_SUMMARY"
|
|
122
|
+
```
|
|
123
|
+
|
|
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
|
+
|
|
126
|
+
## Determinism
|
|
127
|
+
|
|
128
|
+
The same commit gets the same verdict on any machine on any day. The verdict reads the commit's tree, its Contract, and the gem versions in its `Gemfile.lock`, and nothing else. Digests are unseeded, weights are fixed-point integers computed without the platform's floating-point log, and every tie breaks on a stable key.
|
|
129
|
+
|
|
130
|
+
## Narrowing a run
|
|
131
|
+
|
|
132
|
+
`exhale dry PATH...` reports only the findings with a location under those paths. A narrowed run still gates, on the findings inside its paths, and every path has to exist, so a typo like `exhale dyr` exits 2 instead of passing.
|
|
133
|
+
|
|
134
|
+
## How exhale holds itself to this
|
|
135
|
+
|
|
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.
|
|
137
|
+
|
|
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.
|
|
139
|
+
|
|
140
|
+
## Known limits in 0.1
|
|
141
|
+
|
|
142
|
+
- Duplication of intent, where the same idea is written with a different structure, is out of reach for structural matching.
|
|
143
|
+
- A whole method copied into another method as a nested `def` isn't reported when its body alone is under the size floors.
|
|
144
|
+
- Every run sweeps the head cold and caches only the merge base. The exact incremental sweep comes in 0.2.
|
|
145
|
+
- A run of statements copied more than 50 times is connected as a star from its widest copy, which can miss a near-copy that only matches another copy.
|
|
146
|
+
|
|
147
|
+
## License
|
|
148
|
+
|
|
149
|
+
MIT. Copyright Obie Fernandez.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rake/testtask"
|
|
5
|
+
|
|
6
|
+
Rake::TestTask.new(:test) do |t|
|
|
7
|
+
t.libs << "test"
|
|
8
|
+
t.libs << "lib"
|
|
9
|
+
t.test_files = FileList["test/**/*_test.rb"]
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# The Contract must be met, and contract coverage is published. Every
|
|
13
|
+
# obligation in contract/*/README.md needs at least one test that names it
|
|
14
|
+
# with a `# Contract: <primitive>/<id>` line, and a tag naming an obligation
|
|
15
|
+
# that doesn't exist fails too, so the Contract and the tests can't drift.
|
|
16
|
+
desc "Publish contract coverage and fail on any obligation without an executable test"
|
|
17
|
+
task :contract do
|
|
18
|
+
obligations = Dir["contract/*/README.md"].sort.flat_map do |path|
|
|
19
|
+
primitive = File.basename(File.dirname(path))
|
|
20
|
+
File.read(path).scan(/^- \*\*([A-Z]+\d+)\*\*/).flatten.map { |id| "#{primitive}/#{id}" }
|
|
21
|
+
end
|
|
22
|
+
tags = {}
|
|
23
|
+
Dir["test/**/*_test.rb"].sort.each do |file|
|
|
24
|
+
File.foreach(file).with_index(1) do |line, number|
|
|
25
|
+
next unless (match = line.match(%r{#\s*Contract:\s*([a-z]+/[A-Z]+\d+)}))
|
|
26
|
+
|
|
27
|
+
(tags[match[1]] ||= []) << "#{file}:#{number}"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
obligations.each do |id|
|
|
32
|
+
tests = tags.fetch(id, [])
|
|
33
|
+
puts "#{tests.empty? ? 'MISSING' : 'ok '} #{id.ljust(16)} #{tests.size} test#{'s' unless tests.size == 1}"
|
|
34
|
+
end
|
|
35
|
+
covered = obligations.count { |id| tags.key?(id) }
|
|
36
|
+
puts "contract coverage: #{covered}/#{obligations.size} obligations (#{(100.0 * covered / obligations.size).round}%)"
|
|
37
|
+
|
|
38
|
+
unknown = tags.keys - obligations
|
|
39
|
+
abort "tests name obligations the Contract doesn't have: #{unknown.sort.join(', ')}" unless unknown.empty?
|
|
40
|
+
missing = obligations.reject { |id| tags.key?(id) }
|
|
41
|
+
abort "obligations with no executable test: #{missing.join(', ')}" unless missing.empty?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
task default: %i[test contract]
|
|
45
|
+
|
|
46
|
+
# Release tooling, the single-gem shape of the terret repo's rake release
|
|
47
|
+
# tasks. The gemspec is the one source of name and version; nothing here
|
|
48
|
+
# hardcodes either. The release workflow drives these three tasks in order.
|
|
49
|
+
namespace :release do
|
|
50
|
+
gemspec = -> { Gem::Specification.load(Dir["*.gemspec"].first) }
|
|
51
|
+
|
|
52
|
+
published = lambda do |spec|
|
|
53
|
+
require "open-uri"
|
|
54
|
+
require "json"
|
|
55
|
+
body = URI.open("https://rubygems.org/api/v1/versions/#{spec.name}.json", &:read)
|
|
56
|
+
JSON.parse(body).any? { |v| v["number"] == spec.version.to_s }
|
|
57
|
+
rescue OpenURI::HTTPError
|
|
58
|
+
false # 404 => the gem has never been published, so there is nothing to skip
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
desc "Build the gem into pkg/ with --strict (proof the gemspec is valid). " \
|
|
62
|
+
"Reversible, pkg/ is gitignored, and the local half of a release; " \
|
|
63
|
+
"release:push is the irreversible half."
|
|
64
|
+
task :build do
|
|
65
|
+
require "fileutils"
|
|
66
|
+
spec = gemspec.call
|
|
67
|
+
FileUtils.rm_rf("pkg")
|
|
68
|
+
FileUtils.mkdir_p("pkg")
|
|
69
|
+
# --strict escalates any spec warning to a build failure.
|
|
70
|
+
sh "gem build #{File.basename(spec.loaded_from)} --strict --output pkg/#{spec.name}-#{spec.version}.gem"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
desc "Print publish=true when the gemspec's version is not yet on RubyGems " \
|
|
74
|
+
"and publish=false when it is. The release workflow reads this to decide " \
|
|
75
|
+
"whether to fetch credentials and push at all."
|
|
76
|
+
task :status do
|
|
77
|
+
puts "publish=#{!published.call(gemspec.call)}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
desc "Push pkg/<gem>-<version>.gem to RubyGems. A version already published " \
|
|
81
|
+
"is skipped, so a re-run is safe. The release workflow runs this with a " \
|
|
82
|
+
"short-lived key from trusted publishing; by hand it needs RubyGems MFA."
|
|
83
|
+
task :push do
|
|
84
|
+
spec = gemspec.call
|
|
85
|
+
gem_file = "pkg/#{spec.name}-#{spec.version}.gem"
|
|
86
|
+
abort "release:push: #{gem_file} is missing, run `rake release:build` first" unless File.exist?(gem_file)
|
|
87
|
+
|
|
88
|
+
if published.call(spec)
|
|
89
|
+
puts "skip #{spec.name} #{spec.version} (already on RubyGems)"
|
|
90
|
+
next
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
puts "push #{gem_file}"
|
|
94
|
+
sh "gem push #{gem_file}"
|
|
95
|
+
end
|
|
96
|
+
end
|
data/exe/exhale
ADDED
data/exhale.gemspec
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/exhale/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "exhale"
|
|
7
|
+
spec.version = Exhale::VERSION
|
|
8
|
+
spec.authors = ["Obie Fernandez"]
|
|
9
|
+
spec.email = ["obiefernandez@gmail.com"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "The contraction gate for Rails: no PR merges while the codebase holds duplication the Contract doesn't keep"
|
|
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
|
|
15
|
+
structural similarity, and fails the build while any copy is undeclared. Deliberate
|
|
16
|
+
duplication is declared in the Contract, next to the reason for it. Built on Prism and Herb.
|
|
17
|
+
DESC
|
|
18
|
+
spec.homepage = "https://github.com/tools4imps/exhale-ruby"
|
|
19
|
+
spec.license = "MIT"
|
|
20
|
+
spec.required_ruby_version = ">= 3.3"
|
|
21
|
+
|
|
22
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
|
23
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
24
|
+
|
|
25
|
+
spec.files = Dir.chdir(__dir__) do
|
|
26
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
|
27
|
+
(File.expand_path(f) == __FILE__) ||
|
|
28
|
+
f.start_with?(*%w[bin/ test/ spec/ features/ contract/ assets/ .git .github .mutineer appveyor Gemfile])
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
spec.bindir = "exe"
|
|
32
|
+
spec.executables = ["exhale"]
|
|
33
|
+
spec.require_paths = ["lib"]
|
|
34
|
+
|
|
35
|
+
spec.add_dependency "bigdecimal", "~> 3.1"
|
|
36
|
+
spec.add_dependency "herb", "~> 0.11"
|
|
37
|
+
spec.add_dependency "prism", "~> 1.9"
|
|
38
|
+
|
|
39
|
+
spec.add_development_dependency "minitest", "~> 6.0"
|
|
40
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
|
41
|
+
|
|
42
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
43
|
+
end
|
data/lib/exhale/cli.rb
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require_relative "version"
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
require_relative "report"
|
|
7
|
+
require_relative "dry/check"
|
|
8
|
+
|
|
9
|
+
module Exhale
|
|
10
|
+
# exhale [dry] [PATH...] [options]
|
|
11
|
+
# exhale dry explain A B
|
|
12
|
+
#
|
|
13
|
+
# Exit codes: 0 pass, 1 the gate failed, 2 exhale couldn't run.
|
|
14
|
+
class CLI
|
|
15
|
+
CHECKS = %w[dry].freeze
|
|
16
|
+
|
|
17
|
+
def initialize(argv, out: $stdout, err: $stderr)
|
|
18
|
+
@argv = argv.dup
|
|
19
|
+
@out = out
|
|
20
|
+
@err = err
|
|
21
|
+
@options = { root: Dir.pwd, format: "text", include_tests: false, contract_dir: "contract",
|
|
22
|
+
overrides: {}, introduced_only: false, base: nil, cache_dir: nil }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def run
|
|
26
|
+
@argv.shift if CHECKS.include?(@argv.first)
|
|
27
|
+
return explain(@argv.drop(1)) if @argv.first == "explain"
|
|
28
|
+
|
|
29
|
+
paths = parser.parse(@argv)
|
|
30
|
+
return 2 unless valid_format?
|
|
31
|
+
|
|
32
|
+
paths = narrowing(paths)
|
|
33
|
+
return 2 unless paths
|
|
34
|
+
|
|
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
|
|
39
|
+
@out.print Report.render(result, @options[:format])
|
|
40
|
+
result.exit_code
|
|
41
|
+
rescue OptionParser::ParseError, ArgumentError => e
|
|
42
|
+
@err.puts "exhale: #{e.message}"
|
|
43
|
+
@err.puts parser.banner
|
|
44
|
+
2
|
|
45
|
+
rescue Error => e
|
|
46
|
+
@err.puts "exhale: #{e.message}"
|
|
47
|
+
2
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def parser
|
|
53
|
+
@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|
|
|
56
|
+
@options[:base] = v
|
|
57
|
+
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
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Every positional argument has to be a path that exists under the root.
|
|
87
|
+
# A typo like `exhale dyr` must fail loudly, never narrow the run to
|
|
88
|
+
# nothing and pass.
|
|
89
|
+
def narrowing(args)
|
|
90
|
+
root = File.expand_path(@options[:root])
|
|
91
|
+
args.map do |arg|
|
|
92
|
+
full = File.expand_path(arg, root)
|
|
93
|
+
unless File.exist?(full) && (full == root || full.start_with?("#{root}/"))
|
|
94
|
+
@err.puts "exhale: #{arg.inspect} is neither a command nor a path under #{root}"
|
|
95
|
+
return nil
|
|
96
|
+
end
|
|
97
|
+
full == root ? "." : full.delete_prefix("#{root}/")
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def valid_format?
|
|
102
|
+
return true if Report::FORMATS.include?(@options[:format])
|
|
103
|
+
|
|
104
|
+
@err.puts "exhale: unknown format #{@options[:format].inspect} (use text, json or edn)"
|
|
105
|
+
false
|
|
106
|
+
end
|
|
107
|
+
|
|
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.
|
|
111
|
+
def explain(args)
|
|
112
|
+
args = parser.parse(args)
|
|
113
|
+
unless args.size == 2
|
|
114
|
+
@err.puts "usage: exhale dry explain IDENTITY IDENTITY"
|
|
115
|
+
return 2
|
|
116
|
+
end
|
|
117
|
+
return 2 unless valid_format?
|
|
118
|
+
|
|
119
|
+
Dry::Check.new(root: @options[:root], base: @options[:base], cache_dir: @options[:cache_dir]).validate!
|
|
120
|
+
root = File.expand_path(@options[:root])
|
|
121
|
+
git = Git.new(root)
|
|
122
|
+
sweep = Dry::Sweep.new(root, files: (git.files if git.repo?), include_tests: @options[:include_tests],
|
|
123
|
+
contract_dir: @options[:contract_dir]).run
|
|
124
|
+
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
|
|
130
|
+
|
|
131
|
+
[a, b].each do |entry|
|
|
132
|
+
@out.puts "#{entry.unit.identity} #{entry.unit.path}:#{entry.unit.start_line}-#{entry.unit.end_line}"
|
|
133
|
+
print_shape(Dry::Normalizer.normalize(entry.unit), 1)
|
|
134
|
+
@out.puts
|
|
135
|
+
end
|
|
136
|
+
shared = a.set & b.set
|
|
137
|
+
@out.puts "score #{Report.score(sweep.index.score(a.set, a.total, b.set, b.total))}, " \
|
|
138
|
+
"#{shared.size} shared of #{(a.set | b.set).size} fingerprints"
|
|
139
|
+
0
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def print_shape(shape, depth)
|
|
143
|
+
label = shape.label ? " #{shape.label}" : ""
|
|
144
|
+
@out.puts "#{' ' * depth}#{shape.kind}#{label}"
|
|
145
|
+
shape.children.each { |child| print_shape(child, depth + 1) }
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Exhale
|
|
4
|
+
class Contract
|
|
5
|
+
# A line scanner for the little Markdown the Contract needs: fenced blocks
|
|
6
|
+
# (CommonMark fence rules), the nearest ATX heading above each, and the
|
|
7
|
+
# prose between that heading and the block. No Markdown gem.
|
|
8
|
+
module Markdown
|
|
9
|
+
Block = Struct.new(:info, :line, :body, :heading, :prose, keyword_init: true) do
|
|
10
|
+
# body is [[text, line_number], ...]
|
|
11
|
+
def reason
|
|
12
|
+
[heading, *prose].compact.join(" ").gsub(/\s+/, " ").strip
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
FENCE_OPEN = /\A {0,3}(`{3,}|~{3,})(.*)\z/
|
|
17
|
+
HEADING = /\A {0,3}\#{1,6}(?:[ \t]+(.*))?\z/
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def blocks(text)
|
|
22
|
+
state = { blocks: [], heading: nil, prose: [], fence: nil, comment: false }
|
|
23
|
+
text.each_line.with_index(1) { |raw, no| step(state, raw.chomp, no) }
|
|
24
|
+
state[:blocks] << finish(state[:fence]) if state[:fence]
|
|
25
|
+
state[:blocks]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
SETEXT = /\A {0,3}(?:=+|-+)[ \t]*\z/
|
|
29
|
+
|
|
30
|
+
def step(state, line, no)
|
|
31
|
+
after_prose = state[:after_prose]
|
|
32
|
+
state[:after_prose] = false
|
|
33
|
+
fence = state[:fence]
|
|
34
|
+
if fence
|
|
35
|
+
continue_fence(state, fence, line, no)
|
|
36
|
+
elsif (line = outside_comments(state, line)).nil?
|
|
37
|
+
nil
|
|
38
|
+
elsif (fence = open_fence(line, no, state))
|
|
39
|
+
state[:fence] = fence
|
|
40
|
+
elsif after_prose && SETEXT.match?(line)
|
|
41
|
+
state[:heading] = state[:prose].pop
|
|
42
|
+
state[:prose] = []
|
|
43
|
+
elsif (m = HEADING.match(line))
|
|
44
|
+
state[:heading] = m[1].to_s.sub(/[ \t]+#+[ \t]*\z/, "").sub(/\A#+\z/, "").strip
|
|
45
|
+
state[:prose] = []
|
|
46
|
+
elsif !line.strip.empty?
|
|
47
|
+
state[:prose] << line.strip
|
|
48
|
+
state[:after_prose] = true
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Returns the part of the line that is live Markdown, or nil when the
|
|
53
|
+
# whole line sits inside an HTML comment. Fences in comments are dead.
|
|
54
|
+
def outside_comments(state, line)
|
|
55
|
+
if state[:comment]
|
|
56
|
+
return nil unless line.include?("-->")
|
|
57
|
+
|
|
58
|
+
state[:comment] = false
|
|
59
|
+
line = line.sub(/\A.*?-->/, "")
|
|
60
|
+
end
|
|
61
|
+
line = line.gsub(/<!--.*?-->/, "")
|
|
62
|
+
return line unless line.include?("<!--")
|
|
63
|
+
|
|
64
|
+
state[:comment] = true
|
|
65
|
+
line.sub(/<!--.*\z/, "")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def continue_fence(state, fence, line, no)
|
|
69
|
+
if closes?(line, fence)
|
|
70
|
+
state[:blocks] << finish(fence)
|
|
71
|
+
state[:fence] = nil
|
|
72
|
+
state[:prose] = [] if %w[parallel settings covers].include?(fence[:info])
|
|
73
|
+
else
|
|
74
|
+
fence[:body] << [line, no]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def open_fence(line, no, state)
|
|
79
|
+
m = FENCE_OPEN.match(line)
|
|
80
|
+
return nil unless m
|
|
81
|
+
return nil if m[1].start_with?("`") && m[2].include?("`")
|
|
82
|
+
|
|
83
|
+
{ char: m[1][0], len: m[1].size, info: m[2].strip.split(/\s+/).first.to_s, line: no, body: [],
|
|
84
|
+
heading: state[:heading], prose: state[:prose].dup }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def closes?(line, fence)
|
|
88
|
+
m = /\A {0,3}(`{3,}|~{3,})[ \t]*\z/.match(line)
|
|
89
|
+
m && m[1][0] == fence[:char] && m[1].size >= fence[:len]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def finish(fence)
|
|
93
|
+
Block.new(info: fence[:info], line: fence[:line], body: fence[:body],
|
|
94
|
+
heading: fence[:heading], prose: fence[:prose])
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Drops a trailing comment: "#" preceded by whitespace and followed by a
|
|
98
|
+
# space or end of line. A "#" glued to a name is part of a method ref.
|
|
99
|
+
def strip_comment(line)
|
|
100
|
+
stripped = line.strip
|
|
101
|
+
return "" if stripped.match?(/\A#(\s|\z)/)
|
|
102
|
+
|
|
103
|
+
stripped.sub(/\s+#(\s.*)?\z/, "")
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Exhale
|
|
4
|
+
class Contract
|
|
5
|
+
# A line from a covers or parallel block, with where it was written.
|
|
6
|
+
Reference = Struct.new(:text, :path, :line) do
|
|
7
|
+
def kind
|
|
8
|
+
if text.include?("/") then :template_glob
|
|
9
|
+
elsif text.match?(/[#.]/) then :method
|
|
10
|
+
elsif text.include?("*") then :const_glob
|
|
11
|
+
else :constant
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Orders references by how narrowly they point: method refs first, then
|
|
16
|
+
# more literal segments, more segments, fewer "**", longer text.
|
|
17
|
+
def specificity
|
|
18
|
+
return [1, 0, 0, 0, text.length] if kind == :method
|
|
19
|
+
|
|
20
|
+
segs = kind == :template_glob ? text.split("/") : const_segments
|
|
21
|
+
[0, segs.count { |s| !s.include?("*") }, segs.size, -segs.count("**"), text.length]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def const_segments
|
|
25
|
+
text.split("::")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def template_regex
|
|
29
|
+
pieces = text.split(%r{(/\*\*/|\*\*/|/\*\*\z|\*\*|\*)})
|
|
30
|
+
body = pieces.map { |p| TEMPLATE_TOKENS.fetch(p) { Regexp.escape(p) } }.join
|
|
31
|
+
Regexp.new("\\A#{body}\\z")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
TEMPLATE_TOKENS = {
|
|
35
|
+
"/**/" => "/(?:.*/)?", "**/" => "(?:.*/)?", "/**" => "/.*", "**" => ".*", "*" => "[^/]*"
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
# Does a namespace prefix (an Array of segments) match this constant glob?
|
|
39
|
+
def const_glob_match?(segments)
|
|
40
|
+
self.class.seg_match?(const_segments, segments)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Bottom-up DP over (pattern index, segment index): O(patterns * segments)
|
|
44
|
+
# however many "**" the pattern holds.
|
|
45
|
+
def self.seg_match?(pats, segs)
|
|
46
|
+
np = pats.size
|
|
47
|
+
ns = segs.size
|
|
48
|
+
# ok[j]: pats[i..] matches segs[j..], built for i from np down to 0.
|
|
49
|
+
ok = Array.new(ns + 1) { |j| j == ns }
|
|
50
|
+
(np - 1).downto(0) do |i|
|
|
51
|
+
nxt = ok
|
|
52
|
+
ok = Array.new(ns + 1, false)
|
|
53
|
+
if pats[i] == "**"
|
|
54
|
+
ns.downto(0) { |j| ok[j] = nxt[j] || (j < ns && ok[j + 1]) }
|
|
55
|
+
else
|
|
56
|
+
ns.times { |j| ok[j] = nxt[j + 1] && File.fnmatch?(pats[i], segs[j]) }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
ok[0]
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|