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.
@@ -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(dsl, components: options.fetch(:components, DEFAULT_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(dsl, components: options.fetch(:components, DEFAULT_RAILS_MVC))
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(dsl, components: options.fetch(:components), allow: options.fetch(:allow, {}))
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
- proxy_for(dsl, :controllers).can_use(*components.keys & %i[models services helpers mailers jobs])
102
- proxy_for(dsl, :models).cannot_use(*components.keys & %i[controllers helpers])
103
- proxy_for(dsl, :services).cannot_use(*components.keys & %i[controllers helpers])
104
- proxy_for(dsl, :models).cannot_call(*CONTROLLER_METHODS)
105
- proxy_for(dsl, :services).cannot_call(*CONTROLLER_METHODS)
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.verify_zeitwerk_names!
112
- dsl.no_cycles!(among: components.keys)
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
- rails_mvc(dsl, components: components)
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).can_use(*allowed)
202
+ proxy_for(dsl, name).can_only_use(*allowed)
131
203
  end
132
204
 
133
- dsl.no_cycles!(among: names)
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).can_use :domain, :ports
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).can_use :application, :domain, :ports
149
- dsl.no_cycles!(among: roles.keys)
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).can_use(*allowed)
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!(among: components.keys)
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!(among: components.keys)
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).can_use :events
193
- proxy_for(dsl, :subscribers).can_use :events
194
- dsl.no_cycles!(among: roles.keys)
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
- update_baseline: false
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-baseline') { options[:update_baseline] = true }
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
- baseline_path = baseline_path_for(definition, root)
68
- baseline = options[:update_baseline] ? Baseline.empty(root: root) : Baseline.load(baseline_path, root: root)
69
- diagnostics = Evaluator.evaluate(definition, graph, baseline: baseline)
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[:update_baseline]
72
- unless baseline_path
83
+ if options[:update_todo]
84
+ unless todo_path
73
85
  raise Error,
74
- "No baseline configured. Add `baseline \".archspec_todo.yml\"` to #{options[:config]}."
86
+ "No todo configured. Add `todo \"archspec_todo.yml\"` to #{options[:config]}."
75
87
  end
76
88
 
77
- Baseline.write(baseline_path, diagnostics, root: root)
78
- output.puts "Updated #{Pathname(baseline_path).relative_path_from(Pathname(root))} with #{diagnostics.size} violations."
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
- explain_subject(output, graph, subject)
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
- [definition, definition.absolute_root(File.dirname(absolute_config))]
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 baseline_path_for(definition, root)
116
- return unless definition.baseline_path
141
+ def todo_path_for(definition, root)
142
+ return unless definition.todo_path
117
143
 
118
- File.expand_path(definition.baseline_path, root)
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-baseline]
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
 
@@ -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, :baseline_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
- @baseline_path = nil
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
- def absolute_root(base_dir = Dir.pwd)
58
- File.expand_path(root_path, base_dir)
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
@@ -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, location.line, evidence].join("\0")
27
+ [rule, message, path, evidence].join("\0")
22
28
  )[0, 24]
23
29
  end
24
30