call_map 0.1.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +131 -0
- data/exe/call_map +7 -0
- data/lib/call_map/analyzer.rb +143 -0
- data/lib/call_map/call_extractor.rb +124 -0
- data/lib/call_map/call_node.rb +52 -0
- data/lib/call_map/callback_extractor.rb +180 -0
- data/lib/call_map/cli.rb +68 -0
- data/lib/call_map/definition.rb +70 -0
- data/lib/call_map/definition_collector.rb +348 -0
- data/lib/call_map/formatters/text_tree.rb +69 -0
- data/lib/call_map/method_call.rb +74 -0
- data/lib/call_map/resolver.rb +107 -0
- data/lib/call_map/source_index.rb +91 -0
- data/lib/call_map/version.rb +5 -0
- data/lib/call_map.rb +18 -0
- data/sig/call_map.rbs +4 -0
- metadata +81 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: '07679b146de8490eecbaab5863ba5658cb8a5f94deb9509573dec80012f15a42'
|
|
4
|
+
data.tar.gz: c8ffb63ed36c006eb6bfe736682e83279ea48e74e94037e332b04f249412993b
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b3ed3a5485192ef21cd7f42bbc1c86401ec918c9caac0553ee38c6e1c906f355e2e4531923ae14568116e53655a1b0d06d97d59ed3755c74cccc871d810a4411
|
|
7
|
+
data.tar.gz: d462c5c4b7671caa14617056bd8821cd7904c201eb035abb5a31d9edf0de235ac12d7b6f40b0c239224ef5724c249d85856b3f20b8746481dfd2424c091880c9
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 bumpfuji10
|
|
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
|
|
13
|
+
all 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
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# CallMap
|
|
2
|
+
|
|
3
|
+
Rails アプリケーションのメソッド呼び出しチェーンを静的解析して、text tree として出力する gem です。
|
|
4
|
+
|
|
5
|
+
controller action を起点に「この action は何を呼び、その先で何が起きるのか」を、Notion や PR にそのまま貼れる形で可視化します。コードリーディングの補助ツールであり、完全な静的解析器ではありません。
|
|
6
|
+
|
|
7
|
+
## 目的
|
|
8
|
+
|
|
9
|
+
Rails アプリで処理の流れを追うとき、`destroy` action からサービス、モデル、notifier へと手でコードを辿りながらメモを書くことがあります。CallMap はその「呼び出しツリーのメモ」を自動生成します。
|
|
10
|
+
|
|
11
|
+
- framework 内部ではなく、**自分たちのアプリケーションコード**に集中する
|
|
12
|
+
- 出力は安定していて、ドキュメントや PR に貼れる
|
|
13
|
+
- `before_action` などの Rails DSL も処理経路として表示する
|
|
14
|
+
|
|
15
|
+
## インストール
|
|
16
|
+
|
|
17
|
+
現時点では RubyGems には公開していません。GitHub から直接インストールしてください。
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
# Gemfile
|
|
21
|
+
gem "call_map", github: "bumpfuji10/call_map"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
bundle install
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## 使い方
|
|
29
|
+
|
|
30
|
+
Rails アプリのルートディレクトリで、`ClassName#method_name` 形式の起点を指定して実行します。
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
bundle exec call_map OrdersController#destroy --depth=3
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### 出力例
|
|
37
|
+
|
|
38
|
+
```text
|
|
39
|
+
OrdersController#destroy
|
|
40
|
+
├─ before_action authenticate_user!
|
|
41
|
+
├─ before_action set_order
|
|
42
|
+
│ ├─ Order.find [framework]
|
|
43
|
+
│ └─ params.[] [framework]
|
|
44
|
+
├─ authorize [framework]
|
|
45
|
+
├─ OrderDeleteService.execute
|
|
46
|
+
│ └─ OrderDeleteService#execute
|
|
47
|
+
│ └─ @order.destroy! [framework]
|
|
48
|
+
└─ OrderNotifier.notify_deletion
|
|
49
|
+
└─ OrderNotifier#send_notification
|
|
50
|
+
├─ OrderNotifier#deliver
|
|
51
|
+
└─ @order.user [framework]
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### オプション
|
|
55
|
+
|
|
56
|
+
| オプション | 説明 |
|
|
57
|
+
|---|---|
|
|
58
|
+
| `--depth=N` | 探索の深さ制限(デフォルト: 3) |
|
|
59
|
+
| `--include-comments` | メソッド直上のコメントをツリーに表示する |
|
|
60
|
+
| `--root=PATH` | 解析対象のアプリケーションルート(デフォルト: カレントディレクトリ。`app/**/*.rb` を索引します) |
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
bundle exec call_map OrdersController#destroy --depth=2 --include-comments
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### ラベルの意味
|
|
67
|
+
|
|
68
|
+
| ラベル | 意味 |
|
|
69
|
+
|---|---|
|
|
70
|
+
| `before_action foo` | `before_action` 由来の呼び出し(action 本体より前に表示) |
|
|
71
|
+
| `foo [framework]` | 自分のコード内に定義が見つからず、Rails / gem 側と思われる呼び出し。ここで探索を止めます |
|
|
72
|
+
| `foo [dynamic]` | `send` / `public_send` などの動的呼び出し。解決せず葉として表示します |
|
|
73
|
+
| `Foo#bar [circular]` | 探索パス上で同じメソッドに再訪した(循環)。ここで探索を止めます |
|
|
74
|
+
| suffix なしの未解決呼び出し | 自クラス等への呼び出しに見えるが定義を発見できなかったもの。解析漏れの可能性があるため、誤ったラベルを付けません |
|
|
75
|
+
|
|
76
|
+
## 正確性についてのスタンス
|
|
77
|
+
|
|
78
|
+
**CallMap は完全な静的解析器ではありません。** Ruby は動的な言語であり、静的解析で実行時の挙動を完全に再現することはできません。CallMap は「コードを読む人が手で書くメモ」の精度を目標にしており、以下を意図的に選択しています。
|
|
79
|
+
|
|
80
|
+
- 解決できない呼び出しは、誤って潜るのではなく**葉として止める**
|
|
81
|
+
- 確信の持てない呼び出しに誤ったラベルを付けない
|
|
82
|
+
- Rails 内部・Ruby 標準ライブラリ・gem 内部には**潜らない**(`[framework]` として表示して止まります)
|
|
83
|
+
|
|
84
|
+
## MVP で対応している呼び出しパターン
|
|
85
|
+
|
|
86
|
+
- 同一クラス内の bare call / private method(継承チェーン込み)
|
|
87
|
+
- `SomeService.execute` — class method
|
|
88
|
+
- `SomeClass.new(...).execute` / `self.new.perform` — instance method
|
|
89
|
+
- `self.foo` — 呼び出し元のコンテキスト(instance / class method)に応じて解決
|
|
90
|
+
- namespace 内の相対定数(`Reports::Runner` から `Generator.build` → `Reports::Generator.build`)、絶対定数(`::Foo`)
|
|
91
|
+
- 継承: 親クラスのメソッド・親クラス配下のネスト定数・superclass の lexical 解決
|
|
92
|
+
- `before_action` / `skip_before_action`(`only:` / `except:`(値は symbol / 文字列 / 配列)、複数指定、継承、reopen、skip 後の再追加。callback 名は symbol 指定のみ対応 — `before_action "audit"` のような文字列指定の filter 名は未対応)
|
|
93
|
+
- メソッド直上コメントの保持と表示(`--include-comments`)
|
|
94
|
+
|
|
95
|
+
## Known limitations
|
|
96
|
+
|
|
97
|
+
- `around_action` / `after_action` には対応していません
|
|
98
|
+
- `include` / `extend` された concern / module のメソッド解決には対応していません
|
|
99
|
+
- メタプログラミング(`define_method`、`method_missing`、動的な定数参照など)は解決できません
|
|
100
|
+
- 同名メソッドが複数ある場合、Ruby の実行時ディスパッチと異なる解決をすることがあります
|
|
101
|
+
- 索引対象は `app/**/*.rb` のみです(`lib/` などは対象外)
|
|
102
|
+
- `[framework]` 判定はヒューリスティックです(receiver 付き未解決、または既知の Rails メソッド名リスト)
|
|
103
|
+
|
|
104
|
+
設計上のトレードオフの詳細は [docs/initial_design.md](docs/initial_design.md) を参照してください。
|
|
105
|
+
|
|
106
|
+
## バージョン方針
|
|
107
|
+
|
|
108
|
+
[Semantic Versioning](https://semver.org/) に従います。現在は `0.1.0`(MVP)で、`0.x` の間は出力形式や API に破壊的変更が入る可能性があります。RubyGems への公開は `1.0.0` を目安に検討します。
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
[MIT License](LICENSE.txt)
|
|
113
|
+
|
|
114
|
+
## Development
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
bin/setup # 依存のインストール
|
|
118
|
+
bundle exec rspec # テスト
|
|
119
|
+
bundle exec rubocop
|
|
120
|
+
bin/console # 対話プロンプト
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
fixture の Rails アプリ(`spec/fixtures/rails_app`)に対して手元で実行できます:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
ruby -Ilib exe/call_map "OrdersController#destroy" --root=spec/fixtures/rails_app
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Contributing
|
|
130
|
+
|
|
131
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/bumpfuji10/call_map.
|
data/exe/call_map
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
require "set" # rubocop:disable Lint/RedundantRequireStatement -- explicit for clarity; Set autoloads only on 3.2+
|
|
5
|
+
require_relative "call_extractor"
|
|
6
|
+
require_relative "callback_extractor"
|
|
7
|
+
require_relative "resolver"
|
|
8
|
+
require_relative "call_node"
|
|
9
|
+
|
|
10
|
+
module CallMap
|
|
11
|
+
# Builds a call tree from a starting method definition by recursively
|
|
12
|
+
# extracting calls and resolving them against the SourceIndex.
|
|
13
|
+
class Analyzer
|
|
14
|
+
# @param index [SourceIndex]
|
|
15
|
+
def initialize(index)
|
|
16
|
+
@index = index
|
|
17
|
+
@resolver = Resolver.new(index)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Build a call tree rooted at the given definition.
|
|
21
|
+
#
|
|
22
|
+
# @param definition [Definition] the starting method
|
|
23
|
+
# @param depth [Integer] maximum recursion depth (0 = no children)
|
|
24
|
+
# @return [CallNode]
|
|
25
|
+
def build_call_tree(definition, depth: 3)
|
|
26
|
+
visited = Set.new
|
|
27
|
+
build_node(definition, nil, depth, visited, entry: true)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def build_node(definition, method_call, remaining_depth, visited, entry: false)
|
|
33
|
+
key = node_key(definition)
|
|
34
|
+
circular = definition.method? && visited.include?(key)
|
|
35
|
+
children = if remaining_depth.positive? && definition.method? && !circular
|
|
36
|
+
build_children(definition, remaining_depth, visited | [key], entry: entry)
|
|
37
|
+
else
|
|
38
|
+
[]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
CallNode.new(definition: definition, method_call: method_call, children: children, circular: circular)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def build_children(definition, remaining_depth, visited, entry: false)
|
|
45
|
+
callback_nodes = if entry && action_entry?(definition)
|
|
46
|
+
build_callback_nodes(definition, remaining_depth, visited)
|
|
47
|
+
else
|
|
48
|
+
[]
|
|
49
|
+
end
|
|
50
|
+
call_nodes = extract_calls(definition).map do |call|
|
|
51
|
+
resolve_and_build(call, definition, remaining_depth, visited)
|
|
52
|
+
end
|
|
53
|
+
callback_nodes + call_nodes
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# before_action only runs before a controller action — a public instance
|
|
57
|
+
# method. Private/protected helpers used as an entry point get no callbacks.
|
|
58
|
+
def action_entry?(definition)
|
|
59
|
+
definition.kind == :instance_method && definition.public_method?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Callback filter symbols are invoked via normal method lookup on the
|
|
63
|
+
# controller instance — Resolver walks the superclass chain from the
|
|
64
|
+
# action's class, so a child override wins over the parent's method.
|
|
65
|
+
def build_callback_nodes(definition, remaining_depth, visited)
|
|
66
|
+
extract_callbacks(definition).map do |call|
|
|
67
|
+
resolved = @resolver.resolve(call, context_owner: definition.owner)
|
|
68
|
+
build_resolved_node(resolved, call, remaining_depth, visited)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def resolve_and_build(call, parent_definition, remaining_depth, visited)
|
|
73
|
+
resolved = @resolver.resolve(call,
|
|
74
|
+
context_owner: parent_definition.owner,
|
|
75
|
+
context_kind: parent_definition.kind,
|
|
76
|
+
lexical_nesting: parent_definition.lexical_nesting)
|
|
77
|
+
build_resolved_node(resolved, call, remaining_depth, visited)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def build_resolved_node(resolved, call, remaining_depth, visited)
|
|
81
|
+
if resolved
|
|
82
|
+
build_node(resolved, call, remaining_depth - 1, visited)
|
|
83
|
+
else
|
|
84
|
+
CallNode.new(method_call: call)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Collect callbacks for the action by replaying before_action /
|
|
89
|
+
# skip_before_action declarations in inheritance order (parent first) and
|
|
90
|
+
# declaration order, as Rails does. A skip removes only the callbacks
|
|
91
|
+
# accumulated so far — a callback re-added afterwards runs again.
|
|
92
|
+
def extract_callbacks(definition)
|
|
93
|
+
return [] unless definition.kind == :instance_method
|
|
94
|
+
|
|
95
|
+
events = @index.ancestor_chain(definition.owner).reverse.flat_map do |owner|
|
|
96
|
+
callback_events_on(owner, definition.name)
|
|
97
|
+
end
|
|
98
|
+
replay_callback_events(events)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def replay_callback_events(events)
|
|
102
|
+
events.each_with_object([]) do |event, active|
|
|
103
|
+
case event[:type]
|
|
104
|
+
when :add then active << event[:call]
|
|
105
|
+
when :skip then active.reject! { |call| call.method_name == event[:name] }
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def callback_events_on(owner, action_name)
|
|
111
|
+
@index.find_class_definitions(owner).map(&:path).uniq.flat_map do |path|
|
|
112
|
+
CallbackExtractor.extract(File.read(path), action_name, owner: owner)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def extract_calls(definition)
|
|
117
|
+
source = File.read(definition.path)
|
|
118
|
+
def_node = find_def_node(source, definition)
|
|
119
|
+
return [] unless def_node
|
|
120
|
+
|
|
121
|
+
CallExtractor.extract(def_node)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def find_def_node(source, definition)
|
|
125
|
+
root = Prism.parse(source).value
|
|
126
|
+
find_def_at_line(root, definition.name, definition.line)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def find_def_at_line(node, name, line)
|
|
130
|
+
return node if node.is_a?(Prism::DefNode) && node.name.to_s == name && node.location.start_line == line
|
|
131
|
+
|
|
132
|
+
node.child_nodes.compact.each do |child|
|
|
133
|
+
result = find_def_at_line(child, name, line)
|
|
134
|
+
return result if result
|
|
135
|
+
end
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def node_key(definition)
|
|
140
|
+
"#{definition.path}:#{definition.line}:#{definition.qualified_name}"
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
require_relative "method_call"
|
|
5
|
+
|
|
6
|
+
module CallMap
|
|
7
|
+
# Extracts method calls from a method body's AST.
|
|
8
|
+
#
|
|
9
|
+
# Like DefinitionCollector, this is a Prism boundary class — Prism node
|
|
10
|
+
# types are referenced only here.
|
|
11
|
+
class CallExtractor < Prism::Visitor
|
|
12
|
+
DYNAMIC_METHODS = %w[send public_send __send__].freeze
|
|
13
|
+
|
|
14
|
+
# Extract calls from a DefNode's body.
|
|
15
|
+
#
|
|
16
|
+
# @param def_node [Prism::DefNode] the method definition node
|
|
17
|
+
# @return [Array<MethodCall>]
|
|
18
|
+
def self.extract(def_node)
|
|
19
|
+
extractor = new
|
|
20
|
+
def_node.body&.accept(extractor)
|
|
21
|
+
extractor.calls
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
super
|
|
26
|
+
@calls = []
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
attr_reader :calls
|
|
30
|
+
|
|
31
|
+
def visit_call_node(node)
|
|
32
|
+
@calls << build_call(node)
|
|
33
|
+
# Walk arguments and block, but not the receiver itself (it is already
|
|
34
|
+
# captured in the receiver label). However, walk the receiver chain's
|
|
35
|
+
# arguments/blocks so that calls like `SomeClass.new(build_order).execute`
|
|
36
|
+
# still extract `build_order`.
|
|
37
|
+
node.arguments&.accept(self)
|
|
38
|
+
node.block&.accept(self)
|
|
39
|
+
walk_receiver_args(node.receiver)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Do not recurse into nested def bodies — they are not executed
|
|
43
|
+
# as part of the enclosing method's call path.
|
|
44
|
+
def visit_def_node(_node); end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# Walk the receiver chain's arguments/blocks without registering the
|
|
49
|
+
# receiver CallNodes themselves as separate calls.
|
|
50
|
+
def walk_receiver_args(receiver)
|
|
51
|
+
return unless receiver.is_a?(Prism::CallNode)
|
|
52
|
+
|
|
53
|
+
receiver.arguments&.accept(self)
|
|
54
|
+
receiver.block&.accept(self)
|
|
55
|
+
walk_receiver_args(receiver.receiver)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def build_call(node)
|
|
59
|
+
receiver_str = receiver_label(node.receiver)
|
|
60
|
+
method_name = node.name.to_s
|
|
61
|
+
dynamic = DYNAMIC_METHODS.include?(method_name)
|
|
62
|
+
|
|
63
|
+
MethodCall.new(
|
|
64
|
+
receiver: receiver_str,
|
|
65
|
+
method_name: dynamic ? dynamic_target(node) : method_name,
|
|
66
|
+
line: node.location.start_line,
|
|
67
|
+
dynamic: dynamic,
|
|
68
|
+
absolute: absolute_constant?(node.receiver)
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def absolute_constant?(node)
|
|
73
|
+
case node
|
|
74
|
+
when Prism::ConstantPathNode
|
|
75
|
+
current = node
|
|
76
|
+
current = current.parent while current.is_a?(Prism::ConstantPathNode)
|
|
77
|
+
current.nil?
|
|
78
|
+
when Prism::CallNode
|
|
79
|
+
absolute_constant?(node.receiver)
|
|
80
|
+
else
|
|
81
|
+
false
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def receiver_label(receiver)
|
|
86
|
+
case receiver
|
|
87
|
+
when nil then nil
|
|
88
|
+
when Prism::SelfNode then "self"
|
|
89
|
+
when Prism::ConstantReadNode, Prism::InstanceVariableReadNode
|
|
90
|
+
receiver.name.to_s
|
|
91
|
+
when Prism::ConstantPathNode then constant_path_name(receiver)
|
|
92
|
+
when Prism::CallNode then call_chain_label(receiver)
|
|
93
|
+
else "[expr]"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def constant_path_name(node)
|
|
98
|
+
parts = []
|
|
99
|
+
current = node
|
|
100
|
+
while current.is_a?(Prism::ConstantPathNode)
|
|
101
|
+
parts.unshift(current.name.to_s)
|
|
102
|
+
current = current.parent
|
|
103
|
+
end
|
|
104
|
+
parts.unshift(current.name.to_s) if current.is_a?(Prism::ConstantReadNode)
|
|
105
|
+
parts.join("::")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def call_chain_label(node)
|
|
109
|
+
receiver_str = receiver_label(node.receiver)
|
|
110
|
+
method = node.name.to_s
|
|
111
|
+
receiver_str ? "#{receiver_str}.#{method}" : method
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Best-effort extraction of the target method name from send/public_send.
|
|
115
|
+
def dynamic_target(node)
|
|
116
|
+
first_arg = node.arguments&.arguments&.first
|
|
117
|
+
case first_arg
|
|
118
|
+
when Prism::SymbolNode then first_arg.value
|
|
119
|
+
when Prism::StringNode then first_arg.unescaped
|
|
120
|
+
else "[dynamic]"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CallMap
|
|
4
|
+
# A node in the call tree. Holds the definition (if resolved), the original
|
|
5
|
+
# method call, and child nodes representing calls made from within this method.
|
|
6
|
+
class CallNode
|
|
7
|
+
# @param definition [Definition, nil] resolved definition, nil for unresolved leaves
|
|
8
|
+
# @param method_call [MethodCall, nil] the call site that led here (nil for the root)
|
|
9
|
+
# @param children [Array<CallNode>]
|
|
10
|
+
# @param circular [Boolean] true when this node revisits a definition already on the current path
|
|
11
|
+
def initialize(definition: nil, method_call: nil, children: [], circular: false)
|
|
12
|
+
@definition = definition
|
|
13
|
+
@method_call = method_call
|
|
14
|
+
@children = children
|
|
15
|
+
@circular = circular
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
attr_reader :definition, :method_call, :children
|
|
19
|
+
|
|
20
|
+
def resolved?
|
|
21
|
+
!definition.nil?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def circular?
|
|
25
|
+
@circular
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Human-readable label for this node. A callback-originated node keeps its
|
|
29
|
+
# callback label (e.g. "before_action set_order") even when resolved, so
|
|
30
|
+
# tree output can distinguish it from a plain call. An unresolved call
|
|
31
|
+
# that points outside the indexed app code is marked as a framework leaf.
|
|
32
|
+
def label
|
|
33
|
+
base = base_label
|
|
34
|
+
return "#{base} [circular]" if circular?
|
|
35
|
+
return "#{base} [framework]" if !resolved? && method_call&.framework_leaf?
|
|
36
|
+
|
|
37
|
+
base
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def base_label
|
|
43
|
+
if definition && !method_call&.callback?
|
|
44
|
+
definition.qualified_name
|
|
45
|
+
elsif method_call
|
|
46
|
+
method_call.label
|
|
47
|
+
else
|
|
48
|
+
"[unknown]"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
require_relative "method_call"
|
|
5
|
+
|
|
6
|
+
module CallMap
|
|
7
|
+
# Extracts before_action callbacks from a class body that apply to a given action.
|
|
8
|
+
#
|
|
9
|
+
# This is a Prism boundary class — Prism node types are referenced only here.
|
|
10
|
+
class CallbackExtractor < Prism::Visitor
|
|
11
|
+
# @param source [String] the file source
|
|
12
|
+
# @param action_name [String] the action method name
|
|
13
|
+
# @param owner [String] the class name to scope extraction to
|
|
14
|
+
# @return [Array<Hash>] callback events in declaration order:
|
|
15
|
+
# { type: :add, call: MethodCall } for before_action,
|
|
16
|
+
# { type: :skip, name: String } for skip_before_action.
|
|
17
|
+
# Order matters — a callback re-added after a skip runs again.
|
|
18
|
+
def self.extract(source, action_name, owner:)
|
|
19
|
+
root = Prism.parse(source).value
|
|
20
|
+
class_bodies = find_class_bodies(root, owner)
|
|
21
|
+
return [] if class_bodies.empty?
|
|
22
|
+
|
|
23
|
+
extractor = new(action_name)
|
|
24
|
+
class_bodies.each { |body| body.accept(extractor) }
|
|
25
|
+
extractor.events
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Collect ALL class bodies matching the owner (a class may be reopened
|
|
29
|
+
# multiple times in the same file), in source order.
|
|
30
|
+
def self.find_class_bodies(node, owner)
|
|
31
|
+
collect_class_nodes(node, owner, []).filter_map(&:body)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.collect_class_nodes(node, owner, namespace)
|
|
35
|
+
return collect_within_class_or_module(node, owner, namespace) if class_or_module?(node)
|
|
36
|
+
|
|
37
|
+
node.child_nodes.compact.flat_map { |child| collect_class_nodes(child, owner, namespace) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.class_or_module?(node)
|
|
41
|
+
node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.collect_within_class_or_module(node, owner, namespace)
|
|
45
|
+
qualified = build_qualified_name(node, namespace)
|
|
46
|
+
matches = node.is_a?(Prism::ClassNode) && qualified == owner ? [node] : []
|
|
47
|
+
|
|
48
|
+
body_children = (node.body&.child_nodes || []).compact
|
|
49
|
+
matches + body_children.flat_map { |child| collect_class_nodes(child, owner, qualified.split("::")) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.build_qualified_name(node, namespace)
|
|
53
|
+
const = node.constant_path
|
|
54
|
+
return const_path_to_string(const) if namespace.empty? || absolute_constant?(const)
|
|
55
|
+
|
|
56
|
+
name = const_path_to_string(const)
|
|
57
|
+
return name if already_qualified?(name, namespace)
|
|
58
|
+
|
|
59
|
+
"#{namespace.join('::')}::#{name}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.absolute_constant?(const)
|
|
63
|
+
return false unless const.is_a?(Prism::ConstantPathNode)
|
|
64
|
+
|
|
65
|
+
current = const
|
|
66
|
+
current = current.parent while current.is_a?(Prism::ConstantPathNode)
|
|
67
|
+
current.nil?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.already_qualified?(name, namespace)
|
|
71
|
+
prefix = namespace.join("::")
|
|
72
|
+
name == prefix || name.start_with?("#{prefix}::")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.const_path_to_string(const)
|
|
76
|
+
case const
|
|
77
|
+
when Prism::ConstantReadNode then const.name.to_s
|
|
78
|
+
when Prism::ConstantPathNode then full_constant_path(const)
|
|
79
|
+
else const.to_s
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def self.full_constant_path(node)
|
|
84
|
+
parts = []
|
|
85
|
+
current = node
|
|
86
|
+
while current.is_a?(Prism::ConstantPathNode)
|
|
87
|
+
parts.unshift(current.name.to_s)
|
|
88
|
+
current = current.parent
|
|
89
|
+
end
|
|
90
|
+
parts.unshift(current.name.to_s) if current.is_a?(Prism::ConstantReadNode)
|
|
91
|
+
parts.join("::")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private_class_method :find_class_bodies, :collect_class_nodes, :class_or_module?,
|
|
95
|
+
:collect_within_class_or_module,
|
|
96
|
+
:build_qualified_name, :absolute_constant?, :already_qualified?,
|
|
97
|
+
:const_path_to_string, :full_constant_path
|
|
98
|
+
|
|
99
|
+
def initialize(action_name)
|
|
100
|
+
super()
|
|
101
|
+
@action_name = action_name
|
|
102
|
+
@events = []
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
attr_reader :events
|
|
106
|
+
|
|
107
|
+
def visit_call_node(node)
|
|
108
|
+
if node.name == :before_action && callback_applies?(node)
|
|
109
|
+
extract_callback_names(node).each do |name|
|
|
110
|
+
call = MethodCall.new(receiver: nil, method_name: name, line: node.location.start_line,
|
|
111
|
+
callback: "before_action")
|
|
112
|
+
@events << { type: :add, call: call }
|
|
113
|
+
end
|
|
114
|
+
elsif node.name == :skip_before_action && callback_applies?(node)
|
|
115
|
+
extract_callback_names(node).each { |name| @events << { type: :skip, name: name } }
|
|
116
|
+
end
|
|
117
|
+
super
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Callbacks belong to the class they are declared in — do not descend into
|
|
121
|
+
# nested classes/modules or method bodies within the target class body.
|
|
122
|
+
def visit_class_node(_node); end
|
|
123
|
+
def visit_module_node(_node); end
|
|
124
|
+
def visit_singleton_class_node(_node); end
|
|
125
|
+
def visit_def_node(_node); end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def extract_callback_names(node)
|
|
130
|
+
return [] unless node.arguments
|
|
131
|
+
|
|
132
|
+
node.arguments.arguments.filter_map do |arg|
|
|
133
|
+
arg.value if arg.is_a?(Prism::SymbolNode)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def callback_applies?(node)
|
|
138
|
+
filter = find_scope_filter(node)
|
|
139
|
+
return true unless filter
|
|
140
|
+
|
|
141
|
+
key, value = filter
|
|
142
|
+
key == "only" ? action_in_list?(value) : !action_in_list?(value)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def find_scope_filter(node)
|
|
146
|
+
# Options arrive as a KeywordHashNode (`only: :show`) or a HashNode
|
|
147
|
+
# when written with explicit braces (`{ only: :show }`).
|
|
148
|
+
options = node.arguments&.arguments&.find do |a|
|
|
149
|
+
a.is_a?(Prism::KeywordHashNode) || a.is_a?(Prism::HashNode)
|
|
150
|
+
end
|
|
151
|
+
return nil unless options
|
|
152
|
+
|
|
153
|
+
assoc = scope_assoc(options)
|
|
154
|
+
assoc && [assoc.key.value, assoc.value]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def scope_assoc(keyword_hash)
|
|
158
|
+
keyword_hash.elements.find do |el|
|
|
159
|
+
el.is_a?(Prism::AssocNode) && el.key.is_a?(Prism::SymbolNode) && %w[only except].include?(el.key.value)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def action_in_list?(value_node)
|
|
164
|
+
case value_node
|
|
165
|
+
when Prism::ArrayNode
|
|
166
|
+
value_node.elements.any? { |el| action_name_matches?(el) }
|
|
167
|
+
else
|
|
168
|
+
action_name_matches?(value_node)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def action_name_matches?(node)
|
|
173
|
+
case node
|
|
174
|
+
when Prism::SymbolNode then node.value == @action_name
|
|
175
|
+
when Prism::StringNode then node.unescaped == @action_name
|
|
176
|
+
else false
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|