archspec 0.3.0 → 0.5.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 +30 -11
- data/lib/archspec/analyzer.rb +224 -59
- data/lib/archspec/architectures.rb +134 -28
- data/lib/archspec/cli.rb +42 -88
- data/lib/archspec/component_spec.rb +3 -0
- data/lib/archspec/definition.rb +12 -4
- data/lib/archspec/diagnostic.rb +7 -1
- data/lib/archspec/dsl.rb +220 -16
- data/lib/archspec/evaluator.rb +4 -3
- data/lib/archspec/formatters/explanation.rb +93 -0
- data/lib/archspec/model.rb +61 -13
- data/lib/archspec/rules/component_rules.rb +2 -0
- data/lib/archspec/rules/concern_rules.rb +67 -0
- data/lib/archspec/rules/cycle_rule.rb +2 -0
- data/lib/archspec/rules/dependency_rules.rb +65 -0
- data/lib/archspec/rules/naming_rules.rb +209 -0
- data/lib/archspec/rules/privacy_rule.rb +81 -0
- data/lib/archspec/rules/protocol_rules.rb +62 -13
- data/lib/archspec/{baseline.rb → todo.rb} +5 -1
- data/lib/archspec/version.rb +1 -1
- data/lib/archspec.rb +40 -3
- metadata +21 -5
- data/lib/archspec/presets.rb +0 -11
- data/lib/archspec/rules/zeitwerk_rule.rb +0 -27
|
@@ -1,6 +1,45 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module ArchSpec
|
|
4
|
+
# Bundled architecture presets. Each applies a set of components and rules in
|
|
5
|
+
# one call, invoked from the DSL through
|
|
6
|
+
# ArchSpec::DSL::Context#architecture:
|
|
7
|
+
#
|
|
8
|
+
# architecture :rails
|
|
9
|
+
# architecture :layered, layers: { ... }
|
|
10
|
+
#
|
|
11
|
+
# Every preset accepts overrides for its directories, so you can keep the
|
|
12
|
+
# shape while pointing at your own paths. The presets are:
|
|
13
|
+
#
|
|
14
|
+
# - +:rails+ (aliases +:rails_mvc+, +:rails_way+): conventional MVC that keeps
|
|
15
|
+
# controller APIs out of models and services. Options +components:+,
|
|
16
|
+
# +controller_api:+, +share_helpers:+.
|
|
17
|
+
# - +:rails_strict+: +:rails+ plus a cycle check and a concern independence
|
|
18
|
+
# check. Adds option +concerns:+.
|
|
19
|
+
# - +:vanilla_rails+: +:rails+ plus empty-directory rules for the 37signals
|
|
20
|
+
# style (forbidding +app/services+, +app/forms+, +app/policies+, and more)
|
|
21
|
+
# and the concern independence check. Options +components:+, +empty:+,
|
|
22
|
+
# +controller_api:+, +share_helpers:+, +concerns:+.
|
|
23
|
+
# - +:layered+ (alias +:rails_layered+): ordered layers that may only depend
|
|
24
|
+
# inward, with a cycle check. Option +layers:+ (order matters).
|
|
25
|
+
# - +:hexagonal+ (alias +:rails_hexagonal+): ports and adapters, keeping the
|
|
26
|
+
# domain away from adapters. Options +application:+, +domain:+, +ports:+,
|
|
27
|
+
# +adapters:+.
|
|
28
|
+
# - +:clean+ (alias +:rails_clean+): clean architecture layers. Options
|
|
29
|
+
# +frameworks:+, +interface_adapters:+, +use_cases:+, +entities:+.
|
|
30
|
+
# - +:modular_monolith+ (alias +:bounded_contexts+): named packages with
|
|
31
|
+
# per-package allowlists and optional public APIs. Options +components:+
|
|
32
|
+
# (required), +allow:+, +public:+.
|
|
33
|
+
# - +:cqrs+ (alias +:rails_cqrs+): separates commands from queries and keeps
|
|
34
|
+
# writes out of queries. Options +commands:+, +queries:+, +read_models:+,
|
|
35
|
+
# +mutating_methods:+.
|
|
36
|
+
# - +:event_driven+ (alias +:rails_event_driven+): events, publishers, and
|
|
37
|
+
# subscribers. Options +events:+, +publishers:+, +subscribers:+.
|
|
38
|
+
# - +:ruby_conventions+: generic Ruby naming idioms (no +get_+/+set_+, no +is_+
|
|
39
|
+
# prefix), applied project-wide. Adds no components, so it composes with any
|
|
40
|
+
# other architecture. No options.
|
|
41
|
+
#
|
|
42
|
+
# See the guides at https://archspecrb.dev/architectures/ for each in depth.
|
|
4
43
|
module Architectures
|
|
5
44
|
extend self
|
|
6
45
|
|
|
@@ -54,6 +93,8 @@ module ArchSpec
|
|
|
54
93
|
view_components: ['app/components/**/*.rb', 'use helpers and ERB partials']
|
|
55
94
|
}.freeze
|
|
56
95
|
|
|
96
|
+
DEFAULT_CONCERNS = 'app/**/concerns/**/*.rb'
|
|
97
|
+
|
|
57
98
|
CONTROLLER_METHODS = %i[render redirect_to params session cookies flash].freeze
|
|
58
99
|
MUTATING_METHODS = %i[
|
|
59
100
|
create create!
|
|
@@ -65,17 +106,34 @@ module ArchSpec
|
|
|
65
106
|
upsert upsert!
|
|
66
107
|
].freeze
|
|
67
108
|
|
|
109
|
+
# Applies the named preset to +dsl+, forwarding +options+ to it. Raises
|
|
110
|
+
# ArchSpec::Error for an unknown name. Called by
|
|
111
|
+
# ArchSpec::DSL::Context#architecture, so you rarely call it directly.
|
|
68
112
|
def apply(name, dsl, **options)
|
|
69
113
|
case name.to_sym
|
|
70
114
|
when :rails, :rails_mvc, :rails_way
|
|
71
|
-
rails_mvc(
|
|
115
|
+
rails_mvc(
|
|
116
|
+
dsl,
|
|
117
|
+
components: options.fetch(:components, DEFAULT_RAILS_MVC),
|
|
118
|
+
controller_api: options.fetch(:controller_api, CONTROLLER_METHODS),
|
|
119
|
+
share_helpers: options.fetch(:share_helpers, false)
|
|
120
|
+
)
|
|
72
121
|
when :rails_strict
|
|
73
|
-
rails_strict(
|
|
122
|
+
rails_strict(
|
|
123
|
+
dsl,
|
|
124
|
+
components: options.fetch(:components, DEFAULT_RAILS_MVC),
|
|
125
|
+
controller_api: options.fetch(:controller_api, CONTROLLER_METHODS),
|
|
126
|
+
share_helpers: options.fetch(:share_helpers, false),
|
|
127
|
+
concerns: options.fetch(:concerns, DEFAULT_CONCERNS)
|
|
128
|
+
)
|
|
74
129
|
when :vanilla_rails
|
|
75
130
|
vanilla_rails(
|
|
76
131
|
dsl,
|
|
77
132
|
components: options.fetch(:components, DEFAULT_RAILS_MVC),
|
|
78
|
-
empty: options.fetch(:empty, VANILLA_RAILS_EMPTY)
|
|
133
|
+
empty: options.fetch(:empty, VANILLA_RAILS_EMPTY),
|
|
134
|
+
controller_api: options.fetch(:controller_api, CONTROLLER_METHODS),
|
|
135
|
+
share_helpers: options.fetch(:share_helpers, false),
|
|
136
|
+
concerns: options.fetch(:concerns, DEFAULT_CONCERNS)
|
|
79
137
|
)
|
|
80
138
|
when :layered, :rails_layered
|
|
81
139
|
layered(dsl, layers: options.fetch(:layers, DEFAULT_LAYERED))
|
|
@@ -84,40 +142,54 @@ module ArchSpec
|
|
|
84
142
|
when :clean, :rails_clean
|
|
85
143
|
clean(dsl, **with_defaults(DEFAULT_CLEAN, options))
|
|
86
144
|
when :modular_monolith, :bounded_contexts
|
|
87
|
-
modular_monolith(
|
|
145
|
+
modular_monolith(
|
|
146
|
+
dsl,
|
|
147
|
+
components: options.fetch(:components),
|
|
148
|
+
allow: options.fetch(:allow, {}),
|
|
149
|
+
public: options.fetch(:public, {})
|
|
150
|
+
)
|
|
88
151
|
when :cqrs, :rails_cqrs
|
|
89
152
|
cqrs(dsl, **with_defaults(DEFAULT_CQRS, options))
|
|
90
153
|
when :event_driven, :rails_event_driven
|
|
91
154
|
event_driven(dsl, **with_defaults(DEFAULT_EVENT_DRIVEN, options))
|
|
155
|
+
when :ruby_conventions
|
|
156
|
+
ruby_conventions(dsl)
|
|
92
157
|
else
|
|
93
158
|
raise Error, "Unknown ArchSpec architecture: #{name.inspect}"
|
|
94
159
|
end
|
|
95
160
|
end
|
|
96
161
|
|
|
97
|
-
def rails_mvc(dsl, components:)
|
|
162
|
+
def rails_mvc(dsl, components:, controller_api: CONTROLLER_METHODS, share_helpers: false)
|
|
98
163
|
components = normalize_map(components)
|
|
99
164
|
define_components(dsl, components)
|
|
100
165
|
|
|
101
|
-
|
|
102
|
-
proxy_for(dsl, :
|
|
103
|
-
proxy_for(dsl, :
|
|
104
|
-
proxy_for(dsl, :
|
|
105
|
-
|
|
166
|
+
forbidden = share_helpers ? %i[controllers] : %i[controllers helpers]
|
|
167
|
+
proxy_for(dsl, :controllers).can_only_use(*components.keys & %i[models services helpers mailers jobs])
|
|
168
|
+
proxy_for(dsl, :models).cannot_use(*components.keys & forbidden)
|
|
169
|
+
proxy_for(dsl, :services).cannot_use(*components.keys & forbidden)
|
|
170
|
+
|
|
171
|
+
return if controller_api.empty?
|
|
172
|
+
|
|
173
|
+
proxy_for(dsl, :models).cannot_call(*controller_api, receiver: :none)
|
|
174
|
+
proxy_for(dsl, :services).cannot_call(*controller_api, receiver: :none)
|
|
106
175
|
end
|
|
107
176
|
|
|
108
|
-
def rails_strict(dsl, components:)
|
|
177
|
+
def rails_strict(dsl, components:, controller_api: CONTROLLER_METHODS, share_helpers: false, concerns: DEFAULT_CONCERNS)
|
|
109
178
|
components = normalize_map(components)
|
|
110
|
-
rails_mvc(dsl, components: components)
|
|
111
|
-
dsl.
|
|
112
|
-
dsl
|
|
179
|
+
rails_mvc(dsl, components: components, controller_api: controller_api, share_helpers: share_helpers)
|
|
180
|
+
dsl.no_cycles(among: components.keys)
|
|
181
|
+
independent_concerns(dsl, concerns)
|
|
113
182
|
end
|
|
114
183
|
|
|
115
|
-
def vanilla_rails(dsl, components:, empty:
|
|
116
|
-
|
|
184
|
+
def vanilla_rails(dsl, components:, empty:, controller_api: CONTROLLER_METHODS, share_helpers: false,
|
|
185
|
+
concerns: DEFAULT_CONCERNS)
|
|
186
|
+
rails_mvc(dsl, components: components, controller_api: controller_api, share_helpers: share_helpers)
|
|
117
187
|
|
|
118
188
|
empty.each do |name, (pattern, reason)|
|
|
119
189
|
dsl.component(name, in: pattern).must_be_empty(because: reason)
|
|
120
190
|
end
|
|
191
|
+
|
|
192
|
+
independent_concerns(dsl, concerns)
|
|
121
193
|
end
|
|
122
194
|
|
|
123
195
|
def layered(dsl, layers:)
|
|
@@ -127,10 +199,10 @@ module ArchSpec
|
|
|
127
199
|
|
|
128
200
|
names.each_with_index do |name, index|
|
|
129
201
|
allowed = names[(index + 1)..] || []
|
|
130
|
-
proxy_for(dsl, name).
|
|
202
|
+
proxy_for(dsl, name).can_only_use(*allowed)
|
|
131
203
|
end
|
|
132
204
|
|
|
133
|
-
dsl.no_cycles
|
|
205
|
+
dsl.no_cycles(among: names)
|
|
134
206
|
end
|
|
135
207
|
|
|
136
208
|
def hexagonal(dsl, application:, domain:, ports:, adapters:)
|
|
@@ -142,11 +214,11 @@ module ArchSpec
|
|
|
142
214
|
)
|
|
143
215
|
define_components(dsl, roles)
|
|
144
216
|
|
|
145
|
-
proxy_for(dsl, :application).
|
|
217
|
+
proxy_for(dsl, :application).can_only_use :domain, :ports
|
|
146
218
|
proxy_for(dsl, :domain).cannot_use :adapters
|
|
147
219
|
proxy_for(dsl, :ports).cannot_use :adapters
|
|
148
|
-
proxy_for(dsl, :adapters).
|
|
149
|
-
dsl.no_cycles
|
|
220
|
+
proxy_for(dsl, :adapters).can_only_use :application, :domain, :ports
|
|
221
|
+
dsl.no_cycles(among: roles.keys)
|
|
150
222
|
end
|
|
151
223
|
|
|
152
224
|
def clean(dsl, frameworks:, interface_adapters:, use_cases:, entities:)
|
|
@@ -161,16 +233,19 @@ module ArchSpec
|
|
|
161
233
|
)
|
|
162
234
|
end
|
|
163
235
|
|
|
164
|
-
def modular_monolith(dsl, components:, allow: {})
|
|
236
|
+
def modular_monolith(dsl, components:, allow: {}, public: {})
|
|
165
237
|
components = normalize_map(components)
|
|
166
238
|
define_components(dsl, components)
|
|
167
239
|
|
|
168
240
|
components.each_key do |name|
|
|
169
241
|
allowed = Array(allow[name] || allow[name.to_s])
|
|
170
|
-
proxy_for(dsl, name).
|
|
242
|
+
proxy_for(dsl, name).can_only_use(*allowed)
|
|
243
|
+
|
|
244
|
+
patterns = Array(public[name] || public[name.to_s])
|
|
245
|
+
proxy_for(dsl, name).public_api(*patterns) if patterns.any?
|
|
171
246
|
end
|
|
172
247
|
|
|
173
|
-
dsl.no_cycles
|
|
248
|
+
dsl.no_cycles(among: components.keys)
|
|
174
249
|
end
|
|
175
250
|
|
|
176
251
|
def cqrs(dsl, commands:, queries:, read_models: nil, mutating_methods: MUTATING_METHODS)
|
|
@@ -181,7 +256,7 @@ module ArchSpec
|
|
|
181
256
|
proxy_for(dsl, :commands).cannot_use :queries
|
|
182
257
|
proxy_for(dsl, :queries).cannot_use :commands
|
|
183
258
|
proxy_for(dsl, :queries).cannot_call(*mutating_methods)
|
|
184
|
-
dsl.no_cycles
|
|
259
|
+
dsl.no_cycles(among: components.keys)
|
|
185
260
|
end
|
|
186
261
|
|
|
187
262
|
def event_driven(dsl, events:, publishers:, subscribers:)
|
|
@@ -189,13 +264,36 @@ module ArchSpec
|
|
|
189
264
|
define_components(dsl, roles)
|
|
190
265
|
|
|
191
266
|
proxy_for(dsl, :events).cannot_use :publishers, :subscribers
|
|
192
|
-
proxy_for(dsl, :publishers).
|
|
193
|
-
proxy_for(dsl, :subscribers).
|
|
194
|
-
dsl.no_cycles
|
|
267
|
+
proxy_for(dsl, :publishers).can_only_use :events
|
|
268
|
+
proxy_for(dsl, :subscribers).can_only_use :events
|
|
269
|
+
dsl.no_cycles(among: roles.keys)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Applies the generic Ruby naming idioms project-wide: no +get_+/+set_+
|
|
273
|
+
# accessors and no +is_+ predicate prefix. Adds no components, so it composes
|
|
274
|
+
# with any other architecture. Project-specific conventions (the +with_x+ /
|
|
275
|
+
# +without_x+ pairing, the +supports_*?+ ban) stay opt-in through the
|
|
276
|
+
# +method_names.matching(...)+ primitives.
|
|
277
|
+
def ruby_conventions(dsl)
|
|
278
|
+
%i[instance class].each do |scope|
|
|
279
|
+
forbid_name(dsl, /\A(get|set)_/, 'use attr_ readers and writers or plain names, not get_/set_', scope: scope)
|
|
280
|
+
forbid_name(dsl, /\Ais_/, 'name predicates with a trailing ? and no is_ prefix (has_ is fine)', scope: scope)
|
|
281
|
+
end
|
|
195
282
|
end
|
|
196
283
|
|
|
197
284
|
private
|
|
198
285
|
|
|
286
|
+
def forbid_name(dsl, regex, reason, scope:)
|
|
287
|
+
dsl.rule(
|
|
288
|
+
Rules::NamingRule.new(
|
|
289
|
+
source: nil,
|
|
290
|
+
selector: Rules::Naming::NameSelector.new(regex),
|
|
291
|
+
constraint: Rules::Naming::Forbidden.new(because: reason),
|
|
292
|
+
scope: scope
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
end
|
|
296
|
+
|
|
199
297
|
def with_defaults(defaults, options)
|
|
200
298
|
defaults.merge(options)
|
|
201
299
|
end
|
|
@@ -226,5 +324,13 @@ module ArchSpec
|
|
|
226
324
|
def proxy_for(dsl, name)
|
|
227
325
|
DSL::ComponentProxy.new(dsl, name)
|
|
228
326
|
end
|
|
327
|
+
|
|
328
|
+
def independent_concerns(dsl, pattern)
|
|
329
|
+
return unless pattern
|
|
330
|
+
|
|
331
|
+
dsl.component(:concerns, in: pattern).cannot_reference_includers
|
|
332
|
+
# Controllers carry an allowlist, so let them include concerns too.
|
|
333
|
+
proxy_for(dsl, :controllers).can_only_use(:concerns)
|
|
334
|
+
end
|
|
229
335
|
end
|
|
230
336
|
end
|
data/lib/archspec/cli.rb
CHANGED
|
@@ -3,6 +3,15 @@
|
|
|
3
3
|
require 'optparse'
|
|
4
4
|
|
|
5
5
|
module ArchSpec
|
|
6
|
+
# The <tt>archspec</tt> command line. Backs the +exe/archspec+ executable and
|
|
7
|
+
# dispatches the +init+, +check+, +explain+, and +version+ subcommands.
|
|
8
|
+
#
|
|
9
|
+
# archspec init
|
|
10
|
+
# archspec check [PATHS...] [--config PATH] [--format text|json] [--update-todo]
|
|
11
|
+
# archspec explain PATH_OR_CONSTANT
|
|
12
|
+
#
|
|
13
|
+
# #run returns the process exit status: 0 when clean, 1 when violations are
|
|
14
|
+
# found.
|
|
6
15
|
module CLI
|
|
7
16
|
extend self
|
|
8
17
|
|
|
@@ -52,30 +61,33 @@ module ArchSpec
|
|
|
52
61
|
options = {
|
|
53
62
|
config: CONFIG_FILE,
|
|
54
63
|
format: 'text',
|
|
55
|
-
|
|
64
|
+
update_todo: false
|
|
56
65
|
}
|
|
57
66
|
|
|
58
67
|
parser = OptionParser.new do |opts|
|
|
59
68
|
opts.on('--config PATH') { |value| options[:config] = value }
|
|
60
69
|
opts.on('--format FORMAT') { |value| options[:format] = value }
|
|
61
|
-
opts.on('--update-
|
|
70
|
+
opts.on('--update-todo') { options[:update_todo] = true }
|
|
62
71
|
end
|
|
63
72
|
parser.parse!(argv)
|
|
64
73
|
|
|
74
|
+
raise Error, 'Cannot combine --update-todo with path arguments.' if options[:update_todo] && argv.any?
|
|
75
|
+
|
|
65
76
|
definition, root = load_definition(options[:config])
|
|
66
77
|
graph = Analyzer.analyze(definition, root: root)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
diagnostics = Evaluator.evaluate(definition, graph,
|
|
78
|
+
todo_path = todo_path_for(definition, root)
|
|
79
|
+
todo = options[:update_todo] ? Todo.empty(root: root) : Todo.load(todo_path, root: root)
|
|
80
|
+
diagnostics = Evaluator.evaluate(definition, graph, todo: todo)
|
|
81
|
+
diagnostics = scope_to_paths(diagnostics, argv, root)
|
|
70
82
|
|
|
71
|
-
if options[:
|
|
72
|
-
unless
|
|
83
|
+
if options[:update_todo]
|
|
84
|
+
unless todo_path
|
|
73
85
|
raise Error,
|
|
74
|
-
"No
|
|
86
|
+
"No todo configured. Add `todo \"archspec_todo.yml\"` to #{options[:config]}."
|
|
75
87
|
end
|
|
76
88
|
|
|
77
|
-
|
|
78
|
-
output.puts "Updated #{Pathname(
|
|
89
|
+
Todo.write(todo_path, diagnostics, root: root)
|
|
90
|
+
output.puts "Updated #{Pathname(todo_path).relative_path_from(Pathname(root))} with #{diagnostics.size} violations."
|
|
79
91
|
return 0
|
|
80
92
|
end
|
|
81
93
|
|
|
@@ -95,7 +107,7 @@ module ArchSpec
|
|
|
95
107
|
|
|
96
108
|
definition, root = load_definition(options[:config])
|
|
97
109
|
graph = Analyzer.analyze(definition, root: root)
|
|
98
|
-
|
|
110
|
+
Formatters::Explanation.print(output, graph: graph, subject: subject)
|
|
99
111
|
0
|
|
100
112
|
end
|
|
101
113
|
|
|
@@ -104,18 +116,32 @@ module ArchSpec
|
|
|
104
116
|
|
|
105
117
|
ArchSpec.last_definition = nil
|
|
106
118
|
absolute_config = File.expand_path(config_path)
|
|
119
|
+
config_dir = File.dirname(absolute_config)
|
|
107
120
|
definition = Definition.new
|
|
121
|
+
definition.base_dir = config_dir
|
|
108
122
|
definition.extend(DSL::Context)
|
|
109
123
|
definition.instance_eval(File.read(absolute_config), absolute_config)
|
|
110
124
|
definition = ArchSpec.last_definition || definition
|
|
125
|
+
definition.base_dir ||= config_dir
|
|
126
|
+
|
|
127
|
+
[definition, definition.absolute_root(config_dir)]
|
|
128
|
+
end
|
|
111
129
|
|
|
112
|
-
|
|
130
|
+
def scope_to_paths(diagnostics, paths, root)
|
|
131
|
+
return diagnostics if paths.empty?
|
|
132
|
+
|
|
133
|
+
expanded = paths.map { |path| File.expand_path(path, root) }
|
|
134
|
+
diagnostics.select do |diagnostic|
|
|
135
|
+
expanded.any? do |path|
|
|
136
|
+
diagnostic.location.path == path || diagnostic.location.path.start_with?("#{path}/")
|
|
137
|
+
end
|
|
138
|
+
end
|
|
113
139
|
end
|
|
114
140
|
|
|
115
|
-
def
|
|
116
|
-
return unless definition.
|
|
141
|
+
def todo_path_for(definition, root)
|
|
142
|
+
return unless definition.todo_path
|
|
117
143
|
|
|
118
|
-
File.expand_path(definition.
|
|
144
|
+
File.expand_path(definition.todo_path, root)
|
|
119
145
|
end
|
|
120
146
|
|
|
121
147
|
def formatter_for(name)
|
|
@@ -129,83 +155,11 @@ module ArchSpec
|
|
|
129
155
|
end
|
|
130
156
|
end
|
|
131
157
|
|
|
132
|
-
def explain_subject(output, graph, subject)
|
|
133
|
-
path = File.expand_path(subject, graph.root)
|
|
134
|
-
|
|
135
|
-
if graph.files.key?(path)
|
|
136
|
-
file = graph.files.fetch(path)
|
|
137
|
-
output.puts file.relative_path
|
|
138
|
-
output.puts " expected constant: #{file.expected_constant || '(none)'}"
|
|
139
|
-
output.puts " defined constants: #{graph.constants_for_path(path).map(&:name).join(', ')}"
|
|
140
|
-
output_parse_errors(output, file)
|
|
141
|
-
output_component_reasons(output, graph.component_assignment_reasons_for_path(path))
|
|
142
|
-
output_suppressions(output, file)
|
|
143
|
-
output.puts ' outgoing facts:'
|
|
144
|
-
|
|
145
|
-
graph.edges.select { |edge| edge.from_path == path }.each do |edge|
|
|
146
|
-
output.puts " #{edge.type} #{edge.to} at #{edge.location.line}:#{edge.location.column}"
|
|
147
|
-
end
|
|
148
|
-
else
|
|
149
|
-
constants = graph.constants_named(subject)
|
|
150
|
-
raise Error, "No file or constant found for #{subject.inspect}" if constants.empty?
|
|
151
|
-
|
|
152
|
-
constants.each do |constant|
|
|
153
|
-
output.puts constant.name
|
|
154
|
-
output.puts " kind: #{constant.kind}"
|
|
155
|
-
output.puts " file: #{constant.location.relative_path(graph.root)}:#{constant.location.line}"
|
|
156
|
-
output_component_reasons(output, graph.component_assignment_reasons_for_constant(constant.name))
|
|
157
|
-
output.puts " superclass: #{constant.superclass || '(none)'}"
|
|
158
|
-
output.puts " instance methods: #{constant.instance_methods.to_a.sort.join(', ')}"
|
|
159
|
-
output.puts " class methods: #{constant.class_methods.to_a.sort.join(', ')}"
|
|
160
|
-
end
|
|
161
|
-
end
|
|
162
|
-
end
|
|
163
|
-
|
|
164
|
-
def output_component_reasons(output, assignments)
|
|
165
|
-
if assignments.empty?
|
|
166
|
-
output.puts ' components: (none)'
|
|
167
|
-
return
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
output.puts ' components:'
|
|
171
|
-
assignments.sort_by { |name, _reasons| name.to_s }.each do |name, reasons|
|
|
172
|
-
output.puts " #{name}: #{reasons.empty? ? '(no recorded reason)' : reasons.join('; ')}"
|
|
173
|
-
end
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
def output_suppressions(output, file)
|
|
177
|
-
return if file.suppressions.empty?
|
|
178
|
-
|
|
179
|
-
output.puts ' suppressions:'
|
|
180
|
-
file.suppressions.each do |suppression|
|
|
181
|
-
line_range =
|
|
182
|
-
if suppression.end_line == Float::INFINITY
|
|
183
|
-
"#{suppression.start_line}-EOF"
|
|
184
|
-
elsif suppression.start_line == suppression.end_line
|
|
185
|
-
suppression.start_line
|
|
186
|
-
else
|
|
187
|
-
"#{suppression.start_line}-#{suppression.end_line}"
|
|
188
|
-
end
|
|
189
|
-
rule = suppression.rule || '*'
|
|
190
|
-
reason = suppression.reason ? " -- #{suppression.reason}" : ''
|
|
191
|
-
output.puts " #{rule} on line #{line_range}#{reason}"
|
|
192
|
-
end
|
|
193
|
-
end
|
|
194
|
-
|
|
195
|
-
def output_parse_errors(output, file)
|
|
196
|
-
return if file.parse_errors.empty?
|
|
197
|
-
|
|
198
|
-
output.puts ' parse errors:'
|
|
199
|
-
file.parse_errors.each do |parse_error|
|
|
200
|
-
output.puts " #{parse_error.location.line}:#{parse_error.location.column} #{parse_error.message}"
|
|
201
|
-
end
|
|
202
|
-
end
|
|
203
|
-
|
|
204
158
|
def usage
|
|
205
159
|
<<~TEXT
|
|
206
160
|
Usage:
|
|
207
161
|
archspec init [PATH] [--force]
|
|
208
|
-
archspec check [--config PATH] [--format text|json] [--update-
|
|
162
|
+
archspec check [PATHS...] [--config PATH] [--format text|json] [--update-todo]
|
|
209
163
|
archspec explain PATH_OR_CONSTANT [--config PATH]
|
|
210
164
|
archspec version
|
|
211
165
|
TEXT
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module ArchSpec
|
|
4
|
+
# How a component selects its members: by file glob, by namespace, or by
|
|
5
|
+
# explicit constant name. Created by ArchSpec::DSL::Context#component. The
|
|
6
|
+
# analyzer uses it to assign files and constants to the component.
|
|
4
7
|
class ComponentSpec
|
|
5
8
|
attr_reader :name, :file_patterns, :namespaces, :constants
|
|
6
9
|
|
data/lib/archspec/definition.rb
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module ArchSpec
|
|
4
|
+
# The result of evaluating an +Archspec.rb+ file: the project settings,
|
|
5
|
+
# declared components, and rules. ArchSpec::DSL::Context is mixed into an
|
|
6
|
+
# instance to provide the DSL, and the analyzer and evaluator read it to run
|
|
7
|
+
# the checks. Build one with ArchSpec.define.
|
|
4
8
|
class Definition
|
|
5
9
|
DEFAULT_SOURCE_PATTERNS = [
|
|
6
10
|
'app/**/*.rb',
|
|
@@ -17,13 +21,14 @@ module ArchSpec
|
|
|
17
21
|
'vendor/**/*'
|
|
18
22
|
].freeze
|
|
19
23
|
|
|
20
|
-
attr_accessor :name, :root_path, :
|
|
24
|
+
attr_accessor :name, :root_path, :todo_path, :base_dir
|
|
21
25
|
attr_reader :source_patterns, :ignore_patterns, :component_specs, :rules
|
|
22
26
|
|
|
23
27
|
def initialize(name = nil)
|
|
24
28
|
@name = name
|
|
25
29
|
@root_path = '.'
|
|
26
|
-
@
|
|
30
|
+
@todo_path = nil
|
|
31
|
+
@base_dir = nil
|
|
27
32
|
@source_patterns = []
|
|
28
33
|
@ignore_patterns = DEFAULT_IGNORE_PATTERNS.dup
|
|
29
34
|
@component_specs = {}
|
|
@@ -54,8 +59,11 @@ module ArchSpec
|
|
|
54
59
|
rules << rule
|
|
55
60
|
end
|
|
56
61
|
|
|
57
|
-
|
|
58
|
-
|
|
62
|
+
# The directory file patterns resolve against: root_path expanded from the
|
|
63
|
+
# directory the Archspec.rb was loaded from (base_dir), or the working
|
|
64
|
+
# directory when built without a file.
|
|
65
|
+
def absolute_root(base = base_dir || Dir.pwd)
|
|
66
|
+
File.expand_path(root_path, base)
|
|
59
67
|
end
|
|
60
68
|
|
|
61
69
|
def analysis_patterns
|
data/lib/archspec/diagnostic.rb
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
require 'digest'
|
|
4
4
|
|
|
5
5
|
module ArchSpec
|
|
6
|
+
# One reported violation: the rule id, a message, the source location, the
|
|
7
|
+
# evidence ArchSpec found, and a confidence. Formatters and the todo file read
|
|
8
|
+
# these. Its #fingerprint is the stable id used to match todo entries and
|
|
9
|
+
# suppress specific findings.
|
|
6
10
|
class Diagnostic
|
|
7
11
|
attr_reader :rule, :message, :location, :evidence, :confidence
|
|
8
12
|
|
|
@@ -14,11 +18,13 @@ module ArchSpec
|
|
|
14
18
|
@confidence = confidence
|
|
15
19
|
end
|
|
16
20
|
|
|
21
|
+
# Line numbers stay out of the fingerprint so todo entries survive edits
|
|
22
|
+
# that only shift code around.
|
|
17
23
|
def fingerprint(root: nil)
|
|
18
24
|
path = root ? location.relative_path(root) : location.path
|
|
19
25
|
|
|
20
26
|
Digest::SHA256.hexdigest(
|
|
21
|
-
[rule, message, path,
|
|
27
|
+
[rule, message, path, evidence].join("\0")
|
|
22
28
|
)[0, 24]
|
|
23
29
|
end
|
|
24
30
|
|