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,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'tempfile'
|
|
6
|
+
require 'yaml'
|
|
7
|
+
|
|
8
|
+
module ArchSpec
|
|
9
|
+
# Shared semantic facts and versioned snapshots. Static framework passes and
|
|
10
|
+
# explicit runtime producers use Builder; checks import data without booting.
|
|
11
|
+
module Facts
|
|
12
|
+
extend self
|
|
13
|
+
|
|
14
|
+
VERSION = 2
|
|
15
|
+
LIST_KEYS = %w[references methods mixins exposures gaps receivers resolves].freeze
|
|
16
|
+
# Files outside the analyzed source that can change what any producer
|
|
17
|
+
# observes. A change to one of them invalidates a whole document.
|
|
18
|
+
DEFAULT_INPUTS = ['config/**/*.{rb,yml,yaml}', 'Gemfile', 'Gemfile.lock', '*.gemspec', '.ruby-version'].freeze
|
|
19
|
+
STALE_RULE = 'facts.stale'
|
|
20
|
+
|
|
21
|
+
# The exact input map of a version 1 document: every analyzed file plus the
|
|
22
|
+
# default inputs.
|
|
23
|
+
def snapshot(graph, excluding: 'archspec_facts')
|
|
24
|
+
paths = graph.files.keys + input_files(graph, DEFAULT_INPUTS, excluding: excluding)
|
|
25
|
+
digests(graph, paths.reject { |path| excluded?(graph, path, excluding) })
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# A version 2 fingerprint: the producer's global inputs, the analyzed files
|
|
29
|
+
# its source patterns cover, and every file the facts cite.
|
|
30
|
+
def fingerprint(graph, inputs:, sources:, cited:, excluding:)
|
|
31
|
+
paths = input_files(graph, inputs, excluding: excluding) + source_files(graph, sources) +
|
|
32
|
+
cited.select { |path| File.file?(path) }
|
|
33
|
+
digests(graph, paths)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def input_files(graph, patterns, excluding:)
|
|
37
|
+
patterns.flat_map { |pattern| Dir.glob(File.join(graph.root, pattern)) }
|
|
38
|
+
.select { |path| File.file?(path) }.map { |path| File.expand_path(path) }
|
|
39
|
+
.reject { |path| excluded?(graph, path, excluding) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Source patterns match analyzed files only, so covering a directory never
|
|
43
|
+
# walks the file system.
|
|
44
|
+
def source_files(graph, patterns)
|
|
45
|
+
return [] if patterns.empty?
|
|
46
|
+
|
|
47
|
+
graph.files.values.select do |file|
|
|
48
|
+
patterns.any? { |pattern| File.fnmatch?(pattern, file.relative_path, File::FNM_PATHNAME | File::FNM_EXTGLOB) }
|
|
49
|
+
end.map(&:path)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def digest(path)
|
|
53
|
+
Digest::SHA256.file(path).hexdigest if File.file?(path)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Loads every document in the directory. Stale documents and the facts of
|
|
57
|
+
# stale files are skipped and reported as diagnostics; everything else is
|
|
58
|
+
# validated before any of it is applied. Every named +producers+ must
|
|
59
|
+
# have written its document, so a producer that never succeeded cannot
|
|
60
|
+
# pass unnoticed.
|
|
61
|
+
def load_into(graph, directory, producers: [])
|
|
62
|
+
paths = Dir.glob(File.join(File.expand_path(directory, graph.root), '*.yml')).sort
|
|
63
|
+
raise Error, "no facts found in #{directory}; run `archspec reflect` or your facts producer" if paths.empty?
|
|
64
|
+
|
|
65
|
+
missing = producers.map(&:to_s) - paths.map { |path| File.basename(path, '.yml') }
|
|
66
|
+
unless missing.empty?
|
|
67
|
+
raise Error, "no facts from #{missing.join(', ')} in #{directory}; run `archspec reflect`"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
batches = paths.filter_map do |path|
|
|
71
|
+
document = Document.new(graph, YAML.safe_load_file(path, permitted_classes: [], aliases: false), path)
|
|
72
|
+
document.compile_fresh(excluding: directory)
|
|
73
|
+
end
|
|
74
|
+
Application.apply(graph, batches)
|
|
75
|
+
rescue Psych::Exception, SystemCallError => error
|
|
76
|
+
raise Error, "could not load facts: #{error.message}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def write(path, document)
|
|
80
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
81
|
+
Tempfile.create(['.archspec-facts-', '.yml'], File.dirname(path)) do |file|
|
|
82
|
+
file.write(document.to_yaml)
|
|
83
|
+
file.flush
|
|
84
|
+
file.close
|
|
85
|
+
File.chmod(0o666 & ~File.umask, file.path)
|
|
86
|
+
File.rename(file.path, path)
|
|
87
|
+
end
|
|
88
|
+
rescue SystemCallError => error
|
|
89
|
+
raise Error, "could not write facts #{path}: #{error.message}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def excluded?(graph, path, excluding)
|
|
95
|
+
excluded = File.expand_path(excluding, graph.root)
|
|
96
|
+
path == excluded || path.start_with?("#{excluded}/")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def digests(graph, paths)
|
|
100
|
+
root = Pathname(graph.root)
|
|
101
|
+
paths.uniq.sort.to_h { |path| [Pathname(path).relative_path_from(root).to_s, digest(path)] }
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
require_relative 'facts/application'
|
|
107
|
+
require_relative 'facts/document'
|
|
108
|
+
require_relative 'facts/builder'
|
|
@@ -114,7 +114,8 @@ module ArchSpec
|
|
|
114
114
|
locations = facts.map { |edge| "#{edge.location.line}:#{edge.location.column}" }
|
|
115
115
|
in_gutters(locations) do |gutter, index|
|
|
116
116
|
edge = facts[index]
|
|
117
|
-
|
|
117
|
+
source = edge.producer ? style.note(" (from #{edge.producer} facts)") : ''
|
|
118
|
+
output.puts " #{style.faint(gutter)} #{edge.verb} #{edge.to}#{source}"
|
|
118
119
|
end
|
|
119
120
|
end
|
|
120
121
|
|
|
@@ -7,14 +7,17 @@ module ArchSpec
|
|
|
7
7
|
module JSON
|
|
8
8
|
module_function
|
|
9
9
|
|
|
10
|
-
def print(output = $stdout, graph:, diagnostics:)
|
|
11
|
-
|
|
10
|
+
def print(output = $stdout, graph:, diagnostics:, obsolete_todo: nil)
|
|
11
|
+
report = {
|
|
12
12
|
files: graph.files.size,
|
|
13
13
|
constants: graph.constants.size,
|
|
14
14
|
facts: graph.edges.size,
|
|
15
15
|
analysis: graph.analysis_census,
|
|
16
16
|
violations: diagnostics.map { |diagnostic| diagnostic.to_h(root: graph.root) }
|
|
17
|
-
|
|
17
|
+
}
|
|
18
|
+
report[:obsolete_todo] = obsolete_todo if obsolete_todo
|
|
19
|
+
|
|
20
|
+
output.puts ::JSON.pretty_generate(report)
|
|
18
21
|
end
|
|
19
22
|
end
|
|
20
23
|
end
|
|
@@ -24,15 +24,18 @@ module ArchSpec
|
|
|
24
24
|
|
|
25
25
|
module_function
|
|
26
26
|
|
|
27
|
-
def print(output = $stdout, graph:, diagnostics:)
|
|
27
|
+
def print(output = $stdout, graph:, diagnostics:, obsolete_todo: nil)
|
|
28
|
+
style = Style.new(output)
|
|
29
|
+
print_obsolete_todo(output, style, obsolete_todo) if obsolete_todo&.any?
|
|
30
|
+
|
|
28
31
|
if diagnostics.empty?
|
|
29
|
-
|
|
32
|
+
verdict = obsolete_todo&.any? ? 'ArchSpec found no violations' : 'ArchSpec passed'
|
|
33
|
+
output.puts "#{verdict}: #{graph.files.size} files, #{graph.constants.size} constants, " \
|
|
30
34
|
"#{graph.edges.size} facts checked."
|
|
31
35
|
print_census(output, graph)
|
|
32
36
|
return
|
|
33
37
|
end
|
|
34
38
|
|
|
35
|
-
style = Style.new(output)
|
|
36
39
|
sources = Hash.new { |hash, path| hash[path] = read_lines(path) }
|
|
37
40
|
|
|
38
41
|
diagnostics.each do |diagnostic|
|
|
@@ -44,6 +47,22 @@ module ArchSpec
|
|
|
44
47
|
print_census(output, graph)
|
|
45
48
|
end
|
|
46
49
|
|
|
50
|
+
def print_obsolete_todo(output, style, entries)
|
|
51
|
+
label = entries.size == 1 ? 'obsolete todo entry matches' : 'obsolete todo entries match'
|
|
52
|
+
output.puts "#{style.severity('[error]')} #{style.bold("#{entries.size} #{label} no current violation")}"
|
|
53
|
+
output.puts
|
|
54
|
+
entries.each do |entry|
|
|
55
|
+
detail = [entry['path'], entry['message']].compact.join(': ')
|
|
56
|
+
detail = entry['id'] if detail.empty?
|
|
57
|
+
rule = entry['rule'] ? " #{style.faint("[#{entry['rule']}]")}" : ''
|
|
58
|
+
output.puts " #{detail}#{rule}"
|
|
59
|
+
output.puts " #{style.faint(entry['evidence'])}" if entry['evidence']
|
|
60
|
+
end
|
|
61
|
+
output.puts
|
|
62
|
+
output.puts " #{style.note('note:')} fixed or suppressed locally; run `archspec check --update-todo` to drop them"
|
|
63
|
+
output.puts
|
|
64
|
+
end
|
|
65
|
+
|
|
47
66
|
def print_diagnostic(output, style, graph, diagnostic, sources)
|
|
48
67
|
location = diagnostic.location
|
|
49
68
|
relative = location.relative_path(graph.root)
|
data/lib/archspec/model.rb
CHANGED
|
@@ -49,6 +49,12 @@ module ArchSpec
|
|
|
49
49
|
end
|
|
50
50
|
MethodDefinition = Data.define(:owner, :name, :scope, :location, :visibility, :signatures, :alias_target)
|
|
51
51
|
|
|
52
|
+
# A concern callback whose effects depend on control flow. The static pass
|
|
53
|
+
# records it as a gap; a runtime producer can observe which of these
|
|
54
|
+
# candidate mixins and methods a consumer actually received.
|
|
55
|
+
ConditionalCallback = Data.define(:concern, :path, :kind, :location, :mixins, :methods)
|
|
56
|
+
ConditionalMixin = Data.define(:kind, :target, :location)
|
|
57
|
+
|
|
52
58
|
Suppression = Data.define(:rule, :start_line, :end_line, :reason) do
|
|
53
59
|
def matches?(diagnostic)
|
|
54
60
|
(rule.nil? || rule == diagnostic.rule) &&
|
|
@@ -86,7 +92,8 @@ module ArchSpec
|
|
|
86
92
|
@mixins = {
|
|
87
93
|
include: Set.new,
|
|
88
94
|
prepend: Set.new,
|
|
89
|
-
extend: Set.new
|
|
95
|
+
extend: Set.new,
|
|
96
|
+
singleton_prepend: Set.new
|
|
90
97
|
}
|
|
91
98
|
end
|
|
92
99
|
|
|
@@ -138,7 +145,8 @@ module ArchSpec
|
|
|
138
145
|
:resolved_to,
|
|
139
146
|
:resolved_receiver,
|
|
140
147
|
:receiver_scope,
|
|
141
|
-
:resolved_method
|
|
148
|
+
:resolved_method,
|
|
149
|
+
:producer
|
|
142
150
|
) do
|
|
143
151
|
VERBS = {
|
|
144
152
|
references_constant: 'references',
|
|
@@ -200,7 +208,8 @@ module ArchSpec
|
|
|
200
208
|
|
|
201
209
|
RESOLVED_ROOTS = %w[Object BasicObject].freeze
|
|
202
210
|
|
|
203
|
-
attr_reader :root, :files, :constants, :edges, :components, :analysis_diagnostics
|
|
211
|
+
attr_reader :root, :files, :constants, :edges, :components, :analysis_diagnostics, :conditional_callbacks,
|
|
212
|
+
:fact_diagnostics
|
|
204
213
|
|
|
205
214
|
def initialize(root)
|
|
206
215
|
@root = File.expand_path(root)
|
|
@@ -210,8 +219,11 @@ module ArchSpec
|
|
|
210
219
|
@edges = []
|
|
211
220
|
@components = {}
|
|
212
221
|
@analysis_diagnostics = []
|
|
222
|
+
@conditional_callbacks = []
|
|
223
|
+
@fact_diagnostics = []
|
|
213
224
|
@effective_definition_cache = {}
|
|
214
225
|
@effective_method_cache = {}
|
|
226
|
+
@method_exposures = {}
|
|
215
227
|
end
|
|
216
228
|
|
|
217
229
|
def add_file(path:, parse_errors:, suppressions: [])
|
|
@@ -240,7 +252,7 @@ module ArchSpec
|
|
|
240
252
|
|
|
241
253
|
def add_edge(type:, from_path:, from_constant:, to:, location:, confidence: :high, receiver: nil,
|
|
242
254
|
lexical_nesting: nil, resolved_to: nil, resolved_receiver: nil, receiver_scope: nil,
|
|
243
|
-
resolved_method: nil)
|
|
255
|
+
resolved_method: nil, producer: nil)
|
|
244
256
|
nesting = lexical_nesting&.map { |name| normalize_constant(name) }&.freeze
|
|
245
257
|
edges << Edge.new(
|
|
246
258
|
type,
|
|
@@ -254,7 +266,8 @@ module ArchSpec
|
|
|
254
266
|
resolved_to,
|
|
255
267
|
resolved_receiver,
|
|
256
268
|
receiver_scope,
|
|
257
|
-
resolved_method
|
|
269
|
+
resolved_method,
|
|
270
|
+
producer
|
|
258
271
|
)
|
|
259
272
|
end
|
|
260
273
|
|
|
@@ -273,6 +286,14 @@ module ArchSpec
|
|
|
273
286
|
@constants_by_name[normalize_constant(name)]
|
|
274
287
|
end
|
|
275
288
|
|
|
289
|
+
# Classes and modules that include or prepend the named module.
|
|
290
|
+
def consumers_of(name)
|
|
291
|
+
absolute = "::#{normalize_constant(name)}"
|
|
292
|
+
constants.select do |constant|
|
|
293
|
+
!constant.namespace_only && constant.mixins.values_at(:include, :prepend).any? { |names| names.include?(absolute) }
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
276
297
|
def constants_for_path(path)
|
|
277
298
|
constants.select { |constant| constant.path == path }
|
|
278
299
|
end
|
|
@@ -281,13 +302,28 @@ module ArchSpec
|
|
|
281
302
|
component = components[name.to_sym]
|
|
282
303
|
return [] unless component
|
|
283
304
|
|
|
284
|
-
constants_for_component(name).flat_map(&:method_definitions)
|
|
305
|
+
expose_method_definitions(constants_for_component(name).flat_map(&:method_definitions))
|
|
285
306
|
end
|
|
286
307
|
|
|
287
308
|
# Every method definition in the graph, across all constants. Used by
|
|
288
309
|
# project-wide naming rules that are not scoped to one component.
|
|
289
310
|
def method_definitions
|
|
290
|
-
constants.flat_map(&:method_definitions)
|
|
311
|
+
expose_method_definitions(constants.flat_map(&:method_definitions))
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# A module can supply an API on another object's class side while its
|
|
315
|
+
# actual instance methods remain available for ordinary Ruby lookup.
|
|
316
|
+
def expose_instance_methods(name, as_owner:, scope:)
|
|
317
|
+
@method_exposures[name] = [as_owner, scope]
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def method_exposure(name)
|
|
321
|
+
@method_exposures[name]
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def clear_method_caches
|
|
325
|
+
@effective_definition_cache.clear
|
|
326
|
+
@effective_method_cache.clear
|
|
291
327
|
end
|
|
292
328
|
|
|
293
329
|
def assign_components(component_specs)
|
|
@@ -312,7 +348,7 @@ module ArchSpec
|
|
|
312
348
|
constants.each do |constant|
|
|
313
349
|
matched_file = files_matched_by_pattern.include?(constant.path) && !defers_to_real_definition?(constant)
|
|
314
350
|
matched_constant = spec.matches_constant?(constant.name)
|
|
315
|
-
matched_ancestor = spec.matching_ancestor(constant.name, self)
|
|
351
|
+
matched_ancestor = !excluded_files.include?(constant.path) && spec.matching_ancestor(constant.name, self)
|
|
316
352
|
next unless matched_file || matched_constant || matched_ancestor
|
|
317
353
|
|
|
318
354
|
component.add_file(constant.path, reason: "defines #{constant.name}") if matched_constant || matched_ancestor
|
|
@@ -411,7 +447,11 @@ module ArchSpec
|
|
|
411
447
|
ancestors = Set.new
|
|
412
448
|
unresolved = Set.new
|
|
413
449
|
nodes.each do |node|
|
|
414
|
-
mixins = scope == :class
|
|
450
|
+
mixins = if scope == :class
|
|
451
|
+
node.mixins[:singleton_prepend] | node.mixins[:extend]
|
|
452
|
+
else
|
|
453
|
+
node.mixins[:prepend] | node.mixins[:include]
|
|
454
|
+
end
|
|
415
455
|
mixins.each do |ancestor|
|
|
416
456
|
resolved = resolve_constant_reference(ancestor, node.name, lexical_nesting: [node.name] + node.nesting)
|
|
417
457
|
ancestors.add(resolved)
|
|
@@ -461,9 +501,8 @@ module ArchSpec
|
|
|
461
501
|
visible_names = Set.new
|
|
462
502
|
unresolved = Set.new
|
|
463
503
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
end
|
|
504
|
+
prepend_kind = scope == :class ? :singleton_prepend : :prepend
|
|
505
|
+
append_mixin_definitions(nodes, prepend_kind, definitions, visible_names, unresolved, visited)
|
|
467
506
|
|
|
468
507
|
own = nodes.flat_map { |node| node.method_definitions.select { |definition| definition.scope == scope } }
|
|
469
508
|
append_visible_definitions(definitions, visible_names, own)
|
|
@@ -555,6 +594,13 @@ module ArchSpec
|
|
|
555
594
|
|
|
556
595
|
private
|
|
557
596
|
|
|
597
|
+
def expose_method_definitions(definitions)
|
|
598
|
+
definitions.map do |definition|
|
|
599
|
+
exposure = @method_exposures[definition.owner] if definition.scope == :instance
|
|
600
|
+
exposure ? definition.with(owner: exposure.first, scope: exposure.last) : definition
|
|
601
|
+
end
|
|
602
|
+
end
|
|
603
|
+
|
|
558
604
|
def append_mixin_definitions(nodes, kind, definitions, visible_names, unresolved, visited)
|
|
559
605
|
mixins = nodes.flat_map do |node|
|
|
560
606
|
node.mixins[kind].map { |ancestor| [node, ancestor] }
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ArchSpec
|
|
4
|
+
# A named source of runtime facts. <tt>archspec reflect</tt> runs each
|
|
5
|
+
# producer declared in +Archspec.rb+ in its own process: it boots the
|
|
6
|
+
# framework, analyzes the source, lets the producer record what it observes
|
|
7
|
+
# through a Facts::Builder, and writes <tt><facts>/<name>.yml</tt>.
|
|
8
|
+
#
|
|
9
|
+
# ArchSpec.producer :widgets do
|
|
10
|
+
# inputs "config/widgets.yml" # global: a change invalidates every fact
|
|
11
|
+
# sources "app/widgets/**/*.rb" # per file: a change invalidates that file's facts
|
|
12
|
+
# env "WIDGETS_ENV" # must match when a check sets it
|
|
13
|
+
#
|
|
14
|
+
# boot { |root| require File.join(root, "config/environment") }
|
|
15
|
+
#
|
|
16
|
+
# capture do |facts|
|
|
17
|
+
# Widgets.registry.each do |widget, service|
|
|
18
|
+
# facts.reference(source: widget, target: service, location: widget.declaration_site)
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# Checks never run producers; they read the files they wrote.
|
|
24
|
+
class Producer
|
|
25
|
+
attr_reader :name, :input_patterns, :source_patterns, :env_names
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
def registry
|
|
29
|
+
@registry ||= {}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def register(name, &block)
|
|
33
|
+
registry[name.to_s] = new(name, &block)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Requires the file a declaration names, then returns the registered
|
|
37
|
+
# producer. Paths starting with a dot resolve against +Archspec.rb+.
|
|
38
|
+
def load(name, definition)
|
|
39
|
+
path = definition.producers.fetch(name.to_s) do
|
|
40
|
+
raise Error, "producer #{name} is not declared; add `reflect :#{name}` to Archspec.rb"
|
|
41
|
+
end
|
|
42
|
+
if path
|
|
43
|
+
require(path.start_with?('.') ? File.expand_path(path, definition.base_dir || Dir.pwd) : path)
|
|
44
|
+
end
|
|
45
|
+
registry.fetch(name.to_s) do
|
|
46
|
+
raise Error, "no producer named #{name} is registered#{" by #{path}" if path}"
|
|
47
|
+
end
|
|
48
|
+
rescue Error
|
|
49
|
+
raise
|
|
50
|
+
rescue StandardError, ScriptError => error
|
|
51
|
+
# A syntax error's message already names the file and line.
|
|
52
|
+
frame = origin(error, definition.base_dir || Dir.pwd) unless error.is_a?(SyntaxError)
|
|
53
|
+
raise Error, "could not load producer #{name}: #{error.message.strip} (#{error.class})#{frame}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The first backtrace frame in the project, which is usually where the
|
|
57
|
+
# fix belongs.
|
|
58
|
+
def origin(error, root)
|
|
59
|
+
frames = Array(error.backtrace)
|
|
60
|
+
frame = frames.find { |line| line.start_with?("#{root}/") && !line.include?('/vendor/') } || frames.first
|
|
61
|
+
frame ? "\n at #{frame.delete_prefix("#{root}/")}" : ''
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def initialize(name, &block)
|
|
66
|
+
@name = name.to_s
|
|
67
|
+
@input_patterns = Facts::DEFAULT_INPUTS.dup
|
|
68
|
+
@source_patterns = []
|
|
69
|
+
@env_names = []
|
|
70
|
+
block.arity == 1 ? yield(self) : instance_eval(&block) if block
|
|
71
|
+
raise Error, "producer #{@name} has no capture block" unless @capture
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Files outside the analyzed source whose change invalidates the whole
|
|
75
|
+
# document. The defaults cover +config/+, the Gemfile and lockfile,
|
|
76
|
+
# gemspecs, and the Ruby version.
|
|
77
|
+
def inputs(*patterns)
|
|
78
|
+
@input_patterns |= patterns.flatten.map(&:to_s)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Analyzed files this producer inspects. A changed, added, or removed file
|
|
82
|
+
# invalidates only the facts it contributed. Files the facts cite are
|
|
83
|
+
# always covered.
|
|
84
|
+
def sources(*patterns)
|
|
85
|
+
@source_patterns |= patterns.flatten.map(&:to_s)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Environment variables whose values shape what the producer observes.
|
|
89
|
+
# They are recorded as captured, so do not name secrets.
|
|
90
|
+
def env(*names)
|
|
91
|
+
@env_names |= names.flatten.map(&:to_s)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def boot(&block)
|
|
95
|
+
@boot = block
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def capture(&block)
|
|
99
|
+
@capture = block
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Boots, analyzes, and captures; returns the validated document.
|
|
103
|
+
def run(definition, root)
|
|
104
|
+
begin
|
|
105
|
+
instance_exec(root, &@boot) if @boot
|
|
106
|
+
rescue Error
|
|
107
|
+
raise
|
|
108
|
+
rescue StandardError, ScriptError => error
|
|
109
|
+
raise Error, "could not boot: #{error.message.strip} (#{error.class})#{origin(error, root)}"
|
|
110
|
+
end
|
|
111
|
+
graph = Analyzer.analyze(definition, root: root, include_facts: false)
|
|
112
|
+
raise Error, 'cannot reflect source with syntax errors' if graph.files.values.any? { |file| file.parse_errors.any? }
|
|
113
|
+
|
|
114
|
+
facts = Facts::Builder.new(graph, producer: name)
|
|
115
|
+
begin
|
|
116
|
+
instance_exec(facts, &@capture)
|
|
117
|
+
rescue Error
|
|
118
|
+
raise
|
|
119
|
+
rescue StandardError, ScriptError => error
|
|
120
|
+
raise Error, "capture failed: #{error.message.strip} (#{error.class})#{origin(error, root)}"
|
|
121
|
+
end
|
|
122
|
+
facts.to_document(facts_path: definition.facts_path, inputs: input_patterns, sources: source_patterns,
|
|
123
|
+
env: env_names.filter_map { |variable| [variable, ENV[variable]] if ENV[variable] }.to_h)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def origin(error, root)
|
|
129
|
+
self.class.origin(error, root)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Registers a facts producer. See ArchSpec::Producer.
|
|
134
|
+
def self.producer(name, &block)
|
|
135
|
+
Producer.register(name, &block)
|
|
136
|
+
end
|
|
137
|
+
end
|