archspec 0.5.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +26 -8
- data/lib/archspec/analyzer.rb +97 -53
- data/lib/archspec/architectures.rb +88 -84
- data/lib/archspec/cli.rb +114 -34
- data/lib/archspec/diagnostic.rb +2 -0
- data/lib/archspec/dsl.rb +16 -0
- data/lib/archspec/error.rb +7 -0
- data/lib/archspec/evaluator.rb +6 -8
- data/lib/archspec/formatters/explanation.rb +68 -38
- data/lib/archspec/formatters/style.rb +39 -0
- data/lib/archspec/formatters/text.rb +92 -8
- data/lib/archspec/model.rb +119 -36
- 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 +2 -2
- data/lib/archspec/rules/dependency_rules.rb +10 -18
- data/lib/archspec/rules/naming_rules.rb +31 -3
- data/lib/archspec/rules/privacy_rule.rb +3 -3
- data/lib/archspec/rules/protocol_rules.rb +9 -6
- 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 +8 -13
- metadata +4 -2
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
69
|
-
opts.on('--
|
|
70
|
-
opts.on('--
|
|
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
|
-
|
|
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,27 +132,38 @@ module ArchSpec
|
|
|
83
132
|
if options[:update_todo]
|
|
84
133
|
unless todo_path
|
|
85
134
|
raise Error,
|
|
86
|
-
"
|
|
135
|
+
"no todo configured; add `todo \"archspec_todo.yml\"` to #{options[:config]}"
|
|
87
136
|
end
|
|
88
137
|
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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)
|
|
@@ -112,19 +172,25 @@ module ArchSpec
|
|
|
112
172
|
end
|
|
113
173
|
|
|
114
174
|
def load_definition(config_path)
|
|
115
|
-
raise Error, "
|
|
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 =
|
|
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
|
-
|
|
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,18 +217,32 @@ module ArchSpec
|
|
|
151
217
|
when 'json'
|
|
152
218
|
Formatters::JSON
|
|
153
219
|
else
|
|
154
|
-
raise
|
|
220
|
+
raise UsageError, "unknown format: #{name.inspect}"
|
|
155
221
|
end
|
|
156
222
|
end
|
|
157
223
|
|
|
158
|
-
def usage
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
243
|
+
else
|
|
244
|
+
usage
|
|
245
|
+
end
|
|
166
246
|
end
|
|
167
247
|
end
|
|
168
248
|
end
|
data/lib/archspec/diagnostic.rb
CHANGED
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
|
#
|
|
@@ -120,6 +130,7 @@ module ArchSpec
|
|
|
120
130
|
#
|
|
121
131
|
# Rule id: +dependencies.no_cycles+.
|
|
122
132
|
def no_cycles(among: nil)
|
|
133
|
+
DSL.assert_known_components!(self, among, for_rule: 'no_cycles') if among
|
|
123
134
|
add_rule(Rules::NoCyclesRule.new(among: among))
|
|
124
135
|
end
|
|
125
136
|
|
|
@@ -162,6 +173,7 @@ module ArchSpec
|
|
|
162
173
|
#
|
|
163
174
|
# Rule id: +dependencies.allow+.
|
|
164
175
|
def can_only_use(*targets)
|
|
176
|
+
DSL.assert_known_components!(definition, targets, for_rule: "#{name}.can_only_use")
|
|
165
177
|
add_rule(Rules::AllowDependenciesRule.new(name, targets))
|
|
166
178
|
self
|
|
167
179
|
end
|
|
@@ -173,6 +185,7 @@ module ArchSpec
|
|
|
173
185
|
#
|
|
174
186
|
# Rule id: +dependencies.forbid+.
|
|
175
187
|
def cannot_use(*targets)
|
|
188
|
+
DSL.assert_known_components!(definition, targets, for_rule: "#{name}.cannot_use")
|
|
176
189
|
add_rule(Rules::ForbidDependenciesRule.new(name, targets))
|
|
177
190
|
self
|
|
178
191
|
end
|
|
@@ -185,6 +198,7 @@ module ArchSpec
|
|
|
185
198
|
#
|
|
186
199
|
# Rule id: +dependencies.consumers+.
|
|
187
200
|
def can_only_be_used_by(*consumers)
|
|
201
|
+
DSL.assert_known_components!(definition, consumers, for_rule: "#{name}.can_only_be_used_by")
|
|
188
202
|
add_rule(Rules::AllowedConsumersRule.new(name, consumers))
|
|
189
203
|
self
|
|
190
204
|
end
|
|
@@ -288,6 +302,8 @@ module ArchSpec
|
|
|
288
302
|
#
|
|
289
303
|
# Rule id: +protocol.must_implement+.
|
|
290
304
|
def must_implement(*methods)
|
|
305
|
+
raise Error, 'must_implement requires at least one method' if methods.flatten.compact.empty?
|
|
306
|
+
|
|
291
307
|
methods.each do |method_name|
|
|
292
308
|
add_rule(Rules::MustImplementRule.new(name, method_name))
|
|
293
309
|
end
|
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
|
|
@@ -3,79 +3,109 @@
|
|
|
3
3
|
module ArchSpec
|
|
4
4
|
module Formatters
|
|
5
5
|
# Renders <tt>archspec explain</tt>: why a file or constant belongs to its
|
|
6
|
-
# components, and the facts ArchSpec found for it
|
|
7
|
-
#
|
|
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.
|
|
8
9
|
module Explanation
|
|
9
10
|
module_function
|
|
10
11
|
|
|
11
12
|
def print(output = $stdout, graph:, subject:)
|
|
13
|
+
style = Style.new(output)
|
|
12
14
|
path = File.expand_path(subject, graph.root)
|
|
13
15
|
|
|
14
16
|
if graph.files.key?(path)
|
|
15
|
-
explain_file(output, graph, path)
|
|
17
|
+
explain_file(output, style, graph, path)
|
|
16
18
|
else
|
|
17
|
-
explain_constant(output, graph, subject)
|
|
19
|
+
explain_constant(output, style, graph, subject)
|
|
18
20
|
end
|
|
19
21
|
end
|
|
20
22
|
|
|
21
|
-
def explain_file(output, graph, path)
|
|
23
|
+
def explain_file(output, style, graph, path)
|
|
22
24
|
file = graph.files.fetch(path)
|
|
23
|
-
output.puts file.relative_path
|
|
24
|
-
output.puts
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
output
|
|
29
|
-
|
|
30
|
-
graph.edges.select { |edge| edge.from_path == path }.each do |edge|
|
|
31
|
-
output.puts " #{edge.type} #{edge.to} at #{edge.location.line}:#{edge.location.column}"
|
|
32
|
-
end
|
|
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 })
|
|
33
32
|
end
|
|
34
33
|
|
|
35
|
-
def explain_constant(output, graph, subject)
|
|
34
|
+
def explain_constant(output, style, graph, subject)
|
|
36
35
|
constants = graph.constants_named(subject)
|
|
37
|
-
raise Error, "
|
|
38
|
-
|
|
39
|
-
constants.
|
|
40
|
-
output.puts
|
|
41
|
-
output.puts
|
|
42
|
-
output.puts
|
|
43
|
-
|
|
44
|
-
output.puts "
|
|
45
|
-
|
|
46
|
-
|
|
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(', ')}"
|
|
47
51
|
end
|
|
48
52
|
end
|
|
49
53
|
|
|
50
|
-
def print_component_reasons(output, assignments)
|
|
54
|
+
def print_component_reasons(output, style, assignments)
|
|
51
55
|
if assignments.empty?
|
|
52
|
-
output.puts '
|
|
56
|
+
output.puts " #{style.note('components:')} (none)"
|
|
53
57
|
return
|
|
54
58
|
end
|
|
55
59
|
|
|
56
|
-
output.puts '
|
|
60
|
+
output.puts " #{style.note('components:')}"
|
|
57
61
|
assignments.sort_by { |name, _reasons| name.to_s }.each do |name, reasons|
|
|
58
62
|
output.puts " #{name}: #{reasons.empty? ? '(no recorded reason)' : reasons.join('; ')}"
|
|
59
63
|
end
|
|
60
64
|
end
|
|
61
65
|
|
|
62
|
-
def print_suppressions(output, file)
|
|
66
|
+
def print_suppressions(output, style, file)
|
|
63
67
|
return if file.suppressions.empty?
|
|
64
68
|
|
|
65
|
-
output.puts '
|
|
66
|
-
file.suppressions.
|
|
69
|
+
output.puts " #{style.note('suppressions:')}"
|
|
70
|
+
in_gutters(file.suppressions.map { |suppression| line_range(suppression) }) do |gutter, index|
|
|
71
|
+
suppression = file.suppressions[index]
|
|
67
72
|
rule = suppression.rule || '*'
|
|
68
73
|
reason = suppression.reason ? " -- #{suppression.reason}" : ''
|
|
69
|
-
output.puts " #{
|
|
74
|
+
output.puts " #{style.faint(gutter)} #{rule}#{reason}"
|
|
70
75
|
end
|
|
71
76
|
end
|
|
72
77
|
|
|
73
|
-
def print_parse_errors(output, file)
|
|
78
|
+
def print_parse_errors(output, style, file)
|
|
74
79
|
return if file.parse_errors.empty?
|
|
75
80
|
|
|
76
|
-
output.puts '
|
|
77
|
-
file.parse_errors.
|
|
78
|
-
|
|
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
|
|
79
109
|
end
|
|
80
110
|
end
|
|
81
111
|
|
|
@@ -83,7 +113,7 @@ module ArchSpec
|
|
|
83
113
|
if suppression.end_line == Float::INFINITY
|
|
84
114
|
"#{suppression.start_line}-EOF"
|
|
85
115
|
elsif suppression.start_line == suppression.end_line
|
|
86
|
-
suppression.start_line
|
|
116
|
+
suppression.start_line.to_s
|
|
87
117
|
else
|
|
88
118
|
"#{suppression.start_line}-#{suppression.end_line}"
|
|
89
119
|
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
|