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.
data/lib/archspec/dsl.rb CHANGED
@@ -8,6 +8,16 @@ module ArchSpec
8
8
  # An +Archspec.rb+ file is evaluated in this context, so every method here is
9
9
  # a top-level call in that file.
10
10
  module DSL
11
+ # Raises when any of +names+ is not a declared component. Shared by the
12
+ # top-level DSL and the component proxies.
13
+ def self.assert_known_components!(definition, names, for_rule:)
14
+ unknown = Array(names).flatten.compact.map(&:to_sym).reject { |name| definition.component?(name) }.uniq.sort
15
+ return if unknown.empty?
16
+
17
+ label = unknown.length == 1 ? 'component' : 'components'
18
+ raise Error, "#{for_rule} references unknown #{label}: #{unknown.join(', ')}"
19
+ end
20
+
11
21
  # The top-level DSL. Declare the project, its components, an architecture
12
22
  # preset, and global rules.
13
23
  #
@@ -56,16 +66,6 @@ module ArchSpec
56
66
  self.todo_path = path.to_s
57
67
  end
58
68
 
59
- # Declares acronym inflections for Zeitwerk name checks, mirroring your
60
- # +config/initializers/inflections.rb+.
61
- #
62
- # inflect "api" => "API", "graphql" => "GraphQL"
63
- #
64
- # +app/models/api_client.rb+ then expects +APIClient+.
65
- def inflect(map)
66
- add_inflections(map)
67
- end
68
-
69
69
  # Yields each subdirectory matching a glob, so you can declare one
70
70
  # component per engine or pack without hardcoding their names. Paths
71
71
  # resolve against the +Archspec.rb+ directory, not the working directory,
@@ -97,9 +97,6 @@ module ArchSpec
97
97
  #
98
98
  # Returns an ArchSpec::DSL::ComponentProxy for attaching rules. The
99
99
  # component is also available by name later in the file.
100
- #
101
- # +layer+ and +role+ are aliases. Use whichever word fits the
102
- # architecture you are describing.
103
100
  def component(name, in: nil, namespace: nil, constants: nil)
104
101
  add_component(
105
102
  ComponentSpec.new(name, files: binding.local_variable_get(:in), namespace: namespace, constants: constants)
@@ -107,9 +104,6 @@ module ArchSpec
107
104
  ComponentProxy.new(self, name)
108
105
  end
109
106
 
110
- alias layer component
111
- alias role component
112
-
113
107
  # Applies a bundled architecture preset, defining its components and
114
108
  # rules together.
115
109
  #
@@ -117,34 +111,29 @@ module ArchSpec
117
111
  # architecture :hexagonal
118
112
  # architecture :modular_monolith, components: { ... }, allow: { ... }
119
113
  #
114
+ # +preset+ is an alias. Use whichever word fits: +architecture+ reads well
115
+ # for structural bundles like +:rails+, +preset+ for convention packs like
116
+ # +:ruby_conventions+.
117
+ #
120
118
  # See ArchSpec::Architectures for every preset and its options.
121
119
  def architecture(name, **options)
122
120
  Architectures.apply(name, self, **options)
123
121
  end
124
122
 
123
+ alias preset architecture
124
+
125
125
  # Forbids dependency cycles between components. Pass +among:+ to limit the
126
126
  # check to a subset; omit it to check every declared component.
127
127
  #
128
- # no_cycles!
129
- # no_cycles! among: %i[billing catalog shared]
128
+ # no_cycles
129
+ # no_cycles among: %i[billing catalog shared]
130
130
  #
131
131
  # Rule id: +dependencies.no_cycles+.
132
- def no_cycles!(among: nil)
132
+ def no_cycles(among: nil)
133
+ DSL.assert_known_components!(self, among, for_rule: 'no_cycles') if among
133
134
  add_rule(Rules::NoCyclesRule.new(among: among))
134
135
  end
135
136
 
136
- # Checks that files define the constant their path implies under Zeitwerk.
137
- # Pass globs to restrict the check to the autoloaded tree, since Rails
138
- # does not autoload +lib+ by default.
139
- #
140
- # verify_zeitwerk_names!
141
- # verify_zeitwerk_names! "app/**/*.rb"
142
- #
143
- # Rule id: +zeitwerk.naming+.
144
- def verify_zeitwerk_names!(*only)
145
- add_rule(Rules::ZeitwerkNamingRule.new(only: only))
146
- end
147
-
148
137
  # Adds a custom rule object. A rule responds to +id+ and
149
138
  # <tt>evaluate(graph)</tt>, returning ArchSpec::Diagnostic objects. Use
150
139
  # this to extend ArchSpec with project-specific checks.
@@ -176,40 +165,40 @@ module ArchSpec
176
165
  @name = name.to_sym
177
166
  end
178
167
 
179
- # Allowlists the components this one may depend on. A reference to any
180
- # other declared component fails.
168
+ # Allowlists the components this one may depend on: only the listed
169
+ # components are permitted, and a reference to any other declared
170
+ # component fails. The mirror image of #can_only_be_used_by.
181
171
  #
182
- # controllers.can_use :models, :services
172
+ # controllers.can_only_use :models, :services
183
173
  #
184
- # +only_depend_on+ and +must_only_depend_on+ are aliases.
185
174
  # Rule id: +dependencies.allow+.
186
- def can_use(*targets)
175
+ def can_only_use(*targets)
176
+ DSL.assert_known_components!(definition, targets, for_rule: "#{name}.can_only_use")
187
177
  add_rule(Rules::AllowDependenciesRule.new(name, targets))
188
178
  self
189
179
  end
190
180
 
191
- alias only_depend_on can_use
192
- alias must_only_depend_on can_use
193
-
194
- # Forbids depending on the named components. Narrower than #can_use: only
195
- # the listed components fail, other dependencies are left alone.
181
+ # Forbids depending on the named components. Narrower than #can_only_use:
182
+ # only the listed components fail, other dependencies are left alone.
196
183
  #
197
184
  # models.cannot_use :controllers, :helpers
198
185
  #
199
186
  # Rule id: +dependencies.forbid+.
200
187
  def cannot_use(*targets)
188
+ DSL.assert_known_components!(definition, targets, for_rule: "#{name}.cannot_use")
201
189
  add_rule(Rules::ForbidDependenciesRule.new(name, targets))
202
190
  self
203
191
  end
204
192
 
205
193
  # Allowlists the components that may reference this one, the inverse of
206
- # #can_use. A reference from any other component fails. Use it to protect
194
+ # #can_only_use. A reference from any other component fails. Use it to protect
207
195
  # a shared kernel or a component with a deliberately narrow audience.
208
196
  #
209
197
  # shared_kernel.can_only_be_used_by :billing, :catalog
210
198
  #
211
199
  # Rule id: +dependencies.consumers+.
212
200
  def can_only_be_used_by(*consumers)
201
+ DSL.assert_known_components!(definition, consumers, for_rule: "#{name}.can_only_be_used_by")
213
202
  add_rule(Rules::AllowedConsumersRule.new(name, consumers))
214
203
  self
215
204
  end
@@ -223,7 +212,8 @@ module ArchSpec
223
212
  # services.cannot_call :render, :params, receiver: :none
224
213
  #
225
214
  # A bare call to a method the component defines, inherits, or generates
226
- # with +attr_*+ or +delegate+ is treated as its own API and not flagged.
215
+ # with +attr_*+, Rails +attribute+, or +delegate+ is treated as its own API
216
+ # and not flagged.
227
217
  # Rule id: +methods.forbid+.
228
218
  def cannot_call(*methods, receiver: :any)
229
219
  add_rule(Rules::CannotCallRule.new(name, methods, receiver: receiver))
@@ -312,6 +302,8 @@ module ArchSpec
312
302
  #
313
303
  # Rule id: +protocol.must_implement+.
314
304
  def must_implement(*methods)
305
+ raise Error, 'must_implement requires at least one method' if methods.flatten.compact.empty?
306
+
315
307
  methods.each do |method_name|
316
308
  add_rule(Rules::MustImplementRule.new(name, method_name))
317
309
  end
@@ -329,6 +321,21 @@ module ArchSpec
329
321
  self
330
322
  end
331
323
 
324
+ # Starts a naming-convention rule over the component's defined, public
325
+ # methods. Select the methods with +matching+, then assert something about
326
+ # them. Every check is name-based and exact.
327
+ #
328
+ # models.method_names.matching(/\A(get|set)_/).forbidden
329
+ # chat.method_names.matching(/\Awith_(?<base>.+)/).requires("without_%{base}")
330
+ # chat.method_names.matching(/\Awith_(?<b>.+)/).requires("%{b}", on: agent, scope: :class)
331
+ #
332
+ # Pass <tt>scope: :class</tt> to select class methods instead of instance
333
+ # methods. See ArchSpec::Rules::Naming::Selected for the constraints
334
+ # (+forbidden+, +requires+). Rule ids: +naming.forbidden+, +naming.requires+.
335
+ def method_names(scope: :instance)
336
+ Rules::Naming::Builder.new(self, scope: scope)
337
+ end
338
+
332
339
  private
333
340
 
334
341
  def add_rule(rule)
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchSpec
4
+ # Raised for configuration and usage errors, such as an unknown architecture
5
+ # name or a malformed rule option.
6
+ class Error < StandardError; end
7
+ end
@@ -5,14 +5,12 @@ module ArchSpec
5
5
  extend self
6
6
 
7
7
  def evaluate(definition, graph, todo: Todo.empty)
8
- (parser_diagnostics(graph) + definition.rules.flat_map { |rule| rule.evaluate(graph) })
9
- .reject { |diagnostic| graph.suppressed?(diagnostic) }
10
- .reject { |diagnostic| todo.include?(diagnostic) }
11
- .sort_by do |diagnostic|
12
- [diagnostic.location.path, diagnostic.location.line, diagnostic.rule,
13
- diagnostic.message, diagnostic.evidence]
14
- end
15
- .uniq { |diagnostic| [diagnostic.rule, diagnostic.message, diagnostic.location.path, diagnostic.location.line] }
8
+ diagnostics = parser_diagnostics(graph) + definition.rules.flat_map { |rule| rule.evaluate(graph) }
9
+
10
+ diagnostics
11
+ .reject { |diagnostic| graph.suppressed?(diagnostic) || todo.include?(diagnostic) }
12
+ .sort_by { |d| [d.location.path, d.location.line, d.rule, d.message, d.evidence] }
13
+ .uniq { |d| [d.rule, d.message, d.location.path, d.location.line] }
16
14
  end
17
15
 
18
16
  private
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchSpec
4
+ module Formatters
5
+ # Renders <tt>archspec explain</tt>: why a file or constant belongs to its
6
+ # components, and the facts ArchSpec found for it, in the same visual
7
+ # language as the check output. Raises ArchSpec::Error when the subject
8
+ # matches no file and no constant.
9
+ module Explanation
10
+ module_function
11
+
12
+ def print(output = $stdout, graph:, subject:)
13
+ style = Style.new(output)
14
+ path = File.expand_path(subject, graph.root)
15
+
16
+ if graph.files.key?(path)
17
+ explain_file(output, style, graph, path)
18
+ else
19
+ explain_constant(output, style, graph, subject)
20
+ end
21
+ end
22
+
23
+ def explain_file(output, style, graph, path)
24
+ file = graph.files.fetch(path)
25
+ output.puts style.bold(file.relative_path)
26
+ output.puts
27
+ output.puts " #{style.note('defined constants:')} #{graph.constants_for_path(path).map(&:name).join(', ')}"
28
+ print_parse_errors(output, style, file)
29
+ print_component_reasons(output, style, graph.component_assignment_reasons_for_path(path))
30
+ print_suppressions(output, style, file)
31
+ print_facts(output, style, graph.edges.select { |edge| edge.from_path == path })
32
+ end
33
+
34
+ def explain_constant(output, style, graph, subject)
35
+ constants = graph.constants_named(subject)
36
+ raise Error, "no file or constant found for #{subject.inspect}" if constants.empty?
37
+
38
+ constants.each_with_index do |constant, index|
39
+ output.puts unless index.zero?
40
+ output.puts style.bold(constant.name)
41
+ output.puts
42
+ output.puts " #{style.note('kind:')} #{constant.kind}"
43
+ output.puts " #{style.note('file:')} #{constant.location.relative_path(graph.root)}:#{constant.location.line}"
44
+ print_component_reasons(
45
+ output, style,
46
+ graph.component_assignment_reasons_for_constant(constant.name, path: constant.path)
47
+ )
48
+ output.puts " #{style.note('superclass:')} #{constant.superclass || '(none)'}"
49
+ output.puts " #{style.note('instance methods:')} #{constant.instance_methods.to_a.sort.join(', ')}"
50
+ output.puts " #{style.note('class methods:')} #{constant.class_methods.to_a.sort.join(', ')}"
51
+ end
52
+ end
53
+
54
+ def print_component_reasons(output, style, assignments)
55
+ if assignments.empty?
56
+ output.puts " #{style.note('components:')} (none)"
57
+ return
58
+ end
59
+
60
+ output.puts " #{style.note('components:')}"
61
+ assignments.sort_by { |name, _reasons| name.to_s }.each do |name, reasons|
62
+ output.puts " #{name}: #{reasons.empty? ? '(no recorded reason)' : reasons.join('; ')}"
63
+ end
64
+ end
65
+
66
+ def print_suppressions(output, style, file)
67
+ return if file.suppressions.empty?
68
+
69
+ output.puts " #{style.note('suppressions:')}"
70
+ in_gutters(file.suppressions.map { |suppression| line_range(suppression) }) do |gutter, index|
71
+ suppression = file.suppressions[index]
72
+ rule = suppression.rule || '*'
73
+ reason = suppression.reason ? " -- #{suppression.reason}" : ''
74
+ output.puts " #{style.faint(gutter)} #{rule}#{reason}"
75
+ end
76
+ end
77
+
78
+ def print_parse_errors(output, style, file)
79
+ return if file.parse_errors.empty?
80
+
81
+ output.puts " #{style.note('parse errors:')}"
82
+ locations = file.parse_errors.map { |error| "#{error.location.line}:#{error.location.column}" }
83
+ in_gutters(locations) do |gutter, index|
84
+ output.puts " #{style.faint(gutter)} #{file.parse_errors[index].message}"
85
+ end
86
+ end
87
+
88
+ def print_facts(output, style, facts)
89
+ if facts.empty?
90
+ output.puts " #{style.note('outgoing facts:')} (none)"
91
+ return
92
+ end
93
+
94
+ output.puts " #{style.note('outgoing facts:')}"
95
+ locations = facts.map { |edge| "#{edge.location.line}:#{edge.location.column}" }
96
+ in_gutters(locations) do |gutter, index|
97
+ edge = facts[index]
98
+ output.puts " #{style.faint(gutter)} #{edge.verb} #{edge.to}"
99
+ end
100
+ end
101
+
102
+ # Yields each label right-justified to the widest one, with the frame
103
+ # gutter bar appended, so columns line up like the check output.
104
+ def in_gutters(labels)
105
+ width = labels.map(&:length).max
106
+
107
+ labels.each_with_index do |label, index|
108
+ yield "#{label.rjust(width)} │", index
109
+ end
110
+ end
111
+
112
+ def line_range(suppression)
113
+ if suppression.end_line == Float::INFINITY
114
+ "#{suppression.start_line}-EOF"
115
+ elsif suppression.start_line == suppression.end_line
116
+ suppression.start_line.to_s
117
+ else
118
+ "#{suppression.start_line}-#{suppression.end_line}"
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArchSpec
4
+ module Formatters
5
+ # ANSI styling for terminal output, shared by the formatters. Enabled only
6
+ # when the output is a TTY and NO_COLOR is unset.
7
+ class Style
8
+ def initialize(output)
9
+ @enabled = output.respond_to?(:tty?) && output.tty? && ENV['NO_COLOR'].to_s.empty?
10
+ end
11
+
12
+ def bold(text)
13
+ paint(text, '1')
14
+ end
15
+
16
+ def severity(text)
17
+ paint(text, '1;31')
18
+ end
19
+
20
+ def marker(text)
21
+ paint(text, '1;31')
22
+ end
23
+
24
+ def note(text)
25
+ paint(text, '1;36')
26
+ end
27
+
28
+ def faint(text)
29
+ paint(text, '2')
30
+ end
31
+
32
+ private
33
+
34
+ def paint(text, code)
35
+ @enabled ? "\e[#{code}m#{text}\e[0m" : text
36
+ end
37
+ end
38
+ end
39
+ end
@@ -2,26 +2,110 @@
2
2
 
3
3
  module ArchSpec
4
4
  module Formatters
5
+ # Prints diagnostics the way clang and herb do: a severity header with the
6
+ # rule id, the location, a code frame with the offending span underlined,
7
+ # and the evidence as a note.
8
+ #
9
+ # [error] models must not depend on controllers [dependencies.forbid]
10
+ #
11
+ # app/models/user.rb:3:3
12
+ #
13
+ # 2 │ class User
14
+ # → 3 │ UsersController
15
+ # │ ^~~~~~~~~~~~~~~
16
+ # 4 │ end
17
+ #
18
+ # note: User references UsersController
19
+ #
20
+ # Output to a terminal is colored; a non-TTY or a NO_COLOR environment
21
+ # disables the colors.
5
22
  module Text
23
+ CONTEXT_LINES = 1
24
+
6
25
  module_function
7
26
 
8
27
  def print(output = $stdout, graph:, diagnostics:)
9
28
  if diagnostics.empty?
10
- output.puts "ArchSpec passed: #{graph.files.size} files, #{graph.constants.size} constants, #{graph.edges.size} facts checked."
29
+ output.puts "ArchSpec passed: #{graph.files.size} files, #{graph.constants.size} constants, " \
30
+ "#{graph.edges.size} facts checked."
11
31
  return
12
32
  end
13
33
 
14
- output.puts "#{diagnostics.size} architecture #{diagnostics.size == 1 ? 'violation' : 'violations'}"
15
- output.puts
34
+ style = Style.new(output)
35
+ sources = Hash.new { |hash, path| hash[path] = read_lines(path) }
16
36
 
17
37
  diagnostics.each do |diagnostic|
18
- output.puts "[#{diagnostic.rule}] #{diagnostic.location.relative_path(graph.root)}:#{diagnostic.location.line}:#{diagnostic.location.column}"
19
- output.puts " #{diagnostic.message}"
20
- output.puts " evidence: #{diagnostic.evidence}"
21
- output.puts " confidence: #{diagnostic.confidence}"
22
- output.puts " id: #{diagnostic.fingerprint(root: graph.root)}"
38
+ print_diagnostic(output, style, graph, diagnostic, sources)
39
+ end
40
+
41
+ label = diagnostics.size == 1 ? 'architecture violation' : 'architecture violations'
42
+ output.puts style.bold("#{diagnostics.size} #{label} found.")
43
+ end
44
+
45
+ def print_diagnostic(output, style, graph, diagnostic, sources)
46
+ location = diagnostic.location
47
+ relative = location.relative_path(graph.root)
48
+
49
+ output.puts "#{style.severity('[error]')} #{style.bold(diagnostic.message)} #{style.faint("[#{diagnostic.rule}]")}"
50
+ output.puts
51
+ output.puts "#{relative}:#{location.line}:#{location.column}"
52
+ print_frame(output, style, location, sources[location.path])
53
+
54
+ if (note = note_for(diagnostic, relative))
23
55
  output.puts
56
+ output.puts " #{style.note('note:')} #{note}"
24
57
  end
58
+ output.puts
59
+ end
60
+
61
+ def print_frame(output, style, location, lines)
62
+ target = lines[location.line - 1]
63
+ return unless target
64
+
65
+ output.puts
66
+ first = [location.line - CONTEXT_LINES, 1].max
67
+ last = [location.line + CONTEXT_LINES, lines.size].min
68
+ width = last.to_s.length
69
+
70
+ (first..last).each do |number|
71
+ text = lines[number - 1]
72
+ if number == location.line
73
+ output.puts " #{style.marker('→')} #{style.faint("#{number.to_s.rjust(width)} │")} #{text}"
74
+ output.puts " #{style.faint("#{' ' * width} │")} #{style.marker(underline(location, target))}"
75
+ else
76
+ output.puts " #{style.faint("#{number.to_s.rjust(width)} │")} #{text}"
77
+ end
78
+ end
79
+ end
80
+
81
+ # The evidence as a note, or nil when it would only repeat the location
82
+ # shown above it, as parse-error evidence does.
83
+ def note_for(diagnostic, relative)
84
+ note = diagnostic.evidence.to_s
85
+ return if note.empty? || note == relative
86
+
87
+ note = "#{note} (confidence: #{diagnostic.confidence})" unless diagnostic.confidence == :high
88
+ note
89
+ end
90
+
91
+ def underline(location, text)
92
+ span =
93
+ if location.end_line == location.line
94
+ location.end_column - location.column
95
+ else
96
+ text.length - location.column + 1
97
+ end
98
+ span = span.clamp(1, [text.length - location.column + 1, 1].max)
99
+
100
+ "#{' ' * (location.column - 1)}^#{'~' * (span - 1)}"
101
+ end
102
+
103
+ def read_lines(path)
104
+ return [] unless File.file?(path)
105
+
106
+ File.readlines(path, chomp: true).map { |line| line.scrub.tr("\t", ' ') }
107
+ rescue SystemCallError
108
+ []
25
109
  end
26
110
  end
27
111
  end