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,117 @@
|
|
|
1
|
+
require "set"
|
|
2
|
+
|
|
3
|
+
module ArchSpec
|
|
4
|
+
module Rules
|
|
5
|
+
class DependencyRule
|
|
6
|
+
attr_reader :source, :targets
|
|
7
|
+
|
|
8
|
+
def initialize(source, targets)
|
|
9
|
+
@source = source.to_sym
|
|
10
|
+
@targets = Array(targets).flatten.map(&:to_sym).to_set
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def merge_key
|
|
14
|
+
[self.class, source]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def merge!(other)
|
|
18
|
+
targets.merge(other.targets)
|
|
19
|
+
self
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def relevant_edges(graph)
|
|
25
|
+
graph.dependency_edges.select { |edge| graph.component_names_for_path(edge.from_path).include?(source) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def target_components(graph, edge)
|
|
29
|
+
graph.target_components_for(edge)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def edge_target(edge)
|
|
33
|
+
edge.to
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class AllowDependenciesRule < DependencyRule
|
|
38
|
+
def id
|
|
39
|
+
"dependencies.allow"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def evaluate(graph)
|
|
43
|
+
relevant_edges(graph).flat_map do |edge|
|
|
44
|
+
target_components(graph, edge).filter_map do |target|
|
|
45
|
+
next if target == source || targets.include?(target)
|
|
46
|
+
|
|
47
|
+
Diagnostic.new(
|
|
48
|
+
rule: id,
|
|
49
|
+
message: "#{source} may not depend on #{target}",
|
|
50
|
+
location: edge.location,
|
|
51
|
+
evidence: "#{edge.from_constant || edge.from_path} #{edge.type} #{edge_target(edge)}"
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
class ForbidDependenciesRule < DependencyRule
|
|
59
|
+
def id
|
|
60
|
+
"dependencies.forbid"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def evaluate(graph)
|
|
64
|
+
relevant_edges(graph).flat_map do |edge|
|
|
65
|
+
forbidden = target_components(graph, edge) & targets
|
|
66
|
+
|
|
67
|
+
forbidden.map do |target|
|
|
68
|
+
Diagnostic.new(
|
|
69
|
+
rule: id,
|
|
70
|
+
message: "#{source} must not depend on #{target}",
|
|
71
|
+
location: edge.location,
|
|
72
|
+
evidence: "#{edge.from_constant || edge.from_path} #{edge.type} #{edge_target(edge)}"
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
class CannotReferenceConstantsRule
|
|
80
|
+
attr_reader :source, :constants
|
|
81
|
+
|
|
82
|
+
def initialize(source, constants)
|
|
83
|
+
@source = source.to_sym
|
|
84
|
+
@constants = Array(constants).flatten.map { |constant| constant.to_s.sub(/\A::/, "") }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def merge_key
|
|
88
|
+
[self.class, source]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def merge!(other)
|
|
92
|
+
@constants |= other.constants
|
|
93
|
+
self
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def id
|
|
97
|
+
"constants.forbid"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def evaluate(graph)
|
|
101
|
+
graph.dependency_edges.filter_map do |edge|
|
|
102
|
+
next unless graph.component_names_for_path(edge.from_path).include?(source)
|
|
103
|
+
|
|
104
|
+
referenced = graph.resolve_constant_reference(edge.to, edge.from_constant)
|
|
105
|
+
next unless constants.any? { |constant| referenced == constant || referenced.start_with?("#{constant}::") }
|
|
106
|
+
|
|
107
|
+
Diagnostic.new(
|
|
108
|
+
rule: id,
|
|
109
|
+
message: "#{source} must not reference #{referenced}",
|
|
110
|
+
location: edge.location,
|
|
111
|
+
evidence: "#{edge.from_constant || edge.from_path} #{edge.type} #{edge.to}"
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
module ArchSpec
|
|
2
|
+
module Rules
|
|
3
|
+
class CannotCallRule
|
|
4
|
+
attr_reader :source, :method_names
|
|
5
|
+
|
|
6
|
+
def initialize(source, methods)
|
|
7
|
+
@source = source.to_sym
|
|
8
|
+
@method_names = Array(methods).flatten.map(&:to_sym)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def merge_key
|
|
12
|
+
[self.class, source]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def merge!(other)
|
|
16
|
+
@method_names |= other.method_names
|
|
17
|
+
self
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def id
|
|
21
|
+
"methods.forbid"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def evaluate(graph)
|
|
25
|
+
graph.edges.filter_map do |edge|
|
|
26
|
+
next unless edge.type == :calls_named_method
|
|
27
|
+
next unless method_names.include?(edge.to.to_sym)
|
|
28
|
+
next unless graph.component_names_for_path(edge.from_path).include?(source)
|
|
29
|
+
|
|
30
|
+
Diagnostic.new(
|
|
31
|
+
rule: id,
|
|
32
|
+
message: "#{source} must not call ##{edge.to}",
|
|
33
|
+
location: edge.location,
|
|
34
|
+
evidence: "#{edge.from_constant || edge.from_path} calls #{edge.to}"
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class MustImplementRule
|
|
41
|
+
attr_reader :source, :method_name
|
|
42
|
+
|
|
43
|
+
def initialize(source, method_name)
|
|
44
|
+
@source = source.to_sym
|
|
45
|
+
@method_name = method_name.to_sym
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def merge_key
|
|
49
|
+
[self.class, source, method_name]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def id
|
|
53
|
+
"protocol.must_implement"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def evaluate(graph)
|
|
57
|
+
constants_for(graph).filter_map do |constant|
|
|
58
|
+
next if constant.instance_methods.include?(method_name)
|
|
59
|
+
|
|
60
|
+
Diagnostic.new(
|
|
61
|
+
rule: id,
|
|
62
|
+
message: "#{constant.name} must implement ##{method_name}",
|
|
63
|
+
location: constant.location,
|
|
64
|
+
evidence: "#{constant.name} methods: #{constant.instance_methods.to_a.sort.join(", ")}"
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def constants_for(graph)
|
|
72
|
+
graph.components.fetch(source).constants.flat_map { |name| graph.constants_named(name) }.select(&:class?)
|
|
73
|
+
rescue KeyError
|
|
74
|
+
[]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
class MustImplementOneOfRule
|
|
79
|
+
attr_reader :source, :method_names
|
|
80
|
+
|
|
81
|
+
def initialize(source, method_names)
|
|
82
|
+
@source = source.to_sym
|
|
83
|
+
@method_names = Array(method_names).flatten.map(&:to_sym)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def merge_key
|
|
87
|
+
[self.class, source]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def merge!(other)
|
|
91
|
+
@method_names |= other.method_names
|
|
92
|
+
self
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def id
|
|
96
|
+
"protocol.must_implement_one_of"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def evaluate(graph)
|
|
100
|
+
constants_for(graph).filter_map do |constant|
|
|
101
|
+
next if method_names.any? { |method_name| constant.instance_methods.include?(method_name) }
|
|
102
|
+
|
|
103
|
+
Diagnostic.new(
|
|
104
|
+
rule: id,
|
|
105
|
+
message: "#{constant.name} must implement one of #{method_names.map { |name| "##{name}" }.join(", ")}",
|
|
106
|
+
location: constant.location,
|
|
107
|
+
evidence: "#{constant.name} methods: #{constant.instance_methods.to_a.sort.join(", ")}"
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def constants_for(graph)
|
|
115
|
+
graph.components.fetch(source).constants.flat_map { |name| graph.constants_named(name) }.select(&:class?)
|
|
116
|
+
rescue KeyError
|
|
117
|
+
[]
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
class CannotDefineMethodRule
|
|
122
|
+
attr_reader :source, :method_names
|
|
123
|
+
|
|
124
|
+
def initialize(source, methods)
|
|
125
|
+
@source = source.to_sym
|
|
126
|
+
@method_names = Array(methods).flatten.map(&:to_sym)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def merge_key
|
|
130
|
+
[self.class, source]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def merge!(other)
|
|
134
|
+
@method_names |= other.method_names
|
|
135
|
+
self
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def id
|
|
139
|
+
"methods.define_forbid"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def evaluate(graph)
|
|
143
|
+
graph.method_definitions_for_component(source).filter_map do |method_definition|
|
|
144
|
+
next unless method_names.include?(method_definition.name)
|
|
145
|
+
|
|
146
|
+
Diagnostic.new(
|
|
147
|
+
rule: id,
|
|
148
|
+
message: "#{source} must not define ##{method_definition.name}",
|
|
149
|
+
location: method_definition.location,
|
|
150
|
+
evidence: "#{method_definition.owner} defines #{method_definition.scope} method #{method_definition.name}"
|
|
151
|
+
)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
class CannotInstantiateAndInvokeRule
|
|
157
|
+
attr_reader :source
|
|
158
|
+
|
|
159
|
+
def initialize(source)
|
|
160
|
+
@source = source.to_sym
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def merge_key
|
|
164
|
+
[self.class, source]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def id
|
|
168
|
+
"objects.instantiate_and_invoke_forbid"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def evaluate(graph)
|
|
172
|
+
graph.edges.filter_map do |edge|
|
|
173
|
+
next unless edge.type == :instantiates_and_invokes
|
|
174
|
+
next unless graph.component_names_for_path(edge.from_path).include?(source)
|
|
175
|
+
|
|
176
|
+
Diagnostic.new(
|
|
177
|
+
rule: id,
|
|
178
|
+
message: "#{source} must not instantiate and immediately invoke #{edge.to}",
|
|
179
|
+
location: edge.location,
|
|
180
|
+
evidence: "#{edge.from_constant || edge.from_path} uses #{edge.to}"
|
|
181
|
+
)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module ArchSpec
|
|
2
|
+
module Rules
|
|
3
|
+
class ZeitwerkNamingRule
|
|
4
|
+
def id
|
|
5
|
+
"zeitwerk.naming"
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def evaluate(graph)
|
|
9
|
+
graph.files.values.filter_map do |file|
|
|
10
|
+
next unless file.expected_constant
|
|
11
|
+
|
|
12
|
+
defined = graph.constants_for_path(file.path).map(&:name)
|
|
13
|
+
next if defined.include?(file.expected_constant)
|
|
14
|
+
|
|
15
|
+
Diagnostic.new(
|
|
16
|
+
rule: id,
|
|
17
|
+
message: "#{file.relative_path} should define #{file.expected_constant}",
|
|
18
|
+
location: SourceLocation.new(file.path, 1, 1),
|
|
19
|
+
evidence: "defined constants: #{defined.empty? ? "(none)" : defined.join(", ")}"
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
require "pathname"
|
|
2
|
+
|
|
3
|
+
module ArchSpec
|
|
4
|
+
SourceLocation = Data.define(:path, :line, :column) do
|
|
5
|
+
def self.from_prism(path, location)
|
|
6
|
+
new(path, location.start_line, location.start_column + 1)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def relative_path(root)
|
|
10
|
+
Pathname(path).relative_path_from(Pathname(root)).to_s
|
|
11
|
+
rescue ArgumentError
|
|
12
|
+
path
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
data/lib/archspec.rb
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
require_relative "archspec/version"
|
|
2
|
+
require_relative "archspec/source_location"
|
|
3
|
+
require_relative "archspec/diagnostic"
|
|
4
|
+
require_relative "archspec/component_spec"
|
|
5
|
+
require_relative "archspec/model"
|
|
6
|
+
require_relative "archspec/definition"
|
|
7
|
+
require_relative "archspec/baseline"
|
|
8
|
+
require_relative "archspec/dsl"
|
|
9
|
+
require_relative "archspec/analyzer"
|
|
10
|
+
require_relative "archspec/evaluator"
|
|
11
|
+
require_relative "archspec/architectures"
|
|
12
|
+
require_relative "archspec/presets"
|
|
13
|
+
require_relative "archspec/rules/dependency_rules"
|
|
14
|
+
require_relative "archspec/rules/protocol_rules"
|
|
15
|
+
require_relative "archspec/rules/cycle_rule"
|
|
16
|
+
require_relative "archspec/rules/zeitwerk_rule"
|
|
17
|
+
require_relative "archspec/formatters/text"
|
|
18
|
+
require_relative "archspec/formatters/json"
|
|
19
|
+
require_relative "archspec/cli"
|
|
20
|
+
|
|
21
|
+
module ArchSpec
|
|
22
|
+
class Error < StandardError; end
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
attr_accessor :last_definition
|
|
26
|
+
|
|
27
|
+
def define(name = "Architecture", &block)
|
|
28
|
+
definition = Definition.new(name)
|
|
29
|
+
definition.extend(DSL::Context)
|
|
30
|
+
definition.instance_eval(&block) if block
|
|
31
|
+
self.last_definition = definition
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: archspec
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0.pre1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Carmine Paolino
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-06-02 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: prism
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '1.0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '1.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: minitest
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - ">="
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '5.20'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - ">="
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '5.20'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: rake
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - ">="
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '13.0'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - ">="
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '13.0'
|
|
55
|
+
description: Convention-aware architecture checks for Ruby and Rails codebases.
|
|
56
|
+
email:
|
|
57
|
+
- carmine@paolino.me
|
|
58
|
+
executables:
|
|
59
|
+
- archspec
|
|
60
|
+
extensions: []
|
|
61
|
+
extra_rdoc_files: []
|
|
62
|
+
files:
|
|
63
|
+
- LICENSE.txt
|
|
64
|
+
- README.md
|
|
65
|
+
- exe/archspec
|
|
66
|
+
- lib/archspec.rb
|
|
67
|
+
- lib/archspec/analyzer.rb
|
|
68
|
+
- lib/archspec/architectures.rb
|
|
69
|
+
- lib/archspec/baseline.rb
|
|
70
|
+
- lib/archspec/cli.rb
|
|
71
|
+
- lib/archspec/component_spec.rb
|
|
72
|
+
- lib/archspec/definition.rb
|
|
73
|
+
- lib/archspec/diagnostic.rb
|
|
74
|
+
- lib/archspec/dsl.rb
|
|
75
|
+
- lib/archspec/evaluator.rb
|
|
76
|
+
- lib/archspec/formatters/json.rb
|
|
77
|
+
- lib/archspec/formatters/text.rb
|
|
78
|
+
- lib/archspec/model.rb
|
|
79
|
+
- lib/archspec/presets.rb
|
|
80
|
+
- lib/archspec/rules/cycle_rule.rb
|
|
81
|
+
- lib/archspec/rules/dependency_rules.rb
|
|
82
|
+
- lib/archspec/rules/protocol_rules.rb
|
|
83
|
+
- lib/archspec/rules/zeitwerk_rule.rb
|
|
84
|
+
- lib/archspec/source_location.rb
|
|
85
|
+
- lib/archspec/version.rb
|
|
86
|
+
homepage: https://crmne.github.io/archspec
|
|
87
|
+
licenses:
|
|
88
|
+
- MIT
|
|
89
|
+
metadata:
|
|
90
|
+
homepage_uri: https://crmne.github.io/archspec
|
|
91
|
+
source_code_uri: https://github.com/crmne/archspec
|
|
92
|
+
changelog_uri: https://github.com/crmne/archspec/releases
|
|
93
|
+
documentation_uri: https://crmne.github.io/archspec/getting-started/
|
|
94
|
+
bug_tracker_uri: https://github.com/crmne/archspec/issues
|
|
95
|
+
rubygems_mfa_required: 'true'
|
|
96
|
+
post_install_message:
|
|
97
|
+
rdoc_options: []
|
|
98
|
+
require_paths:
|
|
99
|
+
- lib
|
|
100
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
101
|
+
requirements:
|
|
102
|
+
- - ">="
|
|
103
|
+
- !ruby/object:Gem::Version
|
|
104
|
+
version: '3.3'
|
|
105
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
106
|
+
requirements:
|
|
107
|
+
- - ">="
|
|
108
|
+
- !ruby/object:Gem::Version
|
|
109
|
+
version: '0'
|
|
110
|
+
requirements: []
|
|
111
|
+
rubygems_version: 3.5.22
|
|
112
|
+
signing_key:
|
|
113
|
+
specification_version: 4
|
|
114
|
+
summary: Architecture fitness functions for Ruby and Rails.
|
|
115
|
+
test_files: []
|