archspec 1.1.0 → 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/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,291 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ArchSpec
|
|
4
|
+
# The built-in Rails producer. <tt>archspec reflect</tt> boots the
|
|
5
|
+
# application in a separate process and records what Rails itself resolved:
|
|
6
|
+
# association targets and generated methods, custom validator classes, and
|
|
7
|
+
# the effects conditional concern callbacks had on each consumer. Normal
|
|
8
|
+
# analysis never requires Rails.
|
|
9
|
+
module RailsReflector
|
|
10
|
+
extend self
|
|
11
|
+
|
|
12
|
+
ASSOCIATIONS = %i[belongs_to has_one has_many has_and_belongs_to_many].freeze
|
|
13
|
+
# Macros that declare associations under derived names.
|
|
14
|
+
GENERATED_ASSOCIATIONS = {
|
|
15
|
+
has_one_attached: [[:has_one, '%s_attachment'], [:has_one, '%s_blob']],
|
|
16
|
+
has_many_attached: [[:has_many, '%s_attachments'], [:has_many, '%s_blobs']],
|
|
17
|
+
has_rich_text: [[:has_one, 'rich_text_%s']]
|
|
18
|
+
}.freeze
|
|
19
|
+
VALIDATIONS = %i[validates validates!].freeze
|
|
20
|
+
Site = Data.define(:macro, :name, :owner, :location)
|
|
21
|
+
# Module#name, immune to classes that redefine +name+ with other meanings.
|
|
22
|
+
MODULE_NAME = Module.instance_method(:name)
|
|
23
|
+
|
|
24
|
+
def boot(root)
|
|
25
|
+
environment = File.join(root, 'config/environment.rb')
|
|
26
|
+
raise Error, "no #{environment} found; the rails producer requires a Rails application" unless File.file?(environment)
|
|
27
|
+
|
|
28
|
+
ENV['RAILS_ENV'] ||= 'development'
|
|
29
|
+
require environment
|
|
30
|
+
eager_load_application
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Records every Rails fact for the loaded application.
|
|
34
|
+
def capture(facts, models: active_record_models, validated: validated_classes)
|
|
35
|
+
sites = macro_sites(facts.graph, models | validated)
|
|
36
|
+
associations(facts, models, sites)
|
|
37
|
+
validators(facts, validated, sites)
|
|
38
|
+
concerns(facts)
|
|
39
|
+
facts
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def associations(facts, models, sites = macro_sites(facts.graph, models))
|
|
43
|
+
graph = facts.graph
|
|
44
|
+
reflections = models.flat_map(&:reflect_on_all_associations)
|
|
45
|
+
.uniq { |reflection| [reflection.active_record.name, reflection.name] }
|
|
46
|
+
reflections.sort_by { |reflection| [reflection.active_record.name.to_s, reflection.name.to_s] }.each do |reflection|
|
|
47
|
+
model = reflection.active_record
|
|
48
|
+
next if model.name.nil? || graph.constants_named(model.name).empty?
|
|
49
|
+
|
|
50
|
+
ancestors = graph.ancestor_names(model.name).first | Set[model.name]
|
|
51
|
+
candidates = sites.fetch([reflection.macro, reflection.name.to_s], []).select { |site| ancestors.include?(site.owner) }
|
|
52
|
+
source = source_node(graph, model, candidates)
|
|
53
|
+
next unless source
|
|
54
|
+
|
|
55
|
+
if candidates.size != 1
|
|
56
|
+
facts.gap(source: source, location: source.location,
|
|
57
|
+
message: "association #{model.name}.#{reflection.name}: no unique literal declaration")
|
|
58
|
+
next
|
|
59
|
+
end
|
|
60
|
+
site = candidates.first
|
|
61
|
+
generated = model.generated_association_methods.instance_methods(false).map(&:to_s)
|
|
62
|
+
names = [reflection.name.to_s, "#{reflection.name}="] & generated
|
|
63
|
+
facts.methods(owner: source, location: site.location, names: names.sort) unless names.empty?
|
|
64
|
+
if reflection.polymorphic?
|
|
65
|
+
facts.gap(source: source, location: site.location, message: "polymorphic association #{model.name}.#{reflection.name}")
|
|
66
|
+
next
|
|
67
|
+
end
|
|
68
|
+
begin
|
|
69
|
+
facts.reference(source: source, target: reflection.klass, location: site.location)
|
|
70
|
+
rescue NameError, ArgumentError, ActiveRecord::ActiveRecordError, Error => error
|
|
71
|
+
facts.gap(source: source, location: site.location,
|
|
72
|
+
message: "unresolved association #{model.name}.#{reflection.name}: #{error.message.lines.first.strip}")
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# A validation such as <tt>validates :email, email: true</tt> names its
|
|
78
|
+
# validator class only by convention. The loaded model knows the class
|
|
79
|
+
# Rails resolved; the literal +validates+ call supplies the location.
|
|
80
|
+
def validators(facts, classes, sites = macro_sites(facts.graph, classes))
|
|
81
|
+
graph = facts.graph
|
|
82
|
+
classes.to_h { |klass| [MODULE_NAME.bind_call(klass), klass] }.sort.each do |class_name, klass|
|
|
83
|
+
next if class_name.nil? || graph.constants_named(class_name).empty?
|
|
84
|
+
|
|
85
|
+
ancestors = graph.ancestor_names(class_name).first | Set[class_name]
|
|
86
|
+
klass.validators.map(&:class).uniq.map { |validator| MODULE_NAME.bind_call(validator) }.compact.sort.each do |name|
|
|
87
|
+
next if name.start_with?('ActiveModel::', 'ActiveRecord::')
|
|
88
|
+
|
|
89
|
+
candidates = VALIDATIONS.flat_map { |macro| sites.fetch([macro, :validator], []) }.select do |site|
|
|
90
|
+
ancestors.include?(site.owner) && validator_key?(site.name, name)
|
|
91
|
+
end
|
|
92
|
+
if candidates.empty?
|
|
93
|
+
next if referenced?(graph, ancestors, name)
|
|
94
|
+
|
|
95
|
+
source = source_node(graph, klass, [])
|
|
96
|
+
if source
|
|
97
|
+
facts.gap(source: source, location: source.location,
|
|
98
|
+
message: "validator #{name} on #{class_name}: no literal declaration")
|
|
99
|
+
end
|
|
100
|
+
next
|
|
101
|
+
end
|
|
102
|
+
candidates.each do |site|
|
|
103
|
+
source = site_source(graph, klass, site)
|
|
104
|
+
facts.reference(source: source, target: name, location: site.location) if source
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Conditional concern callbacks are gaps in static analysis. Once the
|
|
111
|
+
# application is loaded, each consumer shows which candidate mixins and
|
|
112
|
+
# methods it actually received. An effect is recorded only when exactly one
|
|
113
|
+
# candidate explains it; the gap is resolved only when every consumer could
|
|
114
|
+
# be inspected and every observed effect was attributed.
|
|
115
|
+
def concerns(facts)
|
|
116
|
+
graph = facts.graph
|
|
117
|
+
callbacks = graph.conditional_callbacks
|
|
118
|
+
return if callbacks.empty?
|
|
119
|
+
|
|
120
|
+
unsettled = callbacks.map(&:concern).uniq.reject { |concern| runtime_constant(concern) }.to_set
|
|
121
|
+
consumers(graph, callbacks).each do |node, applicable|
|
|
122
|
+
consumer = runtime_constant(node.name)
|
|
123
|
+
if consumer
|
|
124
|
+
unsettled.merge(observe_mixins(facts, node, consumer, applicable))
|
|
125
|
+
observe_methods(facts, node, consumer, applicable)
|
|
126
|
+
else
|
|
127
|
+
unsettled.merge(applicable.map(&:concern))
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
callbacks.each do |callback|
|
|
131
|
+
next if unsettled.include?(callback.concern)
|
|
132
|
+
|
|
133
|
+
source = graph.constants_named(callback.concern).find { |constant| constant.path == callback.path }
|
|
134
|
+
facts.resolve(source: source, location: callback.location, message: "conditional #{callback.kind} callback",
|
|
135
|
+
consumers: graph.consumers_of(callback.concern))
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
# Loads the application's own code, including engines inside the project,
|
|
142
|
+
# but not installed gems: their models are not analyzed, and loading them
|
|
143
|
+
# can require configuration the chosen environment does not have.
|
|
144
|
+
def eager_load_application
|
|
145
|
+
loader = Rails.autoloaders.main if defined?(Rails.autoloaders)
|
|
146
|
+
return Rails.application.eager_load! unless loader.respond_to?(:dirs) && loader.respond_to?(:eager_load_dir)
|
|
147
|
+
|
|
148
|
+
root = "#{Rails.root}/"
|
|
149
|
+
gems = (Gem.path + [defined?(Bundler) ? Bundler.bundle_path.to_s : nil]).compact.map { |path| "#{File.expand_path(path)}/" }
|
|
150
|
+
loader.dirs.select { |dir| dir.start_with?(root) && gems.none? { |gem_dir| dir.start_with?(gem_dir) } }
|
|
151
|
+
.each { |dir| loader.eager_load_dir(dir) }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def active_record_models
|
|
155
|
+
defined?(ActiveRecord::Base) ? ActiveRecord::Base.descendants.reject(&:abstract_class?).select(&:name) : []
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def validated_classes
|
|
159
|
+
return [] unless defined?(ActiveModel::Validations)
|
|
160
|
+
|
|
161
|
+
ObjectSpace.each_object(Class).select { |klass| klass.include?(ActiveModel::Validations) && MODULE_NAME.bind_call(klass) }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Returns the concerns whose observed effects could not be attributed to a
|
|
165
|
+
# single candidate declaration.
|
|
166
|
+
def observe_mixins(facts, node, consumer, callbacks)
|
|
167
|
+
graph = facts.graph
|
|
168
|
+
instance, = graph.ancestor_names(node.name)
|
|
169
|
+
singleton, = graph.ancestor_names(node.name, scope: :class)
|
|
170
|
+
candidates = callbacks.flat_map { |callback| callback.mixins.map { |mixin| [callback, mixin] } }
|
|
171
|
+
candidates.group_by { |_, mixin| [mixin.kind, mixin.target] }.each_with_object(Set.new) do |((kind, target), sites), ambiguous|
|
|
172
|
+
module_object = runtime_constant(target)
|
|
173
|
+
next unless module_object.is_a?(Module)
|
|
174
|
+
|
|
175
|
+
observed = kind == :extend ? consumer.singleton_class.include?(module_object) : consumer.include?(module_object)
|
|
176
|
+
next unless observed && !(kind == :extend ? singleton : instance).include?(target)
|
|
177
|
+
next ambiguous.merge(sites.map { |callback, _| callback.concern }) unless sites.one?
|
|
178
|
+
|
|
179
|
+
facts.mixin(owner: node, target: target, kind: kind, location: sites.first.last.location)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def observe_methods(facts, node, consumer, callbacks)
|
|
184
|
+
callbacks.flat_map(&:methods).each do |definition|
|
|
185
|
+
method = begin
|
|
186
|
+
definition.scope == :class ? consumer.method(definition.name) : consumer.instance_method(definition.name)
|
|
187
|
+
rescue NameError
|
|
188
|
+
nil
|
|
189
|
+
end
|
|
190
|
+
path, line = method&.source_location
|
|
191
|
+
next unless path && File.expand_path(path) == definition.location.path && line == definition.location.line
|
|
192
|
+
|
|
193
|
+
facts.method_definition(owner: node, definition: definition, installation: definition.location)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Each analyzed consumer with the conditional callbacks it runs: included
|
|
198
|
+
# callbacks for an include, prepended callbacks for a prepend.
|
|
199
|
+
def consumers(graph, callbacks)
|
|
200
|
+
concerns = callbacks.map(&:concern).to_set
|
|
201
|
+
graph.constants.each_with_object({}) do |node, found|
|
|
202
|
+
next if node.namespace_only || concerns.include?(node.name) || found.key?(node.name)
|
|
203
|
+
|
|
204
|
+
applicable = callbacks.select do |callback|
|
|
205
|
+
node.mixins.fetch(callback.kind == :prepended ? :prepend : :include).include?("::#{callback.concern}")
|
|
206
|
+
end
|
|
207
|
+
found[node.name] = [node, applicable] unless applicable.empty?
|
|
208
|
+
end.values
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def runtime_constant(name)
|
|
212
|
+
Object.const_get(name)
|
|
213
|
+
rescue NameError, ArgumentError
|
|
214
|
+
nil
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def validator_key?(key, validator)
|
|
218
|
+
class_name = "#{key.to_s.camelize}Validator"
|
|
219
|
+
validator == class_name || validator.end_with?("::#{class_name}")
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def referenced?(graph, ancestors, name)
|
|
223
|
+
graph.dependency_edges.any? { |edge| ancestors.include?(edge.from_constant) && graph.resolve_edge_constant(edge) == name }
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def site_source(graph, klass, site)
|
|
227
|
+
owner = graph.constants_named(site.owner).find { |node| node.class? && node.path == site.location.path }
|
|
228
|
+
owner || source_node(graph, klass, [site])
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def source_node(graph, model, candidates)
|
|
232
|
+
nodes = graph.constants_named(model.name)
|
|
233
|
+
direct = nodes.select { |node| candidates.any? { |site| site.owner == model.name && site.location.path == node.path } }
|
|
234
|
+
return direct.first if direct.one?
|
|
235
|
+
|
|
236
|
+
location = Object.const_source_location(model.name)
|
|
237
|
+
nodes.find { |node| location && node.path == File.expand_path(location.first) } || nodes.find { |node| !node.namespace_only }
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Literal association and validation declarations, found by parsing only
|
|
241
|
+
# the files that define the given classes and their analyzed ancestors.
|
|
242
|
+
def macro_sites(graph, classes)
|
|
243
|
+
names = classes.filter_map { |klass| MODULE_NAME.bind_call(klass) }.flat_map { |name| graph.ancestor_names(name).first.to_a << name }.to_set
|
|
244
|
+
paths = names.flat_map { |name| graph.constants_named(name).map(&:path) }.uniq.sort
|
|
245
|
+
owners = graph.edges.select { |edge| edge.type == :calls_named_method && paths.include?(edge.from_path) }
|
|
246
|
+
.to_h { |edge| [[edge.from_path, edge.location], edge.from_constant] }
|
|
247
|
+
sites = []
|
|
248
|
+
paths.each { |path| collect_sites(owners, path, Prism.parse_file(path).value, sites) }
|
|
249
|
+
sites.group_by { |site| [site.macro, site.name.is_a?(String) ? site.name : :validator] }
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def collect_sites(owners, path, node, sites)
|
|
253
|
+
return unless node
|
|
254
|
+
return if node.is_a?(Prism::DefNode)
|
|
255
|
+
|
|
256
|
+
if node.is_a?(Prism::CallNode) && (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode))
|
|
257
|
+
location = SourceLocation.from_prism(path, node.location)
|
|
258
|
+
arguments = node.arguments&.arguments || []
|
|
259
|
+
argument = arguments.first
|
|
260
|
+
literal = argument.unescaped if argument.is_a?(Prism::SymbolNode) || argument.is_a?(Prism::StringNode)
|
|
261
|
+
if ASSOCIATIONS.include?(node.name)
|
|
262
|
+
sites << Site.new(node.name, literal, owners[[path, location]], location) if literal
|
|
263
|
+
elsif GENERATED_ASSOCIATIONS.key?(node.name)
|
|
264
|
+
GENERATED_ASSOCIATIONS.fetch(node.name).each do |macro, pattern|
|
|
265
|
+
sites << Site.new(macro, format(pattern, literal), owners[[path, location]], location) if literal
|
|
266
|
+
end
|
|
267
|
+
elsif VALIDATIONS.include?(node.name)
|
|
268
|
+
validation_keys(arguments).each do |key|
|
|
269
|
+
sites << Site.new(node.name, key.to_sym, owners[[path, location]], location)
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
node.compact_child_nodes.each { |child| collect_sites(owners, path, child, sites) }
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def validation_keys(arguments)
|
|
277
|
+
options = arguments.select { |argument| argument.is_a?(Prism::KeywordHashNode) || argument.is_a?(Prism::HashNode) }
|
|
278
|
+
options.flat_map(&:elements).filter_map do |element|
|
|
279
|
+
key = element.key if element.is_a?(Prism::AssocNode)
|
|
280
|
+
key.unescaped if key.is_a?(Prism::SymbolNode) || key.is_a?(Prism::StringNode)
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
producer :rails do
|
|
286
|
+
sources 'app/models/**/*.rb', '**/app/models/**/*.rb'
|
|
287
|
+
env 'RAILS_ENV'
|
|
288
|
+
boot { |root| RailsReflector.boot(root) }
|
|
289
|
+
capture { |facts| RailsReflector.capture(facts) }
|
|
290
|
+
end
|
|
291
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Executed only by `archspec reflect`, once per producer, in its own process.
|
|
4
|
+
require_relative '../archspec'
|
|
5
|
+
|
|
6
|
+
begin
|
|
7
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
8
|
+
definition, root = ArchSpec::CLI.load_definition(ENV.fetch('ARCHSPEC_REFLECTION_CONFIG'))
|
|
9
|
+
producer = ArchSpec::Producer.load(ENV.fetch('ARCHSPEC_PRODUCER'), definition)
|
|
10
|
+
document = producer.run(definition, root)
|
|
11
|
+
output = File.join(File.expand_path(definition.facts_path, root), "#{producer.name}.yml")
|
|
12
|
+
ArchSpec::Facts.write(output, document)
|
|
13
|
+
labels = { 'references' => 'reference', 'methods' => 'method', 'mixins' => 'mixin', 'gaps' => 'gap',
|
|
14
|
+
'resolves' => 'resolved gap' }
|
|
15
|
+
counts = labels.filter_map do |key, label|
|
|
16
|
+
size = key == 'methods' ? document[key].sum { |entry| entry['names'].size } : document[key].size
|
|
17
|
+
"#{size} #{label}#{'s' unless size == 1}" if size.positive?
|
|
18
|
+
end
|
|
19
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
20
|
+
summary = counts.empty? ? 'no facts' : counts.join(', ')
|
|
21
|
+
puts format('Updated %<path>s: %<summary>s (%<seconds>.1fs)',
|
|
22
|
+
path: Pathname(output).relative_path_from(Pathname(root)), summary: summary, seconds: elapsed)
|
|
23
|
+
document['gaps'].each { |gap| puts " gap: #{gap['path']}:#{gap['line']} #{gap['message']}" }
|
|
24
|
+
rescue ArchSpec::Error => error
|
|
25
|
+
warn error.message
|
|
26
|
+
exit 1
|
|
27
|
+
end
|
|
@@ -131,7 +131,9 @@ module ArchSpec
|
|
|
131
131
|
next unless syntax.constant_name_at(path, location)
|
|
132
132
|
|
|
133
133
|
target, resolved = reference_target(reference, path)
|
|
134
|
-
|
|
134
|
+
# A resolved name is absolute; resolving it again from the includer's
|
|
135
|
+
# scope could pick a nested constant with the same name.
|
|
136
|
+
constant.add_mixin(kind, resolved ? "::#{resolved}" : target)
|
|
135
137
|
add_ancestry_edge(graph, :"#{kind}s", name, definition, reference, target, resolved)
|
|
136
138
|
end
|
|
137
139
|
end
|
|
@@ -42,9 +42,10 @@ module ArchSpec
|
|
|
42
42
|
|
|
43
43
|
def evaluate(graph)
|
|
44
44
|
relevant_edges(graph).flat_map do |edge|
|
|
45
|
-
graph.target_components_for(edge)
|
|
46
|
-
|
|
45
|
+
owners = graph.target_components_for(edge)
|
|
46
|
+
next [] if owners.any? { |target| target == source || targets.include?(target) }
|
|
47
47
|
|
|
48
|
+
owners.map do |target|
|
|
48
49
|
Diagnostic.new(
|
|
49
50
|
rule: id,
|
|
50
51
|
message: "#{source} may not depend on #{target}",
|
|
@@ -107,9 +108,8 @@ module ArchSpec
|
|
|
107
108
|
graph.dependency_edges.flat_map do |edge|
|
|
108
109
|
next [] unless graph.target_components_for(edge).include?(source)
|
|
109
110
|
|
|
110
|
-
offenders = graph.source_components_for(edge)
|
|
111
|
-
|
|
112
|
-
end
|
|
111
|
+
offenders = graph.source_components_for(edge)
|
|
112
|
+
next [] if offenders.any? { |component| component == source || consumers.include?(component) }
|
|
113
113
|
|
|
114
114
|
offenders.map do |offender|
|
|
115
115
|
Diagnostic.new(
|
|
@@ -74,7 +74,8 @@ module ArchSpec
|
|
|
74
74
|
def own_method_call?(graph, edge)
|
|
75
75
|
return false unless edge.receiver == :none && edge.from_constant
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
owner = edge.resolved_receiver || edge.from_constant
|
|
78
|
+
methods, = graph.effective_methods(owner, edge.receiver_scope || :instance)
|
|
78
79
|
methods.include?(edge.to.to_sym)
|
|
79
80
|
end
|
|
80
81
|
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'comment'
|
|
4
|
+
|
|
5
|
+
module ArchSpec
|
|
6
|
+
module Sources
|
|
7
|
+
# Shared interface for source adapters. Normalizes the file path, parses
|
|
8
|
+
# eagerly, and converts parser errors to ArchSpec source locations.
|
|
9
|
+
# Subclasses provide supported extensions, a Prism program, and comments.
|
|
10
|
+
class Base
|
|
11
|
+
def self.extensions
|
|
12
|
+
raise NotImplementedError
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.rubydex_indexable?
|
|
16
|
+
false
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
attr_reader :path
|
|
20
|
+
|
|
21
|
+
def initialize(path)
|
|
22
|
+
@path = File.expand_path(path)
|
|
23
|
+
@result = parse_file
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def comments
|
|
27
|
+
raise NotImplementedError
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def prism
|
|
31
|
+
raise NotImplementedError
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def parse_errors
|
|
35
|
+
result.errors.map do |error|
|
|
36
|
+
ParseError.new(error.message, SourceLocation.from_prism(path, error.location))
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
attr_reader :result
|
|
43
|
+
|
|
44
|
+
def parse_file
|
|
45
|
+
raise NotImplementedError
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ArchSpec
|
|
4
|
+
module Sources
|
|
5
|
+
# Parser-independent comment text and position for suppression handling.
|
|
6
|
+
# Lines are one-based and columns are zero-based byte offsets within a line.
|
|
7
|
+
Comment = Data.define(:text, :line, :column) do
|
|
8
|
+
def self.from_prism(comment)
|
|
9
|
+
location = comment.location
|
|
10
|
+
new(comment.slice, location.start_line, location.start_column)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'herb'
|
|
4
|
+
require 'prism'
|
|
5
|
+
require_relative 'base'
|
|
6
|
+
|
|
7
|
+
module ArchSpec
|
|
8
|
+
module Sources
|
|
9
|
+
# Parses ERB templates with Herb, exposing a complete Prism program and
|
|
10
|
+
# suppression comments at their original template positions. ERB sources
|
|
11
|
+
# contribute syntax facts without being indexed by Rubydex.
|
|
12
|
+
class Erb < Base
|
|
13
|
+
attr_reader :comments, :prism
|
|
14
|
+
|
|
15
|
+
def self.extensions
|
|
16
|
+
%w[.erb]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def parse_file
|
|
22
|
+
source = File.read(path)
|
|
23
|
+
document = Herb.parse(source, prism_program: true).value
|
|
24
|
+
# Herb omits the program payload for a completely empty file.
|
|
25
|
+
parsed = source.empty? ? Prism.parse('', filepath: path) : Prism.load(source, document.prism_node)
|
|
26
|
+
@prism = parsed.value
|
|
27
|
+
collect_comments!(source, document, parsed)
|
|
28
|
+
parsed
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def collect_comments!(source, document, parsed)
|
|
32
|
+
bytes = source.b
|
|
33
|
+
@comments = []
|
|
34
|
+
# Work around a Herb bug: Prism comment ranges extend past ERB closing
|
|
35
|
+
# tags to the end of the physical line. This Collects tag boundaries and
|
|
36
|
+
# clamps comments; Intention is to remove once it's fixed upstream.
|
|
37
|
+
ruby_ranges = []
|
|
38
|
+
collect_erb_comments(document, bytes, ruby_ranges)
|
|
39
|
+
ruby_ranges.sort_by!(&:from)
|
|
40
|
+
parsed.comments.each do |comment|
|
|
41
|
+
location = comment.location
|
|
42
|
+
range = ruby_ranges.bsearch { |candidate| candidate.to > location.start_offset }
|
|
43
|
+
finish = location.end_offset
|
|
44
|
+
finish = [finish, range.to].min if range && range.from <= location.start_offset
|
|
45
|
+
@comments << Comment.new(source.byteslice(location.start_offset...finish), location.start_line, location.start_column)
|
|
46
|
+
end
|
|
47
|
+
@comments.uniq! { |comment| [comment.line, comment.column] }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def collect_erb_comments(node, bytes, ruby_ranges)
|
|
51
|
+
if node.is_a?(Herb::AST::ERBCommentNode)
|
|
52
|
+
process_erb_comment(node.content, bytes)
|
|
53
|
+
elsif node.respond_to?(:tag_opening) && node.tag_opening&.value&.start_with?('<%') &&
|
|
54
|
+
node.respond_to?(:content) && node.content
|
|
55
|
+
ruby_ranges << node.content.range
|
|
56
|
+
# Work around a Herb bug: single-line comment-only Ruby tags are absent
|
|
57
|
+
# from the Prism comments. This recovers them from the AST, excluding escapes.
|
|
58
|
+
# Intention is to remove this once the bug is fixed upstream in Herb.
|
|
59
|
+
if node.is_a?(Herb::AST::ERBContentNode) && %w[<% <%-].include?(node.tag_opening.value) &&
|
|
60
|
+
node.content.value.match?(/\A[^\S\r\n]*#[^\r\n]*\z/)
|
|
61
|
+
token = node.content
|
|
62
|
+
offset = token.range.from + token.value.b.index('#')
|
|
63
|
+
@comments << Comment.new(token.value.lstrip, token.location.start.line, byte_column(bytes, offset))
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
node.compact_child_nodes.each do |child|
|
|
68
|
+
collect_erb_comments(child, bytes, ruby_ranges)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def process_erb_comment(token, bytes)
|
|
73
|
+
start = token.location.start
|
|
74
|
+
column = byte_column(bytes, token.range.from)
|
|
75
|
+
token.value.each_line.with_index do |text, index|
|
|
76
|
+
@comments << Comment.new(text, start.line + index, index.zero? ? column : 0)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def byte_column(bytes, offset)
|
|
81
|
+
line_start = (bytes.rindex("\n", offset - 1) || -1) + 1
|
|
82
|
+
offset - line_start
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'prism'
|
|
4
|
+
require_relative 'base'
|
|
5
|
+
|
|
6
|
+
module ArchSpec
|
|
7
|
+
module Sources
|
|
8
|
+
# Parses Ruby and Rake files with Prism and exposes their program and
|
|
9
|
+
# comments. These sources also support semantic indexing with Rubydex.
|
|
10
|
+
class Ruby < Base
|
|
11
|
+
def self.extensions
|
|
12
|
+
%w[.rb .rake]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.rubydex_indexable?
|
|
16
|
+
true
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def comments
|
|
20
|
+
result.comments.map { |comment| Comment.from_prism(comment) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def prism
|
|
24
|
+
result.value
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def parse_file
|
|
30
|
+
Prism.parse_file(path)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
data/lib/archspec/todo.rb
CHANGED
|
@@ -8,8 +8,12 @@ module ArchSpec
|
|
|
8
8
|
# in an existing app and burn the list down over time. Matched by
|
|
9
9
|
# ArchSpec::Diagnostic#fingerprint, so entries survive edits that shift lines.
|
|
10
10
|
class Todo
|
|
11
|
+
# The keys an entry keeps. Anything else in the file, such as the line an
|
|
12
|
+
# older format recorded, is dropped so reports have one fixed shape.
|
|
13
|
+
FIELDS = %w[id rule path message evidence].freeze
|
|
14
|
+
|
|
11
15
|
def self.empty(root: nil)
|
|
12
|
-
new(
|
|
16
|
+
new({}, root: root)
|
|
13
17
|
end
|
|
14
18
|
|
|
15
19
|
def self.load(path, root:)
|
|
@@ -20,16 +24,17 @@ module ArchSpec
|
|
|
20
24
|
raise Error, "invalid todo file #{path}: expected a violations list"
|
|
21
25
|
end
|
|
22
26
|
|
|
23
|
-
|
|
27
|
+
entries = Array(document['violations']).each_with_index.to_h do |entry, index|
|
|
24
28
|
id = entry.is_a?(Hash) ? entry['id'] : entry
|
|
25
29
|
unless id.is_a?(String) && !id.empty?
|
|
26
30
|
raise Error, "invalid todo file #{path}: violation #{index + 1} has no id"
|
|
27
31
|
end
|
|
28
32
|
|
|
29
|
-
|
|
33
|
+
fields = entry.is_a?(Hash) ? entry : {}
|
|
34
|
+
[id, FIELDS.to_h { |key| [key, fields[key]] }.merge('id' => id)]
|
|
30
35
|
end
|
|
31
36
|
|
|
32
|
-
new(
|
|
37
|
+
new(entries, root: root)
|
|
33
38
|
rescue Error
|
|
34
39
|
raise
|
|
35
40
|
rescue Psych::Exception, SystemCallError => e
|
|
@@ -37,34 +42,40 @@ module ArchSpec
|
|
|
37
42
|
end
|
|
38
43
|
|
|
39
44
|
def self.write(path, diagnostics, root:)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
45
|
+
entries = diagnostics.map do |diagnostic|
|
|
46
|
+
{
|
|
47
|
+
'id' => diagnostic.fingerprint(root: root),
|
|
48
|
+
'rule' => diagnostic.rule,
|
|
49
|
+
'path' => diagnostic.location.relative_path(root),
|
|
50
|
+
'message' => diagnostic.message,
|
|
51
|
+
'evidence' => diagnostic.evidence
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
entries.uniq! { |entry| entry['id'] }
|
|
55
|
+
entries.sort_by! { |entry| entry.values_at('path', 'rule', 'message', 'evidence') }
|
|
51
56
|
|
|
52
|
-
File.write(path,
|
|
57
|
+
File.write(path, { 'violations' => entries }.to_yaml)
|
|
58
|
+
entries.size
|
|
53
59
|
rescue SystemCallError => e
|
|
54
60
|
raise Error, "could not write todo file #{path}: #{e.message}"
|
|
55
61
|
end
|
|
56
62
|
|
|
57
|
-
def initialize(
|
|
58
|
-
@
|
|
63
|
+
def initialize(entries, root:)
|
|
64
|
+
@entries = entries
|
|
59
65
|
@root = root
|
|
60
66
|
end
|
|
61
67
|
|
|
62
68
|
def include?(diagnostic)
|
|
63
|
-
|
|
69
|
+
entries.key?(diagnostic.fingerprint(root: root))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def unmatched_by(diagnostics)
|
|
73
|
+
matched = diagnostics.map { |diagnostic| diagnostic.fingerprint(root: root) }.to_set
|
|
74
|
+
entries.reject { |id, _entry| matched.include?(id) }.values
|
|
64
75
|
end
|
|
65
76
|
|
|
66
77
|
private
|
|
67
78
|
|
|
68
|
-
attr_reader :
|
|
79
|
+
attr_reader :entries, :root
|
|
69
80
|
end
|
|
70
81
|
end
|
data/lib/archspec/version.rb
CHANGED