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 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
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "exhale"
5
+
6
+ exit Exhale::CLI.new(ARGV).run
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