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.
@@ -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+ (aliases +:rails_mvc+, +:rails_way+):: Conventional MVC. Keeps
15
- # controller APIs out of models and services. Options: +components:+,
16
- # +controller_api:+, +share_helpers:+.
17
- # +:rails_strict+:: +:rails+ plus Zeitwerk name checks, a cycle check, and a
18
- # concern independence check. Options add +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:+,
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+ (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:+.
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
- case name.to_sym
111
- when :rails, :rails_mvc, :rails_way
112
- rails_mvc(
113
- dsl,
114
- components: options.fetch(:components, DEFAULT_RAILS_MVC),
115
- controller_api: options.fetch(:controller_api, CONTROLLER_METHODS),
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 rails_mvc(dsl, components:, controller_api: CONTROLLER_METHODS, share_helpers: false)
149
+ def rails(dsl, components:, controller_api:, share_helpers:)
158
150
  components = normalize_map(components)
159
- define_components(dsl, components)
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
- forbidden = share_helpers ? %i[controllers] : %i[controllers helpers]
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
- return if controller_api.empty?
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
- proxy_for(dsl, :models).cannot_call(*controller_api, receiver: :none)
169
- proxy_for(dsl, :services).cannot_call(*controller_api, receiver: :none)
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: CONTROLLER_METHODS, share_helpers: false, concerns: DEFAULT_CONCERNS)
168
+ def rails_strict(dsl, components:, controller_api:, share_helpers:, concerns:)
173
169
  components = normalize_map(components)
174
- rails_mvc(dsl, components: components, controller_api: controller_api, share_helpers: share_helpers)
175
- dsl.verify_zeitwerk_names!
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: CONTROLLER_METHODS, share_helpers: false,
181
- concerns: DEFAULT_CONCERNS)
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).can_use(*allowed)
192
+ proxy_for(dsl, name).can_only_use(*allowed)
199
193
  end
200
194
 
201
- dsl.no_cycles!(among: names)
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).can_use :domain, :ports
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).can_use :application, :domain, :ports
217
- dsl.no_cycles!(among: roles.keys)
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).can_use(*allowed)
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!(among: components.keys)
240
+ dsl.no_cycles(among: components.keys)
245
241
  end
246
242
 
247
- def cqrs(dsl, commands:, queries:, read_models: nil, mutating_methods: MUTATING_METHODS)
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!(among: components.keys)
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).can_use :events
264
- proxy_for(dsl, :subscribers).can_use :events
265
- dsl.no_cycles!(among: roles.keys)
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 with_defaults(defaults, options)
271
- defaults.merge(options)
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
- error.puts "Unknown command: #{command}"
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
- force = argv.delete('--force')
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
- raise Error, "#{path} already exists. Use --force to overwrite it." if File.exist?(path) && !force
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.on('--config PATH') { |value| options[:config] = value }
69
- opts.on('--format FORMAT') { |value| options[:format] = value }
70
- opts.on('--update-todo') { options[:update_todo] = true }
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
- raise Error, 'Cannot combine --update-todo with path arguments.' if options[:update_todo] && argv.any?
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
- "No todo configured. Add `todo \"archspec_todo.yml\"` to #{options[:config]}."
135
+ "no todo configured; add `todo \"archspec_todo.yml\"` to #{options[:config]}"
87
136
  end
88
137
 
89
- Todo.write(todo_path, diagnostics, root: root)
90
- output.puts "Updated #{Pathname(todo_path).relative_path_from(Pathname(root))} with #{diagnostics.size} violations."
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
- formatter_for(options[:format]).print(output, graph: graph, diagnostics: diagnostics)
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.on('--config PATH') { |value| options[:config] = value }
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 Error, 'Usage: archspec explain PATH_OR_CONSTANT' unless subject
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
- explain_subject(output, graph, subject)
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, "Missing #{config_path}. Run `archspec init` first." unless File.exist?(config_path)
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 = config_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
- [definition, definition.absolute_root(config_dir)]
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 Error, "Unknown format: #{name.inspect}"
220
+ raise UsageError, "unknown format: #{name.inspect}"
155
221
  end
156
222
  end
157
223
 
158
- def explain_subject(output, graph, subject)
159
- path = File.expand_path(subject, graph.root)
160
-
161
- if graph.files.key?(path)
162
- file = graph.files.fetch(path)
163
- output.puts file.relative_path
164
- output.puts " expected constant: #{file.expected_constant || '(none)'}"
165
- output.puts " defined constants: #{graph.constants_for_path(path).map(&:name).join(', ')}"
166
- output_parse_errors(output, file)
167
- output_component_reasons(output, graph.component_assignment_reasons_for_path(path))
168
- output_suppressions(output, file)
169
- output.puts ' outgoing facts:'
170
-
171
- graph.edges.select { |edge| edge.from_path == path }.each do |edge|
172
- output.puts " #{edge.type} #{edge.to} at #{edge.location.line}:#{edge.location.column}"
173
- end
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
- constants = graph.constants_named(subject)
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
@@ -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, :inflections
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)
@@ -36,6 +36,8 @@ module ArchSpec
36
36
  path: location.relative_path(root),
37
37
  line: location.line,
38
38
  column: location.column,
39
+ end_line: location.end_line,
40
+ end_column: location.end_column,
39
41
  evidence: evidence,
40
42
  confidence: confidence.to_s
41
43
  }