taski 0.4.2 → 0.5.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.
@@ -0,0 +1,506 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+ require "stringio"
5
+
6
+ module Taski
7
+ module Execution
8
+ # Tree-based progress display that shows task execution in a tree structure
9
+ # similar to Task.tree, with real-time status updates and stdout capture.
10
+ class TreeProgressDisplay
11
+ SPINNER_FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
12
+
13
+ # ANSI color codes (matching Task.tree)
14
+ COLORS = {
15
+ reset: "\e[0m",
16
+ task: "\e[32m", # green
17
+ section: "\e[34m", # blue
18
+ impl: "\e[33m", # yellow
19
+ tree: "\e[90m", # gray
20
+ name: "\e[1m", # bold
21
+ success: "\e[32m", # green
22
+ error: "\e[31m", # red
23
+ running: "\e[36m", # cyan
24
+ pending: "\e[90m", # gray
25
+ dim: "\e[2m" # dim
26
+ }.freeze
27
+
28
+ # Status icons
29
+ ICONS = {
30
+ pending: "⏸", # Pause for waiting
31
+ running_prefix: "", # Will use spinner
32
+ completed: "✓",
33
+ failed: "✗",
34
+ skipped: "⊘" # Prohibition sign for unselected impl candidates
35
+ }.freeze
36
+
37
+ # Shared helper methods
38
+ def self.section_class?(klass)
39
+ defined?(Taski::Section) && klass < Taski::Section
40
+ end
41
+
42
+ def self.nested_class?(child_class, parent_class)
43
+ child_name = child_class.name.to_s
44
+ parent_name = parent_class.name.to_s
45
+ child_name.start_with?("#{parent_name}::")
46
+ end
47
+
48
+ # Render a static tree structure for a task class (used by Task.tree)
49
+ # @param root_task_class [Class] The root task class
50
+ # @return [String] The rendered tree string
51
+ def self.render_static_tree(root_task_class)
52
+ renderer = StaticTreeRenderer.new
53
+ renderer.render(root_task_class)
54
+ end
55
+
56
+ # Internal renderer for static tree display (no progress tracking)
57
+ class StaticTreeRenderer
58
+ def render(root_task_class)
59
+ @task_index_map = {}
60
+ build_tree(root_task_class, "", false, Set.new)
61
+ end
62
+
63
+ private
64
+
65
+ def build_tree(task_class, prefix, is_impl, ancestors)
66
+ type_label = colored_type_label(task_class)
67
+ impl_prefix = is_impl ? "#{COLORS[:impl]}[impl]#{COLORS[:reset]} " : ""
68
+ task_number = get_task_number(task_class)
69
+ name = "#{COLORS[:name]}#{task_class.name}#{COLORS[:reset]}"
70
+
71
+ # Detect circular reference
72
+ if ancestors.include?(task_class)
73
+ circular_marker = "#{COLORS[:impl]}(circular)#{COLORS[:reset]}"
74
+ return "#{impl_prefix}#{task_number} #{name} #{type_label} #{circular_marker}\n"
75
+ end
76
+
77
+ result = "#{impl_prefix}#{task_number} #{name} #{type_label}\n"
78
+
79
+ # Register task number if not already registered
80
+ @task_index_map[task_class] = @task_index_map.size + 1 unless @task_index_map.key?(task_class)
81
+
82
+ new_ancestors = ancestors + [task_class]
83
+ dependencies = StaticAnalysis::Analyzer.analyze(task_class).to_a
84
+ is_section = TreeProgressDisplay.section_class?(task_class)
85
+
86
+ dependencies.each_with_index do |dep, index|
87
+ is_last = (index == dependencies.size - 1)
88
+ is_impl_candidate = is_section && TreeProgressDisplay.nested_class?(dep, task_class)
89
+ result += render_dependency_branch(dep, prefix, is_last, is_impl_candidate, new_ancestors)
90
+ end
91
+
92
+ result
93
+ end
94
+
95
+ def render_dependency_branch(dep, prefix, is_last, is_impl, ancestors)
96
+ connector = is_last ? "└── " : "├── "
97
+ extension = is_last ? " " : "│ "
98
+ dep_tree = build_tree(dep, "#{prefix}#{extension}", is_impl, ancestors)
99
+
100
+ result = "#{prefix}#{COLORS[:tree]}#{connector}#{COLORS[:reset]}"
101
+ lines = dep_tree.lines
102
+ result += lines.first
103
+ lines.drop(1).each { |line| result += line }
104
+ result
105
+ end
106
+
107
+ def get_task_number(task_class)
108
+ number = @task_index_map[task_class] || (@task_index_map.size + 1)
109
+ "#{COLORS[:tree]}[#{number}]#{COLORS[:reset]}"
110
+ end
111
+
112
+ def colored_type_label(klass)
113
+ if TreeProgressDisplay.section_class?(klass)
114
+ "#{COLORS[:section]}(Section)#{COLORS[:reset]}"
115
+ else
116
+ "#{COLORS[:task]}(Task)#{COLORS[:reset]}"
117
+ end
118
+ end
119
+ end
120
+
121
+ class TaskProgress
122
+ attr_accessor :state, :start_time, :end_time, :error, :duration
123
+ attr_accessor :is_impl_candidate
124
+
125
+ def initialize
126
+ @state = :pending
127
+ @start_time = nil
128
+ @end_time = nil
129
+ @error = nil
130
+ @duration = nil
131
+ @is_impl_candidate = false
132
+ end
133
+ end
134
+
135
+ def initialize(output: $stdout)
136
+ @output = output
137
+ @tasks = {}
138
+ @monitor = Monitor.new
139
+ @spinner_index = 0
140
+ @renderer_thread = nil
141
+ @running = false
142
+ @nest_level = 0 # Track nested executor calls
143
+ @root_task_class = nil
144
+ @tree_structure = nil
145
+ @section_impl_map = {} # Section -> selected impl class
146
+ @last_line_count = 0
147
+ end
148
+
149
+ # Set the root task to build tree structure
150
+ # Only sets root task if not already set (prevents nested executor overwrite)
151
+ # @param root_task_class [Class] The root task class
152
+ def set_root_task(root_task_class)
153
+ @monitor.synchronize do
154
+ return if @root_task_class # Don't overwrite existing root task
155
+ @root_task_class = root_task_class
156
+ build_tree_structure
157
+ end
158
+ end
159
+
160
+ # Register which impl was selected for a section
161
+ # @param section_class [Class] The section class
162
+ # @param impl_class [Class] The selected implementation class
163
+ def register_section_impl(section_class, impl_class)
164
+ @monitor.synchronize do
165
+ @section_impl_map[section_class] = impl_class
166
+ end
167
+ end
168
+
169
+ # @param task_class [Class] The task class to register
170
+ def register_task(task_class)
171
+ @monitor.synchronize do
172
+ return if @tasks.key?(task_class)
173
+ @tasks[task_class] = TaskProgress.new
174
+ end
175
+ end
176
+
177
+ # @param task_class [Class] The task class to check
178
+ # @return [Boolean] true if the task is registered
179
+ def task_registered?(task_class)
180
+ @monitor.synchronize do
181
+ @tasks.key?(task_class)
182
+ end
183
+ end
184
+
185
+ # @param task_class [Class] The task class to update
186
+ # @param state [Symbol] The new state (:pending, :running, :completed, :failed)
187
+ # @param duration [Float] Duration in milliseconds (for completed tasks)
188
+ # @param error [Exception] Error object (for failed tasks)
189
+ def update_task(task_class, state:, duration: nil, error: nil)
190
+ @monitor.synchronize do
191
+ progress = @tasks[task_class]
192
+ return unless progress
193
+
194
+ progress.state = state
195
+ progress.duration = duration if duration
196
+ progress.error = error if error
197
+
198
+ case state
199
+ when :running
200
+ progress.start_time = Time.now
201
+ when :completed, :failed
202
+ progress.end_time = Time.now
203
+ end
204
+ end
205
+ end
206
+
207
+ # @param task_class [Class] The task class
208
+ # @return [Symbol] The task state
209
+ def task_state(task_class)
210
+ @monitor.synchronize do
211
+ @tasks[task_class]&.state
212
+ end
213
+ end
214
+
215
+ def start
216
+ should_start = false
217
+ @monitor.synchronize do
218
+ @nest_level += 1
219
+ return if @nest_level > 1 # Already running from outer executor
220
+ return if @running
221
+ return unless @output.tty?
222
+
223
+ @running = true
224
+ should_start = true
225
+ end
226
+
227
+ return unless should_start
228
+
229
+ # Hide cursor (outside monitor to avoid holding lock during I/O)
230
+ @output.print "\e[?25l"
231
+ @renderer_thread = Thread.new do
232
+ loop do
233
+ break unless @running
234
+ render_live
235
+ sleep 0.1
236
+ end
237
+ end
238
+ end
239
+
240
+ def stop
241
+ should_stop = false
242
+ @monitor.synchronize do
243
+ @nest_level -= 1 if @nest_level > 0
244
+ return unless @nest_level == 0
245
+ return unless @running
246
+
247
+ @running = false
248
+ should_stop = true
249
+ end
250
+
251
+ return unless should_stop
252
+
253
+ @renderer_thread&.join
254
+ # Show cursor
255
+ @output.print "\e[?25h"
256
+ render_final
257
+ end
258
+
259
+ private
260
+
261
+ # Build tree structure from root task for display
262
+ def build_tree_structure
263
+ return unless @root_task_class
264
+
265
+ @tree_structure = build_tree_node(@root_task_class, Set.new)
266
+ register_tasks_from_tree(@tree_structure)
267
+ end
268
+
269
+ # Build a single tree node
270
+ def build_tree_node(task_class, ancestors)
271
+ return nil if ancestors.include?(task_class)
272
+
273
+ node = {
274
+ task_class: task_class,
275
+ is_section: section_class?(task_class),
276
+ children: [],
277
+ is_impl_candidate: false
278
+ }
279
+
280
+ new_ancestors = ancestors + [task_class]
281
+ dependencies = StaticAnalysis::Analyzer.analyze(task_class).to_a
282
+ is_section = section_class?(task_class)
283
+
284
+ dependencies.each do |dep|
285
+ child_node = build_tree_node(dep, new_ancestors)
286
+ if child_node
287
+ # Only mark as impl candidate if parent is Section AND
288
+ # the dependency is a nested class of that Section
289
+ child_node[:is_impl_candidate] = is_section && nested_class?(dep, task_class)
290
+ node[:children] << child_node
291
+ end
292
+ end
293
+
294
+ node
295
+ end
296
+
297
+ # Register all tasks from tree structure
298
+ def register_tasks_from_tree(node)
299
+ return unless node
300
+
301
+ task_class = node[:task_class]
302
+ register_task(task_class)
303
+
304
+ # Mark as impl candidate if applicable
305
+ if node[:is_impl_candidate]
306
+ @tasks[task_class].is_impl_candidate = true
307
+ end
308
+
309
+ node[:children].each { |child| register_tasks_from_tree(child) }
310
+ end
311
+
312
+ def render_live
313
+ lines = nil
314
+ line_count = nil
315
+
316
+ @monitor.synchronize do
317
+ @spinner_index += 1
318
+ lines = build_tree_display
319
+ line_count = @last_line_count
320
+ end
321
+
322
+ return if lines.nil? || lines.empty?
323
+
324
+ # Move cursor up to beginning of display area
325
+ if line_count && line_count > 0
326
+ @output.print "\e[#{line_count}A\r"
327
+ end
328
+
329
+ # Redraw all lines
330
+ lines.each do |line|
331
+ @output.print "\e[K#{line}\n"
332
+ end
333
+
334
+ @monitor.synchronize do
335
+ @last_line_count = lines.length
336
+ end
337
+
338
+ @output.flush
339
+ end
340
+
341
+ def render_final
342
+ @monitor.synchronize do
343
+ lines = build_tree_display
344
+ return if lines.empty?
345
+
346
+ # Clear previous animated output
347
+ if @last_line_count && @last_line_count > 0
348
+ @last_line_count.times do
349
+ @output.print "\e[1A\e[K"
350
+ end
351
+ end
352
+
353
+ # Print final state
354
+ lines.each { |line| @output.puts line }
355
+ end
356
+ end
357
+
358
+ # Build display lines from tree structure
359
+ def build_tree_display
360
+ return [] unless @tree_structure
361
+
362
+ lines = []
363
+ build_root_tree_lines(@tree_structure, "", lines)
364
+ lines
365
+ end
366
+
367
+ # Build tree lines starting from root node
368
+ # @param node [Hash] Tree node (root)
369
+ # @param prefix [String] Line prefix for tree drawing
370
+ # @param lines [Array<String>] Accumulated output lines
371
+ def build_root_tree_lines(node, prefix, lines)
372
+ task_class = node[:task_class]
373
+ progress = @tasks[task_class]
374
+
375
+ # Root node is never an impl candidate and is always selected
376
+ line = format_tree_line(task_class, progress, false, true)
377
+ lines << "#{prefix}#{line}"
378
+
379
+ render_children(node, prefix, lines, task_class, true)
380
+ end
381
+
382
+ # Render all children of a node recursively
383
+ # @param node [Hash] Tree node
384
+ # @param prefix [String] Line prefix for tree drawing
385
+ # @param lines [Array<String>] Accumulated output lines
386
+ # @param parent_task_class [Class] Parent task class (for impl selection lookup)
387
+ # @param ancestor_selected [Boolean] Whether all ancestor impl candidates were selected
388
+ def render_children(node, prefix, lines, parent_task_class, ancestor_selected)
389
+ children = node[:children]
390
+ children.each_with_index do |child, index|
391
+ is_last = (index == children.size - 1)
392
+ connector = is_last ? "└── " : "├── "
393
+ extension = is_last ? " " : "│ "
394
+
395
+ child_progress = @tasks[child[:task_class]]
396
+
397
+ # Determine child's selection status
398
+ child_is_selected = true
399
+ if child[:is_impl_candidate]
400
+ selected_impl = @section_impl_map[parent_task_class]
401
+ child_is_selected = (selected_impl == child[:task_class])
402
+ end
403
+ # Propagate ancestor selection state
404
+ child_effective_selected = ancestor_selected && child_is_selected
405
+
406
+ child_line = format_tree_line(
407
+ child[:task_class],
408
+ child_progress,
409
+ child[:is_impl_candidate],
410
+ child_effective_selected
411
+ )
412
+ lines << "#{prefix}#{COLORS[:tree]}#{connector}#{COLORS[:reset]}#{child_line}"
413
+
414
+ if child[:children].any?
415
+ render_children(child, "#{prefix}#{COLORS[:tree]}#{extension}#{COLORS[:reset]}", lines, child[:task_class], child_effective_selected)
416
+ end
417
+ end
418
+ end
419
+
420
+ def format_tree_line(task_class, progress, is_impl, is_selected)
421
+ return format_unknown_task(task_class, is_selected) unless progress
422
+
423
+ type_label = type_label_for(task_class, is_selected)
424
+ impl_prefix = is_impl ? "#{COLORS[:impl]}[impl]#{COLORS[:reset]} " : ""
425
+
426
+ # Handle unselected nodes (either impl candidates or children of unselected impl)
427
+ # Show dimmed regardless of task state since they belong to unselected branch
428
+ unless is_selected
429
+ name = "#{COLORS[:dim]}#{task_class.name}#{COLORS[:reset]}"
430
+ suffix = is_impl ? " #{COLORS[:dim]}(not selected)#{COLORS[:reset]}" : ""
431
+ return "#{COLORS[:dim]}#{ICONS[:skipped]}#{COLORS[:reset]} #{impl_prefix}#{name} #{type_label}#{suffix}"
432
+ end
433
+
434
+ status_icon = task_status_icon(progress.state, is_selected)
435
+ name = "#{COLORS[:name]}#{task_class.name}#{COLORS[:reset]}"
436
+ details = task_details(progress)
437
+
438
+ "#{status_icon} #{impl_prefix}#{name} #{type_label}#{details}"
439
+ end
440
+
441
+ def format_unknown_task(task_class, is_selected = true)
442
+ if is_selected
443
+ name = "#{COLORS[:name]}#{task_class.name}#{COLORS[:reset]}"
444
+ type_label = type_label_for(task_class, true)
445
+ "#{COLORS[:pending]}#{ICONS[:pending]}#{COLORS[:reset]} #{name} #{type_label}"
446
+ else
447
+ name = "#{COLORS[:dim]}#{task_class.name}#{COLORS[:reset]}"
448
+ type_label = type_label_for(task_class, false)
449
+ "#{COLORS[:dim]}#{ICONS[:skipped]}#{COLORS[:reset]} #{name} #{type_label}"
450
+ end
451
+ end
452
+
453
+ def task_status_icon(state, is_selected)
454
+ # If not selected (either direct impl candidate or child of unselected), show skipped
455
+ unless is_selected
456
+ return "#{COLORS[:dim]}#{ICONS[:skipped]}#{COLORS[:reset]}"
457
+ end
458
+
459
+ case state
460
+ when :completed
461
+ "#{COLORS[:success]}#{ICONS[:completed]}#{COLORS[:reset]}"
462
+ when :failed
463
+ "#{COLORS[:error]}#{ICONS[:failed]}#{COLORS[:reset]}"
464
+ when :running
465
+ "#{COLORS[:running]}#{spinner_char}#{COLORS[:reset]}"
466
+ else
467
+ "#{COLORS[:pending]}#{ICONS[:pending]}#{COLORS[:reset]}"
468
+ end
469
+ end
470
+
471
+ def spinner_char
472
+ SPINNER_FRAMES[@spinner_index % SPINNER_FRAMES.length]
473
+ end
474
+
475
+ def type_label_for(task_class, is_selected = true)
476
+ if section_class?(task_class)
477
+ is_selected ? "#{COLORS[:section]}(Section)#{COLORS[:reset]}" : "#{COLORS[:dim]}(Section)#{COLORS[:reset]}"
478
+ else
479
+ is_selected ? "#{COLORS[:task]}(Task)#{COLORS[:reset]}" : "#{COLORS[:dim]}(Task)#{COLORS[:reset]}"
480
+ end
481
+ end
482
+
483
+ def task_details(progress)
484
+ case progress.state
485
+ when :completed
486
+ " #{COLORS[:success]}(#{progress.duration}ms)#{COLORS[:reset]}"
487
+ when :failed
488
+ " #{COLORS[:error]}(failed)#{COLORS[:reset]}"
489
+ when :running
490
+ elapsed = ((Time.now - progress.start_time) * 1000).round(0)
491
+ " #{COLORS[:running]}(#{elapsed}ms)#{COLORS[:reset]}"
492
+ else
493
+ ""
494
+ end
495
+ end
496
+
497
+ def section_class?(klass)
498
+ self.class.section_class?(klass)
499
+ end
500
+
501
+ def nested_class?(child_class, parent_class)
502
+ self.class.nested_class?(child_class, parent_class)
503
+ end
504
+ end
505
+ end
506
+ end
data/lib/taski/section.rb CHANGED
@@ -24,6 +24,9 @@ module Taski
24
24
  raise "Section #{self.class} does not have an implementation. Override 'impl' method."
25
25
  end
26
26
 
27
+ # Register selected impl for progress display
28
+ register_impl_selection(implementation_class)
29
+
27
30
  apply_interface_to_implementation(implementation_class)
28
31
 
29
32
  self.class.exported_methods.each do |method|
@@ -40,6 +43,13 @@ module Taski
40
43
 
41
44
  private
42
45
 
46
+ def register_impl_selection(implementation_class)
47
+ progress = Taski.progress_display
48
+ return unless progress.is_a?(Execution::TreeProgressDisplay)
49
+
50
+ progress.register_section_impl(self.class, implementation_class)
51
+ end
52
+
43
53
  # @param implementation_class [Class] The implementation task class
44
54
  def apply_interface_to_implementation(implementation_class)
45
55
  interface_methods = self.class.exported_methods
@@ -7,8 +7,8 @@ module Taski
7
7
  module StaticAnalysis
8
8
  class Analyzer
9
9
  # Analyzes a task class and returns its static dependencies.
10
- # For Task: dependencies detected from run method (SomeTask.method calls)
11
- # For Section: impl candidates detected from impl method (constants returned)
10
+ # For Task: dependencies detected from run method and called methods (SomeTask.method calls)
11
+ # For Section: impl candidates detected from impl method and called methods (constants returned)
12
12
  #
13
13
  # Static dependencies are used for:
14
14
  # - Tree display visualization
@@ -27,6 +27,8 @@ module Taski
27
27
 
28
28
  visitor = Visitor.new(task_class, target_method)
29
29
  visitor.visit(parse_result.value)
30
+ # Follow method calls to analyze dependencies in called methods
31
+ visitor.follow_method_calls
30
32
  visitor.dependencies
31
33
  end
32
34
 
@@ -9,17 +9,34 @@ module Taski
9
9
 
10
10
  # @param target_task_class [Class] The task class to analyze
11
11
  # @param target_method [Symbol] The method name to analyze (:run or :impl)
12
- def initialize(target_task_class, target_method = :run)
12
+ # @param methods_to_analyze [Set<Symbol>] Set of method names to analyze (for following calls)
13
+ def initialize(target_task_class, target_method = :run, methods_to_analyze = nil)
13
14
  super()
14
15
  @target_task_class = target_task_class
15
16
  @target_method = target_method
16
17
  @dependencies = Set.new
17
18
  @in_target_method = false
18
19
  @current_namespace_path = []
20
+ # Methods to analyze: starts with just the target method, grows as we find calls
21
+ @methods_to_analyze = methods_to_analyze || Set.new([@target_method])
22
+ # Track which methods we've already analyzed to prevent infinite loops
23
+ @analyzed_methods = Set.new
24
+ # Collect method calls made within analyzed methods (for following)
25
+ @method_calls_to_follow = Set.new
26
+ # Store method definitions found in the class for later analysis
27
+ @class_method_defs = {}
28
+ # Track if we're in an impl call chain (for Section constant detection)
29
+ @in_impl_chain = false
19
30
  end
20
31
 
21
32
  def visit_class_node(node)
22
- within_namespace(extract_constant_name(node.constant_path)) { super }
33
+ within_namespace(extract_constant_name(node.constant_path)) do
34
+ if in_target_class?
35
+ # First pass: collect all method definitions in the target class
36
+ collect_method_definitions(node)
37
+ end
38
+ super
39
+ end
23
40
  end
24
41
 
25
42
  def visit_module_node(node)
@@ -27,17 +44,25 @@ module Taski
27
44
  end
28
45
 
29
46
  def visit_def_node(node)
30
- if node.name == @target_method && in_target_class?
47
+ if in_target_class? && should_analyze_method?(node.name)
48
+ @analyzed_methods.add(node.name)
31
49
  @in_target_method = true
50
+ @current_analyzing_method = node.name
51
+ # Start impl chain when entering impl method
52
+ @in_impl_chain = true if node.name == :impl && @target_method == :impl
32
53
  super
33
54
  @in_target_method = false
55
+ @current_analyzing_method = nil
34
56
  else
35
57
  super
36
58
  end
37
59
  end
38
60
 
39
61
  def visit_call_node(node)
40
- detect_task_dependency(node) if @in_target_method
62
+ if @in_target_method
63
+ detect_task_dependency(node)
64
+ detect_method_call_to_follow(node)
65
+ end
41
66
  super
42
67
  end
43
68
 
@@ -53,8 +78,46 @@ module Taski
53
78
  super
54
79
  end
55
80
 
81
+ # After visiting, follow any method calls that need analysis
82
+ # @in_impl_chain is preserved because methods called from impl should
83
+ # also detect constants as impl candidates
84
+ def follow_method_calls
85
+ new_methods = @method_calls_to_follow - @analyzed_methods
86
+ return if new_methods.empty?
87
+
88
+ # Add new methods to analyze
89
+ @methods_to_analyze.merge(new_methods)
90
+ @method_calls_to_follow.clear
91
+
92
+ # Re-analyze the class methods
93
+ # Preserve impl chain context: methods called from impl should continue
94
+ # detecting constants as impl candidates
95
+ @class_method_defs.each do |method_name, method_node|
96
+ next unless new_methods.include?(method_name)
97
+
98
+ @analyzed_methods.add(method_name)
99
+ @in_target_method = true
100
+ @current_analyzing_method = method_name
101
+ visit(method_node)
102
+ @in_target_method = false
103
+ @current_analyzing_method = nil
104
+ end
105
+
106
+ # Recursively follow any new calls discovered
107
+ follow_method_calls
108
+ end
109
+
56
110
  private
57
111
 
112
+ # Collect all method definitions in the target class for later analysis
113
+ def collect_method_definitions(class_node)
114
+ class_node.body&.body&.each do |node|
115
+ if node.is_a?(Prism::DefNode)
116
+ @class_method_defs[node.name] = node
117
+ end
118
+ end
119
+ end
120
+
58
121
  def within_namespace(name)
59
122
  @current_namespace_path.push(name)
60
123
  yield
@@ -70,8 +133,26 @@ module Taski
70
133
  node.slice
71
134
  end
72
135
 
136
+ def should_analyze_method?(method_name)
137
+ @methods_to_analyze.include?(method_name) && !@analyzed_methods.include?(method_name)
138
+ end
139
+
73
140
  def in_impl_method?
74
- @in_target_method && @target_method == :impl
141
+ @in_target_method && @in_impl_chain
142
+ end
143
+
144
+ # Detect method calls that should be followed (calls to methods in the same class)
145
+ def detect_method_call_to_follow(node)
146
+ # Only follow calls without explicit receiver (self.method or just method)
147
+ return if node.receiver && !self_receiver?(node.receiver)
148
+
149
+ method_name = node.name
150
+ # Mark this method for later analysis if it's defined in the class
151
+ @method_calls_to_follow.add(method_name) if @class_method_defs.key?(method_name)
152
+ end
153
+
154
+ def self_receiver?(receiver)
155
+ receiver.is_a?(Prism::SelfNode)
75
156
  end
76
157
 
77
158
  def detect_impl_candidate(node)