archspec 1.1.0.rc2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
- output.puts " #{style.faint(gutter)} #{edge.verb} #{edge.to}"
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
- output.puts ::JSON.pretty_generate(
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
- output.puts "ArchSpec passed: #{graph.files.size} files, #{graph.constants.size} constants, " \
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)
@@ -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 ? node.mixins[:extend] : node.mixins[:prepend] | node.mixins[:include]
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
- if scope == :instance
465
- append_mixin_definitions(nodes, :prepend, definitions, visible_names, unresolved, visited)
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