archspec 0.4.0 → 1.0.0.rc1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +29 -8
- data/lib/archspec/analyzer.rb +217 -84
- data/lib/archspec/architectures.rb +130 -98
- data/lib/archspec/cli.rb +113 -105
- data/lib/archspec/definition.rb +1 -6
- data/lib/archspec/diagnostic.rb +2 -0
- data/lib/archspec/dsl.rb +50 -43
- data/lib/archspec/error.rb +7 -0
- data/lib/archspec/evaluator.rb +6 -8
- data/lib/archspec/formatters/explanation.rb +123 -0
- data/lib/archspec/formatters/style.rb +39 -0
- data/lib/archspec/formatters/text.rb +92 -8
- data/lib/archspec/model.rb +142 -46
- data/lib/archspec/rules/component_rules.rb +1 -1
- data/lib/archspec/rules/concern_rules.rb +3 -3
- data/lib/archspec/rules/cycle_rule.rb +3 -3
- data/lib/archspec/rules/dependency_rules.rb +11 -19
- data/lib/archspec/rules/naming_rules.rb +237 -0
- data/lib/archspec/rules/privacy_rule.rb +3 -3
- data/lib/archspec/rules/protocol_rules.rb +10 -7
- data/lib/archspec/source_location.rb +8 -2
- data/lib/archspec/todo.rb +17 -2
- data/lib/archspec/value_object.rb +4 -0
- data/lib/archspec/version.rb +1 -1
- data/lib/archspec.rb +10 -15
- metadata +6 -4
- data/lib/archspec/presets.rb +0 -14
- data/lib/archspec/rules/zeitwerk_rule.rb +0 -51
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
|
|
128
|
+
# no_cycles
|
|
129
|
+
# no_cycles among: %i[billing catalog shared]
|
|
130
130
|
#
|
|
131
131
|
# Rule id: +dependencies.no_cycles+.
|
|
132
|
-
def no_cycles
|
|
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
|
|
180
|
-
# other declared
|
|
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.
|
|
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
|
|
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
|
-
|
|
192
|
-
|
|
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
|
-
# #
|
|
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_
|
|
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)
|
data/lib/archspec/evaluator.rb
CHANGED
|
@@ -5,14 +5,12 @@ module ArchSpec
|
|
|
5
5
|
extend self
|
|
6
6
|
|
|
7
7
|
def evaluate(definition, graph, todo: Todo.empty)
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
.
|
|
12
|
-
[
|
|
13
|
-
|
|
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,
|
|
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
|
-
|
|
15
|
-
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|