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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 14c27bcdf86ac400321503fdb1f2a5b904068aafaac65921e00a33a80acea45f
|
|
4
|
+
data.tar.gz: fca658eec0f2f6becfc7159c989ba8ad132c3de0d39c5dfb59561f249afb2407
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 4b51c1666d0ec2b27913e796be1969f81b030582b2faf18f9d18f29a1badd7ae1f4b68f2d196a94e976d16fc40e217699a899c6460e037e6a413a68a6aac2299
|
|
7
|
+
data.tar.gz: 8853a35c37af66fd6f18d2aaace5c6e44a22fccd941cff3b00fae5ec739184451329cd639d9cba730a3f925b2a66c232c0e85dbd6b5122c85c91437a17515e1e
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ArchSpec contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# ArchSpec
|
|
2
|
+
|
|
3
|
+
Architecture checks for Ruby and Rails.
|
|
4
|
+
|
|
5
|
+
Turn your application's architecture into executable checks.
|
|
6
|
+
|
|
7
|
+
ArchSpec reads Ruby source with Prism, maps conventional Rails files to
|
|
8
|
+
constants, and checks the structural rules you write down: components, layers,
|
|
9
|
+
constant references, inheritance, mixins, named method calls, method protocols,
|
|
10
|
+
cycles, and Rails boundaries.
|
|
11
|
+
|
|
12
|
+
It does not try to infer the "true" design pattern of arbitrary Ruby code. You
|
|
13
|
+
describe the architecture your team wants. ArchSpec checks whether the code still
|
|
14
|
+
matches it.
|
|
15
|
+
|
|
16
|
+
## Why ArchSpec?
|
|
17
|
+
|
|
18
|
+
Architecture usually lives in pull request comments, onboarding docs, and senior
|
|
19
|
+
engineers' heads. That does not scale well, especially when code is moving fast.
|
|
20
|
+
|
|
21
|
+
ArchSpec gives you a small Ruby DSL for the rules that code review otherwise has
|
|
22
|
+
to remember:
|
|
23
|
+
|
|
24
|
+
- models do not reach into controllers
|
|
25
|
+
- domain code does not depend on adapters
|
|
26
|
+
- packs only depend on approved packs
|
|
27
|
+
- query objects do not call obvious write methods
|
|
28
|
+
- generated code follows the same boundaries as hand-written code
|
|
29
|
+
|
|
30
|
+
## Show me the code
|
|
31
|
+
|
|
32
|
+
Start with conventional Rails boundaries:
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
# Archspec.rb
|
|
36
|
+
ArchSpec.define "Application architecture" do
|
|
37
|
+
root "."
|
|
38
|
+
preset :rails_way
|
|
39
|
+
end
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Add layers when the app has a clear direction of dependencies:
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
ArchSpec.define "Application architecture" do
|
|
46
|
+
root "."
|
|
47
|
+
architecture :layered, layers: {
|
|
48
|
+
interface: "app/controllers/**/*.rb",
|
|
49
|
+
application: "app/services/**/*.rb",
|
|
50
|
+
domain: "app/models/**/*.rb"
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Keep a hexagonal core away from adapters:
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
ArchSpec.define "Application architecture" do
|
|
59
|
+
root "."
|
|
60
|
+
|
|
61
|
+
architecture :hexagonal,
|
|
62
|
+
application: %w[app/services/**/*.rb app/use_cases/**/*.rb],
|
|
63
|
+
domain: "app/domain/**/*.rb",
|
|
64
|
+
ports: "app/ports/**/*.rb",
|
|
65
|
+
adapters: %w[app/adapters/**/*.rb app/integrations/**/*.rb]
|
|
66
|
+
end
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Check a modular monolith:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
ArchSpec.define "Application architecture" do
|
|
73
|
+
root "."
|
|
74
|
+
|
|
75
|
+
architecture :modular_monolith,
|
|
76
|
+
components: {
|
|
77
|
+
billing: "packs/billing/**/*.rb",
|
|
78
|
+
catalog: "packs/catalog/**/*.rb",
|
|
79
|
+
shared: "packs/shared/**/*.rb"
|
|
80
|
+
},
|
|
81
|
+
allow: {
|
|
82
|
+
billing: %i[shared],
|
|
83
|
+
catalog: %i[shared]
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Write local rules in plain Ruby:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
ArchSpec.define "Application architecture" do
|
|
92
|
+
root "."
|
|
93
|
+
|
|
94
|
+
component :controllers, in: "app/controllers/**/*.rb"
|
|
95
|
+
component :models, in: "app/models/**/*.rb"
|
|
96
|
+
component :services, in: "app/services/**/*.rb"
|
|
97
|
+
|
|
98
|
+
controllers.can_use :models, :services
|
|
99
|
+
models.cannot_use :controllers
|
|
100
|
+
services.cannot_call :render, :redirect_to, :params, :session
|
|
101
|
+
services.cannot_instantiate_and_invoke
|
|
102
|
+
end
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Check command/query separation:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
ArchSpec.define "Application architecture" do
|
|
109
|
+
root "."
|
|
110
|
+
|
|
111
|
+
architecture :cqrs,
|
|
112
|
+
commands: "app/commands/**/*.rb",
|
|
113
|
+
queries: "app/queries/**/*.rb",
|
|
114
|
+
read_models: "app/read_models/**/*.rb"
|
|
115
|
+
end
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## What It Checks
|
|
119
|
+
|
|
120
|
+
- **Dependencies:** allowed and forbidden references between components
|
|
121
|
+
- **Layers:** dependency direction and cycles
|
|
122
|
+
- **Rails MVC:** controller APIs kept out of models and services
|
|
123
|
+
- **Architectures:** Rails MVC, layered, hexagonal, clean, modular monolith, CQRS, and event-driven bundles
|
|
124
|
+
- **Protocols:** required methods such as `resolve`, `perform`, or project-specific interfaces
|
|
125
|
+
- **Objects:** rules against one-shot `Something.new(...).whatever` command objects
|
|
126
|
+
- **Zeitwerk names:** conventional file names defining the expected constants
|
|
127
|
+
- **Suppressions:** narrow local exceptions with a reason
|
|
128
|
+
|
|
129
|
+
## Installation
|
|
130
|
+
|
|
131
|
+
Add ArchSpec to your Gemfile:
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
group :development, :test do
|
|
135
|
+
gem "archspec"
|
|
136
|
+
end
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Then install it:
|
|
140
|
+
|
|
141
|
+
```sh
|
|
142
|
+
bundle install
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Create `Archspec.rb`:
|
|
146
|
+
|
|
147
|
+
```sh
|
|
148
|
+
bundle exec archspec init
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Run the checks:
|
|
152
|
+
|
|
153
|
+
```sh
|
|
154
|
+
bundle exec archspec check
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Commands
|
|
158
|
+
|
|
159
|
+
```sh
|
|
160
|
+
bundle exec archspec init
|
|
161
|
+
bundle exec archspec check
|
|
162
|
+
bundle exec archspec check --format json
|
|
163
|
+
bundle exec archspec check --update-baseline
|
|
164
|
+
bundle exec archspec explain app/models/user.rb
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
`explain` shows why a file or constant belongs to a component and which outgoing
|
|
168
|
+
facts ArchSpec found.
|
|
169
|
+
|
|
170
|
+
## Checking AI-Written Code
|
|
171
|
+
|
|
172
|
+
Generated code should pass the same architecture checks as hand-written code.
|
|
173
|
+
|
|
174
|
+
After an AI-assisted change:
|
|
175
|
+
|
|
176
|
+
```sh
|
|
177
|
+
bundle exec archspec check
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
If it fails, read the evidence before changing the spec:
|
|
181
|
+
|
|
182
|
+
```text
|
|
183
|
+
[dependencies.forbid] app/models/user.rb:2:3
|
|
184
|
+
models must not depend on controllers
|
|
185
|
+
evidence: User references_constant UsersController
|
|
186
|
+
confidence: high
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Most failures should be fixed in the generated code. Update the spec only when
|
|
190
|
+
the architecture decision itself has changed.
|
|
191
|
+
|
|
192
|
+
## Baselines and Suppressions
|
|
193
|
+
|
|
194
|
+
Use a baseline when adopting ArchSpec in an existing app:
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
baseline ".archspec_todo.yml"
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
```sh
|
|
201
|
+
bundle exec archspec check --update-baseline
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Use local suppressions for deliberate exceptions:
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
# archspec:disable-next-line dependencies.forbid -- legacy admin export
|
|
208
|
+
Admin::UsersController
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## Documentation
|
|
212
|
+
|
|
213
|
+
Read the guides at [crmne.github.io/archspec](https://crmne.github.io/archspec/).
|
|
214
|
+
|
|
215
|
+
## Dogfooding
|
|
216
|
+
|
|
217
|
+
This repository checks its own architecture:
|
|
218
|
+
|
|
219
|
+
```sh
|
|
220
|
+
bundle exec rake architecture
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## License
|
|
224
|
+
|
|
225
|
+
Released under the MIT License.
|
data/exe/archspec
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
require "prism"
|
|
2
|
+
require "pathname"
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module ArchSpec
|
|
6
|
+
module Analyzer
|
|
7
|
+
extend self
|
|
8
|
+
|
|
9
|
+
def analyze(definition, root:)
|
|
10
|
+
root = File.expand_path(root)
|
|
11
|
+
graph = Graph.new(root)
|
|
12
|
+
|
|
13
|
+
ruby_files(definition, root).each do |path|
|
|
14
|
+
result = Prism.parse_file(path)
|
|
15
|
+
graph.add_file(
|
|
16
|
+
path: path,
|
|
17
|
+
expected_constant: expected_constant_for(path, root),
|
|
18
|
+
parse_errors: parse_errors_for(path, result.errors),
|
|
19
|
+
suppressions: suppressions_for(result.comments)
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
SourceVisitor.visit(graph, path, result.value) if result.value
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
graph.assign_components(definition.component_specs.values)
|
|
26
|
+
graph
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def ruby_files(definition, root)
|
|
32
|
+
ignored = ignored_files(definition, root)
|
|
33
|
+
|
|
34
|
+
definition.analysis_patterns.flat_map do |pattern|
|
|
35
|
+
Dir.glob(File.absolute_path(pattern, root))
|
|
36
|
+
end.select do |path|
|
|
37
|
+
File.file?(path) && path.end_with?(".rb")
|
|
38
|
+
end.map do |path|
|
|
39
|
+
File.expand_path(path)
|
|
40
|
+
end.uniq.reject do |path|
|
|
41
|
+
ignored.include?(path)
|
|
42
|
+
end.sort
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def ignored_files(definition, root)
|
|
46
|
+
definition.ignore_patterns.flat_map do |pattern|
|
|
47
|
+
Dir.glob(File.absolute_path(pattern, root))
|
|
48
|
+
end.select { |path| File.file?(path) }.map { |path| File.expand_path(path) }.to_set
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def expected_constant_for(path, root)
|
|
52
|
+
relative = Pathname(path).relative_path_from(Pathname(root)).to_s
|
|
53
|
+
stem =
|
|
54
|
+
case relative
|
|
55
|
+
when %r{\Aapp/[^/]+/concerns/(.+)\.rb\z}
|
|
56
|
+
Regexp.last_match(1)
|
|
57
|
+
when %r{\Aapp/[^/]+/(.+)\.rb\z}
|
|
58
|
+
Regexp.last_match(1)
|
|
59
|
+
when %r{\Alib/(.+)\.rb\z}
|
|
60
|
+
Regexp.last_match(1)
|
|
61
|
+
when %r{\A(?:packs|engines)/[^/]+/app/[^/]+/(.+)\.rb\z}
|
|
62
|
+
Regexp.last_match(1)
|
|
63
|
+
else
|
|
64
|
+
return nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
camelize_path(stem)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def camelize_path(path)
|
|
71
|
+
path.split("/").map do |part|
|
|
72
|
+
part.split("_").map { |word| word[0] ? word[0].upcase + word[1..] : word }.join
|
|
73
|
+
end.join("::")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def suppressions_for(comments)
|
|
77
|
+
SuppressionParser.parse(comments)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def parse_errors_for(path, errors)
|
|
81
|
+
errors.map do |error|
|
|
82
|
+
ParseError.new(error.message, SourceLocation.from_prism(path, error.location))
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
module SuppressionParser
|
|
87
|
+
extend self
|
|
88
|
+
|
|
89
|
+
DISABLE_PATTERN = /\Aarchspec:disable(?:-(next-line|line))?(?:\s+([a-z0-9_.-]+|\*))?(?:\s+--\s*(.+))?\z/i
|
|
90
|
+
ENABLE_PATTERN = /\Aarchspec:enable(?:\s+([a-z0-9_.-]+|\*))?\z/i
|
|
91
|
+
|
|
92
|
+
def parse(comments)
|
|
93
|
+
suppressions = []
|
|
94
|
+
active = Hash.new { |hash, key| hash[key] = [] }
|
|
95
|
+
|
|
96
|
+
sorted_comments(comments).each do |comment|
|
|
97
|
+
text = comment.slice.sub(/\A#\s?/, "").strip
|
|
98
|
+
line = comment.location.start_line
|
|
99
|
+
|
|
100
|
+
if (match = text.match(DISABLE_PATTERN))
|
|
101
|
+
mode, rule, reason = match.captures
|
|
102
|
+
rule = normalize_rule(rule)
|
|
103
|
+
|
|
104
|
+
case mode
|
|
105
|
+
when "line"
|
|
106
|
+
suppressions << Suppression.new(rule, line, line, reason)
|
|
107
|
+
when "next-line"
|
|
108
|
+
suppressions << Suppression.new(rule, line + 1, line + 1, reason)
|
|
109
|
+
else
|
|
110
|
+
active[rule] << [line + 1, reason]
|
|
111
|
+
end
|
|
112
|
+
elsif (match = text.match(ENABLE_PATTERN))
|
|
113
|
+
rule = normalize_rule(match[1])
|
|
114
|
+
if active[rule].any?
|
|
115
|
+
start_line, reason = active[rule].pop
|
|
116
|
+
suppressions << Suppression.new(rule, start_line, [line - 1, start_line].max, reason)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
active.each do |rule, entries|
|
|
122
|
+
entries.each do |start_line, reason|
|
|
123
|
+
suppressions << Suppression.new(rule, start_line, Float::INFINITY, reason)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
suppressions
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def sorted_comments(comments)
|
|
133
|
+
comments.sort_by { |comment| [comment.location.start_line, comment.location.start_column] }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def normalize_rule(rule)
|
|
137
|
+
return nil if rule.nil? || rule == "*"
|
|
138
|
+
|
|
139
|
+
rule.downcase
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
module SourceVisitor
|
|
144
|
+
extend self
|
|
145
|
+
|
|
146
|
+
DYNAMIC_MESSAGES = %i[
|
|
147
|
+
class_eval
|
|
148
|
+
const_get
|
|
149
|
+
const_set
|
|
150
|
+
define_method
|
|
151
|
+
instance_eval
|
|
152
|
+
method_missing
|
|
153
|
+
module_eval
|
|
154
|
+
public_send
|
|
155
|
+
send
|
|
156
|
+
].freeze
|
|
157
|
+
|
|
158
|
+
MIXIN_MESSAGES = {
|
|
159
|
+
include: :includes,
|
|
160
|
+
prepend: :prepends,
|
|
161
|
+
extend: :extends
|
|
162
|
+
}.freeze
|
|
163
|
+
|
|
164
|
+
def visit(graph, path, node, current_constant: nil, namespace: [])
|
|
165
|
+
return unless node
|
|
166
|
+
|
|
167
|
+
case node
|
|
168
|
+
when Prism::ProgramNode, Prism::StatementsNode
|
|
169
|
+
visit_children(graph, path, node, current_constant: current_constant, namespace: namespace)
|
|
170
|
+
when Prism::ClassNode
|
|
171
|
+
visit_class(graph, path, node, current_constant: current_constant, namespace: namespace)
|
|
172
|
+
when Prism::ModuleNode
|
|
173
|
+
visit_module(graph, path, node, current_constant: current_constant, namespace: namespace)
|
|
174
|
+
when Prism::DefNode
|
|
175
|
+
visit_def(graph, path, node, current_constant: current_constant, namespace: namespace)
|
|
176
|
+
when Prism::CallNode
|
|
177
|
+
visit_call(graph, path, node, current_constant: current_constant, namespace: namespace)
|
|
178
|
+
when Prism::ConstantPathNode, Prism::ConstantReadNode
|
|
179
|
+
add_constant_reference(graph, path, node, current_constant)
|
|
180
|
+
else
|
|
181
|
+
visit_children(graph, path, node, current_constant: current_constant, namespace: namespace)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
private
|
|
186
|
+
|
|
187
|
+
def visit_class(graph, path, node, current_constant:, namespace:)
|
|
188
|
+
name = qualified_constant_name(node.constant_path, namespace)
|
|
189
|
+
constant = graph.add_constant(
|
|
190
|
+
name: name,
|
|
191
|
+
kind: :class,
|
|
192
|
+
path: path,
|
|
193
|
+
location: SourceLocation.from_prism(path, node.location)
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
if node.superclass
|
|
197
|
+
superclass = constant_reference_name(node.superclass)
|
|
198
|
+
constant.superclass = superclass
|
|
199
|
+
graph.add_edge(
|
|
200
|
+
type: :inherits_from,
|
|
201
|
+
from_path: path,
|
|
202
|
+
from_constant: constant.name,
|
|
203
|
+
to: superclass,
|
|
204
|
+
location: SourceLocation.from_prism(path, node.superclass.location)
|
|
205
|
+
)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
visit(graph, path, node.body, current_constant: constant.name, namespace: constant.name.split("::"))
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def visit_module(graph, path, node, current_constant:, namespace:)
|
|
212
|
+
name = qualified_constant_name(node.constant_path, namespace)
|
|
213
|
+
constant = graph.add_constant(
|
|
214
|
+
name: name,
|
|
215
|
+
kind: :module,
|
|
216
|
+
path: path,
|
|
217
|
+
location: SourceLocation.from_prism(path, node.location)
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
visit(graph, path, node.body, current_constant: constant.name, namespace: constant.name.split("::"))
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def visit_def(graph, path, node, current_constant:, namespace:)
|
|
224
|
+
if current_constant && (constant = graph.constants_named(current_constant).find { |candidate| candidate.path == path })
|
|
225
|
+
if node.receiver
|
|
226
|
+
constant.add_class_method(node.name, location: SourceLocation.from_prism(path, node.location))
|
|
227
|
+
else
|
|
228
|
+
constant.add_instance_method(node.name, location: SourceLocation.from_prism(path, node.location))
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
if node.name == :method_missing
|
|
233
|
+
graph.add_edge(
|
|
234
|
+
type: :dynamic_feature,
|
|
235
|
+
from_path: path,
|
|
236
|
+
from_constant: current_constant,
|
|
237
|
+
to: "method_missing",
|
|
238
|
+
location: SourceLocation.from_prism(path, node.location),
|
|
239
|
+
confidence: :unknown_due_to_dynamic_feature
|
|
240
|
+
)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
visit_children(graph, path, node, current_constant: current_constant, namespace: namespace)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def visit_call(graph, path, node, current_constant:, namespace:)
|
|
247
|
+
return visit_children(graph, path, node, current_constant: current_constant, namespace: namespace) unless node.message
|
|
248
|
+
|
|
249
|
+
message = node.message.to_sym
|
|
250
|
+
location = SourceLocation.from_prism(path, node.location)
|
|
251
|
+
|
|
252
|
+
if (one_shot = instantiates_and_invokes(node))
|
|
253
|
+
graph.add_edge(
|
|
254
|
+
type: :instantiates_and_invokes,
|
|
255
|
+
from_path: path,
|
|
256
|
+
from_constant: current_constant,
|
|
257
|
+
to: one_shot,
|
|
258
|
+
location: location
|
|
259
|
+
)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
if (required = literal_require_argument(node))
|
|
263
|
+
graph.add_edge(
|
|
264
|
+
type: message == :require_relative ? :requires_relative : :requires,
|
|
265
|
+
from_path: path,
|
|
266
|
+
from_constant: current_constant,
|
|
267
|
+
to: required,
|
|
268
|
+
location: location
|
|
269
|
+
)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
if (edge_type = MIXIN_MESSAGES[message])
|
|
273
|
+
constant_arguments(node).each do |constant_name|
|
|
274
|
+
if current_constant && (constant = graph.constants_named(current_constant).find { |candidate| candidate.path == path })
|
|
275
|
+
constant.add_mixin(message, constant_name)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
graph.add_edge(
|
|
279
|
+
type: edge_type,
|
|
280
|
+
from_path: path,
|
|
281
|
+
from_constant: current_constant,
|
|
282
|
+
to: constant_name,
|
|
283
|
+
location: location
|
|
284
|
+
)
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
graph.add_edge(
|
|
289
|
+
type: :calls_named_method,
|
|
290
|
+
from_path: path,
|
|
291
|
+
from_constant: current_constant,
|
|
292
|
+
to: message,
|
|
293
|
+
location: location
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
if DYNAMIC_MESSAGES.include?(message)
|
|
297
|
+
graph.add_edge(
|
|
298
|
+
type: :dynamic_feature,
|
|
299
|
+
from_path: path,
|
|
300
|
+
from_constant: current_constant,
|
|
301
|
+
to: message,
|
|
302
|
+
location: location,
|
|
303
|
+
confidence: :unknown_due_to_dynamic_feature
|
|
304
|
+
)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
visit_children(graph, path, node, current_constant: current_constant, namespace: namespace)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def add_constant_reference(graph, path, node, current_constant)
|
|
311
|
+
graph.add_edge(
|
|
312
|
+
type: :references_constant,
|
|
313
|
+
from_path: path,
|
|
314
|
+
from_constant: current_constant,
|
|
315
|
+
to: constant_reference_name(node),
|
|
316
|
+
location: SourceLocation.from_prism(path, node.location)
|
|
317
|
+
)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def visit_children(graph, path, node, current_constant:, namespace:)
|
|
321
|
+
node.child_nodes.compact.each do |child|
|
|
322
|
+
visit(graph, path, child, current_constant: current_constant, namespace: namespace)
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def literal_require_argument(node)
|
|
327
|
+
return unless %i[require require_relative].include?(node.message.to_sym)
|
|
328
|
+
return if node.receiver
|
|
329
|
+
|
|
330
|
+
first_argument = node.arguments&.arguments&.first
|
|
331
|
+
return unless first_argument.is_a?(Prism::StringNode)
|
|
332
|
+
|
|
333
|
+
first_argument.unescaped
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def constant_arguments(node)
|
|
337
|
+
node.arguments&.arguments&.filter_map do |argument|
|
|
338
|
+
constant_reference_name(argument) if constant_node?(argument)
|
|
339
|
+
end || []
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def instantiates_and_invokes(node)
|
|
343
|
+
receiver = node.receiver
|
|
344
|
+
return unless receiver.is_a?(Prism::CallNode)
|
|
345
|
+
return unless receiver.message == "new"
|
|
346
|
+
|
|
347
|
+
"#{new_receiver_name(receiver)}##{node.message}"
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def new_receiver_name(node)
|
|
351
|
+
return constant_reference_name(node.receiver) if constant_node?(node.receiver)
|
|
352
|
+
|
|
353
|
+
node.receiver&.slice || "(unknown)"
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def qualified_constant_name(node, namespace)
|
|
357
|
+
raw = constant_reference_name(node)
|
|
358
|
+
absolute = node.respond_to?(:full_name_parts) && node.full_name_parts.first == :""
|
|
359
|
+
|
|
360
|
+
if absolute || raw.include?("::") || namespace.empty?
|
|
361
|
+
raw
|
|
362
|
+
else
|
|
363
|
+
"#{namespace.join("::")}::#{raw}"
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def constant_reference_name(node)
|
|
368
|
+
node.full_name.to_s.sub(/\A::/, "")
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def constant_node?(node)
|
|
372
|
+
node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
end
|