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,223 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ArchSpec
|
|
4
|
+
module Facts
|
|
5
|
+
# Framework-neutral facts, established by source semantics or by reflection.
|
|
6
|
+
# Locations retain the original evidence, even when it lives in a different
|
|
7
|
+
# file from the owner.
|
|
8
|
+
#
|
|
9
|
+
# Owners, sources, and targets can be graph ConstantNodes, constant names,
|
|
10
|
+
# or the runtime classes and modules themselves. Locations can be
|
|
11
|
+
# SourceLocations, <tt>[path, line]</tt> pairs such as
|
|
12
|
+
# <tt>Module#const_source_location</tt> returns, backtrace locations a macro
|
|
13
|
+
# recorded with +caller_locations+, or anything with a +source_location+:
|
|
14
|
+
#
|
|
15
|
+
# facts.reference(source: Invoice, target: Customer, location: ["app/models/invoice.rb", 3])
|
|
16
|
+
# facts.methods(owner: "Invoice", names: %w[customer customer=], location: declaration_site)
|
|
17
|
+
#
|
|
18
|
+
# Use #apply for an in-memory semantic pass, or #to_document and Facts.write
|
|
19
|
+
# for an explicit external producer. Both routes use the same validation and
|
|
20
|
+
# application code. Building facts never mutates the graph.
|
|
21
|
+
class Builder
|
|
22
|
+
MODULE_NAME = Module.instance_method(:name)
|
|
23
|
+
|
|
24
|
+
attr_reader :graph, :producer
|
|
25
|
+
|
|
26
|
+
def initialize(graph, producer:)
|
|
27
|
+
@graph = graph
|
|
28
|
+
@producer = producer.to_s
|
|
29
|
+
@entries = LIST_KEYS.to_h { |key| [key, []] }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def reference(source:, target:, location:)
|
|
33
|
+
location = location_of(location)
|
|
34
|
+
source = constant(source, location)
|
|
35
|
+
add('references', entry_for(source, location).merge('source' => source.name, 'target' => name_of(target)))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def methods(owner:, names:, location:, scope: :instance, visibility: :public,
|
|
39
|
+
signatures: [], alias_target: nil, mode: :if_missing, installation: nil)
|
|
40
|
+
unless alias_target.nil? || alias_target.is_a?(String) || alias_target.is_a?(Symbol)
|
|
41
|
+
raise Error, 'alias target must be a String or Symbol'
|
|
42
|
+
end
|
|
43
|
+
location = location_of(location)
|
|
44
|
+
owner = constant(owner, location)
|
|
45
|
+
entry = entry_for(owner, location).merge('owner' => owner.name, 'names' => Array(names).map(&:to_s),
|
|
46
|
+
'scope' => scope.to_s, 'visibility' => visibility.to_s, 'mode' => mode.to_s,
|
|
47
|
+
'signatures' => signatures.map do |signature|
|
|
48
|
+
signature.to_h.transform_keys(&:to_s).transform_values do |value|
|
|
49
|
+
value.is_a?(Array) ? value.map(&:to_s) : value
|
|
50
|
+
end
|
|
51
|
+
end)
|
|
52
|
+
entry['alias_target'] = alias_target.to_s unless alias_target.nil?
|
|
53
|
+
entry['installation'] = location_for(location_of(installation)) if installation
|
|
54
|
+
add('methods', entry)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Preserve a source definition's metadata while installing it on an owner.
|
|
58
|
+
# An installation site gives callback definitions their Ruby precedence:
|
|
59
|
+
# later local definitions win, earlier definitions are replaced.
|
|
60
|
+
def method_definition(owner:, definition:, installation: nil)
|
|
61
|
+
methods(owner: owner, names: [definition.name], scope: definition.scope, location: definition.location,
|
|
62
|
+
visibility: definition.visibility, signatures: definition.signatures, alias_target: definition.alias_target,
|
|
63
|
+
mode: installation ? :define : :append, installation: installation)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Emit mixins in installation order, including singleton prepends.
|
|
67
|
+
def mixin(owner:, target:, kind:, location:)
|
|
68
|
+
location = location_of(location)
|
|
69
|
+
owner = constant(owner, location)
|
|
70
|
+
add('mixins', entry_for(owner, location).merge('owner' => owner.name,
|
|
71
|
+
'target' => name_of(target), 'kind' => kind.to_s))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Bind an existing receiverless call without changing its lexical owner.
|
|
75
|
+
# Emit every consumer of a shared callback before applying the batch.
|
|
76
|
+
def bind_receiver(edge:, receiver:, scope:)
|
|
77
|
+
source = @graph.constants_named(edge.from_constant).find { |node| node.path == edge.from_path }
|
|
78
|
+
raise Error, 'receiver fact requires a call owned by an analyzed constant' unless source
|
|
79
|
+
|
|
80
|
+
add('receivers', entry_for(source, edge.location).merge('source' => source.name,
|
|
81
|
+
'name' => edge.to, 'receiver' => name_of(receiver), 'scope' => scope.to_s))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def expose_methods(source:, owner:, scope:)
|
|
85
|
+
source = constant(source, nil)
|
|
86
|
+
entry = entry_for(source, source.location).merge('source' => source.name,
|
|
87
|
+
'owner' => name_of(owner), 'scope' => scope.to_s)
|
|
88
|
+
owners = @graph.constants_named(name_of(owner)).map { |node| relative(node.path) }.uniq - [entry['source_path']]
|
|
89
|
+
entry['depends_on'] = owners.sort unless owners.empty?
|
|
90
|
+
add('exposures', entry)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def gap(source:, message:, location:)
|
|
94
|
+
location = location_of(location)
|
|
95
|
+
source = constant(source, location)
|
|
96
|
+
add('gaps', entry_for(source, location).merge('source' => source.name, 'message' => message))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Settle a static analysis gap this producer observed, such as a
|
|
100
|
+
# conditional concern callback. The location is the gap's own location.
|
|
101
|
+
# Pass the +consumers+ you inspected when the gap is about a module's
|
|
102
|
+
# includers: their files are fingerprinted, and a check reports any
|
|
103
|
+
# includer added later instead of keeping the gap settled.
|
|
104
|
+
def resolve(source:, location:, message: nil, consumers: nil)
|
|
105
|
+
location = location_of(location)
|
|
106
|
+
source = constant(source, location)
|
|
107
|
+
entry = entry_for(source, location).merge('source' => source.name)
|
|
108
|
+
entry['message'] = message if message
|
|
109
|
+
if consumers
|
|
110
|
+
nodes = consumers.map { |consumer| constant(consumer, nil) }
|
|
111
|
+
entry['consumers'] = nodes.map(&:name).uniq.sort
|
|
112
|
+
paths = nodes.map { |node| relative(node.path) }.uniq - [entry['source_path'], entry['path']]
|
|
113
|
+
entry['depends_on'] = paths.sort unless paths.empty?
|
|
114
|
+
end
|
|
115
|
+
add('resolves', entry)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def apply
|
|
119
|
+
document = Document.new(@graph, facts, @producer)
|
|
120
|
+
Application.apply(@graph, [document.compile.with(producer: nil)])
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Validate before publication, so malformed evidence cannot replace a
|
|
124
|
+
# producer's previous snapshot. +inputs+ are global files whose change
|
|
125
|
+
# invalidates the whole document; +sources+ are patterns of analyzed
|
|
126
|
+
# files whose change invalidates the facts they cite. +env+ records
|
|
127
|
+
# environment variables that must match when a check sets them.
|
|
128
|
+
def to_document(facts_path: 'archspec_facts', inputs: DEFAULT_INPUTS, sources: [], env: {})
|
|
129
|
+
cited = @entries.values.flatten.flat_map do |entry|
|
|
130
|
+
[entry['path'], entry['source_path'], entry.dig('installation', 'path'), *entry['depends_on']]
|
|
131
|
+
end.compact.uniq.map { |path| File.expand_path(path, @graph.root) }
|
|
132
|
+
document = { 'version' => VERSION, 'producer' => @producer,
|
|
133
|
+
'env' => env.to_h { |name, value| [name.to_s, value.to_s] },
|
|
134
|
+
'inputs' => inputs.map(&:to_s), 'sources' => sources.map(&:to_s),
|
|
135
|
+
'snapshot' => Facts.fingerprint(@graph, inputs: inputs, sources: sources, cited: cited,
|
|
136
|
+
excluding: facts_path) }.merge(facts)
|
|
137
|
+
reader = Document.new(@graph, document, @producer)
|
|
138
|
+
Application.validate(@graph, [reader.compile])
|
|
139
|
+
document
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def count(key)
|
|
143
|
+
@entries.fetch(key.to_s).size
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private
|
|
147
|
+
|
|
148
|
+
def add(key, entry)
|
|
149
|
+
@entries[key] << entry
|
|
150
|
+
entry
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def facts
|
|
154
|
+
{ 'version' => VERSION, 'producer' => @producer }.merge(@entries.transform_values(&:uniq))
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def constant(value, location)
|
|
158
|
+
return value if value.is_a?(ConstantNode)
|
|
159
|
+
|
|
160
|
+
name = name_of(value)
|
|
161
|
+
nodes = @graph.constants_named(name)
|
|
162
|
+
raise Error, "#{name} is not an analyzed constant" if nodes.empty?
|
|
163
|
+
|
|
164
|
+
candidates = [
|
|
165
|
+
nodes.select { |node| location && node.path == location.path },
|
|
166
|
+
nodes.reject(&:namespace_only),
|
|
167
|
+
nodes
|
|
168
|
+
]
|
|
169
|
+
match = candidates.find(&:one?)
|
|
170
|
+
raise Error, "#{name} is defined in several files; pass its graph node instead" unless match
|
|
171
|
+
|
|
172
|
+
match.first
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def name_of(value)
|
|
176
|
+
name = case value
|
|
177
|
+
when Module then MODULE_NAME.bind_call(value)
|
|
178
|
+
when ConstantNode then value.name
|
|
179
|
+
else value.to_s
|
|
180
|
+
end
|
|
181
|
+
raise Error, 'facts cannot name an anonymous class or module' if name.nil? || name.empty?
|
|
182
|
+
|
|
183
|
+
name.delete_prefix('::')
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def location_of(value)
|
|
187
|
+
value = value.source_location if value.respond_to?(:source_location)
|
|
188
|
+
value = [value.path, value.lineno] if value.respond_to?(:lineno) && value.respond_to?(:path)
|
|
189
|
+
case value
|
|
190
|
+
when SourceLocation
|
|
191
|
+
value
|
|
192
|
+
when Array
|
|
193
|
+
path, line = value
|
|
194
|
+
raise Error, "invalid location #{value.inspect}" unless path && line.is_a?(Integer) && line.positive?
|
|
195
|
+
|
|
196
|
+
line_location(File.expand_path(path.to_s, @graph.root), line)
|
|
197
|
+
else
|
|
198
|
+
raise Error, "invalid location #{value.inspect}"
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# A line given without columns spans its code, so diagnostics underline
|
|
203
|
+
# the declaration rather than the indentation before it.
|
|
204
|
+
def line_location(path, line)
|
|
205
|
+
text = File.file?(path) ? File.foreach(path).drop(line - 1).first.to_s.chomp : ''
|
|
206
|
+
start = (text.index(/\S/) || 0) + 1
|
|
207
|
+
SourceLocation.new(path, line, start, line, [text.rstrip.bytesize + 1, start].max)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def entry_for(source, location)
|
|
211
|
+
location_for(location).merge('source_path' => relative(source.path))
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def location_for(location)
|
|
215
|
+
location.to_h.transform_keys(&:to_s).merge('path' => relative(location.path))
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def relative(path)
|
|
219
|
+
Pathname(path).relative_path_from(Pathname(@graph.root)).to_s
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ArchSpec
|
|
4
|
+
module Facts
|
|
5
|
+
# Decodes a complete batch before any graph mutation. Both the in-memory
|
|
6
|
+
# builder and external YAML documents must satisfy this contract.
|
|
7
|
+
class Document
|
|
8
|
+
LOCATION_KEYS = %w[path line column end_line end_column].freeze
|
|
9
|
+
ENTRY_LOCATION_KEYS = LOCATION_KEYS + %w[source_path depends_on]
|
|
10
|
+
LEGACY_LIST_KEYS = %w[references methods gaps].freeze
|
|
11
|
+
LEGACY_ENVELOPE_KEYS = %w[version producer environment snapshot].freeze
|
|
12
|
+
ENVELOPE_KEYS = %w[version producer env inputs sources snapshot].freeze
|
|
13
|
+
METHOD_KEYS = %w[visibility signatures alias_target mode installation].freeze
|
|
14
|
+
SCOPES = %w[instance class].freeze
|
|
15
|
+
|
|
16
|
+
def initialize(graph, document, label)
|
|
17
|
+
@graph = graph
|
|
18
|
+
@document = document
|
|
19
|
+
@label = label
|
|
20
|
+
@lines = {}
|
|
21
|
+
@stale = Set.new
|
|
22
|
+
@removed = Set.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Compiles an imported document, or returns nil when it no longer
|
|
26
|
+
# describes the source. Stale files are reported and their facts skipped.
|
|
27
|
+
def compile_fresh(excluding:)
|
|
28
|
+
validate_envelope
|
|
29
|
+
invalid('version 2 facts require a snapshot') if @document['version'] != 1 && @document['snapshot'].nil?
|
|
30
|
+
reason = @document['version'] == 1 ? legacy_staleness(excluding) : staleness(excluding)
|
|
31
|
+
if reason
|
|
32
|
+
stale_diagnostic(@label, "#{producer} facts are out of date: #{reason}; run `archspec reflect`")
|
|
33
|
+
return
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
@stale.each do |path|
|
|
37
|
+
stale_diagnostic(path, "#{producer} facts are out of date for this file; run `archspec reflect`")
|
|
38
|
+
end
|
|
39
|
+
compile
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def compile
|
|
43
|
+
validate_envelope
|
|
44
|
+
Operations.new(*LIST_KEYS.map do |key|
|
|
45
|
+
Array(@document[key]).reject { |entry| stale_entry?(entry) }
|
|
46
|
+
.flat_map do |entry|
|
|
47
|
+
validate_dependencies(entry)
|
|
48
|
+
public_send("decode_#{key}", entry)
|
|
49
|
+
end
|
|
50
|
+
end, producer)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def decode_references(entry)
|
|
54
|
+
validate_entry(entry, %w[source target])
|
|
55
|
+
Reference.new(source_for(entry, 'source'), constant_name(entry['target']), location_for(entry))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def decode_methods(entry)
|
|
59
|
+
validate_entry(entry, %w[owner scope names] + (@document['version'] == 1 ? [] : METHOD_KEYS))
|
|
60
|
+
owner = source_for(entry, 'owner')
|
|
61
|
+
location = location_for(entry)
|
|
62
|
+
scope = enum(entry['scope'], SCOPES, 'method scope')
|
|
63
|
+
names = string_list(entry['names'], 'method names', nonempty: true)
|
|
64
|
+
visibility = enum(entry.fetch('visibility', 'public'), %w[public protected private], 'method visibility')
|
|
65
|
+
mode = enum(entry.fetch('mode', 'if_missing'), %w[if_missing append define], 'method mode')
|
|
66
|
+
installation = entry['installation']
|
|
67
|
+
if mode == :define
|
|
68
|
+
invalid('define mode requires an installation location') unless installation.is_a?(Hash)
|
|
69
|
+
invalid('invalid installation fields') unless (installation.keys - LOCATION_KEYS).empty?
|
|
70
|
+
installation = location_for(installation)
|
|
71
|
+
elsif !installation.nil?
|
|
72
|
+
invalid('installation requires define mode')
|
|
73
|
+
end
|
|
74
|
+
signatures = entry.fetch('signatures', [])
|
|
75
|
+
invalid('expected a list of signatures') unless signatures.is_a?(Array)
|
|
76
|
+
signatures = signatures.map { |signature| signature_for(signature) }
|
|
77
|
+
alias_target = entry['alias_target']
|
|
78
|
+
string(alias_target, 'alias target') unless alias_target.nil?
|
|
79
|
+
names.map do |name|
|
|
80
|
+
definition = MethodDefinition.new(owner.name, name.to_sym, scope, location, visibility, signatures, alias_target&.to_sym)
|
|
81
|
+
Method.new(owner, definition, mode, installation)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def decode_mixins(entry)
|
|
86
|
+
validate_entry(entry, %w[owner target kind])
|
|
87
|
+
Mixin.new(source_for(entry, 'owner'), constant_name(entry['target']),
|
|
88
|
+
enum(entry['kind'], %w[include prepend extend singleton_prepend], 'mixin kind'), location_for(entry))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def decode_receivers(entry)
|
|
92
|
+
validate_entry(entry, %w[source name receiver scope])
|
|
93
|
+
source = source_for(entry, 'source')
|
|
94
|
+
name = string(entry['name'], 'call name')
|
|
95
|
+
location = location_for(entry)
|
|
96
|
+
unless receiverless_calls.include?([source.name, source.path, name, location])
|
|
97
|
+
invalid('receiver binding must identify an existing receiverless call with its complete source range')
|
|
98
|
+
end
|
|
99
|
+
Receiver.new(source, name, location, constant_name(entry['receiver']), enum(entry['scope'], SCOPES, 'receiver scope'))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def decode_exposures(entry)
|
|
103
|
+
validate_entry(entry, %w[source owner scope])
|
|
104
|
+
source = source_for(entry, 'source')
|
|
105
|
+
location_for(entry)
|
|
106
|
+
invalid('method exposure source must be a module') unless source.module?
|
|
107
|
+
owner = constant_name(entry['owner'])
|
|
108
|
+
invalid("method exposure owner #{owner} is not analyzed") if @graph.constants_named(owner).empty?
|
|
109
|
+
Exposure.new(source, owner, enum(entry['scope'], SCOPES, 'exposure scope'))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def decode_gaps(entry)
|
|
113
|
+
validate_entry(entry, %w[source message])
|
|
114
|
+
Gap.new(source_for(entry, 'source'), string(entry['message'], 'gap message'), location_for(entry))
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def decode_resolves(entry)
|
|
118
|
+
validate_entry(entry, %w[source message consumers])
|
|
119
|
+
source = source_for(entry, 'source')
|
|
120
|
+
location = location_for(entry)
|
|
121
|
+
message = entry['message'].nil? ? nil : string(entry['message'], 'gap message')
|
|
122
|
+
return [] if new_consumers?(source, entry['consumers'])
|
|
123
|
+
|
|
124
|
+
unless @graph.edges.any? do |edge|
|
|
125
|
+
edge.type == :dynamic_feature && edge.from_constant == source.name && edge.from_path == source.path &&
|
|
126
|
+
edge.location == location && (message.nil? || edge.to == message)
|
|
127
|
+
end
|
|
128
|
+
invalid('a resolution must identify an existing analysis gap with its complete source range')
|
|
129
|
+
end
|
|
130
|
+
Resolve.new(source, message, location)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def producer
|
|
136
|
+
@document['producer']
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def legacy_staleness(excluding)
|
|
140
|
+
return 'source or configuration changed' unless @document['snapshot'] == Facts.snapshot(@graph, excluding: excluding)
|
|
141
|
+
|
|
142
|
+
environment = @document['environment']
|
|
143
|
+
return unless environment && ENV['RAILS_ENV'] && ENV['RAILS_ENV'] != environment
|
|
144
|
+
|
|
145
|
+
"captured for RAILS_ENV=#{environment}, but RAILS_ENV is #{ENV['RAILS_ENV']}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Changes to global inputs, or to the recorded environment, invalidate
|
|
149
|
+
# the document. A changed, added, or removed analyzed file only
|
|
150
|
+
# invalidates the facts that cite it.
|
|
151
|
+
def staleness(excluding)
|
|
152
|
+
(@document['env'] || {}).each do |name, value|
|
|
153
|
+
current = ENV.fetch(name, nil)
|
|
154
|
+
return "captured with #{name}=#{value}, but it is #{current}" if current && current != value
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
recorded = @document['snapshot'] || {}
|
|
158
|
+
inputs = Facts.input_files(@graph, @document['inputs'] || [], excluding: excluding)
|
|
159
|
+
sources = Facts.source_files(@graph, @document['sources'] || [])
|
|
160
|
+
candidates = recorded.keys.map { |path| File.expand_path(path, @graph.root) } | inputs | sources
|
|
161
|
+
root = Pathname(@graph.root)
|
|
162
|
+
candidates.sort.each do |path|
|
|
163
|
+
relative = Pathname(path).relative_path_from(root).to_s
|
|
164
|
+
next if recorded[relative] == Facts.digest(path)
|
|
165
|
+
return "#{relative} changed" if inputs.include?(path) || input_pattern?(relative)
|
|
166
|
+
|
|
167
|
+
# A covered file that is gone takes its facts with it; one that
|
|
168
|
+
# changed reports them as stale.
|
|
169
|
+
(@graph.files.key?(path) ? @stale : @removed) << path
|
|
170
|
+
end
|
|
171
|
+
nil
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def stale_diagnostic(path, message)
|
|
175
|
+
@graph.fact_diagnostics << Diagnostic.new(rule: STALE_RULE, message: message,
|
|
176
|
+
location: SourceLocation.point(File.expand_path(path), 1, 1),
|
|
177
|
+
evidence: Pathname(File.expand_path(@label)).relative_path_from(Pathname(@graph.root)).to_s)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def stale_entry?(entry)
|
|
181
|
+
return false if (@stale.empty? && @removed.empty?) || !entry.is_a?(Hash)
|
|
182
|
+
|
|
183
|
+
installation = entry['installation'].is_a?(Hash) ? entry['installation']['path'] : nil
|
|
184
|
+
dependencies = entry['depends_on'].is_a?(Array) ? entry['depends_on'] : []
|
|
185
|
+
[entry['path'], entry['source_path'], installation, *dependencies].any? do |path|
|
|
186
|
+
next false unless path.is_a?(String)
|
|
187
|
+
|
|
188
|
+
expanded = File.expand_path(path, @graph.root)
|
|
189
|
+
@stale.include?(expanded) || @removed.include?(expanded)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def input_pattern?(relative)
|
|
194
|
+
(@document['inputs'] || []).any? do |pattern|
|
|
195
|
+
File.fnmatch?(pattern, relative, File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Files an entry depends on beyond its own location, such as the owner
|
|
200
|
+
# of an exposure. They must be fingerprinted so an edit is noticed.
|
|
201
|
+
def validate_dependencies(entry)
|
|
202
|
+
return unless entry.is_a?(Hash) && entry.key?('depends_on')
|
|
203
|
+
|
|
204
|
+
paths = string_list(entry['depends_on'], 'dependencies')
|
|
205
|
+
snapshot = @document['snapshot']
|
|
206
|
+
missing = snapshot && paths.find { |path| !snapshot.key?(path) }
|
|
207
|
+
invalid("the snapshot does not fingerprint #{missing}") if missing
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# A resolution holds only for the consumers the producer inspected. A
|
|
211
|
+
# consumer added since then was never observed, so it is reported.
|
|
212
|
+
def new_consumers?(source, recorded)
|
|
213
|
+
return false if recorded.nil?
|
|
214
|
+
|
|
215
|
+
string_list(recorded, 'consumers')
|
|
216
|
+
added = @graph.consumers_of(source.name).reject { |node| recorded.include?(node.name) }
|
|
217
|
+
added.each do |node|
|
|
218
|
+
stale_diagnostic(node.path, "#{producer} facts have not observed this consumer of #{source.name}; " \
|
|
219
|
+
'run `archspec reflect`')
|
|
220
|
+
end
|
|
221
|
+
added.any?
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Only calls owned by constants that receiver facts name are indexed.
|
|
225
|
+
def receiverless_calls
|
|
226
|
+
@receiverless_calls ||= begin
|
|
227
|
+
sources = Array(@document['receivers']).filter_map { |entry| entry['source'].to_s.delete_prefix('::') if entry.is_a?(Hash) }.to_set
|
|
228
|
+
@graph.edges.each_with_object(Set.new) do |edge, calls|
|
|
229
|
+
next unless sources.include?(edge.from_constant) && edge.type == :calls_named_method && edge.receiver == :none
|
|
230
|
+
|
|
231
|
+
calls << [edge.from_constant, edge.from_path, edge.to, edge.location]
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def validate_envelope
|
|
237
|
+
unless @document.is_a?(Hash) && @document['version'].is_a?(Integer) && [1, VERSION].include?(@document['version'])
|
|
238
|
+
invalid("expected version 1 or #{VERSION}")
|
|
239
|
+
end
|
|
240
|
+
legacy = @document['version'] == 1
|
|
241
|
+
lists = legacy ? LEGACY_LIST_KEYS : LIST_KEYS
|
|
242
|
+
keys = (legacy ? LEGACY_ENVELOPE_KEYS : ENVELOPE_KEYS) + lists
|
|
243
|
+
invalid('unknown document fields') unless (@document.keys - keys).empty?
|
|
244
|
+
string(@document['producer'], 'producer')
|
|
245
|
+
unless @document['environment'].nil? || @document['environment'].is_a?(String)
|
|
246
|
+
invalid('expected an environment string')
|
|
247
|
+
end
|
|
248
|
+
invalid('expected fact lists') unless lists.all? { |key| @document[key].nil? || @document[key].is_a?(Array) }
|
|
249
|
+
validate_freshness_fields unless legacy
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def validate_freshness_fields
|
|
253
|
+
env = @document['env']
|
|
254
|
+
unless env.nil? || (env.is_a?(Hash) && env.all? { |key, value| key.is_a?(String) && value.is_a?(String) })
|
|
255
|
+
invalid('expected env to map variable names to strings')
|
|
256
|
+
end
|
|
257
|
+
%w[inputs sources].each do |key|
|
|
258
|
+
string_list(@document[key], "#{key} patterns") unless @document[key].nil?
|
|
259
|
+
end
|
|
260
|
+
snapshot = @document['snapshot']
|
|
261
|
+
return if snapshot.nil?
|
|
262
|
+
|
|
263
|
+
unless snapshot.is_a?(Hash) && snapshot.all? { |key, value| key.is_a?(String) && value.is_a?(String) }
|
|
264
|
+
invalid('expected a snapshot of file digests')
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def signature_for(value)
|
|
269
|
+
unless value.is_a?(Hash) && (value.keys - MethodSignature.members.map(&:to_s)).empty?
|
|
270
|
+
invalid('invalid method signature fields')
|
|
271
|
+
end
|
|
272
|
+
counts = %w[required optional].to_h do |key|
|
|
273
|
+
number = value.fetch(key, 0)
|
|
274
|
+
invalid("invalid signature #{key}") unless number.is_a?(Integer) && number >= 0
|
|
275
|
+
[key.to_sym, number]
|
|
276
|
+
end
|
|
277
|
+
flags = %w[rest keyword_rest block forward].to_h do |key|
|
|
278
|
+
flag = value.fetch(key, false)
|
|
279
|
+
invalid("invalid signature #{key}") unless flag == true || flag == false
|
|
280
|
+
[key.to_sym, flag]
|
|
281
|
+
end
|
|
282
|
+
keywords = %w[keywords optional_keywords].to_h do |key|
|
|
283
|
+
[key.to_sym, string_list(value.fetch(key, []), "signature #{key}").map(&:to_sym)]
|
|
284
|
+
end
|
|
285
|
+
invalid('required and optional keywords overlap') unless (keywords[:keywords] & keywords[:optional_keywords]).empty?
|
|
286
|
+
MethodSignature.new(
|
|
287
|
+
counts.fetch(:required),
|
|
288
|
+
counts.fetch(:optional),
|
|
289
|
+
flags.fetch(:rest),
|
|
290
|
+
keywords.fetch(:keywords),
|
|
291
|
+
keywords.fetch(:optional_keywords),
|
|
292
|
+
flags.fetch(:keyword_rest),
|
|
293
|
+
flags.fetch(:block),
|
|
294
|
+
flags.fetch(:forward)
|
|
295
|
+
)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def validate_entry(entry, keys)
|
|
299
|
+
invalid('invalid entry fields') unless entry.is_a?(Hash) && (entry.keys - keys - ENTRY_LOCATION_KEYS).empty?
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def enum(value, values, label)
|
|
303
|
+
invalid("invalid #{label}: expected #{values.join(', ')}") unless values.include?(value)
|
|
304
|
+
value.to_sym
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def string(value, label)
|
|
308
|
+
invalid("expected a nonempty #{label}") unless value.is_a?(String) && !value.empty?
|
|
309
|
+
value
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def string_list(value, label, nonempty: false)
|
|
313
|
+
unless value.is_a?(Array) && (!nonempty || !value.empty?) && value.all? { |item| item.is_a?(String) && !item.empty? }
|
|
314
|
+
invalid("invalid #{label}")
|
|
315
|
+
end
|
|
316
|
+
value
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def source_for(entry, key)
|
|
320
|
+
name = constant_name(entry[key])
|
|
321
|
+
source_path = project_path(entry['source_path'] || entry['path'])
|
|
322
|
+
source = @graph.constants_named(name).find { |node| node.path == source_path }
|
|
323
|
+
invalid("#{name} is not defined in #{entry['source_path'] || entry['path']}") unless source
|
|
324
|
+
source
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def constant_name(value)
|
|
328
|
+
unless value.is_a?(String) && /\A(?:::)?[[:upper:]][[:alnum:]_]*(?:::[[:upper:]][[:alnum:]_]*)*\z/.match?(value)
|
|
329
|
+
invalid('invalid constant name')
|
|
330
|
+
end
|
|
331
|
+
value.delete_prefix('::')
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Every cited file must be analyzed and, in a version 2 snapshot,
|
|
335
|
+
# fingerprinted, so a later edit to it is always detected.
|
|
336
|
+
def project_path(value)
|
|
337
|
+
invalid('invalid source path') unless value.is_a?(String) && !Pathname(value).absolute?
|
|
338
|
+
expanded = File.expand_path(value, @graph.root)
|
|
339
|
+
invalid("source path #{value} is not analyzed") unless @graph.files.key?(expanded)
|
|
340
|
+
snapshot = @document['snapshot']
|
|
341
|
+
if @document['version'] != 1 && snapshot && !snapshot.key?(value)
|
|
342
|
+
invalid("the snapshot does not fingerprint #{value}")
|
|
343
|
+
end
|
|
344
|
+
expanded
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def location_for(entry)
|
|
348
|
+
source = project_path(entry['path'])
|
|
349
|
+
line = entry['line']
|
|
350
|
+
column = entry.fetch('column', 1)
|
|
351
|
+
end_line = entry.fetch('end_line', line)
|
|
352
|
+
end_column = entry.fetch('end_column', column)
|
|
353
|
+
lines = @lines[source] ||= File.readlines(source)
|
|
354
|
+
valid = [line, column, end_line, end_column].all? { |value| value.is_a?(Integer) && value.positive? }
|
|
355
|
+
valid &&= line <= lines.size && end_line <= lines.size &&
|
|
356
|
+
([line, column] <=> [end_line, end_column]) <= 0 &&
|
|
357
|
+
column <= lines[line - 1].chomp.bytesize + 1 && end_column <= lines[end_line - 1].chomp.bytesize + 1
|
|
358
|
+
invalid('invalid source location') unless valid
|
|
359
|
+
SourceLocation.new(source, line, column, end_line, end_column)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def invalid(message)
|
|
363
|
+
raise Error, "invalid facts file #{@label}: #{message}"
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
end
|