archspec 1.1.0.rc2 → 1.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.
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchSpec
4
+ # Normalizes the explicit ActiveSupport::Concern DSL after semantic indexing.
5
+ # Callback bodies retain lexical constant references, but their mixins and
6
+ # methods are installed on the consumer. No application code is executed.
7
+ # Source ownership is normalized here; established effects are emitted through
8
+ # Facts::Builder, using the same application path as external producers.
9
+ class ConcernSemantics
10
+ ModuleBody = Data.define(:name, :path, :body)
11
+ Callback = Data.define(:kind, :methods, :mixins, :calls)
12
+ MIXINS = { includes: :include, prepends: :prepend, extends: :extend }.freeze
13
+
14
+ def initialize
15
+ @modules = []
16
+ @callbacks = Hash.new { |hash, key| hash[key] = [] }
17
+ @dependencies = Hash.new { |hash, key| hash[key] = [] }
18
+ end
19
+
20
+ def record_module(name, path, body)
21
+ @modules << ModuleBody.new(name, path, body) if body.is_a?(Prism::StatementsNode)
22
+ end
23
+
24
+ def apply(graph)
25
+ @graph = graph
26
+ @mixin_edges = graph.edges.select { |edge| MIXINS.key?(edge.type) }
27
+ .group_by { |edge| [edge.from_constant, edge.from_path] }
28
+ @concerns = @modules.select { |mod| extends_concern?(mod) }.map(&:name).to_set
29
+ return if @concerns.empty?
30
+
31
+ @facts = Facts::Builder.new(graph, producer: 'active_support_concern')
32
+ normalize_blocks
33
+ defer_dependencies
34
+ rebuild_mixins
35
+ install_consumers
36
+ @concerns.each do |name|
37
+ source = graph.constants_named("#{name}::ClassMethods").first
38
+ @facts.expose_methods(source: source, owner: graph.constants_named(name).first, scope: :class) if source
39
+ end
40
+ @facts.apply
41
+ end
42
+
43
+ private
44
+
45
+ attr_reader :graph
46
+
47
+ def extends_concern?(mod)
48
+ mod.body.body.any? do |node|
49
+ next false unless node.is_a?(Prism::CallNode) && node.name == :extend
50
+ next false unless node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
51
+
52
+ span = SourceLocation.from_prism(mod.path, node.location)
53
+ @mixin_edges.fetch([mod.name, mod.path], []).any? do |edge|
54
+ edge.type == :extends && edge.from_constant == mod.name && within?(span, edge.location) &&
55
+ graph.resolve_edge_constant(edge) == 'ActiveSupport::Concern'
56
+ end
57
+ end
58
+ end
59
+
60
+ def install_consumers
61
+ events = graph.edges.select { |edge| MIXINS.key?(edge.type) }.group_by { |edge| [edge.from_constant, edge.from_path] }
62
+ events.each do |(name, path), edges|
63
+ next if @concerns.include?(name)
64
+ next unless edges.any? do |edge|
65
+ edge.type != :extends && @concerns.include?(graph.resolve_edge_constant(edge))
66
+ end
67
+
68
+ consumer = graph.constants_named(name).find { |node| node.path == path }
69
+ next unless consumer
70
+
71
+ consumer.mixins.each_value(&:clear)
72
+ visited = Set.new
73
+ edges.sort_by { |edge| [edge.location.line, edge.location.column] }.each do |edge|
74
+ target = graph.resolve_edge_constant(edge)
75
+ kind = MIXINS.fetch(edge.type)
76
+ if kind != :extend && @concerns.include?(target)
77
+ install_concern(consumer, target, kind, edge, visited)
78
+ else
79
+ add_mixin(consumer, kind, target, edge)
80
+ end
81
+ end
82
+ end
83
+ end
84
+
85
+ # Each block only touches edges that its concern owns, so the edges are
86
+ # indexed by owner once. Removed edges are compacted at the end, which
87
+ # keeps the surviving edges in their original order.
88
+ def normalize_blocks
89
+ @concern_edges = Hash.new { |hash, key| hash[key] = [] }
90
+ graph.edges.each_with_index do |edge, index|
91
+ @concern_edges[edge.from_constant] << index if @concerns.include?(edge.from_constant)
92
+ end
93
+ @removed_edges = Set.new
94
+
95
+ @modules.each do |mod|
96
+ next unless @concerns.include?(mod.name)
97
+
98
+ mod.body.body.each do |node|
99
+ next unless node.is_a?(Prism::CallNode) && (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode))
100
+ next unless %i[class_methods included prepended].include?(node.name)
101
+ next unless node.arguments.nil? && node.block.is_a?(Prism::BlockNode)
102
+ next if graph.constants_named(mod.name).any? { |constant| constant.class_methods.include?(node.name) }
103
+
104
+ location = SourceLocation.from_prism(mod.path, node.block.location)
105
+ if node.name == :class_methods
106
+ normalize_class_methods(mod, location)
107
+ else
108
+ defer_callback(mod, node, location)
109
+ end
110
+ end
111
+ end
112
+ graph.edges.replace(graph.edges.reject.with_index { |_, index| @removed_edges.include?(index) })
113
+ end
114
+
115
+ def owned_edges(name)
116
+ @concern_edges[name].filter_map do |index|
117
+ edge = graph.edges[index]
118
+ [index, edge] if edge.from_constant == name && !@removed_edges.include?(index)
119
+ end
120
+ end
121
+
122
+ def normalize_class_methods(mod, location)
123
+ name = "#{mod.name}::ClassMethods"
124
+ target = graph.add_constant(name: name, kind: :module, path: mod.path,
125
+ location: location, nesting: [mod.name])
126
+ extract_methods(mod, location).each { |method| @facts.method_definition(owner: target, definition: method) }
127
+ owned_edges(mod.name).each do |index, edge|
128
+ next unless within?(location, edge.location)
129
+
130
+ graph.edges[index] = edge.with(from_constant: name,
131
+ resolved_receiver: edge.resolved_receiver == mod.name ? name : edge.resolved_receiver)
132
+ end
133
+ end
134
+
135
+ def defer_callback(mod, node, location)
136
+ methods = extract_methods(mod, location)
137
+ owned = owned_edges(mod.name)
138
+ mixins = owned.filter_map do |index, edge|
139
+ next unless MIXINS.key?(edge.type) && within?(location, edge.location)
140
+
141
+ @removed_edges.add(index)
142
+ edge
143
+ end
144
+ statements = node.block.body.is_a?(Prism::StatementsNode) ? node.block.body.body : []
145
+ direct = statements.select do |statement|
146
+ statement.is_a?(Prism::DefNode) ||
147
+ (statement.is_a?(Prism::CallNode) && statement.block.nil? &&
148
+ (statement.receiver.nil? || statement.receiver.is_a?(Prism::SelfNode)))
149
+ end
150
+ .map { |statement| SourceLocation.from_prism(mod.path, statement.location) }
151
+ certain_methods = methods.select { |method| direct.any? { |span| within?(span, method.location) } }
152
+ certain_mixins = mixins.select { |edge| direct.any? { |span| within?(span, edge.location) } }
153
+ calls = owned.filter_map do |_, edge|
154
+ edge if edge.type == :calls_named_method && edge.receiver == :none &&
155
+ (direct.include?(edge.location) || certain_methods.any? { |method| within?(method.location, edge.location) })
156
+ end
157
+ @callbacks[mod.name] << Callback.new(node.name, certain_methods, certain_mixins, calls)
158
+ return if methods == certain_methods && mixins == certain_mixins
159
+
160
+ graph.conditional_callbacks << ConditionalCallback.new(mod.name, mod.path, node.name, location,
161
+ (mixins - certain_mixins).map do |edge|
162
+ ConditionalMixin.new(MIXINS.fetch(edge.type), graph.resolve_edge_constant(edge), edge.location)
163
+ end,
164
+ methods - certain_methods)
165
+ source = graph.constants_named(mod.name).find { |constant| constant.path == mod.path }
166
+ @facts.gap(source: source, message: "conditional #{node.name} callback", location: location)
167
+ end
168
+
169
+ def extract_methods(mod, location)
170
+ graph.constants_named(mod.name).flat_map do |constant|
171
+ selected = constant.method_definitions.select { |method| within?(location, method.location) }
172
+ constant.method_definitions.reject! { |method| selected.include?(method) }
173
+ constant.instance_methods.replace(constant.method_definitions.select { |method| method.scope == :instance }.map(&:name))
174
+ constant.class_methods.replace(constant.method_definitions.select { |method| method.scope == :class }.map(&:name))
175
+ selected
176
+ end
177
+ end
178
+
179
+ def defer_dependencies
180
+ graph.edges.reject! do |edge|
181
+ next false unless @concerns.include?(edge.from_constant) && %i[includes prepends].include?(edge.type)
182
+ next false unless @concerns.include?(graph.resolve_edge_constant(edge))
183
+
184
+ @dependencies[edge.from_constant] << edge
185
+ true
186
+ end
187
+ end
188
+
189
+ def rebuild_mixins
190
+ affected = @concerns | @concerns.map { |name| "#{name}::ClassMethods" }.to_set
191
+ mixins = graph.edges.select { |edge| MIXINS.key?(edge.type) }
192
+ .group_by { |edge| [edge.from_constant, edge.from_path] }
193
+ graph.constants.each do |constant|
194
+ next unless affected.include?(constant.name)
195
+
196
+ constant.mixins.each_value(&:clear)
197
+ mixins.fetch([constant.name, constant.path], []).each do |edge|
198
+ constant.add_mixin(MIXINS.fetch(edge.type), "::#{graph.resolve_edge_constant(edge)}")
199
+ end
200
+ end
201
+ end
202
+
203
+ def install_concern(consumer, name, kind, origin, visited)
204
+ return if visited.include?([consumer.name, name])
205
+
206
+ visited.add([consumer.name, name])
207
+ @dependencies[name].each do |dependency|
208
+ target = graph.resolve_edge_constant(dependency)
209
+ install_concern(consumer, target, kind, dependency, visited)
210
+ end
211
+ add_mixin(consumer, kind, name, origin)
212
+ class_methods = "#{name}::ClassMethods"
213
+ unless graph.constants_named(class_methods).empty?
214
+ add_mixin(consumer, kind == :prepend ? :singleton_prepend : :extend, class_methods, origin)
215
+ end
216
+ callback_kind = kind == :prepend ? :prepended : :included
217
+ @callbacks[name].select { |callback| callback.kind == callback_kind }.each do |callback|
218
+ callback.mixins.each do |edge|
219
+ target = graph.resolve_edge_constant(edge)
220
+ mixin_kind = MIXINS.fetch(edge.type)
221
+ if @concerns.include?(target) && mixin_kind != :extend
222
+ install_concern(consumer, target, mixin_kind, edge, visited)
223
+ else
224
+ add_mixin(consumer, mixin_kind, target, edge)
225
+ end
226
+ end
227
+ callback.methods.each do |method|
228
+ @facts.method_definition(owner: consumer, definition: method, installation: origin.location)
229
+ end
230
+ callback.calls.each do |edge|
231
+ method = callback.methods.find { |definition| within?(definition.location, edge.location) }
232
+ @facts.bind_receiver(edge: edge, receiver: consumer.name, scope: method ? method.scope : :class)
233
+ end
234
+ end
235
+ end
236
+
237
+ def add_mixin(consumer, kind, target, origin)
238
+ @facts.mixin(owner: consumer, kind: kind, target: target, location: origin.location)
239
+ end
240
+
241
+ def within?(outer, inner)
242
+ outer.path == inner.path &&
243
+ ([outer.line, outer.column] <=> [inner.line, inner.column]) <= 0 &&
244
+ ([outer.end_line, outer.end_column] <=> [inner.end_line, inner.end_column]) >= 0
245
+ end
246
+ end
247
+ end
@@ -21,18 +21,43 @@ module ArchSpec
21
21
  'vendor/**/*'
22
22
  ].freeze
23
23
 
24
- attr_accessor :name, :root_path, :todo_path, :base_dir
25
- attr_reader :source_patterns, :ignore_patterns, :component_specs, :rules
24
+ attr_accessor :name, :root_path, :todo_path, :facts_path, :base_dir
25
+ attr_reader :source_patterns, :ignore_patterns, :user_ignore_patterns, :component_specs, :rules
26
26
 
27
27
  def initialize(name = nil)
28
28
  @name = name
29
29
  @root_path = '.'
30
30
  @todo_path = nil
31
+ @facts_path = nil
31
32
  @base_dir = nil
32
33
  @source_patterns = []
33
34
  @ignore_patterns = DEFAULT_IGNORE_PATTERNS.dup
35
+ @user_ignore_patterns = []
34
36
  @component_specs = {}
35
37
  @rules = []
38
+ @producers = {}
39
+ @auto_producers = []
40
+ end
41
+
42
+ # Producer names mapped to the file that registers them, or nil for a
43
+ # built-in producer. Declaring facts without producers selects Rails.
44
+ def producers
45
+ @producers.empty? ? { 'rails' => nil } : @producers
46
+ end
47
+
48
+ # Producers named by +reflect+, whose documents a check requires.
49
+ def declared_producers
50
+ @producers.keys
51
+ end
52
+
53
+ # Producers whose facts a check refreshes on its own when they are
54
+ # missing or stale.
55
+ attr_reader :auto_producers
56
+
57
+ def add_producer(name, require: nil, auto: false)
58
+ @producers[name.to_s] = require&.to_s
59
+ @auto_producers |= [name.to_s] if auto
60
+ @facts_path ||= 'archspec_facts'
36
61
  end
37
62
 
38
63
  def add_source_patterns(patterns)
@@ -40,7 +65,9 @@ module ArchSpec
40
65
  end
41
66
 
42
67
  def add_ignore_patterns(patterns)
43
- @ignore_patterns |= Array(patterns).flatten.compact.map(&:to_s)
68
+ patterns = Array(patterns).flatten.compact.map(&:to_s)
69
+ @user_ignore_patterns |= patterns
70
+ @ignore_patterns |= patterns
44
71
  end
45
72
 
46
73
  def add_component(spec)
@@ -70,5 +97,17 @@ module ArchSpec
70
97
  patterns = source_patterns.empty? ? DEFAULT_SOURCE_PATTERNS.dup : source_patterns.dup
71
98
  patterns | component_specs.values.flat_map(&:file_patterns)
72
99
  end
100
+
101
+ # Whether a component file pattern explicitly reaches into a directory that
102
+ # only a default ignore excludes: its literal leading directories, before
103
+ # any glob character, sit inside that ignore. +vendor/engines/*/**/*.rb+
104
+ # does; a broad +**/*.rb+ does not. Ignores the user declared always win.
105
+ def claims_default_ignored?(pattern)
106
+ directory = pattern.sub(%r{\A\./}, '')[/\A[^*?\[{]*/].sub(%r{[^/]*\z}, '')
107
+
108
+ (DEFAULT_IGNORE_PATTERNS - user_ignore_patterns).any? do |ignore|
109
+ File.fnmatch?(ignore, "#{directory}_", File::FNM_PATHNAME | File::FNM_DOTMATCH)
110
+ end
111
+ end
73
112
  end
74
113
  end
data/lib/archspec/dsl.rb CHANGED
@@ -50,7 +50,10 @@ module ArchSpec
50
50
  end
51
51
 
52
52
  # Adds glob patterns for files to skip. Combines with the built-in
53
- # ignores for +.git+, +tmp+, +vendor+, and +node_modules+.
53
+ # ignores for +.git+, +.bundle+, +tmp+, +vendor+, and +node_modules+.
54
+ # A built-in ignore steps aside for a component pattern whose literal
55
+ # leading directories sit inside it, such as
56
+ # <tt>vendor/engines/*/**/*.rb</tt>; patterns declared here always win.
54
57
  def ignore(*patterns)
55
58
  add_ignore_patterns(patterns)
56
59
  end
@@ -61,11 +64,36 @@ module ArchSpec
61
64
  #
62
65
  # todo "archspec_todo.yml"
63
66
  #
64
- # Write or refresh it with <tt>archspec check --update-todo</tt>.
67
+ # Write or refresh it with <tt>archspec check --update-todo</tt>; flag
68
+ # entries that no longer match with <tt>archspec check --check-todo</tt>.
65
69
  def todo(path = 'archspec_todo.yml')
66
70
  self.todo_path = path.to_s
67
71
  end
68
72
 
73
+ # Loads the facts that <tt>archspec reflect</tt> captured from this
74
+ # directory. Checks only read these files; they never boot the app.
75
+ def facts(path = 'archspec_facts')
76
+ self.facts_path = path.to_s
77
+ end
78
+
79
+ # Chooses the producers <tt>archspec reflect</tt> runs, and turns on
80
+ # facts in +archspec_facts+ unless #facts names another directory.
81
+ # Without it, a facts directory is filled by the built-in +:rails+
82
+ # producer. Load your own producer from a gem or a project file:
83
+ #
84
+ # reflect :rails
85
+ # reflect :widgets, require: "widgets/archspec"
86
+ # reflect :billing, require: "./lib/archspec/billing_facts"
87
+ #
88
+ # With <tt>auto: true</tt>, +check+ and +explain+ run the producer
89
+ # themselves when its facts are missing or stale, which boots the app.
90
+ # Leave it off where checks must not boot anything, such as CI.
91
+ def reflect(*names, require: nil, auto: false)
92
+ raise Error, 'reflect needs at least one producer name' if names.empty?
93
+
94
+ names.flatten.each { |name| add_producer(name, require: require, auto: auto) }
95
+ end
96
+
69
97
  # Yields each subdirectory matching a glob, so you can declare one
70
98
  # component per engine or pack without hardcoding their names. Paths
71
99
  # resolve against the +Archspec.rb+ directory, not the working directory,
@@ -5,7 +5,11 @@ module ArchSpec
5
5
  extend self
6
6
 
7
7
  def evaluate(definition, graph, todo: Todo.empty)
8
- diagnostics = parser_diagnostics(graph) + definition.rules.flat_map { |rule| rule.evaluate(graph) }
8
+ unsuppressed(definition, graph).reject { |diagnostic| todo.include?(diagnostic) }
9
+ end
10
+
11
+ def unsuppressed(definition, graph)
12
+ diagnostics = parser_diagnostics(graph) + graph.fact_diagnostics + definition.rules.flat_map { |rule| rule.evaluate(graph) }
9
13
 
10
14
  # Deduplicate before rejecting. One statement can raise several diagnostics
11
15
  # that differ only in evidence, because `include Foo` is both an includes
@@ -15,7 +19,7 @@ module ArchSpec
15
19
  diagnostics
16
20
  .sort_by { |d| [d.location.path, d.location.line, d.rule, d.message, d.evidence] }
17
21
  .uniq { |d| [d.rule, d.message, d.location.path, d.location.line] }
18
- .reject { |diagnostic| graph.suppressed?(diagnostic) || todo.include?(diagnostic) }
22
+ .reject { |diagnostic| graph.suppressed?(diagnostic) }
19
23
  end
20
24
 
21
25
  private
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchSpec
4
+ module Facts
5
+ Reference = Data.define(:source, :target, :location)
6
+ Method = Data.define(:owner, :definition, :mode, :installation)
7
+ Mixin = Data.define(:owner, :target, :kind, :location)
8
+ Receiver = Data.define(:source, :name, :location, :receiver, :scope)
9
+ Exposure = Data.define(:source, :owner, :scope)
10
+ Gap = Data.define(:source, :message, :location)
11
+ Resolve = Data.define(:source, :message, :location)
12
+ # One document's facts. The producer is recorded on imported edges; facts
13
+ # applied in memory by a static pass have none.
14
+ Operations = Data.define(*LIST_KEYS.map(&:to_sym), :producer)
15
+
16
+ # Applies validated semantic facts, independently of how they were found.
17
+ # Source normalization happens before this boundary; facts establish the
18
+ # resulting relationships, APIs, and execution contexts.
19
+ module Application
20
+ extend self
21
+
22
+ MIXIN_TYPES = { include: :includes, prepend: :prepends, extend: :extends,
23
+ singleton_prepend: :extends }.freeze
24
+
25
+ def apply(graph, batches)
26
+ validate(graph, batches)
27
+ dependencies = dependency_index(graph, batches)
28
+ batches.each do |batch|
29
+ producer = batch.producer
30
+ batch.references.each do |fact|
31
+ add_dependency(graph, dependencies, :references_constant, fact.source, fact.target, fact.location, producer)
32
+ end
33
+ batch.methods.each { |fact| install_method(fact) }
34
+ batch.mixins.each do |fact|
35
+ fact.owner.add_mixin(fact.kind, "::#{fact.target}")
36
+ add_dependency(graph, dependencies, MIXIN_TYPES.fetch(fact.kind), fact.owner, fact.target, fact.location,
37
+ producer, match_location: false)
38
+ end
39
+ batch.exposures.each do |fact|
40
+ graph.expose_instance_methods(fact.source.name, as_owner: exposure_owner_name(fact.owner), scope: fact.scope)
41
+ end
42
+ batch.gaps.each do |fact|
43
+ graph.add_edge(type: :dynamic_feature, from_path: fact.source.path, from_constant: fact.source.name,
44
+ to: fact.message, location: fact.location, confidence: :unknown_due_to_dynamic_feature,
45
+ producer: producer)
46
+ end
47
+ end
48
+ resolve_gaps(graph, batches.flat_map(&:resolves))
49
+ graph.clear_method_caches
50
+ bind_receivers(graph, batches.flat_map(&:receivers))
51
+ graph
52
+ end
53
+
54
+ def validate(graph, batches)
55
+ validate_exposures(graph, batches.flat_map(&:exposures))
56
+ end
57
+
58
+ private
59
+
60
+ def validate_exposures(graph, facts)
61
+ facts.group_by { |fact| fact.source.name }.each do |source, entries|
62
+ projections = entries.map { |fact| [exposure_owner_name(fact.owner), fact.scope] }
63
+ existing = graph.method_exposure(source)
64
+ projections << existing if existing
65
+ raise Error, "conflicting method exposure facts for #{source}" if projections.uniq.size > 1
66
+ end
67
+ end
68
+
69
+ def exposure_owner_name(owner)
70
+ owner.respond_to?(:name) ? owner.name : owner
71
+ end
72
+
73
+ # Existing dependency edges, keyed with and without their location, so
74
+ # each fact checks for a duplicate without scanning the whole graph.
75
+ def dependency_index(graph, batches)
76
+ sources = batches.flat_map { |batch| batch.references.map(&:source) + batch.mixins.map(&:owner) }
77
+ .to_set(&:name)
78
+ return Set.new if sources.empty?
79
+
80
+ types = MIXIN_TYPES.values.to_set << :references_constant
81
+ graph.edges.each_with_object(Set.new) do |edge, index|
82
+ next unless sources.include?(edge.from_constant) && types.include?(edge.type)
83
+
84
+ key = [edge.type, edge.from_constant, edge.from_path, graph.resolve_edge_constant(edge)]
85
+ index << key << (key + [edge.location])
86
+ end
87
+ end
88
+
89
+ def add_dependency(graph, index, type, source, target, location, producer, match_location: true)
90
+ key = [type, source.name, source.path, target]
91
+ return unless index.add?(match_location ? key + [location] : key)
92
+
93
+ index << key << (key + [location])
94
+ graph.add_edge(type: type, from_path: source.path, from_constant: source.name,
95
+ to: target, resolved_to: target, location: location, producer: producer)
96
+ end
97
+
98
+ # A producer that observed what a static gap left open settles it, so
99
+ # the gap no longer counts against the analysis.
100
+ def resolve_gaps(graph, facts)
101
+ return if facts.empty?
102
+
103
+ resolved = facts.group_by { |fact| [fact.source.name, fact.source.path, fact.location] }
104
+ graph.edges.reject! do |edge|
105
+ edge.type == :dynamic_feature &&
106
+ resolved.fetch([edge.from_constant, edge.from_path, edge.location], []).any? do |fact|
107
+ fact.message.nil? || edge.to == fact.message
108
+ end
109
+ end
110
+ end
111
+
112
+ def install_method(fact)
113
+ owner = fact.owner
114
+ definition = fact.definition
115
+ existing = owner.method_definitions.select do |method|
116
+ method.name == definition.name && method.scope == definition.scope
117
+ end
118
+ return if fact.mode == :if_missing && existing.any?
119
+
120
+ if fact.mode == :define
121
+ return if existing.any? do |method|
122
+ method.location.path == owner.path && fact.installation.path == owner.path &&
123
+ ([method.location.line, method.location.column] <=>
124
+ [fact.installation.line, fact.installation.column]).positive?
125
+ end
126
+
127
+ owner.method_definitions.reject! { |method| existing.include?(method) }
128
+ end
129
+ return if owner.method_definitions.include?(definition)
130
+
131
+ owner.method_definitions << definition
132
+ (definition.scope == :class ? owner.class_methods : owner.instance_methods).add(definition.name)
133
+ end
134
+
135
+ def bind_receivers(graph, facts)
136
+ return if facts.empty?
137
+
138
+ groups = facts.group_by { |fact| [fact.source.name, fact.source.path, fact.name, fact.location] }
139
+ sources = facts.to_set { |fact| fact.source.name }
140
+ seen = Set.new
141
+ edges = graph.edges.flat_map do |edge|
142
+ next edge unless sources.include?(edge.from_constant) && edge.type == :calls_named_method && edge.receiver == :none
143
+
144
+ key = [edge.from_constant, edge.from_path, edge.to, edge.location]
145
+ bindings = groups[key]
146
+ next edge unless bindings
147
+
148
+ bindings.filter_map do |fact|
149
+ next unless seen.add?([key, fact.receiver, fact.scope])
150
+
151
+ edge.with(resolved_receiver: fact.receiver, receiver_scope: fact.scope,
152
+ resolved_method: graph.resolve_method_alias(fact.receiver, edge.to, fact.scope))
153
+ end
154
+ end
155
+ graph.edges.replace(edges)
156
+ end
157
+ end
158
+ end
159
+ end