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.
- checksums.yaml +4 -4
- data/README.md +12 -13
- data/Steepfile +1 -0
- data/examples/README.md +3 -3
- data/examples/data_pipeline_demo.rb +3 -3
- data/examples/nested_section_demo.rb +161 -0
- data/examples/parallel_progress_demo.rb +1 -1
- data/examples/section_progress_demo.rb +78 -0
- data/examples/tree_progress_demo.rb +164 -0
- data/lib/taski/execution/executor.rb +247 -0
- data/lib/taski/execution/registry.rb +9 -1
- data/lib/taski/execution/task_wrapper.rb +126 -147
- data/lib/taski/execution/tree_progress_display.rb +506 -0
- data/lib/taski/section.rb +10 -0
- data/lib/taski/static_analysis/analyzer.rb +4 -2
- data/lib/taski/static_analysis/visitor.rb +86 -5
- data/lib/taski/task.rb +11 -94
- data/lib/taski/version.rb +1 -1
- data/lib/taski.rb +10 -6
- data/sig/taski.rbs +127 -64
- metadata +6 -3
- data/lib/taski/execution/coordinator.rb +0 -63
- data/lib/taski/execution/parallel_progress_display.rb +0 -201
|
@@ -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
|
-
|
|
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))
|
|
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
|
|
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
|
-
|
|
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 && @
|
|
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)
|