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.
- 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/concern_rules.rb +5 -5
- 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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bb5f7cda6a11326fedead006aa062155c7fdb2e1a3ea6e374d5cd1bca45d17e9
|
|
4
|
+
data.tar.gz: 417ad99fbfc27aa871fcb0bd4ce3ced72d8c1aad3810d35f51722586a151bf81
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
data/lib/archspec/analyzer.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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(
|
|
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
|
|
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?(
|
|
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
|
|
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
|
-
|
|
57
|
-
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
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.
|
|
78
|
-
line = comment.
|
|
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.
|
|
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
|
-
|
|
451
|
-
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
147
|
-
diagnostics.
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
diagnostic.
|
|
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
|