taski 0.2.0 → 0.2.2
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/.standard.yml +9 -0
- data/README.md +112 -195
- data/Rakefile +7 -1
- data/examples/README.md +57 -0
- data/examples/{complex_example.rb → advanced_patterns.rb} +31 -21
- data/examples/progress_demo.rb +166 -0
- data/examples/{readme_example.rb → quick_start.rb} +15 -4
- data/examples/tree_demo.rb +80 -0
- data/lib/taski/dependency_analyzer.rb +18 -8
- data/lib/taski/exceptions.rb +4 -4
- data/lib/taski/logger.rb +213 -0
- data/lib/taski/progress_display.rb +356 -0
- data/lib/taski/reference.rb +3 -3
- data/lib/taski/task/base.rb +54 -3
- data/lib/taski/task/define_api.rb +36 -7
- data/lib/taski/task/dependency_resolver.rb +39 -11
- data/lib/taski/task/exports_api.rb +1 -1
- data/lib/taski/task/instance_management.rb +75 -20
- data/lib/taski/utils.rb +107 -0
- data/lib/taski/version.rb +1 -1
- data/lib/taski.rb +7 -5
- data/sig/taski.rbs +39 -2
- metadata +12 -6
- data/Steepfile +0 -20
@@ -0,0 +1,356 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "stringio"
|
4
|
+
|
5
|
+
module Taski
|
6
|
+
# Terminal control operations with ANSI escape sequences
|
7
|
+
class TerminalController
|
8
|
+
# ANSI escape sequences
|
9
|
+
MOVE_UP = "\033[A"
|
10
|
+
CLEAR_LINE = "\033[K"
|
11
|
+
MOVE_UP_AND_CLEAR = "#{MOVE_UP}#{CLEAR_LINE}"
|
12
|
+
|
13
|
+
def initialize(output)
|
14
|
+
@output = output
|
15
|
+
end
|
16
|
+
|
17
|
+
def clear_lines(count)
|
18
|
+
return if count == 0
|
19
|
+
|
20
|
+
count.times { @output.print MOVE_UP_AND_CLEAR }
|
21
|
+
end
|
22
|
+
|
23
|
+
def puts(text)
|
24
|
+
@output.puts text
|
25
|
+
end
|
26
|
+
|
27
|
+
def print(text)
|
28
|
+
@output.print text
|
29
|
+
end
|
30
|
+
|
31
|
+
def flush
|
32
|
+
@output.flush
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
# Spinner animation with dots-style characters
|
37
|
+
class SpinnerAnimation
|
38
|
+
SPINNER_CHARS = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"].freeze
|
39
|
+
FRAME_DELAY = 0.1
|
40
|
+
|
41
|
+
def initialize
|
42
|
+
@frame = 0
|
43
|
+
@running = false
|
44
|
+
@thread = nil
|
45
|
+
end
|
46
|
+
|
47
|
+
def start(terminal, task_name, &display_callback)
|
48
|
+
return if @running
|
49
|
+
|
50
|
+
@running = true
|
51
|
+
@frame = 0
|
52
|
+
|
53
|
+
@thread = Thread.new do
|
54
|
+
while @running
|
55
|
+
current_char = SPINNER_CHARS[@frame % SPINNER_CHARS.length]
|
56
|
+
display_callback&.call(current_char, task_name)
|
57
|
+
|
58
|
+
@frame += 1
|
59
|
+
sleep FRAME_DELAY
|
60
|
+
end
|
61
|
+
rescue
|
62
|
+
# Silently handle thread errors
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def stop
|
67
|
+
@running = false
|
68
|
+
@thread&.join(0.2)
|
69
|
+
@thread = nil
|
70
|
+
end
|
71
|
+
|
72
|
+
def running?
|
73
|
+
@running
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
# Captures stdout and maintains last N lines like tail -f
|
78
|
+
class OutputCapture
|
79
|
+
MAX_LINES = 10
|
80
|
+
DISPLAY_LINES = 5
|
81
|
+
|
82
|
+
def initialize(main_output)
|
83
|
+
@main_output = main_output
|
84
|
+
@buffer = []
|
85
|
+
@capturing = false
|
86
|
+
@original_stdout = nil
|
87
|
+
@pipe_reader = nil
|
88
|
+
@pipe_writer = nil
|
89
|
+
@capture_thread = nil
|
90
|
+
end
|
91
|
+
|
92
|
+
def start
|
93
|
+
return if @capturing
|
94
|
+
|
95
|
+
@buffer.clear
|
96
|
+
setup_stdout_redirection
|
97
|
+
@capturing = true
|
98
|
+
|
99
|
+
start_capture_thread
|
100
|
+
end
|
101
|
+
|
102
|
+
def stop
|
103
|
+
return unless @capturing
|
104
|
+
|
105
|
+
@capturing = false
|
106
|
+
|
107
|
+
# Restore stdout
|
108
|
+
restore_stdout
|
109
|
+
|
110
|
+
# Clean up pipes and thread
|
111
|
+
cleanup_capture_thread
|
112
|
+
cleanup_pipes
|
113
|
+
end
|
114
|
+
|
115
|
+
def last_lines
|
116
|
+
@buffer.last(DISPLAY_LINES)
|
117
|
+
end
|
118
|
+
|
119
|
+
def capturing?
|
120
|
+
@capturing
|
121
|
+
end
|
122
|
+
|
123
|
+
private
|
124
|
+
|
125
|
+
def setup_stdout_redirection
|
126
|
+
@original_stdout = $stdout
|
127
|
+
@pipe_reader, @pipe_writer = IO.pipe
|
128
|
+
$stdout = @pipe_writer
|
129
|
+
end
|
130
|
+
|
131
|
+
def restore_stdout
|
132
|
+
return unless @original_stdout
|
133
|
+
|
134
|
+
$stdout = @original_stdout
|
135
|
+
@original_stdout = nil
|
136
|
+
end
|
137
|
+
|
138
|
+
def start_capture_thread
|
139
|
+
@capture_thread = Thread.new do
|
140
|
+
while (line = @pipe_reader.gets)
|
141
|
+
line = line.chomp
|
142
|
+
next if line.empty?
|
143
|
+
next if skip_line?(line)
|
144
|
+
|
145
|
+
add_line_to_buffer(line)
|
146
|
+
end
|
147
|
+
rescue IOError
|
148
|
+
# Pipe closed, normal termination
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
def skip_line?(line)
|
153
|
+
# Skip logger lines (they appear separately)
|
154
|
+
line.match?(/^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}\]/)
|
155
|
+
end
|
156
|
+
|
157
|
+
def add_line_to_buffer(line)
|
158
|
+
@buffer << line
|
159
|
+
@buffer.shift while @buffer.length > MAX_LINES
|
160
|
+
end
|
161
|
+
|
162
|
+
def cleanup_capture_thread
|
163
|
+
@capture_thread&.join(0.1)
|
164
|
+
@capture_thread = nil
|
165
|
+
end
|
166
|
+
|
167
|
+
def cleanup_pipes
|
168
|
+
[@pipe_writer, @pipe_reader].each do |pipe|
|
169
|
+
pipe&.close
|
170
|
+
rescue IOError
|
171
|
+
# Already closed, ignore
|
172
|
+
end
|
173
|
+
@pipe_writer = @pipe_reader = nil
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
# Represents task execution status
|
178
|
+
class TaskStatus
|
179
|
+
attr_reader :name, :duration, :error
|
180
|
+
|
181
|
+
def initialize(name:, duration: nil, error: nil)
|
182
|
+
@name = name
|
183
|
+
@duration = duration
|
184
|
+
@error = error
|
185
|
+
end
|
186
|
+
|
187
|
+
def success?
|
188
|
+
@error.nil?
|
189
|
+
end
|
190
|
+
|
191
|
+
def failure?
|
192
|
+
!success?
|
193
|
+
end
|
194
|
+
|
195
|
+
def duration_ms
|
196
|
+
return nil unless @duration
|
197
|
+
(@duration * 1000).round(1)
|
198
|
+
end
|
199
|
+
|
200
|
+
def icon
|
201
|
+
success? ? "✅" : "❌"
|
202
|
+
end
|
203
|
+
|
204
|
+
def format_duration
|
205
|
+
return "" unless duration_ms
|
206
|
+
"(#{duration_ms}ms)"
|
207
|
+
end
|
208
|
+
end
|
209
|
+
|
210
|
+
# Main progress display controller
|
211
|
+
class ProgressDisplay
|
212
|
+
# ANSI colors
|
213
|
+
COLORS = {
|
214
|
+
reset: "\033[0m",
|
215
|
+
bold: "\033[1m",
|
216
|
+
dim: "\033[2m",
|
217
|
+
cyan: "\033[36m",
|
218
|
+
green: "\033[32m",
|
219
|
+
red: "\033[31m"
|
220
|
+
}.freeze
|
221
|
+
|
222
|
+
def initialize(output: $stdout, force_enable: nil)
|
223
|
+
@output = output
|
224
|
+
@terminal = TerminalController.new(output)
|
225
|
+
@spinner = SpinnerAnimation.new
|
226
|
+
@output_capture = OutputCapture.new(output)
|
227
|
+
|
228
|
+
# Enable if TTY or force enabled or environment variable set
|
229
|
+
@enabled = force_enable.nil? ? (output.tty? || ENV["TASKI_FORCE_PROGRESS"] == "1") : force_enable
|
230
|
+
|
231
|
+
@completed_tasks = []
|
232
|
+
@current_display_lines = 0
|
233
|
+
end
|
234
|
+
|
235
|
+
def start_task(task_name, dependencies: [])
|
236
|
+
puts "DEBUG: start_task called for #{task_name}, enabled: #{@enabled}" if ENV["TASKI_DEBUG"]
|
237
|
+
return unless @enabled
|
238
|
+
|
239
|
+
clear_current_display
|
240
|
+
@output_capture.start
|
241
|
+
|
242
|
+
start_spinner_display(task_name)
|
243
|
+
end
|
244
|
+
|
245
|
+
def complete_task(task_name, duration:)
|
246
|
+
return unless @enabled
|
247
|
+
|
248
|
+
status = TaskStatus.new(name: task_name, duration: duration)
|
249
|
+
finish_task(status)
|
250
|
+
end
|
251
|
+
|
252
|
+
def fail_task(task_name, error:, duration:)
|
253
|
+
return unless @enabled
|
254
|
+
|
255
|
+
status = TaskStatus.new(name: task_name, duration: duration, error: error)
|
256
|
+
finish_task(status)
|
257
|
+
end
|
258
|
+
|
259
|
+
def clear
|
260
|
+
return unless @enabled
|
261
|
+
|
262
|
+
@spinner.stop
|
263
|
+
@output_capture.stop
|
264
|
+
clear_current_display
|
265
|
+
|
266
|
+
# Display final summary of all completed tasks
|
267
|
+
if @completed_tasks.any?
|
268
|
+
@completed_tasks.each do |status|
|
269
|
+
@terminal.puts format_completed_task(status)
|
270
|
+
end
|
271
|
+
@terminal.flush
|
272
|
+
end
|
273
|
+
|
274
|
+
@completed_tasks.clear
|
275
|
+
@current_display_lines = 0
|
276
|
+
end
|
277
|
+
|
278
|
+
def enabled?
|
279
|
+
@enabled
|
280
|
+
end
|
281
|
+
|
282
|
+
private
|
283
|
+
|
284
|
+
def start_spinner_display(task_name)
|
285
|
+
@spinner.start(@terminal, task_name) do |spinner_char, name|
|
286
|
+
display_current_state(spinner_char, name)
|
287
|
+
end
|
288
|
+
end
|
289
|
+
|
290
|
+
def display_current_state(spinner_char, task_name)
|
291
|
+
clear_current_display
|
292
|
+
|
293
|
+
lines_count = 0
|
294
|
+
|
295
|
+
# Only display current task with spinner (no past completed tasks during execution)
|
296
|
+
@terminal.puts format_current_task(spinner_char, task_name)
|
297
|
+
lines_count += 1
|
298
|
+
|
299
|
+
# Display output lines
|
300
|
+
@output_capture.last_lines.each do |line|
|
301
|
+
@terminal.puts format_output_line(line)
|
302
|
+
lines_count += 1
|
303
|
+
end
|
304
|
+
|
305
|
+
@current_display_lines = lines_count
|
306
|
+
@terminal.flush
|
307
|
+
end
|
308
|
+
|
309
|
+
def finish_task(status)
|
310
|
+
@spinner.stop
|
311
|
+
|
312
|
+
# Capture output before stopping
|
313
|
+
captured_output = @output_capture.last_lines
|
314
|
+
@output_capture.stop
|
315
|
+
clear_current_display
|
316
|
+
|
317
|
+
# In test environments (when terminal is StringIO), include captured output
|
318
|
+
if @terminal.is_a?(StringIO) && captured_output.any?
|
319
|
+
captured_output.each do |line|
|
320
|
+
@terminal.puts line.chomp
|
321
|
+
end
|
322
|
+
end
|
323
|
+
|
324
|
+
@completed_tasks << status
|
325
|
+
display_final_state
|
326
|
+
end
|
327
|
+
|
328
|
+
def display_final_state
|
329
|
+
# Only display the newly completed task (last one)
|
330
|
+
if @completed_tasks.any?
|
331
|
+
latest_task = @completed_tasks.last
|
332
|
+
@terminal.puts format_completed_task(latest_task)
|
333
|
+
end
|
334
|
+
@terminal.flush
|
335
|
+
@current_display_lines = 1 # Only one line for the latest task
|
336
|
+
end
|
337
|
+
|
338
|
+
def format_completed_task(status)
|
339
|
+
color = status.success? ? COLORS[:green] : COLORS[:red]
|
340
|
+
"#{color}#{COLORS[:bold]}#{status.icon} #{status.name}#{COLORS[:reset]} #{COLORS[:dim]}#{status.format_duration}#{COLORS[:reset]}"
|
341
|
+
end
|
342
|
+
|
343
|
+
def format_current_task(spinner_char, task_name)
|
344
|
+
"#{COLORS[:cyan]}#{spinner_char}#{COLORS[:reset]} #{COLORS[:bold]}#{task_name}#{COLORS[:reset]}"
|
345
|
+
end
|
346
|
+
|
347
|
+
def format_output_line(line)
|
348
|
+
" #{COLORS[:dim]}#{line}#{COLORS[:reset]}"
|
349
|
+
end
|
350
|
+
|
351
|
+
def clear_current_display
|
352
|
+
@terminal.clear_lines(@current_display_lines)
|
353
|
+
@current_display_lines = 0
|
354
|
+
end
|
355
|
+
end
|
356
|
+
end
|
data/lib/taski/reference.rb
CHANGED
@@ -1,10 +1,10 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require_relative
|
3
|
+
require_relative "exceptions"
|
4
4
|
|
5
5
|
module Taski
|
6
6
|
# Reference class for task references
|
7
|
-
#
|
7
|
+
#
|
8
8
|
# Used to create lazy references to task classes by name,
|
9
9
|
# which is useful for dependency tracking and metaprogramming.
|
10
10
|
class Reference
|
@@ -37,4 +37,4 @@ module Taski
|
|
37
37
|
"&#{@klass}"
|
38
38
|
end
|
39
39
|
end
|
40
|
-
end
|
40
|
+
end
|
data/lib/taski/task/base.rb
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require_relative
|
3
|
+
require_relative "../exceptions"
|
4
4
|
|
5
5
|
module Taski
|
6
6
|
# Base Task class that provides the foundation for task framework
|
7
7
|
# This module contains the core constants and basic structure
|
8
8
|
class Task
|
9
9
|
# Constants for thread-local keys and method tracking
|
10
|
-
THREAD_KEY_SUFFIX =
|
10
|
+
THREAD_KEY_SUFFIX = "_building"
|
11
11
|
TASKI_ANALYZING_DEFINE_KEY = :taski_analyzing_define
|
12
12
|
ANALYZED_METHODS = [:build, :clean].freeze
|
13
13
|
|
@@ -41,6 +41,51 @@ module Taski
|
|
41
41
|
def __resolve__
|
42
42
|
@__resolve__ ||= {}
|
43
43
|
end
|
44
|
+
|
45
|
+
# Display dependency tree for this task
|
46
|
+
# @param prefix [String] Current indentation prefix
|
47
|
+
# @param visited [Set] Set of visited classes to prevent infinite loops
|
48
|
+
# @return [String] Formatted dependency tree
|
49
|
+
def tree(prefix = "", visited = Set.new)
|
50
|
+
return "#{prefix}#{name} (circular)\n" if visited.include?(self)
|
51
|
+
|
52
|
+
visited = visited.dup
|
53
|
+
visited << self
|
54
|
+
|
55
|
+
result = "#{prefix}#{name}\n"
|
56
|
+
|
57
|
+
dependencies = (@dependencies || []).uniq { |dep| extract_class(dep) }
|
58
|
+
dependencies.each_with_index do |dep, index|
|
59
|
+
dep_class = extract_class(dep)
|
60
|
+
is_last = index == dependencies.length - 1
|
61
|
+
|
62
|
+
connector = is_last ? "└── " : "├── "
|
63
|
+
child_prefix = prefix + (is_last ? " " : "│ ")
|
64
|
+
|
65
|
+
# For the dependency itself, we want to use the connector
|
66
|
+
# For its children, we want to use the child_prefix
|
67
|
+
dep_tree = dep_class.tree(child_prefix, visited)
|
68
|
+
# Replace the first line (which has child_prefix) with the proper connector
|
69
|
+
dep_lines = dep_tree.lines
|
70
|
+
if dep_lines.any?
|
71
|
+
# Replace the first line prefix with connector
|
72
|
+
first_line = dep_lines[0]
|
73
|
+
fixed_first_line = first_line.sub(/^#{Regexp.escape(child_prefix)}/, prefix + connector)
|
74
|
+
result += fixed_first_line
|
75
|
+
# Add the rest of the lines as-is
|
76
|
+
result += dep_lines[1..].join if dep_lines.length > 1
|
77
|
+
else
|
78
|
+
result += "#{prefix}#{connector}#{dep_class.name}\n"
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
result
|
83
|
+
end
|
84
|
+
|
85
|
+
private
|
86
|
+
|
87
|
+
include Utils::DependencyUtils
|
88
|
+
private :extract_class
|
44
89
|
end
|
45
90
|
|
46
91
|
# === Instance Methods ===
|
@@ -51,10 +96,16 @@ module Taski
|
|
51
96
|
raise NotImplementedError, "You must implement the build method in your task class"
|
52
97
|
end
|
53
98
|
|
99
|
+
# Access build arguments passed to parametrized builds
|
100
|
+
# @return [Hash] Build arguments or empty hash if none provided
|
101
|
+
def build_args
|
102
|
+
@build_args || {}
|
103
|
+
end
|
104
|
+
|
54
105
|
# Clean method with default empty implementation
|
55
106
|
# Subclasses can override this method to implement cleanup logic
|
56
107
|
def clean
|
57
108
|
# Default implementation does nothing
|
58
109
|
end
|
59
110
|
end
|
60
|
-
end
|
111
|
+
end
|
@@ -1,7 +1,5 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require 'set'
|
4
|
-
|
5
3
|
module Taski
|
6
4
|
class Task
|
7
5
|
class << self
|
@@ -18,6 +16,9 @@ module Taski
|
|
18
16
|
@dependencies ||= []
|
19
17
|
@definitions ||= {}
|
20
18
|
|
19
|
+
# Ensure ref method is defined first time define is called
|
20
|
+
create_ref_method_if_needed
|
21
|
+
|
21
22
|
# Create method that tracks dependencies on first call
|
22
23
|
create_tracking_method(name)
|
23
24
|
|
@@ -25,16 +26,37 @@ module Taski
|
|
25
26
|
dependencies = analyze_define_dependencies(block)
|
26
27
|
|
27
28
|
@dependencies += dependencies
|
28
|
-
@definitions[name] = {
|
29
|
+
@definitions[name] = {block:, options:, classes: dependencies}
|
29
30
|
end
|
30
31
|
|
31
32
|
private
|
32
33
|
|
33
34
|
# === Define API Implementation ===
|
34
35
|
|
36
|
+
# Create ref method if needed to avoid redefinition warnings
|
37
|
+
def create_ref_method_if_needed
|
38
|
+
return if method_defined_for_define?(:ref)
|
39
|
+
|
40
|
+
define_singleton_method(:ref) do |klass_name|
|
41
|
+
# During dependency analysis, track as dependency but defer resolution
|
42
|
+
if Thread.current[TASKI_ANALYZING_DEFINE_KEY]
|
43
|
+
# Create Reference object for deferred resolution
|
44
|
+
reference = Taski::Reference.new(klass_name)
|
45
|
+
|
46
|
+
# Track as dependency by throwing unresolved
|
47
|
+
throw :unresolved, [reference, :deref]
|
48
|
+
else
|
49
|
+
# At runtime, resolve to actual class
|
50
|
+
Object.const_get(klass_name)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
mark_method_as_defined(:ref)
|
54
|
+
end
|
55
|
+
|
35
56
|
# Create method that tracks dependencies for define API
|
36
57
|
# @param name [Symbol] Method name to create
|
37
58
|
def create_tracking_method(name)
|
59
|
+
# Only create tracking method during dependency analysis
|
38
60
|
class_eval(<<-RUBY, __FILE__, __LINE__ + 1)
|
39
61
|
def self.#{name}
|
40
62
|
__resolve__[__callee__] ||= false
|
@@ -65,12 +87,16 @@ module Taski
|
|
65
87
|
|
66
88
|
break if klass.nil?
|
67
89
|
|
68
|
-
classes << {
|
90
|
+
classes << {klass:, task:}
|
69
91
|
end
|
70
92
|
|
71
93
|
# Reset resolution state
|
72
94
|
classes.each do |task_class|
|
73
|
-
task_class[:klass]
|
95
|
+
klass = task_class[:klass]
|
96
|
+
# Only reset Task classes, not Reference objects
|
97
|
+
if klass.respond_to?(:instance_variable_set) && !klass.is_a?(Taski::Reference)
|
98
|
+
klass.instance_variable_set(:@__resolve__, {})
|
99
|
+
end
|
74
100
|
end
|
75
101
|
|
76
102
|
classes
|
@@ -90,6 +116,9 @@ module Taski
|
|
90
116
|
# @param name [Symbol] Method name
|
91
117
|
# @param definition [Hash] Method definition information
|
92
118
|
def create_defined_method(name, definition)
|
119
|
+
# Remove tracking method first to avoid redefinition warnings
|
120
|
+
singleton_class.undef_method(name) if singleton_class.method_defined?(name)
|
121
|
+
|
93
122
|
# Class method with lazy evaluation
|
94
123
|
define_singleton_method(name) do
|
95
124
|
@__defined_values ||= {}
|
@@ -101,7 +130,7 @@ module Taski
|
|
101
130
|
@__defined_values ||= {}
|
102
131
|
@__defined_values[name] ||= self.class.send(name)
|
103
132
|
end
|
104
|
-
|
133
|
+
|
105
134
|
# Mark as defined for this resolution
|
106
135
|
mark_method_as_defined(name)
|
107
136
|
end
|
@@ -122,4 +151,4 @@ module Taski
|
|
122
151
|
end
|
123
152
|
end
|
124
153
|
end
|
125
|
-
end
|
154
|
+
end
|
@@ -1,7 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
4
|
-
require_relative '../dependency_analyzer'
|
3
|
+
require_relative "../dependency_analyzer"
|
5
4
|
|
6
5
|
module Taski
|
7
6
|
class Task
|
@@ -23,12 +22,6 @@ module Taski
|
|
23
22
|
queue << task_class
|
24
23
|
end
|
25
24
|
|
26
|
-
# Override ref method after resolution for define API compatibility
|
27
|
-
# Note: This may cause "method redefined" warnings, which is expected behavior
|
28
|
-
if (@definitions && !@definitions.empty?) && !method_defined_for_define?(:ref)
|
29
|
-
define_singleton_method(:ref) { |klass| Object.const_get(klass) }
|
30
|
-
end
|
31
|
-
|
32
25
|
# Create getter methods for defined values
|
33
26
|
create_defined_methods
|
34
27
|
|
@@ -42,6 +35,7 @@ module Taski
|
|
42
35
|
resolved = []
|
43
36
|
visited = Set.new
|
44
37
|
resolving = Set.new # Track currently resolving tasks
|
38
|
+
path_map = {self => []} # Track paths to each task
|
45
39
|
|
46
40
|
while queue.any?
|
47
41
|
task_class = queue.shift
|
@@ -49,12 +43,26 @@ module Taski
|
|
49
43
|
|
50
44
|
# Check for circular dependency
|
51
45
|
if resolving.include?(task_class)
|
52
|
-
|
46
|
+
# Build error message with path information
|
47
|
+
cycle_path = build_cycle_path(task_class, path_map)
|
48
|
+
raise CircularDependencyError, build_circular_dependency_message(cycle_path)
|
53
49
|
end
|
54
50
|
|
55
51
|
resolving << task_class
|
56
52
|
visited << task_class
|
53
|
+
|
54
|
+
# Store current path for dependencies
|
55
|
+
current_path = path_map[task_class] || []
|
56
|
+
|
57
|
+
# Let task resolve its dependencies
|
57
58
|
task_class.resolve(queue, resolved)
|
59
|
+
|
60
|
+
# Track paths for each dependency
|
61
|
+
task_class.instance_variable_get(:@dependencies)&.each do |dep|
|
62
|
+
dep_class = extract_class(dep)
|
63
|
+
path_map[dep_class] = current_path + [task_class] unless path_map.key?(dep_class)
|
64
|
+
end
|
65
|
+
|
58
66
|
resolving.delete(task_class)
|
59
67
|
resolved << task_class unless resolved.include?(task_class)
|
60
68
|
end
|
@@ -62,6 +70,21 @@ module Taski
|
|
62
70
|
resolved
|
63
71
|
end
|
64
72
|
|
73
|
+
private
|
74
|
+
|
75
|
+
# Build the cycle path from path tracking information
|
76
|
+
def build_cycle_path(task_class, path_map)
|
77
|
+
path = path_map[task_class] || []
|
78
|
+
path + [task_class]
|
79
|
+
end
|
80
|
+
|
81
|
+
# Build detailed error message for circular dependencies
|
82
|
+
def build_circular_dependency_message(cycle_path)
|
83
|
+
Utils::CircularDependencyHelpers.build_error_message(cycle_path, "dependency")
|
84
|
+
end
|
85
|
+
|
86
|
+
public
|
87
|
+
|
65
88
|
# === Static Analysis ===
|
66
89
|
|
67
90
|
# Analyze dependencies when methods are defined
|
@@ -91,7 +114,7 @@ module Taski
|
|
91
114
|
# @param dep_class [Class] Dependency class to add
|
92
115
|
def add_dependency(dep_class)
|
93
116
|
@dependencies ||= []
|
94
|
-
@dependencies << {
|
117
|
+
@dependencies << {klass: dep_class}
|
95
118
|
end
|
96
119
|
|
97
120
|
# Check if dependency already exists
|
@@ -100,6 +123,11 @@ module Taski
|
|
100
123
|
def dependency_exists?(dep_class)
|
101
124
|
(@dependencies || []).any? { |d| d[:klass] == dep_class }
|
102
125
|
end
|
126
|
+
|
127
|
+
private
|
128
|
+
|
129
|
+
include Utils::DependencyUtils
|
130
|
+
private :extract_class
|
103
131
|
end
|
104
132
|
end
|
105
|
-
end
|
133
|
+
end
|