archspec 0.1.0.pre1
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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +225 -0
- data/exe/archspec +7 -0
- data/lib/archspec/analyzer.rb +376 -0
- data/lib/archspec/architectures.rb +196 -0
- data/lib/archspec/baseline.rb +50 -0
- data/lib/archspec/cli.rb +213 -0
- data/lib/archspec/component_spec.rb +34 -0
- data/lib/archspec/definition.rb +64 -0
- data/lib/archspec/diagnostic.rb +36 -0
- data/lib/archspec/dsl.rb +132 -0
- data/lib/archspec/evaluator.rb +28 -0
- data/lib/archspec/formatters/json.rb +18 -0
- data/lib/archspec/formatters/text.rb +26 -0
- data/lib/archspec/model.rb +278 -0
- data/lib/archspec/presets.rb +56 -0
- data/lib/archspec/rules/cycle_rule.rb +79 -0
- data/lib/archspec/rules/dependency_rules.rb +117 -0
- data/lib/archspec/rules/protocol_rules.rb +186 -0
- data/lib/archspec/rules/zeitwerk_rule.rb +25 -0
- data/lib/archspec/source_location.rb +15 -0
- data/lib/archspec/version.rb +3 -0
- data/lib/archspec.rb +34 -0
- metadata +115 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
module ArchSpec
|
|
2
|
+
module Architectures
|
|
3
|
+
extend self
|
|
4
|
+
|
|
5
|
+
DEFAULT_LAYERED = {
|
|
6
|
+
interface: "app/controllers/**/*.rb",
|
|
7
|
+
application: %w[app/services/**/*.rb app/jobs/**/*.rb app/mailers/**/*.rb],
|
|
8
|
+
domain: "app/models/**/*.rb"
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
DEFAULT_RAILS_MVC = {
|
|
12
|
+
controllers: "app/controllers/**/*.rb",
|
|
13
|
+
models: "app/models/**/*.rb",
|
|
14
|
+
helpers: "app/helpers/**/*.rb",
|
|
15
|
+
mailers: "app/mailers/**/*.rb",
|
|
16
|
+
jobs: "app/jobs/**/*.rb",
|
|
17
|
+
services: "app/services/**/*.rb"
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
DEFAULT_HEXAGONAL = {
|
|
21
|
+
application: %w[app/services/**/*.rb app/use_cases/**/*.rb],
|
|
22
|
+
domain: "app/domain/**/*.rb",
|
|
23
|
+
ports: "app/ports/**/*.rb",
|
|
24
|
+
adapters: %w[app/adapters/**/*.rb app/integrations/**/*.rb app/infrastructure/**/*.rb]
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
DEFAULT_CLEAN = {
|
|
28
|
+
frameworks: %w[app/controllers/**/*.rb app/jobs/**/*.rb app/mailers/**/*.rb],
|
|
29
|
+
interface_adapters: %w[app/adapters/**/*.rb app/presenters/**/*.rb app/serializers/**/*.rb],
|
|
30
|
+
use_cases: %w[app/use_cases/**/*.rb app/services/**/*.rb],
|
|
31
|
+
entities: %w[app/entities/**/*.rb app/domain/**/*.rb app/models/**/*.rb]
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
DEFAULT_CQRS = {
|
|
35
|
+
commands: "app/commands/**/*.rb",
|
|
36
|
+
queries: "app/queries/**/*.rb",
|
|
37
|
+
read_models: "app/read_models/**/*.rb"
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
DEFAULT_EVENT_DRIVEN = {
|
|
41
|
+
events: "app/events/**/*.rb",
|
|
42
|
+
publishers: "app/publishers/**/*.rb",
|
|
43
|
+
subscribers: "app/subscribers/**/*.rb"
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
CONTROLLER_METHODS = %i[render redirect_to params session cookies flash].freeze
|
|
47
|
+
MUTATING_METHODS = %i[
|
|
48
|
+
create create!
|
|
49
|
+
delete delete_all
|
|
50
|
+
destroy destroy!
|
|
51
|
+
insert insert!
|
|
52
|
+
save save!
|
|
53
|
+
update update! update_attribute update_attributes update_columns
|
|
54
|
+
upsert upsert!
|
|
55
|
+
].freeze
|
|
56
|
+
|
|
57
|
+
def apply(name, dsl, **options)
|
|
58
|
+
case name.to_sym
|
|
59
|
+
when :rails_mvc, :rails_way
|
|
60
|
+
rails_mvc(dsl, components: options.fetch(:components, DEFAULT_RAILS_MVC))
|
|
61
|
+
when :layered
|
|
62
|
+
layered(dsl, layers: options.fetch(:layers, DEFAULT_LAYERED))
|
|
63
|
+
when :hexagonal
|
|
64
|
+
hexagonal(dsl, **with_defaults(DEFAULT_HEXAGONAL, options))
|
|
65
|
+
when :clean
|
|
66
|
+
clean(dsl, **with_defaults(DEFAULT_CLEAN, options))
|
|
67
|
+
when :modular_monolith, :bounded_contexts
|
|
68
|
+
modular_monolith(dsl, components: options.fetch(:components), allow: options.fetch(:allow, {}))
|
|
69
|
+
when :cqrs
|
|
70
|
+
cqrs(dsl, **with_defaults(DEFAULT_CQRS, options))
|
|
71
|
+
when :event_driven
|
|
72
|
+
event_driven(dsl, **with_defaults(DEFAULT_EVENT_DRIVEN, options))
|
|
73
|
+
else
|
|
74
|
+
raise Error, "Unknown ArchSpec architecture: #{name.inspect}"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def rails_mvc(dsl, components:)
|
|
79
|
+
components = normalize_map(components)
|
|
80
|
+
define_components(dsl, components)
|
|
81
|
+
|
|
82
|
+
proxy_for(dsl, :controllers).can_use(*components.keys & %i[models services helpers mailers jobs])
|
|
83
|
+
proxy_for(dsl, :models).cannot_use(*components.keys & %i[controllers helpers])
|
|
84
|
+
proxy_for(dsl, :services).cannot_use(*components.keys & %i[controllers helpers])
|
|
85
|
+
proxy_for(dsl, :models).cannot_call(*CONTROLLER_METHODS)
|
|
86
|
+
proxy_for(dsl, :services).cannot_call(*CONTROLLER_METHODS)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def layered(dsl, layers:)
|
|
90
|
+
ordered = normalize_map(layers)
|
|
91
|
+
define_components(dsl, ordered)
|
|
92
|
+
names = ordered.keys
|
|
93
|
+
|
|
94
|
+
names.each_with_index do |name, index|
|
|
95
|
+
allowed = names[(index + 1)..] || []
|
|
96
|
+
proxy_for(dsl, name).can_use(*allowed)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
dsl.no_cycles!(among: names)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def hexagonal(dsl, application:, domain:, ports:, adapters:)
|
|
103
|
+
roles = normalize_map(
|
|
104
|
+
application: application,
|
|
105
|
+
domain: domain,
|
|
106
|
+
ports: ports,
|
|
107
|
+
adapters: adapters
|
|
108
|
+
)
|
|
109
|
+
define_components(dsl, roles)
|
|
110
|
+
|
|
111
|
+
proxy_for(dsl, :application).can_use :domain, :ports
|
|
112
|
+
proxy_for(dsl, :domain).cannot_use :adapters
|
|
113
|
+
proxy_for(dsl, :ports).cannot_use :adapters
|
|
114
|
+
proxy_for(dsl, :adapters).can_use :application, :domain, :ports
|
|
115
|
+
dsl.no_cycles!(among: roles.keys)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def clean(dsl, frameworks:, interface_adapters:, use_cases:, entities:)
|
|
119
|
+
layered(
|
|
120
|
+
dsl,
|
|
121
|
+
layers: {
|
|
122
|
+
frameworks: frameworks,
|
|
123
|
+
interface_adapters: interface_adapters,
|
|
124
|
+
use_cases: use_cases,
|
|
125
|
+
entities: entities
|
|
126
|
+
}
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def modular_monolith(dsl, components:, allow: {})
|
|
131
|
+
components = normalize_map(components)
|
|
132
|
+
define_components(dsl, components)
|
|
133
|
+
|
|
134
|
+
components.each_key do |name|
|
|
135
|
+
allowed = Array(allow[name] || allow[name.to_s])
|
|
136
|
+
proxy_for(dsl, name).can_use(*allowed)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
dsl.no_cycles!(among: components.keys)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def cqrs(dsl, commands:, queries:, read_models: nil, mutating_methods: MUTATING_METHODS)
|
|
143
|
+
components = normalize_map(commands: commands, queries: queries)
|
|
144
|
+
components[:read_models] = read_models if read_models
|
|
145
|
+
define_components(dsl, components)
|
|
146
|
+
|
|
147
|
+
proxy_for(dsl, :commands).cannot_use :queries
|
|
148
|
+
proxy_for(dsl, :queries).cannot_use :commands
|
|
149
|
+
proxy_for(dsl, :queries).cannot_call(*mutating_methods)
|
|
150
|
+
dsl.no_cycles!(among: components.keys)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def event_driven(dsl, events:, publishers:, subscribers:)
|
|
154
|
+
roles = normalize_map(events: events, publishers: publishers, subscribers: subscribers)
|
|
155
|
+
define_components(dsl, roles)
|
|
156
|
+
|
|
157
|
+
proxy_for(dsl, :events).cannot_use :publishers, :subscribers
|
|
158
|
+
proxy_for(dsl, :publishers).can_use :events
|
|
159
|
+
proxy_for(dsl, :subscribers).can_use :events
|
|
160
|
+
dsl.no_cycles!(among: roles.keys)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
def with_defaults(defaults, options)
|
|
166
|
+
defaults.merge(options)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def normalize_map(map)
|
|
170
|
+
map.to_h.transform_keys(&:to_sym)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def define_components(dsl, components)
|
|
174
|
+
components.each do |name, selector|
|
|
175
|
+
define_component(dsl, name, selector)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def define_component(dsl, name, selector)
|
|
180
|
+
if selector.is_a?(Hash)
|
|
181
|
+
dsl.component(
|
|
182
|
+
name,
|
|
183
|
+
in: selector[:in] || selector[:files],
|
|
184
|
+
namespace: selector[:namespace],
|
|
185
|
+
constants: selector[:constants]
|
|
186
|
+
)
|
|
187
|
+
else
|
|
188
|
+
dsl.component(name, in: selector)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def proxy_for(dsl, name)
|
|
193
|
+
DSL::ComponentProxy.new(dsl, name)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
require "set"
|
|
2
|
+
require "yaml"
|
|
3
|
+
|
|
4
|
+
module ArchSpec
|
|
5
|
+
class Baseline
|
|
6
|
+
def self.empty(root: nil)
|
|
7
|
+
new(Set.new, root: root)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.load(path, root:)
|
|
11
|
+
return empty(root: root) unless path && File.exist?(path)
|
|
12
|
+
|
|
13
|
+
document = YAML.safe_load_file(path, permitted_classes: [], aliases: false) || {}
|
|
14
|
+
ids = Array(document["violations"]).filter_map do |entry|
|
|
15
|
+
entry.is_a?(Hash) ? entry["id"] : entry
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
new(ids.to_set, root: root)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.write(path, diagnostics, root:)
|
|
22
|
+
payload = {
|
|
23
|
+
"violations" => diagnostics.map do |diagnostic|
|
|
24
|
+
{
|
|
25
|
+
"id" => diagnostic.fingerprint(root: root),
|
|
26
|
+
"rule" => diagnostic.rule,
|
|
27
|
+
"path" => diagnostic.location.relative_path(root),
|
|
28
|
+
"line" => diagnostic.location.line,
|
|
29
|
+
"message" => diagnostic.message
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
File.write(path, payload.to_yaml)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initialize(ids, root:)
|
|
38
|
+
@ids = ids
|
|
39
|
+
@root = root
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def include?(diagnostic)
|
|
43
|
+
ids.include?(diagnostic.fingerprint(root: root))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
attr_reader :ids, :root
|
|
49
|
+
end
|
|
50
|
+
end
|
data/lib/archspec/cli.rb
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
require "optparse"
|
|
2
|
+
|
|
3
|
+
module ArchSpec
|
|
4
|
+
module CLI
|
|
5
|
+
extend self
|
|
6
|
+
|
|
7
|
+
CONFIG_FILE = "Archspec.rb"
|
|
8
|
+
TEMPLATE = <<~RUBY
|
|
9
|
+
ArchSpec.define "Application architecture" do
|
|
10
|
+
root "."
|
|
11
|
+
preset :rails_way
|
|
12
|
+
end
|
|
13
|
+
RUBY
|
|
14
|
+
|
|
15
|
+
def run(argv, output: $stdout, error: $stderr)
|
|
16
|
+
argv = argv.dup
|
|
17
|
+
command = argv.shift || "check"
|
|
18
|
+
|
|
19
|
+
case command
|
|
20
|
+
when "init"
|
|
21
|
+
init(argv, output)
|
|
22
|
+
when "check"
|
|
23
|
+
check(argv, output)
|
|
24
|
+
when "explain"
|
|
25
|
+
explain(argv, output)
|
|
26
|
+
when "version", "--version", "-v"
|
|
27
|
+
output.puts ArchSpec::VERSION
|
|
28
|
+
0
|
|
29
|
+
else
|
|
30
|
+
error.puts "Unknown command: #{command}"
|
|
31
|
+
error.puts usage
|
|
32
|
+
64
|
|
33
|
+
end
|
|
34
|
+
rescue Error => exception
|
|
35
|
+
error.puts exception.message
|
|
36
|
+
1
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def init(argv, output)
|
|
42
|
+
force = argv.delete("--force")
|
|
43
|
+
path = argv.shift || CONFIG_FILE
|
|
44
|
+
|
|
45
|
+
if File.exist?(path) && !force
|
|
46
|
+
raise Error, "#{path} already exists. Use --force to overwrite it."
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
File.write(path, TEMPLATE)
|
|
50
|
+
output.puts "Created #{path}"
|
|
51
|
+
0
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def check(argv, output)
|
|
55
|
+
options = {
|
|
56
|
+
config: CONFIG_FILE,
|
|
57
|
+
format: "text",
|
|
58
|
+
update_baseline: false
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
parser = OptionParser.new do |parser|
|
|
62
|
+
parser.on("--config PATH") { |value| options[:config] = value }
|
|
63
|
+
parser.on("--format FORMAT") { |value| options[:format] = value }
|
|
64
|
+
parser.on("--update-baseline") { options[:update_baseline] = true }
|
|
65
|
+
end
|
|
66
|
+
parser.parse!(argv)
|
|
67
|
+
|
|
68
|
+
definition, root = load_definition(options[:config])
|
|
69
|
+
graph = Analyzer.analyze(definition, root: root)
|
|
70
|
+
baseline_path = baseline_path_for(definition, root)
|
|
71
|
+
baseline = options[:update_baseline] ? Baseline.empty(root: root) : Baseline.load(baseline_path, root: root)
|
|
72
|
+
diagnostics = Evaluator.evaluate(definition, graph, baseline: baseline)
|
|
73
|
+
|
|
74
|
+
if options[:update_baseline]
|
|
75
|
+
raise Error, "No baseline configured. Add `baseline \".archspec_todo.yml\"` to #{options[:config]}." unless baseline_path
|
|
76
|
+
|
|
77
|
+
Baseline.write(baseline_path, diagnostics, root: root)
|
|
78
|
+
output.puts "Updated #{Pathname(baseline_path).relative_path_from(Pathname(root))} with #{diagnostics.size} violations."
|
|
79
|
+
return 0
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
formatter_for(options[:format]).print(output, graph: graph, diagnostics: diagnostics)
|
|
83
|
+
diagnostics.empty? ? 0 : 1
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def explain(argv, output)
|
|
87
|
+
options = { config: CONFIG_FILE }
|
|
88
|
+
parser = OptionParser.new do |parser|
|
|
89
|
+
parser.on("--config PATH") { |value| options[:config] = value }
|
|
90
|
+
end
|
|
91
|
+
parser.parse!(argv)
|
|
92
|
+
|
|
93
|
+
subject = argv.shift
|
|
94
|
+
raise Error, "Usage: archspec explain PATH_OR_CONSTANT" unless subject
|
|
95
|
+
|
|
96
|
+
definition, root = load_definition(options[:config])
|
|
97
|
+
graph = Analyzer.analyze(definition, root: root)
|
|
98
|
+
explain_subject(output, graph, subject)
|
|
99
|
+
0
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def load_definition(config_path)
|
|
103
|
+
raise Error, "Missing #{config_path}. Run `archspec init` first." unless File.exist?(config_path)
|
|
104
|
+
|
|
105
|
+
ArchSpec.last_definition = nil
|
|
106
|
+
absolute_config = File.expand_path(config_path)
|
|
107
|
+
load absolute_config
|
|
108
|
+
definition = ArchSpec.last_definition
|
|
109
|
+
raise Error, "#{config_path} did not call ArchSpec.define." unless definition
|
|
110
|
+
|
|
111
|
+
[definition, definition.absolute_root(File.dirname(absolute_config))]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def baseline_path_for(definition, root)
|
|
115
|
+
return unless definition.baseline_path
|
|
116
|
+
|
|
117
|
+
File.expand_path(definition.baseline_path, root)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def formatter_for(name)
|
|
121
|
+
case name
|
|
122
|
+
when "text"
|
|
123
|
+
Formatters::Text
|
|
124
|
+
when "json"
|
|
125
|
+
Formatters::JSON
|
|
126
|
+
else
|
|
127
|
+
raise Error, "Unknown format: #{name.inspect}"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def explain_subject(output, graph, subject)
|
|
132
|
+
path = File.expand_path(subject, graph.root)
|
|
133
|
+
|
|
134
|
+
if graph.files.key?(path)
|
|
135
|
+
file = graph.files.fetch(path)
|
|
136
|
+
output.puts file.relative_path
|
|
137
|
+
output.puts " expected constant: #{file.expected_constant || "(none)"}"
|
|
138
|
+
output.puts " defined constants: #{graph.constants_for_path(path).map(&:name).join(", ")}"
|
|
139
|
+
output_parse_errors(output, file)
|
|
140
|
+
output_component_reasons(output, graph.component_assignment_reasons_for_path(path))
|
|
141
|
+
output_suppressions(output, file)
|
|
142
|
+
output.puts " outgoing facts:"
|
|
143
|
+
|
|
144
|
+
graph.edges.select { |edge| edge.from_path == path }.each do |edge|
|
|
145
|
+
output.puts " #{edge.type} #{edge.to} at #{edge.location.line}:#{edge.location.column}"
|
|
146
|
+
end
|
|
147
|
+
else
|
|
148
|
+
constants = graph.constants_named(subject)
|
|
149
|
+
raise Error, "No file or constant found for #{subject.inspect}" if constants.empty?
|
|
150
|
+
|
|
151
|
+
constants.each do |constant|
|
|
152
|
+
output.puts constant.name
|
|
153
|
+
output.puts " kind: #{constant.kind}"
|
|
154
|
+
output.puts " file: #{constant.location.relative_path(graph.root)}:#{constant.location.line}"
|
|
155
|
+
output_component_reasons(output, graph.component_assignment_reasons_for_constant(constant.name))
|
|
156
|
+
output.puts " superclass: #{constant.superclass || "(none)"}"
|
|
157
|
+
output.puts " instance methods: #{constant.instance_methods.to_a.sort.join(", ")}"
|
|
158
|
+
output.puts " class methods: #{constant.class_methods.to_a.sort.join(", ")}"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def output_component_reasons(output, assignments)
|
|
164
|
+
if assignments.empty?
|
|
165
|
+
output.puts " components: (none)"
|
|
166
|
+
return
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
output.puts " components:"
|
|
170
|
+
assignments.sort_by { |name, _reasons| name.to_s }.each do |name, reasons|
|
|
171
|
+
output.puts " #{name}: #{reasons.empty? ? "(no recorded reason)" : reasons.join("; ")}"
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def output_suppressions(output, file)
|
|
176
|
+
return if file.suppressions.empty?
|
|
177
|
+
|
|
178
|
+
output.puts " suppressions:"
|
|
179
|
+
file.suppressions.each do |suppression|
|
|
180
|
+
line_range =
|
|
181
|
+
if suppression.end_line == Float::INFINITY
|
|
182
|
+
"#{suppression.start_line}-EOF"
|
|
183
|
+
elsif suppression.start_line == suppression.end_line
|
|
184
|
+
suppression.start_line
|
|
185
|
+
else
|
|
186
|
+
"#{suppression.start_line}-#{suppression.end_line}"
|
|
187
|
+
end
|
|
188
|
+
rule = suppression.rule || "*"
|
|
189
|
+
reason = suppression.reason ? " -- #{suppression.reason}" : ""
|
|
190
|
+
output.puts " #{rule} on line #{line_range}#{reason}"
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def output_parse_errors(output, file)
|
|
195
|
+
return if file.parse_errors.empty?
|
|
196
|
+
|
|
197
|
+
output.puts " parse errors:"
|
|
198
|
+
file.parse_errors.each do |parse_error|
|
|
199
|
+
output.puts " #{parse_error.location.line}:#{parse_error.location.column} #{parse_error.message}"
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def usage
|
|
204
|
+
<<~TEXT
|
|
205
|
+
Usage:
|
|
206
|
+
archspec init [PATH] [--force]
|
|
207
|
+
archspec check [--config PATH] [--format text|json] [--update-baseline]
|
|
208
|
+
archspec explain PATH_OR_CONSTANT [--config PATH]
|
|
209
|
+
archspec version
|
|
210
|
+
TEXT
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module ArchSpec
|
|
2
|
+
class ComponentSpec
|
|
3
|
+
attr_reader :name, :file_patterns, :namespaces, :constants
|
|
4
|
+
|
|
5
|
+
def initialize(name, files: [], namespace: nil, constants: nil)
|
|
6
|
+
@name = name.to_sym
|
|
7
|
+
@file_patterns = Array(files).compact.map(&:to_s)
|
|
8
|
+
@namespaces = Array(namespace).compact.map { |value| normalize_constant(value) }
|
|
9
|
+
@constants = Array(constants).compact.map { |value| normalize_constant(value) }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def merge!(other)
|
|
13
|
+
@file_patterns |= other.file_patterns
|
|
14
|
+
@namespaces |= other.namespaces
|
|
15
|
+
@constants |= other.constants
|
|
16
|
+
self
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def matches_constant?(name)
|
|
20
|
+
normalized = normalize_constant(name)
|
|
21
|
+
|
|
22
|
+
constants.include?(normalized) ||
|
|
23
|
+
namespaces.any? do |namespace|
|
|
24
|
+
normalized == namespace || normalized.start_with?("#{namespace}::")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def normalize_constant(value)
|
|
31
|
+
value.to_s.sub(/\A::/, "")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
module ArchSpec
|
|
2
|
+
class Definition
|
|
3
|
+
DEFAULT_SOURCE_PATTERNS = [
|
|
4
|
+
"app/**/*.rb",
|
|
5
|
+
"lib/**/*.rb",
|
|
6
|
+
"packs/*/app/**/*.rb",
|
|
7
|
+
"engines/*/app/**/*.rb"
|
|
8
|
+
].freeze
|
|
9
|
+
|
|
10
|
+
DEFAULT_IGNORE_PATTERNS = [
|
|
11
|
+
".git/**/*",
|
|
12
|
+
".bundle/**/*",
|
|
13
|
+
"node_modules/**/*",
|
|
14
|
+
"tmp/**/*",
|
|
15
|
+
"vendor/**/*"
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
attr_accessor :name, :root_path, :baseline_path
|
|
19
|
+
attr_reader :source_patterns, :ignore_patterns, :component_specs, :rules
|
|
20
|
+
|
|
21
|
+
def initialize(name)
|
|
22
|
+
@name = name
|
|
23
|
+
@root_path = "."
|
|
24
|
+
@baseline_path = nil
|
|
25
|
+
@source_patterns = []
|
|
26
|
+
@ignore_patterns = DEFAULT_IGNORE_PATTERNS.dup
|
|
27
|
+
@component_specs = {}
|
|
28
|
+
@rules = []
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def add_source_patterns(patterns)
|
|
32
|
+
@source_patterns |= Array(patterns).flatten.compact.map(&:to_s)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def add_ignore_patterns(patterns)
|
|
36
|
+
@ignore_patterns |= Array(patterns).flatten.compact.map(&:to_s)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def add_component(spec)
|
|
40
|
+
if component_specs.key?(spec.name)
|
|
41
|
+
component_specs.fetch(spec.name).merge!(spec)
|
|
42
|
+
else
|
|
43
|
+
component_specs[spec.name] = spec
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def component?(name)
|
|
48
|
+
component_specs.key?(name.to_sym)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def add_rule(rule)
|
|
52
|
+
rules << rule
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def absolute_root(base_dir = Dir.pwd)
|
|
56
|
+
File.expand_path(root_path, base_dir)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def analysis_patterns
|
|
60
|
+
patterns = source_patterns.empty? ? DEFAULT_SOURCE_PATTERNS.dup : source_patterns.dup
|
|
61
|
+
patterns | component_specs.values.flat_map(&:file_patterns)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
|
|
3
|
+
module ArchSpec
|
|
4
|
+
class Diagnostic
|
|
5
|
+
attr_reader :rule, :message, :location, :evidence, :confidence
|
|
6
|
+
|
|
7
|
+
def initialize(rule:, message:, location:, evidence:, confidence: :high)
|
|
8
|
+
@rule = rule
|
|
9
|
+
@message = message
|
|
10
|
+
@location = location
|
|
11
|
+
@evidence = evidence
|
|
12
|
+
@confidence = confidence
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def fingerprint(root: nil)
|
|
16
|
+
path = root ? location.relative_path(root) : location.path
|
|
17
|
+
|
|
18
|
+
Digest::SHA256.hexdigest(
|
|
19
|
+
[rule, message, path, location.line, evidence].join("\0")
|
|
20
|
+
)[0, 24]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_h(root:)
|
|
24
|
+
{
|
|
25
|
+
id: fingerprint(root: root),
|
|
26
|
+
rule: rule,
|
|
27
|
+
message: message,
|
|
28
|
+
path: location.relative_path(root),
|
|
29
|
+
line: location.line,
|
|
30
|
+
column: location.column,
|
|
31
|
+
evidence: evidence,
|
|
32
|
+
confidence: confidence.to_s
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|