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.
@@ -0,0 +1,283 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "../errors"
5
+ require_relative "../unit"
6
+
7
+ module Exhale
8
+ module Units
9
+ # Finds the units in one Ruby file: methods, and the bodies of Rails DSL
10
+ # calls (scopes, callbacks, validations, rescue_from, job hooks).
11
+ module Ruby
12
+ DSL = %i[
13
+ scope default_scope validate validates_each
14
+ before_validation after_validation
15
+ before_save around_save after_save before_create around_create after_create
16
+ before_update around_update after_update before_destroy around_destroy after_destroy
17
+ after_commit after_create_commit after_update_commit after_destroy_commit after_save_commit after_rollback
18
+ after_initialize after_find after_touch
19
+ before_action after_action around_action prepend_before_action append_before_action
20
+ prepend_after_action append_after_action rescue_from
21
+ before_perform after_perform around_perform before_enqueue after_enqueue around_enqueue
22
+ ].freeze
23
+
24
+ # Concern blocks whose contents read as the class body itself.
25
+ CLASS_BODIES = %i[included prepended concerning].freeze
26
+
27
+ # Blocks that reopen their constant receiver as a class body.
28
+ CLASS_EVALS = %i[class_eval class_exec module_eval module_exec].freeze
29
+
30
+ # Block-defined methods, and whether each defines a singleton method.
31
+ DEFINE_METHODS = { define_method: false, define_singleton_method: true }.freeze
32
+
33
+ # A string naming a constant, as `rescue_from "Billing::Declined"` takes.
34
+ CONSTANT_PATH = /\A(?:::)?[A-Z]\w*(?:::[A-Z]\w*)*\z/
35
+
36
+ module_function
37
+
38
+ def extract(source, path)
39
+ result = Prism.parse(source)
40
+ if result.failure?
41
+ error = result.errors.first
42
+ raise ParseError.new(path, error.location.start_line, error.message)
43
+ end
44
+
45
+ Walker.new(path).walk(result.value)
46
+ end
47
+
48
+ # Walks a file keeping the namespace a statement sits in, and whether
49
+ # it sits in a singleton body (`class << self`, `class_methods do`).
50
+ class Walker
51
+ def initialize(path)
52
+ @path = path
53
+ @units = []
54
+ @ordinals = Hash.new(0)
55
+ end
56
+
57
+ def walk(program)
58
+ visit(program, "Object", false)
59
+ number_repeats
60
+ @units
61
+ end
62
+
63
+ private
64
+
65
+ def visit(node, namespace, singleton)
66
+ case node
67
+ when Prism::ModuleNode, Prism::ClassNode
68
+ visit_body(node.body, nest(namespace, node.constant_path), false)
69
+ when Prism::SingletonClassNode then singleton_class(node, namespace)
70
+ when Prism::DefNode then add_method(node, namespace, singleton)
71
+ when Prism::ConstantWriteNode, Prism::ConstantPathWriteNode then constant_write(node, namespace, singleton)
72
+ when Prism::CallNode then call(node, namespace, singleton)
73
+ else visit_children(node, namespace, singleton)
74
+ end
75
+ end
76
+
77
+ def visit_body(body, namespace, singleton)
78
+ visit(body, namespace, singleton) if body
79
+ end
80
+
81
+ def visit_children(node, namespace, singleton)
82
+ node.compact_child_nodes.each { |child| visit(child, namespace, singleton) }
83
+ end
84
+
85
+ # `class << self` opens this namespace's singleton class, `class <<
86
+ # Other` opens Other's. Any other object's isn't a namespace.
87
+ def singleton_class(node, namespace)
88
+ owner = owner_of(node.expression, namespace)
89
+ visit_body(node.body, owner, true) if owner
90
+ end
91
+
92
+ # A def nested inside this one belongs to it, so the walk stops here.
93
+ # `def obj.x` on a non-constant object belongs to no namespace.
94
+ def add_method(node, namespace, singleton)
95
+ return add(:method, node, namespace, node.name.to_s, singleton ? "." : "#") unless node.receiver
96
+
97
+ owner = owner_of(node.receiver, namespace)
98
+ add(:method, node, owner, node.name.to_s, ".") if owner
99
+ end
100
+
101
+ # `Point = Struct.new(:x) do ... end` and `Class.new do ... end`
102
+ # define their methods on the constant.
103
+ def constant_write(node, namespace, singleton)
104
+ value = node.value
105
+ if value.is_a?(Prism::CallNode) && value.block.is_a?(Prism::BlockNode)
106
+ name = node.is_a?(Prism::ConstantWriteNode) ? join(namespace, node.name.to_s) : nest(namespace, node.target)
107
+ visit_body(value.block.body, name, false)
108
+ else
109
+ visit_children(node, namespace, singleton)
110
+ end
111
+ end
112
+
113
+ def call(node, namespace, singleton)
114
+ block = node.block if node.block.is_a?(Prism::BlockNode)
115
+ if block && DEFINE_METHODS.key?(node.name) && (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode))
116
+ return define_method(node, block, namespace, singleton || DEFINE_METHODS[node.name])
117
+ end
118
+ return receiver_call(node, block, namespace, singleton) if node.receiver
119
+
120
+ if CLASS_BODIES.include?(node.name) && block then visit_body(block.body, namespace, false)
121
+ elsif node.name == :class_methods && block then visit_body(block.body, namespace, true)
122
+ elsif DSL.include?(node.name) && (body = block || lambda_argument(node)) then add_dsl(node, body, namespace)
123
+ else visit_children(node, namespace, singleton)
124
+ end
125
+ end
126
+
127
+ # `Order.class_eval do ... end` reads as Order's class body.
128
+ def receiver_call(node, block, namespace, singleton)
129
+ if block && CLASS_EVALS.include?(node.name) && constant?(node.receiver)
130
+ visit_body(block.body, resolve(namespace, node.receiver), false)
131
+ else
132
+ visit_children(node, namespace, singleton)
133
+ end
134
+ end
135
+
136
+ def define_method(node, block, namespace, singleton)
137
+ separator = singleton ? "." : "#"
138
+ name = literal_name(node) || "#{node.name}[#{next_ordinal(namespace, node.name)}]"
139
+ add(:method, block, namespace, name, separator)
140
+ end
141
+
142
+ def add_dsl(node, body, namespace)
143
+ add(:dsl, body, namespace, dsl_name(node, namespace), ".")
144
+ end
145
+
146
+ # A body named by its first argument, a symbol or string (`scope
147
+ # :settled`, `scope "settled"`) or constants (`rescue_from
148
+ # Billing::Declined`), keeps its identity as the bodies around it
149
+ # change. An unnamed body gets an ordinal, and ordinals drift: a new
150
+ # `before_save do` above it renumbers it.
151
+ def dsl_name(node, namespace)
152
+ constants = Array(node.arguments&.arguments).take_while { |argument| constant_reference?(argument) }
153
+ return "#{node.name}(#{constants.map { |c| constant_text(c) }.join(", ")})" if constants.any?
154
+
155
+ name = literal_name(node)
156
+ return "#{node.name}(:#{name})" if name
157
+
158
+ "#{node.name}[#{next_ordinal(namespace, node.name)}]"
159
+ end
160
+
161
+ def constant_reference?(node)
162
+ constant?(node) || (node.is_a?(Prism::StringNode) && node.unescaped.match?(CONSTANT_PATH))
163
+ end
164
+
165
+ def constant_text(node)
166
+ node.is_a?(Prism::StringNode) ? node.unescaped.delete_prefix("::") : constant_name(node).last
167
+ end
168
+
169
+ # `scope :x, -> { ... }`, or the older `scope :x, lambda { ... }`.
170
+ def lambda_argument(node)
171
+ Array(node.arguments&.arguments).each do |argument|
172
+ return argument if argument.is_a?(Prism::LambdaNode)
173
+ return argument.block if lambda_call?(argument)
174
+ end
175
+ nil
176
+ end
177
+
178
+ def lambda_call?(node)
179
+ node.is_a?(Prism::CallNode) && node.receiver.nil? && %i[lambda proc].include?(node.name) &&
180
+ node.block.is_a?(Prism::BlockNode)
181
+ end
182
+
183
+ def literal_name(node)
184
+ first = node.arguments&.arguments&.first
185
+ first.unescaped if first.is_a?(Prism::SymbolNode) || first.is_a?(Prism::StringNode)
186
+ end
187
+
188
+ def next_ordinal(namespace, macro)
189
+ @ordinals[[namespace, macro]] += 1
190
+ end
191
+
192
+ def add(kind, node, namespace, name, separator)
193
+ @units << Unit.new(kind: kind, identity: "#{namespace}#{separator}#{name}", namespace: namespace,
194
+ name: name, path: @path, start_line: node.location.start_line,
195
+ end_line: last_line(node), language: :ruby, node: node)
196
+ end
197
+
198
+ # A name defined twice in one file (in both branches of an `if`, or in
199
+ # a class reopened further down) still gets one identity per unit, in
200
+ # source order: `Cart#total`, then `Cart#total[2]`.
201
+ def number_repeats
202
+ seen = Hash.new(0)
203
+ @units.each do |unit|
204
+ count = seen[unit.identity] += 1
205
+ next if count == 1
206
+
207
+ unit.identity = "#{unit.identity}[#{count}]"
208
+ unit.name = "#{unit.name}[#{count}]"
209
+ end
210
+ end
211
+
212
+ # A heredoc's body and terminator sit below the line its node ends on.
213
+ def last_line(node)
214
+ last = node.location.end_line
215
+ stack = [node]
216
+ until stack.empty?
217
+ current = stack.pop
218
+ last = [last, current.closing_loc.start_line].max if heredoc?(current)
219
+ stack.concat(current.compact_child_nodes)
220
+ end
221
+ last
222
+ end
223
+
224
+ def heredoc?(node)
225
+ node.respond_to?(:heredoc?) && node.heredoc? && node.closing_loc
226
+ end
227
+
228
+ # The namespace a `self` or constant receiver names; nil for any
229
+ # other object.
230
+ def owner_of(node, namespace)
231
+ case node
232
+ when Prism::SelfNode then namespace
233
+ when Prism::ConstantReadNode, Prism::ConstantPathNode then resolve(namespace, node)
234
+ end
235
+ end
236
+
237
+ def constant?(node)
238
+ node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
239
+ end
240
+
241
+ # A definition (`class Foo`) nests inside the enclosing namespace.
242
+ def nest(namespace, constant_path)
243
+ anchor, name = constant_name(constant_path)
244
+ anchor == :root ? name : join(namespace, name)
245
+ end
246
+
247
+ # A reference (`def Foo.x`, `class << Foo`, `Foo.class_eval`) names
248
+ # an existing constant. Short of full constant lookup, it resolves to
249
+ # an enclosing namespace when its first segment names one, so `def
250
+ # Billing.x` inside `module Billing` is `Billing.x`, and otherwise
251
+ # reads as written.
252
+ def resolve(namespace, node)
253
+ anchor, name = constant_name(node)
254
+ return name if anchor == :root
255
+ return join(namespace, name) if anchor == :self
256
+
257
+ segments = namespace.split("::")
258
+ index = segments.rindex(name.split("::").first)
259
+ index ? (segments[0...index] + [name]).join("::") : name
260
+ end
261
+
262
+ def join(namespace, name)
263
+ namespace == "Object" ? name : "#{namespace}::#{name}"
264
+ end
265
+
266
+ # [anchor, "Foo::Bar"]. anchor is :root for a leading `::`, :self for
267
+ # a leading `self::` (the current namespace), and nil otherwise.
268
+ def constant_name(node)
269
+ case node
270
+ when Prism::ConstantReadNode then [nil, node.name.to_s]
271
+ when Prism::ConstantPathNode
272
+ return [:root, node.name.to_s] if node.parent.nil?
273
+ return [:self, node.name.to_s] if node.parent.is_a?(Prism::SelfNode)
274
+
275
+ anchor, parent = constant_name(node.parent)
276
+ [anchor, "#{parent}::#{node.name}"]
277
+ else [nil, node.slice]
278
+ end
279
+ end
280
+ end
281
+ end
282
+ end
283
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exhale
4
+ VERSION = "0.1.0"
5
+ end
data/lib/exhale.rb ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "exhale/version"
4
+ require_relative "exhale/errors"
5
+ require_relative "exhale/shape"
6
+ require_relative "exhale/unit"
7
+ require_relative "exhale/git"
8
+ require_relative "exhale/source_files"
9
+ require_relative "exhale/units/ruby"
10
+ require_relative "exhale/units/erb"
11
+ require_relative "exhale/contract"
12
+ require_relative "exhale/dry/normalizer/ruby"
13
+ require_relative "exhale/dry/normalizer/erb"
14
+ require_relative "exhale/dry/normalizer"
15
+ require_relative "exhale/dry/fingerprints"
16
+ require_relative "exhale/dry/index"
17
+ require_relative "exhale/dry/matcher"
18
+ require_relative "exhale/dry/gate"
19
+ require_relative "exhale/dry/check"
20
+ require_relative "exhale/report"
21
+ require_relative "exhale/cli"
22
+
23
+ # exhale gates the exhale of every pull request. See README.md.
24
+ module Exhale
25
+ end
metadata ADDED
@@ -0,0 +1,152 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: exhale
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Obie Fernandez
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-10-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bigdecimal
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: herb
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.11'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.11'
41
+ - !ruby/object:Gem::Dependency
42
+ name: prism
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.9'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.9'
55
+ - !ruby/object:Gem::Dependency
56
+ name: minitest
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '6.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '6.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rake
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '13.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '13.0'
83
+ description: exhale gates the exhale of every pull request in a Rails app. Its first
84
+ check, exhale dry, sweeps the whole codebase for duplicated Ruby and ERB, scores
85
+ near-copies by rarity-weighted structural similarity, and fails the build while
86
+ any copy is undeclared. Deliberate duplication is declared in the Contract, next
87
+ to the reason for it. Built on Prism and Herb.
88
+ email:
89
+ - obiefernandez@gmail.com
90
+ executables:
91
+ - exhale
92
+ extensions: []
93
+ extra_rdoc_files: []
94
+ files:
95
+ - ".ruby-version"
96
+ - CHANGELOG.md
97
+ - LICENSE.txt
98
+ - README.md
99
+ - Rakefile
100
+ - exe/exhale
101
+ - exhale.gemspec
102
+ - lib/exhale.rb
103
+ - lib/exhale/cli.rb
104
+ - lib/exhale/contract.rb
105
+ - lib/exhale/contract/markdown.rb
106
+ - lib/exhale/contract/reference.rb
107
+ - lib/exhale/contract/resolver.rb
108
+ - lib/exhale/dry/check.rb
109
+ - lib/exhale/dry/fingerprints.rb
110
+ - lib/exhale/dry/gate.rb
111
+ - lib/exhale/dry/index.rb
112
+ - lib/exhale/dry/matcher.rb
113
+ - lib/exhale/dry/normalizer.rb
114
+ - lib/exhale/dry/normalizer/erb.rb
115
+ - lib/exhale/dry/normalizer/ruby.rb
116
+ - lib/exhale/errors.rb
117
+ - lib/exhale/git.rb
118
+ - lib/exhale/report.rb
119
+ - lib/exhale/shape.rb
120
+ - lib/exhale/source_files.rb
121
+ - lib/exhale/unit.rb
122
+ - lib/exhale/units/erb.rb
123
+ - lib/exhale/units/ruby.rb
124
+ - lib/exhale/version.rb
125
+ homepage: https://github.com/tools4imps/exhale-ruby
126
+ licenses:
127
+ - MIT
128
+ metadata:
129
+ source_code_uri: https://github.com/tools4imps/exhale-ruby
130
+ changelog_uri: https://github.com/tools4imps/exhale-ruby/blob/main/CHANGELOG.md
131
+ rubygems_mfa_required: 'true'
132
+ post_install_message:
133
+ rdoc_options: []
134
+ require_paths:
135
+ - lib
136
+ required_ruby_version: !ruby/object:Gem::Requirement
137
+ requirements:
138
+ - - ">="
139
+ - !ruby/object:Gem::Version
140
+ version: '3.3'
141
+ required_rubygems_version: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - ">="
144
+ - !ruby/object:Gem::Version
145
+ version: '0'
146
+ requirements: []
147
+ rubygems_version: 3.5.11
148
+ signing_key:
149
+ specification_version: 4
150
+ summary: 'The contraction gate for Rails: no PR merges while the codebase holds duplication
151
+ the Contract doesn''t keep'
152
+ test_files: []