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.
@@ -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
@@ -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