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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8c675a4a926497ac7ff0228a2eebe90ad4eddb39463bdbbcd1732f60f078ba5e
4
- data.tar.gz: 2bbde647e0e899bba0a20f7953028506cfb0dcc756101a75bd4c49c11282624e
3
+ metadata.gz: bb5f7cda6a11326fedead006aa062155c7fdb2e1a3ea6e374d5cd1bca45d17e9
4
+ data.tar.gz: 417ad99fbfc27aa871fcb0bd4ce3ced72d8c1aad3810d35f51722586a151bf81
5
5
  SHA512:
6
- metadata.gz: c42af70bb7cfb04ad3f0feca6dc429b14bc1f9e55eb3b4001f4fd11aead8ca0061b22c14097def6b182d09435764e9c44e0132f02b4964718ba47fc64ee37544
7
- data.tar.gz: 3505aa0af7fc166ce59d316eda7dfbe4deb24dbede4bbbc507f4516b82d0090c9c8b3eba4f70c3379978be6a9f582c6bb742c9416ac0b63529bb5f68aa90eb1b
6
+ metadata.gz: 2dc335e5f43af785346921ca4b57e699a5b748d80e09441ac3abe6123ba7b17a730dba2a7c0f7c4c0c11b6288dc7e79ee87e95d8f9454a167df7d98a98de88d5
7
+ data.tar.gz: 8b5736b3d23a1c053f8e158636a8582184c9c924ce478e1019818b527960ada6c8d5d7ab4e581849564978ae4c4f05d4238c0a571bf8d78ef479fb6242bc3868
data/README.md CHANGED
@@ -18,7 +18,7 @@ Battle tested in [<picture><source media="(prefers-color-scheme: dark)" srcset="
18
18
 
19
19
  ---
20
20
 
21
- ArchSpec turns your architecture into executable checks: components, layers, constant references, inheritance, mixins, named method calls, method protocols, naming conventions, cycles, and Rails boundaries. It is plain static analysis: Rubydex builds the semantic index, Prism supplies a small syntax overlay, the app never boots, and no AI is involved in checking your code.
21
+ ArchSpec turns your architecture into executable checks: components, layers, constant references, inheritance, mixins, named method calls, method protocols, naming conventions, cycles, and Rails boundaries. Checking is plain static analysis: Rubydex builds the semantic index, Prism supplies a small syntax overlay, the app never boots during a check, and no AI is involved in checking your code.
22
22
 
23
23
  ## Why ArchSpec?
24
24
 
@@ -122,6 +122,7 @@ architecture :cqrs,
122
122
  ## What It Checks
123
123
 
124
124
  - **Dependencies:** allowed and forbidden references between components, in both directions
125
+ - **Rails reflection:** opt-in runtime capture of association targets, custom validators, and conditional concern effects, enforced by later static checks
125
126
  - **Privacy:** other components must go through a component's public API
126
127
  - **Concerns:** a concern must not depend on the classes that include it
127
128
  - **Layers:** dependency direction and cycles
@@ -177,6 +178,18 @@ bundle exec archspec check --update-todo
177
178
  bundle exec archspec explain app/models/user.rb
178
179
  ```
179
180
 
181
+ To check dependencies Rails resolves at runtime, such as association targets
182
+ and custom validators, add `reflect :rails` to `Archspec.rb` and run
183
+ `bundle exec archspec reflect --environment test`. It boots the app once, in a
184
+ separate process, and saves what Rails resolved. Checks read those facts
185
+ without booting anything, and fail with `facts.stale` for files edited since.
186
+ Locally, `archspec check --reflect` refreshes stale facts before checking.
187
+ See the [Rails reflection guide](https://archspecrb.dev/association-reflection/).
188
+
189
+ Other frameworks and macro libraries can supply facts with a small producer
190
+ that the same command runs. See the
191
+ [framework integration guide](https://archspecrb.dev/framework-integrations/).
192
+
180
193
  `explain` shows why a file or constant belongs to a component, its resolved
181
194
  ancestry, outgoing facts, incoming dependencies, and anything the analysis
182
195
  could not prove:
@@ -236,6 +249,13 @@ todo "archspec_todo.yml"
236
249
  bundle exec archspec check --update-todo
237
250
  ```
238
251
 
252
+ In CI, add `--check-todo` so a fixed violation that is still listed fails the
253
+ build instead of rotting in the file:
254
+
255
+ ```sh
256
+ bundle exec archspec check --check-todo
257
+ ```
258
+
239
259
  Use local suppressions for deliberate exceptions:
240
260
 
241
261
  ```ruby
@@ -3,43 +3,57 @@
3
3
  require 'prism'
4
4
  require 'set'
5
5
 
6
+ require_relative 'sources/ruby'
7
+ require_relative 'sources/erb'
8
+
6
9
  module ArchSpec
7
10
  module Analyzer
8
11
  extend self
9
12
 
10
- def analyze(definition, root:)
13
+ SOURCES = [Sources::Ruby, Sources::Erb].freeze
14
+
15
+ def analyze(definition, root:, include_facts: true)
11
16
  root = File.expand_path(root)
12
17
  graph = Graph.new(root)
13
- paths = ruby_files(definition, root)
14
18
  syntax = SyntaxOverlay.new
15
-
16
- paths.each do |path|
17
- result = Prism.parse_file(path)
18
- graph.add_file(
19
- path: path,
20
- parse_errors: parse_errors_for(path, result.errors),
21
- suppressions: suppressions_for(result.comments)
22
- )
23
- syntax.scan(path, result.value) if result.value
19
+ index_paths = []
20
+
21
+ all_extensions = SOURCES.flat_map(&:extensions)
22
+ paths = source_files(definition, root, all_extensions)
23
+ SOURCES.each do |source_class|
24
+ source_class_paths = paths.select { |path| path.end_with?(*source_class.extensions) }
25
+ index_paths.concat(source_class_paths) if source_class.rubydex_indexable?
26
+
27
+ source_class_paths.each do |path|
28
+ source = source_class.new(path)
29
+ graph.add_file(
30
+ path: source.path,
31
+ parse_errors: source.parse_errors,
32
+ suppressions: SuppressionParser.parse(source.comments)
33
+ )
34
+ syntax.scan(source.path, source.prism)
35
+ end
24
36
  end
25
37
 
26
38
  method_names = definition.rules.grep(Rules::CannotCallRule).flat_map(&:method_names)
27
- index = RubydexIndex.new(paths, syntax: syntax, method_names: method_names)
39
+ index = RubydexIndex.new(index_paths, syntax: syntax, method_names: method_names)
28
40
  index.populate(graph)
29
41
  syntax.apply(graph, call_resolutions: index.call_resolutions)
42
+ syntax.apply_concerns(graph)
43
+ Facts.load_into(graph, definition.facts_path, producers: definition.declared_producers) if include_facts && definition.facts_path
30
44
  graph.assign_components(definition.component_specs.values)
31
45
  graph
32
46
  end
33
47
 
34
48
  private
35
49
 
36
- def ruby_files(definition, root)
50
+ def source_files(definition, root, extensions)
37
51
  ignored = ignored_files(definition, root)
38
52
 
39
53
  definition.analysis_patterns.flat_map do |pattern|
40
54
  Dir.glob(File.absolute_path(pattern, root))
41
55
  end.select do |path|
42
- File.file?(path) && path.end_with?('.rb')
56
+ File.file?(path) && path.end_with?(*extensions)
43
57
  end.map do |path|
44
58
  File.expand_path(path)
45
59
  end.uniq.reject do |path|
@@ -48,19 +62,26 @@ module ArchSpec
48
62
  end
49
63
 
50
64
  def ignored_files(definition, root)
51
- definition.ignore_patterns.flat_map do |pattern|
52
- Dir.glob(File.absolute_path(pattern, root))
53
- end.select { |path| File.file?(path) }.map { |path| File.expand_path(path) }.to_set
65
+ glob_files(definition.ignore_patterns, root) - claimed_files(definition, root)
54
66
  end
55
67
 
56
- def suppressions_for(comments)
57
- SuppressionParser.parse(comments)
68
+ # Files a component pattern explicitly claims inside a default-ignored
69
+ # directory, minus its +except:+ matches and anything the user ignored.
70
+ def claimed_files(definition, root)
71
+ claimed = definition.component_specs.values.flat_map do |spec|
72
+ patterns = spec.file_patterns.select { |pattern| definition.claims_default_ignored?(pattern) }
73
+ next [] if patterns.empty?
74
+
75
+ (glob_files(patterns, root) - glob_files(spec.exclude_patterns, root)).to_a
76
+ end.to_set
77
+
78
+ claimed.empty? ? claimed : claimed - glob_files(definition.user_ignore_patterns, root)
58
79
  end
59
80
 
60
- def parse_errors_for(path, errors)
61
- errors.map do |error|
62
- ParseError.new(error.message, SourceLocation.from_prism(path, error.location))
63
- end
81
+ def glob_files(patterns, root)
82
+ patterns.flat_map do |pattern|
83
+ Dir.glob(File.absolute_path(pattern, root))
84
+ end.select { |path| File.file?(path) }.map { |path| File.expand_path(path) }.to_set
64
85
  end
65
86
 
66
87
  module SuppressionParser
@@ -74,8 +95,8 @@ module ArchSpec
74
95
  active = Hash.new { |hash, key| hash[key] = [] }
75
96
 
76
97
  sorted_comments(comments).each do |comment|
77
- text = comment.slice.sub(/\A#\s?/, '').strip
78
- line = comment.location.start_line
98
+ text = comment.text.sub(/\A#\s?/, '').strip
99
+ line = comment.line
79
100
 
80
101
  if (match = text.match(DISABLE_PATTERN))
81
102
  mode, rule, reason = match.captures
@@ -110,7 +131,7 @@ module ArchSpec
110
131
  private
111
132
 
112
133
  def sorted_comments(comments)
113
- comments.sort_by { |comment| [comment.location.start_line, comment.location.start_column] }
134
+ comments.sort_by { |comment| [comment.line, comment.column] }
114
135
  end
115
136
 
116
137
  def normalize_rule(rule)
@@ -221,6 +242,7 @@ module ArchSpec
221
242
  @superclass_overrides = {}
222
243
  @superclass_fallbacks = {}
223
244
  @method_owner_overrides = []
245
+ @concerns = ConcernSemantics.new
224
246
  end
225
247
 
226
248
  def scan(path, node)
@@ -270,6 +292,10 @@ module ArchSpec
270
292
  @declaration_kinds[[normalize(name), path]]
271
293
  end
272
294
 
295
+ def apply_concerns(graph)
296
+ @concerns.apply(graph)
297
+ end
298
+
273
299
  def declaration_name(name, path = nil, location = nil)
274
300
  normalized = normalize(name)
275
301
  return normalized unless path
@@ -393,6 +419,7 @@ module ArchSpec
393
419
  end
394
420
 
395
421
  location = SourceLocation.from_prism(path, node.location)
422
+ @concerns.record_module(name, path, node.body)
396
423
  record_namespace(
397
424
  name,
398
425
  path,
@@ -447,8 +474,8 @@ module ArchSpec
447
474
  if kind == :class
448
475
  if static_superclass
449
476
  @superclass_fallbacks[[normalize(name), path]] = superclass
450
- else
451
- record_builder_superclass(name, path, node.value)
477
+ elsif superclass
478
+ @superclass_overrides[[normalize(name), path]] = superclass
452
479
  end
453
480
  end
454
481
 
@@ -671,22 +698,12 @@ module ArchSpec
671
698
  node.superclass.slice
672
699
  end
673
700
 
674
- def record_builder_superclass(name, path, value)
675
- argument = value.arguments&.arguments&.first
676
- return if argument && constant_node?(argument)
677
-
678
- receiver = constant_reference_name(value.receiver)&.sub(/\A::/, '')
679
- return if receiver == 'Class'
680
-
681
- @superclass_overrides[[normalize(name), path]] = "#{constant_reference_name(value.receiver)}.#{value.message}"
682
- end
683
-
684
701
  def builder_superclass(value)
685
702
  argument = value.arguments&.arguments&.first
686
703
  return constant_reference_name(argument) if argument && constant_node?(argument)
687
704
 
688
705
  receiver = constant_reference_name(value.receiver)&.sub(/\A::/, '')
689
- "#{constant_reference_name(value.receiver)}.#{value.message}" unless receiver == 'Class'
706
+ constant_reference_name(value.receiver) unless receiver == 'Class'
690
707
  end
691
708
 
692
709
  def singleton_target(expression, current_constant)
data/lib/archspec/cli.rb CHANGED
@@ -1,17 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'open3'
3
4
  require 'optparse'
5
+ require 'rbconfig'
4
6
 
5
7
  module ArchSpec
6
8
  # The <tt>archspec</tt> command line. Backs the +exe/archspec+ executable and
7
- # dispatches the +init+, +check+, +explain+, and +version+ subcommands.
9
+ # dispatches the +init+, +check+, +explain+, +reflect+, and +version+ subcommands.
8
10
  #
9
11
  # archspec init
10
- # archspec check [PATHS...] [--config PATH] [--format text|json] [--update-todo]
11
- # archspec explain PATH_OR_CONSTANT
12
+ # archspec check [PATHS...] [--config PATH] [--format text|json] [--update-todo|--check-todo] [--reflect]
13
+ # archspec explain PATH_OR_CONSTANT [--reflect]
12
14
  #
13
15
  # #run returns the process exit status: 0 when clean, 1 when violations are
14
- # found.
16
+ # found (or, with <tt>--check-todo</tt>, when the todo lists violations that
17
+ # no longer occur).
15
18
  module CLI
16
19
  extend self
17
20
 
@@ -21,6 +24,8 @@ module ArchSpec
21
24
  architecture :rails
22
25
  RUBY
23
26
 
27
+ REFLECT_HELP = 'Run producers whose facts are missing or stale first (boots the app)'
28
+
24
29
  class UsageError < Error; end
25
30
 
26
31
  def run(argv, output: $stdout, error: $stderr)
@@ -33,9 +38,11 @@ module ArchSpec
33
38
  when 'init'
34
39
  init(argv, output)
35
40
  when 'check'
36
- check(argv, output)
41
+ check(argv, output, error)
37
42
  when 'explain'
38
- explain(argv, output)
43
+ explain(argv, output, error)
44
+ when 'reflect'
45
+ reflect(argv, output)
39
46
  when 'version', '--version', '-v'
40
47
  raise UsageError, "unexpected argument: #{argv.first}" if argv.any?
41
48
 
@@ -58,7 +65,7 @@ module ArchSpec
58
65
  def help(argv, output)
59
66
  subject = argv.shift
60
67
  raise UsageError, "unexpected argument: #{argv.first}" if argv.any?
61
- if subject && !%w[init check explain version].include?(subject)
68
+ if subject && !%w[init check explain reflect version].include?(subject)
62
69
  raise UsageError, "unknown command: #{subject}"
63
70
  end
64
71
 
@@ -95,11 +102,13 @@ module ArchSpec
95
102
  raise Error, "could not create #{path}: #{e.message}"
96
103
  end
97
104
 
98
- def check(argv, output)
105
+ def check(argv, output, error)
99
106
  options = {
100
107
  config: CONFIG_FILE,
101
108
  format: 'text',
102
109
  update_todo: false,
110
+ check_todo: false,
111
+ reflect: false,
103
112
  help: false
104
113
  }
105
114
 
@@ -110,6 +119,10 @@ module ArchSpec
110
119
  opts.on('--update-todo', 'Replace the configured todo with current violations') do
111
120
  options[:update_todo] = true
112
121
  end
122
+ opts.on('--check-todo', 'Also fail when the configured todo lists violations that no longer occur') do
123
+ options[:check_todo] = true
124
+ end
125
+ opts.on('--reflect', REFLECT_HELP) { options[:reflect] = true }
113
126
  opts.on('-h', '--help', 'Show this help') { options[:help] = true }
114
127
  end
115
128
  parser.parse!(argv)
@@ -120,38 +133,51 @@ module ArchSpec
120
133
  end
121
134
 
122
135
  raise Error, 'cannot combine --update-todo with path arguments' if options[:update_todo] && argv.any?
136
+ raise Error, 'cannot combine --update-todo with --check-todo' if options[:update_todo] && options[:check_todo]
123
137
 
124
138
  formatter = formatter_for(options[:format])
125
139
  definition, root = load_definition(options[:config])
126
- graph = Analyzer.analyze(definition, root: root)
127
140
  todo_path = todo_path_for(definition, root)
128
- todo = options[:update_todo] ? Todo.empty(root: root) : Todo.load(todo_path, root: root)
129
- diagnostics = Evaluator.evaluate(definition, graph, todo: todo)
130
- diagnostics = scope_to_paths(diagnostics, argv, root)
141
+ if (options[:update_todo] || options[:check_todo]) && !todo_path
142
+ raise Error, "no todo configured; add `todo \"archspec_todo.yml\"` to #{options[:config]}"
143
+ end
144
+
145
+ if options[:check_todo] && !File.exist?(todo_path)
146
+ raise Error, "todo file #{Pathname(todo_path).relative_path_from(Pathname(root))} does not exist; " \
147
+ 'run `archspec check --update-todo` to create it'
148
+ end
149
+
150
+ graph = analyze_with_facts(definition, root, options, error)
151
+ candidates = Evaluator.unsuppressed(definition, graph)
152
+ # Syntax errors and stale facts are never an accepted baseline; they must be fixed.
153
+ acceptable = candidates.reject { |diagnostic| ['parser.syntax', Facts::STALE_RULE].include?(diagnostic.rule) }
131
154
 
132
155
  if options[:update_todo]
133
- unless todo_path
134
- raise Error,
135
- "no todo configured; add `todo \"archspec_todo.yml\"` to #{options[:config]}"
156
+ if candidates.any? { |diagnostic| diagnostic.rule == Facts::STALE_RULE }
157
+ raise Error, 'facts are out of date; run `archspec reflect` before updating the todo'
136
158
  end
137
159
 
138
- # Syntax errors are never an accepted baseline; they must be fixed.
139
- accepted = diagnostics.reject { |diagnostic| diagnostic.rule == 'parser.syntax' }
140
- Todo.write(todo_path, accepted, root: root)
141
- label = accepted.size == 1 ? 'violation' : 'violations'
142
- output.puts "Updated #{Pathname(todo_path).relative_path_from(Pathname(root))} with #{accepted.size} #{label}."
160
+ count = Todo.write(todo_path, acceptable, root: root)
161
+ label = count == 1 ? 'entry' : 'entries'
162
+ output.puts "Updated #{Pathname(todo_path).relative_path_from(Pathname(root))} with #{count} #{label}."
143
163
  return 0
144
164
  end
145
165
 
146
- formatter.print(output, graph: graph, diagnostics: diagnostics)
147
- diagnostics.empty? ? 0 : 1
166
+ todo = Todo.load(todo_path, root: root)
167
+ diagnostics = candidates.reject { |diagnostic| todo.include?(diagnostic) }
168
+ diagnostics = scope_to_paths(diagnostics, argv, root, graph)
169
+ obsolete = scope_entries_to_paths(todo.unmatched_by(acceptable), argv, root) if options[:check_todo]
170
+
171
+ formatter.print(output, graph: graph, diagnostics: diagnostics, obsolete_todo: obsolete)
172
+ diagnostics.empty? && (obsolete.nil? || obsolete.empty?) ? 0 : 1
148
173
  end
149
174
 
150
- def explain(argv, output)
151
- options = { config: CONFIG_FILE, help: false }
175
+ def explain(argv, output, error)
176
+ options = { config: CONFIG_FILE, reflect: false, help: false }
152
177
  parser = OptionParser.new do |opts|
153
178
  opts.banner = usage('explain').strip
154
179
  opts.on('--config PATH', 'Use a different architecture file') { |value| options[:config] = value }
180
+ opts.on('--reflect', REFLECT_HELP) { options[:reflect] = true }
155
181
  opts.on('-h', '--help', 'Show this help') { options[:help] = true }
156
182
  end
157
183
  parser.parse!(argv)
@@ -166,11 +192,14 @@ module ArchSpec
166
192
  raise UsageError, "unexpected argument: #{argv.first}" if argv.any?
167
193
 
168
194
  definition, root = load_definition(options[:config])
169
- graph = Analyzer.analyze(definition, root: root)
195
+ graph = analyze_with_facts(definition, root, options, error)
170
196
  Formatters::Explanation.print(output, graph: graph, subject: subject)
171
197
  0
172
198
  end
173
199
 
200
+ public
201
+
202
+ # Evaluates an +Archspec.rb+ file and returns the definition and its root.
174
203
  def load_definition(config_path)
175
204
  raise Error, "no #{config_path} found; run `archspec init` first" unless File.exist?(config_path)
176
205
 
@@ -193,17 +222,106 @@ module ArchSpec
193
222
  raise Error, "could not load #{config_path}: #{detail}"
194
223
  end
195
224
 
196
- def scope_to_paths(diagnostics, paths, root)
225
+ private
226
+
227
+ def reflect(argv, output)
228
+ options = { config: CONFIG_FILE, environment: nil, help: false }
229
+ parser = OptionParser.new do |opts|
230
+ opts.banner = usage('reflect')
231
+ opts.on('--config PATH', 'Use a different architecture file') { |value| options[:config] = value }
232
+ opts.on('--environment NAME', 'Set RAILS_ENV for the producers (default: RAILS_ENV or development)') do |value|
233
+ options[:environment] = value
234
+ end
235
+ opts.on('-h', '--help', 'Show this help') { options[:help] = true }
236
+ end
237
+ parser.parse!(argv)
238
+ if options[:help]
239
+ output.puts parser
240
+ return 0
241
+ end
242
+
243
+ definition, root = load_definition(options[:config])
244
+ require_facts(definition, options[:config])
245
+ names = argv.empty? ? definition.producers.keys : argv
246
+ undeclared = names - definition.producers.keys
247
+ raise UsageError, "undeclared producer: #{undeclared.first}" if undeclared.any?
248
+
249
+ run_producers(names, options[:config], root, output, environment: options[:environment])
250
+ end
251
+
252
+ # Analyzes with facts, first running the producers that --reflect or
253
+ # <tt>reflect auto: true</tt> select when their documents are missing,
254
+ # then again for any whose documents turn out stale.
255
+ def analyze_with_facts(definition, root, options, output)
256
+ require_facts(definition, options[:config]) if options[:reflect]
257
+ names = options[:reflect] ? definition.producers.keys : definition.auto_producers
258
+ return Analyzer.analyze(definition, root: root) if names.empty?
259
+
260
+ directory = File.expand_path(definition.facts_path, root)
261
+ missing = names.reject { |name| File.file?(File.join(directory, "#{name}.yml")) }
262
+ run_producers(missing, options[:config], root, output) if missing.any?
263
+ graph = Analyzer.analyze(definition, root: root)
264
+ stale = graph.fact_diagnostics.select { |diagnostic| diagnostic.rule == Facts::STALE_RULE }
265
+ .map { |diagnostic| File.basename(diagnostic.evidence, '.yml') }.uniq & names
266
+ return graph if stale.empty?
267
+
268
+ run_producers(stale, options[:config], root, output)
269
+ Analyzer.analyze(definition, root: root)
270
+ end
271
+
272
+ def require_facts(definition, config_path)
273
+ return if definition.facts_path
274
+
275
+ raise Error, "no facts configured; add `reflect :rails` or `facts \"archspec_facts\"` to #{config_path}"
276
+ end
277
+
278
+ # Runs each producer in its own process, in parallel, and reports every
279
+ # failure after the others finish.
280
+ def run_producers(names, config_path, root, output, environment: nil)
281
+ env = { 'ARCHSPEC_REFLECTION_CONFIG' => File.expand_path(config_path) }
282
+ env['RAILS_ENV'] = environment if environment
283
+ runner = File.expand_path('reflect_runner.rb', __dir__)
284
+ results = names.map do |name|
285
+ Thread.new do
286
+ Open3.capture3(env.merge('ARCHSPEC_PRODUCER' => name), RbConfig.ruby, runner, chdir: root)
287
+ rescue SystemCallError => e
288
+ ['', e.message, nil]
289
+ end
290
+ end.map(&:value)
291
+
292
+ failures = names.zip(results).reject do |_, (stdout, _, status)|
293
+ output.print stdout
294
+ status&.success?
295
+ end
296
+ return 0 if failures.empty?
297
+
298
+ raise Error, failures.map { |name, (_, stderr, status)|
299
+ "#{name} producer failed#{" (exit #{status.exitstatus})" if status&.exitstatus}: #{stderr.strip}"
300
+ }.join("\n")
301
+ end
302
+
303
+ # A stale facts document affects every path, so it is always reported.
304
+ def scope_to_paths(diagnostics, paths, root, graph)
197
305
  return diagnostics if paths.empty?
198
306
 
199
307
  expanded = paths.map { |path| File.expand_path(path, root) }
200
308
  diagnostics.select do |diagnostic|
201
- expanded.any? do |path|
202
- diagnostic.location.path == path || diagnostic.location.path.start_with?("#{path}/")
203
- end
309
+ within?(diagnostic.location.path, expanded) ||
310
+ (diagnostic.rule == Facts::STALE_RULE && !graph.files.key?(diagnostic.location.path))
204
311
  end
205
312
  end
206
313
 
314
+ def scope_entries_to_paths(entries, paths, root)
315
+ return entries if paths.empty?
316
+
317
+ expanded = paths.map { |path| File.expand_path(path, root) }
318
+ entries.select { |entry| entry['path'] && within?(File.expand_path(entry['path'], root), expanded) }
319
+ end
320
+
321
+ def within?(path, scopes)
322
+ scopes.any? { |scope| path == scope || path.start_with?("#{scope}/") }
323
+ end
324
+
207
325
  def todo_path_for(definition, root)
208
326
  return unless definition.todo_path
209
327
 
@@ -226,17 +344,20 @@ module ArchSpec
226
344
  when 'init'
227
345
  'Usage: archspec init [PATH] [--force]'
228
346
  when 'check'
229
- 'Usage: archspec check [PATHS...] [--config PATH] [--format text|json] [--update-todo]'
347
+ 'Usage: archspec check [PATHS...] [--config PATH] [--format text|json] [--update-todo|--check-todo] [--reflect]'
230
348
  when 'explain'
231
- 'Usage: archspec explain PATH_OR_CONSTANT [--config PATH]'
349
+ 'Usage: archspec explain PATH_OR_CONSTANT [--config PATH] [--reflect]'
350
+ when 'reflect'
351
+ 'Usage: archspec reflect [PRODUCERS...] [--config PATH] [--environment NAME]'
232
352
  when 'version'
233
353
  'Usage: archspec version'
234
354
  when ''
235
355
  <<~TEXT
236
356
  Usage:
237
357
  archspec init [PATH] [--force]
238
- archspec check [PATHS...] [--config PATH] [--format text|json] [--update-todo]
239
- archspec explain PATH_OR_CONSTANT [--config PATH]
358
+ archspec check [PATHS...] [--config PATH] [--format text|json] [--update-todo|--check-todo] [--reflect]
359
+ archspec explain PATH_OR_CONSTANT [--config PATH] [--reflect]
360
+ archspec reflect [PRODUCERS...] [--config PATH] [--environment NAME]
240
361
  archspec version
241
362
  archspec help [COMMAND]
242
363
  TEXT