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.
- checksums.yaml +4 -4
- data/README.md +21 -1
- data/lib/archspec/analyzer.rb +56 -39
- data/lib/archspec/cli.rb +154 -33
- data/lib/archspec/concern_semantics.rb +247 -0
- data/lib/archspec/definition.rb +42 -3
- data/lib/archspec/dsl.rb +30 -2
- data/lib/archspec/evaluator.rb +6 -2
- data/lib/archspec/facts/application.rb +159 -0
- data/lib/archspec/facts/builder.rb +223 -0
- data/lib/archspec/facts/document.rb +367 -0
- data/lib/archspec/facts.rb +108 -0
- data/lib/archspec/formatters/explanation.rb +2 -1
- data/lib/archspec/formatters/json.rb +6 -3
- data/lib/archspec/formatters/text.rb +22 -3
- data/lib/archspec/model.rb +58 -12
- data/lib/archspec/producer.rb +137 -0
- data/lib/archspec/rails_reflector.rb +291 -0
- data/lib/archspec/reflect_runner.rb +27 -0
- data/lib/archspec/rubydex_index.rb +3 -1
- data/lib/archspec/rules/concern_rules.rb +5 -5
- data/lib/archspec/rules/dependency_rules.rb +5 -5
- data/lib/archspec/rules/protocol_rules.rb +2 -1
- data/lib/archspec/sources/base.rb +49 -0
- data/lib/archspec/sources/comment.rb +14 -0
- data/lib/archspec/sources/erb.rb +86 -0
- data/lib/archspec/sources/ruby.rb +34 -0
- data/lib/archspec/todo.rb +31 -20
- data/lib/archspec/version.rb +1 -1
- data/lib/archspec.rb +9 -1
- metadata +74 -2
|
@@ -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
|
data/lib/archspec/definition.rb
CHANGED
|
@@ -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
|
-
|
|
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,
|
data/lib/archspec/evaluator.rb
CHANGED
|
@@ -5,7 +5,11 @@ module ArchSpec
|
|
|
5
5
|
extend self
|
|
6
6
|
|
|
7
7
|
def evaluate(definition, graph, todo: Todo.empty)
|
|
8
|
-
|
|
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)
|
|
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
|