archspec 0.4.0 → 1.0.0.rc1
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 +29 -8
- data/lib/archspec/analyzer.rb +217 -84
- data/lib/archspec/architectures.rb +130 -98
- data/lib/archspec/cli.rb +113 -105
- data/lib/archspec/definition.rb +1 -6
- data/lib/archspec/diagnostic.rb +2 -0
- data/lib/archspec/dsl.rb +50 -43
- data/lib/archspec/error.rb +7 -0
- data/lib/archspec/evaluator.rb +6 -8
- data/lib/archspec/formatters/explanation.rb +123 -0
- data/lib/archspec/formatters/style.rb +39 -0
- data/lib/archspec/formatters/text.rb +92 -8
- data/lib/archspec/model.rb +142 -46
- data/lib/archspec/rules/component_rules.rb +1 -1
- data/lib/archspec/rules/concern_rules.rb +3 -3
- data/lib/archspec/rules/cycle_rule.rb +3 -3
- data/lib/archspec/rules/dependency_rules.rb +11 -19
- data/lib/archspec/rules/naming_rules.rb +237 -0
- data/lib/archspec/rules/privacy_rule.rb +3 -3
- data/lib/archspec/rules/protocol_rules.rb +10 -7
- data/lib/archspec/source_location.rb +8 -2
- data/lib/archspec/todo.rb +17 -2
- data/lib/archspec/value_object.rb +4 -0
- data/lib/archspec/version.rb +1 -1
- data/lib/archspec.rb +10 -15
- metadata +6 -4
- data/lib/archspec/presets.rb +0 -14
- data/lib/archspec/rules/zeitwerk_rule.rb +0 -51
|
@@ -11,30 +11,30 @@ module ArchSpec
|
|
|
11
11
|
# Every preset accepts overrides for its directories, so you can keep the
|
|
12
12
|
# shape while pointing at your own paths. The presets are:
|
|
13
13
|
#
|
|
14
|
-
# +:rails
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
# and the concern independence check. Options: +components:+, +empty:+,
|
|
14
|
+
# - +:rails+: conventional MVC that keeps controller APIs out of models and
|
|
15
|
+
# services. Options +components:+, +controller_api:+, +share_helpers:+.
|
|
16
|
+
# - +:rails_strict+: +:rails+ plus a cycle check and a concern independence
|
|
17
|
+
# check. Adds option +concerns:+.
|
|
18
|
+
# - +:vanilla_rails+: +:rails+ plus empty-directory rules for the 37signals
|
|
19
|
+
# style (forbidding +app/services+, +app/forms+, +app/policies+, and more)
|
|
20
|
+
# and the concern independence check. Options +components:+, +empty:+,
|
|
22
21
|
# +controller_api:+, +share_helpers:+, +concerns:+.
|
|
23
|
-
# +:layered
|
|
24
|
-
#
|
|
25
|
-
# +:hexagonal
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
# +
|
|
36
|
-
# +:
|
|
37
|
-
#
|
|
22
|
+
# - +:layered+: ordered layers that may only depend inward, with a cycle
|
|
23
|
+
# check. Option +layers:+ (order matters).
|
|
24
|
+
# - +:hexagonal+: ports and adapters, keeping the domain away from adapters.
|
|
25
|
+
# Options +application:+, +domain:+, +ports:+, +adapters:+.
|
|
26
|
+
# - +:clean+: clean architecture layers. Options +frameworks:+,
|
|
27
|
+
# +interface_adapters:+, +use_cases:+, +entities:+.
|
|
28
|
+
# - +:modular_monolith+: named packages with per-package allowlists and
|
|
29
|
+
# optional public APIs. Options +components:+ (required), +allow:+,
|
|
30
|
+
# +public:+.
|
|
31
|
+
# - +:cqrs+: separates commands from queries and keeps writes out of queries.
|
|
32
|
+
# Options +commands:+, +queries:+, +read_models:+, +mutating_methods:+.
|
|
33
|
+
# - +:event_driven+: events, publishers, and subscribers. Options +events:+,
|
|
34
|
+
# +publishers:+, +subscribers:+.
|
|
35
|
+
# - +:ruby_conventions+: generic Ruby naming idioms (no +get_+/+set_+, no +is_+
|
|
36
|
+
# prefix), applied project-wide. Adds no components, so it composes with any
|
|
37
|
+
# other architecture. No options.
|
|
38
38
|
#
|
|
39
39
|
# See the guides at https://archspecrb.dev/architectures/ for each in depth.
|
|
40
40
|
module Architectures
|
|
@@ -103,83 +103,77 @@ module ArchSpec
|
|
|
103
103
|
upsert upsert!
|
|
104
104
|
].freeze
|
|
105
105
|
|
|
106
|
+
# Every option each architecture accepts, with its default. The single
|
|
107
|
+
# source of truth for #apply: option validation checks these keys, and the
|
|
108
|
+
# architecture methods receive these values merged with the caller's.
|
|
109
|
+
DEFAULTS = {
|
|
110
|
+
rails: {
|
|
111
|
+
components: DEFAULT_RAILS_MVC,
|
|
112
|
+
controller_api: CONTROLLER_METHODS,
|
|
113
|
+
share_helpers: false
|
|
114
|
+
},
|
|
115
|
+
rails_strict: {
|
|
116
|
+
components: DEFAULT_RAILS_MVC,
|
|
117
|
+
controller_api: CONTROLLER_METHODS,
|
|
118
|
+
share_helpers: false,
|
|
119
|
+
concerns: DEFAULT_CONCERNS
|
|
120
|
+
},
|
|
121
|
+
vanilla_rails: {
|
|
122
|
+
components: DEFAULT_RAILS_MVC,
|
|
123
|
+
empty: VANILLA_RAILS_EMPTY,
|
|
124
|
+
controller_api: CONTROLLER_METHODS,
|
|
125
|
+
share_helpers: false,
|
|
126
|
+
concerns: DEFAULT_CONCERNS
|
|
127
|
+
},
|
|
128
|
+
layered: { layers: DEFAULT_LAYERED },
|
|
129
|
+
hexagonal: DEFAULT_HEXAGONAL,
|
|
130
|
+
clean: DEFAULT_CLEAN,
|
|
131
|
+
modular_monolith: { components: nil, allow: {}, public: {} },
|
|
132
|
+
cqrs: DEFAULT_CQRS.merge(mutating_methods: MUTATING_METHODS),
|
|
133
|
+
event_driven: DEFAULT_EVENT_DRIVEN,
|
|
134
|
+
ruby_conventions: {}
|
|
135
|
+
}.freeze
|
|
136
|
+
|
|
106
137
|
# Applies the named preset to +dsl+, forwarding +options+ to it. Raises
|
|
107
138
|
# ArchSpec::Error for an unknown name. Called by
|
|
108
139
|
# ArchSpec::DSL::Context#architecture, so you rarely call it directly.
|
|
109
140
|
def apply(name, dsl, **options)
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
share_helpers: options.fetch(:share_helpers, false)
|
|
117
|
-
)
|
|
118
|
-
when :rails_strict
|
|
119
|
-
rails_strict(
|
|
120
|
-
dsl,
|
|
121
|
-
components: options.fetch(:components, DEFAULT_RAILS_MVC),
|
|
122
|
-
controller_api: options.fetch(:controller_api, CONTROLLER_METHODS),
|
|
123
|
-
share_helpers: options.fetch(:share_helpers, false),
|
|
124
|
-
concerns: options.fetch(:concerns, DEFAULT_CONCERNS)
|
|
125
|
-
)
|
|
126
|
-
when :vanilla_rails
|
|
127
|
-
vanilla_rails(
|
|
128
|
-
dsl,
|
|
129
|
-
components: options.fetch(:components, DEFAULT_RAILS_MVC),
|
|
130
|
-
empty: options.fetch(:empty, VANILLA_RAILS_EMPTY),
|
|
131
|
-
controller_api: options.fetch(:controller_api, CONTROLLER_METHODS),
|
|
132
|
-
share_helpers: options.fetch(:share_helpers, false),
|
|
133
|
-
concerns: options.fetch(:concerns, DEFAULT_CONCERNS)
|
|
134
|
-
)
|
|
135
|
-
when :layered, :rails_layered
|
|
136
|
-
layered(dsl, layers: options.fetch(:layers, DEFAULT_LAYERED))
|
|
137
|
-
when :hexagonal, :rails_hexagonal
|
|
138
|
-
hexagonal(dsl, **with_defaults(DEFAULT_HEXAGONAL, options))
|
|
139
|
-
when :clean, :rails_clean
|
|
140
|
-
clean(dsl, **with_defaults(DEFAULT_CLEAN, options))
|
|
141
|
-
when :modular_monolith, :bounded_contexts
|
|
142
|
-
modular_monolith(
|
|
143
|
-
dsl,
|
|
144
|
-
components: options.fetch(:components),
|
|
145
|
-
allow: options.fetch(:allow, {}),
|
|
146
|
-
public: options.fetch(:public, {})
|
|
147
|
-
)
|
|
148
|
-
when :cqrs, :rails_cqrs
|
|
149
|
-
cqrs(dsl, **with_defaults(DEFAULT_CQRS, options))
|
|
150
|
-
when :event_driven, :rails_event_driven
|
|
151
|
-
event_driven(dsl, **with_defaults(DEFAULT_EVENT_DRIVEN, options))
|
|
152
|
-
else
|
|
153
|
-
raise Error, "Unknown ArchSpec architecture: #{name.inspect}"
|
|
154
|
-
end
|
|
141
|
+
name = architecture_name(name)
|
|
142
|
+
defaults = DEFAULTS[name]
|
|
143
|
+
raise Error, "unknown architecture: #{name.inspect}" unless defaults
|
|
144
|
+
|
|
145
|
+
validate_options!(name, defaults, options)
|
|
146
|
+
send(name, dsl, **defaults.merge(options))
|
|
155
147
|
end
|
|
156
148
|
|
|
157
|
-
def
|
|
149
|
+
def rails(dsl, components:, controller_api:, share_helpers:)
|
|
158
150
|
components = normalize_map(components)
|
|
159
|
-
|
|
151
|
+
missing = %i[controllers models] - components.keys
|
|
152
|
+
if missing.any?
|
|
153
|
+
raise Error, "the rails architectures need controllers and models components, missing: #{missing.join(', ')}"
|
|
154
|
+
end
|
|
160
155
|
|
|
161
|
-
|
|
162
|
-
proxy_for(dsl, :controllers).can_use(*components.keys & %i[models services helpers mailers jobs])
|
|
163
|
-
proxy_for(dsl, :models).cannot_use(*components.keys & forbidden)
|
|
164
|
-
proxy_for(dsl, :services).cannot_use(*components.keys & forbidden)
|
|
156
|
+
define_components(dsl, components)
|
|
165
157
|
|
|
166
|
-
|
|
158
|
+
forbidden = (share_helpers ? %i[controllers] : %i[controllers helpers]) & components.keys
|
|
159
|
+
proxy_for(dsl, :controllers).can_only_use(*components.keys & %i[models services helpers mailers jobs])
|
|
167
160
|
|
|
168
|
-
|
|
169
|
-
|
|
161
|
+
(%i[models services] & components.keys).each do |name|
|
|
162
|
+
proxy = proxy_for(dsl, name)
|
|
163
|
+
proxy.cannot_use(*forbidden)
|
|
164
|
+
proxy.cannot_call(*controller_api, receiver: :none) unless controller_api.empty?
|
|
165
|
+
end
|
|
170
166
|
end
|
|
171
167
|
|
|
172
|
-
def rails_strict(dsl, components:, controller_api
|
|
168
|
+
def rails_strict(dsl, components:, controller_api:, share_helpers:, concerns:)
|
|
173
169
|
components = normalize_map(components)
|
|
174
|
-
|
|
175
|
-
dsl.
|
|
176
|
-
dsl.no_cycles!(among: components.keys)
|
|
170
|
+
rails(dsl, components: components, controller_api: controller_api, share_helpers: share_helpers)
|
|
171
|
+
dsl.no_cycles(among: components.keys)
|
|
177
172
|
independent_concerns(dsl, concerns)
|
|
178
173
|
end
|
|
179
174
|
|
|
180
|
-
def vanilla_rails(dsl, components:, empty:, controller_api
|
|
181
|
-
|
|
182
|
-
rails_mvc(dsl, components: components, controller_api: controller_api, share_helpers: share_helpers)
|
|
175
|
+
def vanilla_rails(dsl, components:, empty:, controller_api:, share_helpers:, concerns:)
|
|
176
|
+
rails(dsl, components: components, controller_api: controller_api, share_helpers: share_helpers)
|
|
183
177
|
|
|
184
178
|
empty.each do |name, (pattern, reason)|
|
|
185
179
|
dsl.component(name, in: pattern).must_be_empty(because: reason)
|
|
@@ -195,10 +189,10 @@ module ArchSpec
|
|
|
195
189
|
|
|
196
190
|
names.each_with_index do |name, index|
|
|
197
191
|
allowed = names[(index + 1)..] || []
|
|
198
|
-
proxy_for(dsl, name).
|
|
192
|
+
proxy_for(dsl, name).can_only_use(*allowed)
|
|
199
193
|
end
|
|
200
194
|
|
|
201
|
-
dsl.no_cycles
|
|
195
|
+
dsl.no_cycles(among: names)
|
|
202
196
|
end
|
|
203
197
|
|
|
204
198
|
def hexagonal(dsl, application:, domain:, ports:, adapters:)
|
|
@@ -210,11 +204,11 @@ module ArchSpec
|
|
|
210
204
|
)
|
|
211
205
|
define_components(dsl, roles)
|
|
212
206
|
|
|
213
|
-
proxy_for(dsl, :application).
|
|
207
|
+
proxy_for(dsl, :application).can_only_use :domain, :ports
|
|
214
208
|
proxy_for(dsl, :domain).cannot_use :adapters
|
|
215
209
|
proxy_for(dsl, :ports).cannot_use :adapters
|
|
216
|
-
proxy_for(dsl, :adapters).
|
|
217
|
-
dsl.no_cycles
|
|
210
|
+
proxy_for(dsl, :adapters).can_only_use :application, :domain, :ports
|
|
211
|
+
dsl.no_cycles(among: roles.keys)
|
|
218
212
|
end
|
|
219
213
|
|
|
220
214
|
def clean(dsl, frameworks:, interface_adapters:, use_cases:, entities:)
|
|
@@ -230,21 +224,23 @@ module ArchSpec
|
|
|
230
224
|
end
|
|
231
225
|
|
|
232
226
|
def modular_monolith(dsl, components:, allow: {}, public: {})
|
|
227
|
+
raise Error, 'architecture :modular_monolith requires the components: option' unless components
|
|
228
|
+
|
|
233
229
|
components = normalize_map(components)
|
|
234
230
|
define_components(dsl, components)
|
|
235
231
|
|
|
236
232
|
components.each_key do |name|
|
|
237
233
|
allowed = Array(allow[name] || allow[name.to_s])
|
|
238
|
-
proxy_for(dsl, name).
|
|
234
|
+
proxy_for(dsl, name).can_only_use(*allowed)
|
|
239
235
|
|
|
240
236
|
patterns = Array(public[name] || public[name.to_s])
|
|
241
237
|
proxy_for(dsl, name).public_api(*patterns) if patterns.any?
|
|
242
238
|
end
|
|
243
239
|
|
|
244
|
-
dsl.no_cycles
|
|
240
|
+
dsl.no_cycles(among: components.keys)
|
|
245
241
|
end
|
|
246
242
|
|
|
247
|
-
def cqrs(dsl, commands:, queries:, read_models
|
|
243
|
+
def cqrs(dsl, commands:, queries:, read_models:, mutating_methods:)
|
|
248
244
|
components = normalize_map(commands: commands, queries: queries)
|
|
249
245
|
components[:read_models] = read_models if read_models
|
|
250
246
|
define_components(dsl, components)
|
|
@@ -252,7 +248,7 @@ module ArchSpec
|
|
|
252
248
|
proxy_for(dsl, :commands).cannot_use :queries
|
|
253
249
|
proxy_for(dsl, :queries).cannot_use :commands
|
|
254
250
|
proxy_for(dsl, :queries).cannot_call(*mutating_methods)
|
|
255
|
-
dsl.no_cycles
|
|
251
|
+
dsl.no_cycles(among: components.keys)
|
|
256
252
|
end
|
|
257
253
|
|
|
258
254
|
def event_driven(dsl, events:, publishers:, subscribers:)
|
|
@@ -260,15 +256,49 @@ module ArchSpec
|
|
|
260
256
|
define_components(dsl, roles)
|
|
261
257
|
|
|
262
258
|
proxy_for(dsl, :events).cannot_use :publishers, :subscribers
|
|
263
|
-
proxy_for(dsl, :publishers).
|
|
264
|
-
proxy_for(dsl, :subscribers).
|
|
265
|
-
dsl.no_cycles
|
|
259
|
+
proxy_for(dsl, :publishers).can_only_use :events
|
|
260
|
+
proxy_for(dsl, :subscribers).can_only_use :events
|
|
261
|
+
dsl.no_cycles(among: roles.keys)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Applies the generic Ruby naming idioms project-wide: no +get_+/+set_+
|
|
265
|
+
# accessors and no +is_+ predicate prefix. Adds no components, so it composes
|
|
266
|
+
# with any other architecture. Project-specific conventions (the +with_x+ /
|
|
267
|
+
# +without_x+ pairing, the +supports_*?+ ban) stay opt-in through the
|
|
268
|
+
# +method_names.matching(...)+ primitives.
|
|
269
|
+
def ruby_conventions(dsl)
|
|
270
|
+
%i[instance class].each do |scope|
|
|
271
|
+
forbid_name(dsl, /\A(get|set)_/, 'use attr_ readers and writers or plain names, not get_/set_', scope: scope)
|
|
272
|
+
forbid_name(dsl, /\Ais_/, 'name predicates with a trailing ? and no is_ prefix (has_ is fine)', scope: scope)
|
|
273
|
+
end
|
|
266
274
|
end
|
|
267
275
|
|
|
268
276
|
private
|
|
269
277
|
|
|
270
|
-
def
|
|
271
|
-
|
|
278
|
+
def architecture_name(name)
|
|
279
|
+
name.to_sym
|
|
280
|
+
rescue NoMethodError
|
|
281
|
+
raise Error, "unknown architecture: #{name.inspect}"
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def validate_options!(name, defaults, options)
|
|
285
|
+
unknown = options.keys - defaults.keys
|
|
286
|
+
return if unknown.empty?
|
|
287
|
+
|
|
288
|
+
label = unknown.length == 1 ? 'option' : 'options'
|
|
289
|
+
names = unknown.map { |option| "#{option}:" }.sort.join(', ')
|
|
290
|
+
raise Error, "unknown #{label} for architecture :#{name}: #{names}"
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def forbid_name(dsl, regex, reason, scope:)
|
|
294
|
+
dsl.rule(
|
|
295
|
+
Rules::NamingRule.new(
|
|
296
|
+
source: nil,
|
|
297
|
+
selector: Rules::Naming::NameSelector.new(regex),
|
|
298
|
+
constraint: Rules::Naming::Forbidden.new(because: reason),
|
|
299
|
+
scope: scope
|
|
300
|
+
)
|
|
301
|
+
)
|
|
272
302
|
end
|
|
273
303
|
|
|
274
304
|
def normalize_map(map)
|
|
@@ -302,6 +332,8 @@ module ArchSpec
|
|
|
302
332
|
return unless pattern
|
|
303
333
|
|
|
304
334
|
dsl.component(:concerns, in: pattern).cannot_reference_includers
|
|
335
|
+
# Controllers carry an allowlist, so let them include concerns too.
|
|
336
|
+
proxy_for(dsl, :controllers).can_only_use(:concerns)
|
|
305
337
|
end
|
|
306
338
|
end
|
|
307
339
|
end
|
data/lib/archspec/cli.rb
CHANGED
|
@@ -16,15 +16,20 @@ module ArchSpec
|
|
|
16
16
|
extend self
|
|
17
17
|
|
|
18
18
|
CONFIG_FILE = 'Archspec.rb'
|
|
19
|
+
USAGE_ERROR_STATUS = 64
|
|
19
20
|
TEMPLATE = <<~RUBY
|
|
20
21
|
architecture :rails
|
|
21
22
|
RUBY
|
|
22
23
|
|
|
24
|
+
class UsageError < Error; end
|
|
25
|
+
|
|
23
26
|
def run(argv, output: $stdout, error: $stderr)
|
|
24
27
|
argv = argv.dup
|
|
25
28
|
command = argv.shift || 'check'
|
|
26
29
|
|
|
27
30
|
case command
|
|
31
|
+
when 'help', '--help', '-h'
|
|
32
|
+
help(argv, output)
|
|
28
33
|
when 'init'
|
|
29
34
|
init(argv, output)
|
|
30
35
|
when 'check'
|
|
@@ -32,47 +37,91 @@ module ArchSpec
|
|
|
32
37
|
when 'explain'
|
|
33
38
|
explain(argv, output)
|
|
34
39
|
when 'version', '--version', '-v'
|
|
40
|
+
raise UsageError, "unexpected argument: #{argv.first}" if argv.any?
|
|
41
|
+
|
|
35
42
|
output.puts ArchSpec::VERSION
|
|
36
43
|
0
|
|
37
44
|
else
|
|
38
|
-
|
|
39
|
-
error.puts usage
|
|
40
|
-
64
|
|
45
|
+
raise UsageError, "unknown command: #{command}"
|
|
41
46
|
end
|
|
47
|
+
rescue OptionParser::ParseError, UsageError => e
|
|
48
|
+
error.puts "archspec: error: #{e.message}"
|
|
49
|
+
error.puts usage(command)
|
|
50
|
+
USAGE_ERROR_STATUS
|
|
42
51
|
rescue Error => e
|
|
43
|
-
error.puts e.message
|
|
52
|
+
error.puts "archspec: error: #{e.message}"
|
|
44
53
|
1
|
|
45
54
|
end
|
|
46
55
|
|
|
47
56
|
private
|
|
48
57
|
|
|
58
|
+
def help(argv, output)
|
|
59
|
+
subject = argv.shift
|
|
60
|
+
raise UsageError, "unexpected argument: #{argv.first}" if argv.any?
|
|
61
|
+
if subject && !%w[init check explain version].include?(subject)
|
|
62
|
+
raise UsageError, "unknown command: #{subject}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
output.puts usage(subject)
|
|
66
|
+
0
|
|
67
|
+
end
|
|
68
|
+
|
|
49
69
|
def init(argv, output)
|
|
50
|
-
|
|
70
|
+
options = { force: false, help: false }
|
|
71
|
+
parser = OptionParser.new do |opts|
|
|
72
|
+
opts.banner = usage('init').strip
|
|
73
|
+
opts.on('--force', 'Overwrite an existing file') { options[:force] = true }
|
|
74
|
+
opts.on('-h', '--help', 'Show this help') { options[:help] = true }
|
|
75
|
+
end
|
|
76
|
+
parser.parse!(argv)
|
|
77
|
+
|
|
78
|
+
if options[:help]
|
|
79
|
+
output.puts parser
|
|
80
|
+
return 0
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
raise UsageError, "unexpected argument: #{argv[1]}" if argv.length > 1
|
|
84
|
+
|
|
51
85
|
path = argv.shift || CONFIG_FILE
|
|
52
86
|
|
|
53
|
-
|
|
87
|
+
if File.exist?(path) && !options[:force]
|
|
88
|
+
raise Error, "#{path} already exists (use --force to overwrite)"
|
|
89
|
+
end
|
|
54
90
|
|
|
55
91
|
File.write(path, TEMPLATE)
|
|
56
92
|
output.puts "Created #{path}"
|
|
57
93
|
0
|
|
94
|
+
rescue SystemCallError => e
|
|
95
|
+
raise Error, "could not create #{path}: #{e.message}"
|
|
58
96
|
end
|
|
59
97
|
|
|
60
98
|
def check(argv, output)
|
|
61
99
|
options = {
|
|
62
100
|
config: CONFIG_FILE,
|
|
63
101
|
format: 'text',
|
|
64
|
-
update_todo: false
|
|
102
|
+
update_todo: false,
|
|
103
|
+
help: false
|
|
65
104
|
}
|
|
66
105
|
|
|
67
106
|
parser = OptionParser.new do |opts|
|
|
68
|
-
opts.
|
|
69
|
-
opts.on('--
|
|
70
|
-
opts.on('--
|
|
107
|
+
opts.banner = usage('check').strip
|
|
108
|
+
opts.on('--config PATH', 'Use a different architecture file') { |value| options[:config] = value }
|
|
109
|
+
opts.on('--format FORMAT', 'Output text or json') { |value| options[:format] = value }
|
|
110
|
+
opts.on('--update-todo', 'Replace the configured todo with current violations') do
|
|
111
|
+
options[:update_todo] = true
|
|
112
|
+
end
|
|
113
|
+
opts.on('-h', '--help', 'Show this help') { options[:help] = true }
|
|
71
114
|
end
|
|
72
115
|
parser.parse!(argv)
|
|
73
116
|
|
|
74
|
-
|
|
117
|
+
if options[:help]
|
|
118
|
+
output.puts parser
|
|
119
|
+
return 0
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
raise Error, 'cannot combine --update-todo with path arguments' if options[:update_todo] && argv.any?
|
|
75
123
|
|
|
124
|
+
formatter = formatter_for(options[:format])
|
|
76
125
|
definition, root = load_definition(options[:config])
|
|
77
126
|
graph = Analyzer.analyze(definition, root: root)
|
|
78
127
|
todo_path = todo_path_for(definition, root)
|
|
@@ -83,48 +132,65 @@ module ArchSpec
|
|
|
83
132
|
if options[:update_todo]
|
|
84
133
|
unless todo_path
|
|
85
134
|
raise Error,
|
|
86
|
-
"
|
|
135
|
+
"no todo configured; add `todo \"archspec_todo.yml\"` to #{options[:config]}"
|
|
87
136
|
end
|
|
88
137
|
|
|
89
|
-
|
|
90
|
-
|
|
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}."
|
|
91
143
|
return 0
|
|
92
144
|
end
|
|
93
145
|
|
|
94
|
-
|
|
146
|
+
formatter.print(output, graph: graph, diagnostics: diagnostics)
|
|
95
147
|
diagnostics.empty? ? 0 : 1
|
|
96
148
|
end
|
|
97
149
|
|
|
98
150
|
def explain(argv, output)
|
|
99
|
-
options = { config: CONFIG_FILE }
|
|
151
|
+
options = { config: CONFIG_FILE, help: false }
|
|
100
152
|
parser = OptionParser.new do |opts|
|
|
101
|
-
opts.
|
|
153
|
+
opts.banner = usage('explain').strip
|
|
154
|
+
opts.on('--config PATH', 'Use a different architecture file') { |value| options[:config] = value }
|
|
155
|
+
opts.on('-h', '--help', 'Show this help') { options[:help] = true }
|
|
102
156
|
end
|
|
103
157
|
parser.parse!(argv)
|
|
104
158
|
|
|
159
|
+
if options[:help]
|
|
160
|
+
output.puts parser
|
|
161
|
+
return 0
|
|
162
|
+
end
|
|
163
|
+
|
|
105
164
|
subject = argv.shift
|
|
106
|
-
raise
|
|
165
|
+
raise UsageError, 'missing PATH_OR_CONSTANT' unless subject
|
|
166
|
+
raise UsageError, "unexpected argument: #{argv.first}" if argv.any?
|
|
107
167
|
|
|
108
168
|
definition, root = load_definition(options[:config])
|
|
109
169
|
graph = Analyzer.analyze(definition, root: root)
|
|
110
|
-
|
|
170
|
+
Formatters::Explanation.print(output, graph: graph, subject: subject)
|
|
111
171
|
0
|
|
112
172
|
end
|
|
113
173
|
|
|
114
174
|
def load_definition(config_path)
|
|
115
|
-
raise Error, "
|
|
175
|
+
raise Error, "no #{config_path} found; run `archspec init` first" unless File.exist?(config_path)
|
|
116
176
|
|
|
117
|
-
ArchSpec.last_definition = nil
|
|
118
177
|
absolute_config = File.expand_path(config_path)
|
|
119
|
-
config_dir = File.dirname(absolute_config)
|
|
120
178
|
definition = Definition.new
|
|
121
|
-
definition.base_dir =
|
|
179
|
+
definition.base_dir = File.dirname(absolute_config)
|
|
122
180
|
definition.extend(DSL::Context)
|
|
123
181
|
definition.instance_eval(File.read(absolute_config), absolute_config)
|
|
124
|
-
definition = ArchSpec.last_definition || definition
|
|
125
|
-
definition.base_dir ||= config_dir
|
|
126
182
|
|
|
127
|
-
|
|
183
|
+
if definition.component_specs.empty? && definition.rules.empty?
|
|
184
|
+
raise Error, "#{config_path} declared no components or rules; the file's top level is already " \
|
|
185
|
+
'the DSL, so do not wrap declarations in ArchSpec.define'
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
[definition, definition.absolute_root]
|
|
189
|
+
rescue Error
|
|
190
|
+
raise
|
|
191
|
+
rescue SyntaxError, LoadError, StandardError => e
|
|
192
|
+
detail = e.message.lines.first&.strip || e.class.name
|
|
193
|
+
raise Error, "could not load #{config_path}: #{detail}"
|
|
128
194
|
end
|
|
129
195
|
|
|
130
196
|
def scope_to_paths(diagnostics, paths, root)
|
|
@@ -151,90 +217,32 @@ module ArchSpec
|
|
|
151
217
|
when 'json'
|
|
152
218
|
Formatters::JSON
|
|
153
219
|
else
|
|
154
|
-
raise
|
|
220
|
+
raise UsageError, "unknown format: #{name.inspect}"
|
|
155
221
|
end
|
|
156
222
|
end
|
|
157
223
|
|
|
158
|
-
def
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
224
|
+
def usage(command = nil)
|
|
225
|
+
case command.to_s
|
|
226
|
+
when 'init'
|
|
227
|
+
'Usage: archspec init [PATH] [--force]'
|
|
228
|
+
when 'check'
|
|
229
|
+
'Usage: archspec check [PATHS...] [--config PATH] [--format text|json] [--update-todo]'
|
|
230
|
+
when 'explain'
|
|
231
|
+
'Usage: archspec explain PATH_OR_CONSTANT [--config PATH]'
|
|
232
|
+
when 'version'
|
|
233
|
+
'Usage: archspec version'
|
|
234
|
+
when ''
|
|
235
|
+
<<~TEXT
|
|
236
|
+
Usage:
|
|
237
|
+
archspec init [PATH] [--force]
|
|
238
|
+
archspec check [PATHS...] [--config PATH] [--format text|json] [--update-todo]
|
|
239
|
+
archspec explain PATH_OR_CONSTANT [--config PATH]
|
|
240
|
+
archspec version
|
|
241
|
+
archspec help [COMMAND]
|
|
242
|
+
TEXT
|
|
174
243
|
else
|
|
175
|
-
|
|
176
|
-
raise Error, "No file or constant found for #{subject.inspect}" if constants.empty?
|
|
177
|
-
|
|
178
|
-
constants.each do |constant|
|
|
179
|
-
output.puts constant.name
|
|
180
|
-
output.puts " kind: #{constant.kind}"
|
|
181
|
-
output.puts " file: #{constant.location.relative_path(graph.root)}:#{constant.location.line}"
|
|
182
|
-
output_component_reasons(output, graph.component_assignment_reasons_for_constant(constant.name))
|
|
183
|
-
output.puts " superclass: #{constant.superclass || '(none)'}"
|
|
184
|
-
output.puts " instance methods: #{constant.instance_methods.to_a.sort.join(', ')}"
|
|
185
|
-
output.puts " class methods: #{constant.class_methods.to_a.sort.join(', ')}"
|
|
186
|
-
end
|
|
187
|
-
end
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
def output_component_reasons(output, assignments)
|
|
191
|
-
if assignments.empty?
|
|
192
|
-
output.puts ' components: (none)'
|
|
193
|
-
return
|
|
244
|
+
usage
|
|
194
245
|
end
|
|
195
|
-
|
|
196
|
-
output.puts ' components:'
|
|
197
|
-
assignments.sort_by { |name, _reasons| name.to_s }.each do |name, reasons|
|
|
198
|
-
output.puts " #{name}: #{reasons.empty? ? '(no recorded reason)' : reasons.join('; ')}"
|
|
199
|
-
end
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
def output_suppressions(output, file)
|
|
203
|
-
return if file.suppressions.empty?
|
|
204
|
-
|
|
205
|
-
output.puts ' suppressions:'
|
|
206
|
-
file.suppressions.each do |suppression|
|
|
207
|
-
line_range =
|
|
208
|
-
if suppression.end_line == Float::INFINITY
|
|
209
|
-
"#{suppression.start_line}-EOF"
|
|
210
|
-
elsif suppression.start_line == suppression.end_line
|
|
211
|
-
suppression.start_line
|
|
212
|
-
else
|
|
213
|
-
"#{suppression.start_line}-#{suppression.end_line}"
|
|
214
|
-
end
|
|
215
|
-
rule = suppression.rule || '*'
|
|
216
|
-
reason = suppression.reason ? " -- #{suppression.reason}" : ''
|
|
217
|
-
output.puts " #{rule} on line #{line_range}#{reason}"
|
|
218
|
-
end
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
def output_parse_errors(output, file)
|
|
222
|
-
return if file.parse_errors.empty?
|
|
223
|
-
|
|
224
|
-
output.puts ' parse errors:'
|
|
225
|
-
file.parse_errors.each do |parse_error|
|
|
226
|
-
output.puts " #{parse_error.location.line}:#{parse_error.location.column} #{parse_error.message}"
|
|
227
|
-
end
|
|
228
|
-
end
|
|
229
|
-
|
|
230
|
-
def usage
|
|
231
|
-
<<~TEXT
|
|
232
|
-
Usage:
|
|
233
|
-
archspec init [PATH] [--force]
|
|
234
|
-
archspec check [PATHS...] [--config PATH] [--format text|json] [--update-todo]
|
|
235
|
-
archspec explain PATH_OR_CONSTANT [--config PATH]
|
|
236
|
-
archspec version
|
|
237
|
-
TEXT
|
|
238
246
|
end
|
|
239
247
|
end
|
|
240
248
|
end
|
data/lib/archspec/definition.rb
CHANGED
|
@@ -22,7 +22,7 @@ module ArchSpec
|
|
|
22
22
|
].freeze
|
|
23
23
|
|
|
24
24
|
attr_accessor :name, :root_path, :todo_path, :base_dir
|
|
25
|
-
attr_reader :source_patterns, :ignore_patterns, :component_specs, :rules
|
|
25
|
+
attr_reader :source_patterns, :ignore_patterns, :component_specs, :rules
|
|
26
26
|
|
|
27
27
|
def initialize(name = nil)
|
|
28
28
|
@name = name
|
|
@@ -33,11 +33,6 @@ module ArchSpec
|
|
|
33
33
|
@ignore_patterns = DEFAULT_IGNORE_PATTERNS.dup
|
|
34
34
|
@component_specs = {}
|
|
35
35
|
@rules = []
|
|
36
|
-
@inflections = {}
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def add_inflections(map)
|
|
40
|
-
@inflections.merge!(map.to_h.transform_keys(&:to_s).transform_values(&:to_s))
|
|
41
36
|
end
|
|
42
37
|
|
|
43
38
|
def add_source_patterns(patterns)
|
data/lib/archspec/diagnostic.rb
CHANGED