taski 0.1.0 → 0.2.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,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prism'
4
+
5
+ module Taski
6
+ module DependencyAnalyzer
7
+ class << self
8
+ def analyze_method(klass, method_name)
9
+ return [] unless klass.instance_methods(false).include?(method_name)
10
+
11
+ method = klass.instance_method(method_name)
12
+ source_location = method.source_location
13
+ return [] unless source_location
14
+
15
+ file_path, line_number = source_location
16
+ return [] unless File.exist?(file_path)
17
+
18
+ begin
19
+ result = Prism.parse_file(file_path)
20
+
21
+ unless result.success?
22
+ warn "Taski: Parse errors in #{file_path}: #{result.errors.map(&:message).join(', ')}"
23
+ return []
24
+ end
25
+
26
+ # Handle warnings if present
27
+ if result.warnings.any?
28
+ warn "Taski: Parse warnings in #{file_path}: #{result.warnings.map(&:message).join(', ')}"
29
+ end
30
+
31
+ dependencies = []
32
+ method_node = find_method_node(result.value, method_name, line_number)
33
+
34
+ if method_node
35
+ visitor = TaskDependencyVisitor.new
36
+ visitor.visit(method_node)
37
+ dependencies = visitor.dependencies
38
+ end
39
+
40
+ dependencies.uniq
41
+ rescue IOError, SystemCallError => e
42
+ warn "Taski: Failed to read file #{file_path}: #{e.message}"
43
+ []
44
+ rescue => e
45
+ warn "Taski: Failed to analyze method #{klass}##{method_name}: #{e.message}"
46
+ []
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def find_method_node(node, method_name, target_line)
53
+ return nil unless node
54
+
55
+ case node
56
+ when Prism::DefNode
57
+ if node.name == method_name && node.location.start_line <= target_line && node.location.end_line >= target_line
58
+ return node
59
+ end
60
+ when Prism::ClassNode, Prism::ModuleNode
61
+ if node.respond_to?(:body)
62
+ return find_method_node(node.body, method_name, target_line)
63
+ end
64
+ when Prism::StatementsNode
65
+ node.body.each do |child|
66
+ result = find_method_node(child, method_name, target_line)
67
+ return result if result
68
+ end
69
+ end
70
+
71
+ # Recursively search child nodes
72
+ if node.respond_to?(:child_nodes)
73
+ node.child_nodes.each do |child|
74
+ result = find_method_node(child, method_name, target_line)
75
+ return result if result
76
+ end
77
+ end
78
+
79
+ nil
80
+ end
81
+
82
+ # Task dependency visitor using Prism's visitor pattern
83
+ class TaskDependencyVisitor < Prism::Visitor
84
+ attr_reader :dependencies
85
+
86
+ def initialize
87
+ @dependencies = []
88
+ @constant_cache = {}
89
+ end
90
+
91
+ def visit_constant_read_node(node)
92
+ check_task_constant(node.name.to_s)
93
+ super
94
+ end
95
+
96
+ def visit_constant_path_node(node)
97
+ const_path = extract_constant_path(node)
98
+ check_task_constant(const_path) if const_path
99
+ super
100
+ end
101
+
102
+ def visit_call_node(node)
103
+ # Check for method calls on constants (e.g., TaskA.result)
104
+ case node.receiver
105
+ when Prism::ConstantReadNode
106
+ check_task_constant(node.receiver.name.to_s)
107
+ when Prism::ConstantPathNode
108
+ const_path = extract_constant_path(node.receiver)
109
+ check_task_constant(const_path) if const_path
110
+ end
111
+ super
112
+ end
113
+
114
+ private
115
+
116
+ def check_task_constant(const_name)
117
+ return unless const_name
118
+
119
+ # Use caching to avoid repeated constant resolution
120
+ cached_result = @constant_cache[const_name]
121
+ return cached_result if cached_result == false # Cached negative result
122
+ return @dependencies << cached_result if cached_result # Cached positive result
123
+
124
+ begin
125
+ if Object.const_defined?(const_name)
126
+ klass = Object.const_get(const_name)
127
+ if klass.is_a?(Class) && klass < Taski::Task
128
+ @constant_cache[const_name] = klass
129
+ @dependencies << klass
130
+ else
131
+ @constant_cache[const_name] = false
132
+ end
133
+ else
134
+ @constant_cache[const_name] = false
135
+ end
136
+ rescue NameError, ArgumentError
137
+ @constant_cache[const_name] = false
138
+ end
139
+ end
140
+
141
+ def extract_constant_path(node)
142
+ case node
143
+ when Prism::ConstantReadNode
144
+ node.name.to_s
145
+ when Prism::ConstantPathNode
146
+ parent_path = extract_constant_path(node.parent) if node.parent
147
+ child_name = node.name.to_s
148
+
149
+ if parent_path && child_name
150
+ "#{parent_path}::#{child_name}"
151
+ else
152
+ child_name
153
+ end
154
+ else
155
+ nil
156
+ end
157
+ end
158
+ end
159
+
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Taski
4
+ # Custom exceptions for Taski framework
5
+
6
+ # Raised when circular dependencies are detected between tasks
7
+ class CircularDependencyError < StandardError; end
8
+
9
+ # Raised when task analysis fails (e.g., constant resolution errors)
10
+ class TaskAnalysisError < StandardError; end
11
+
12
+ # Raised when task building fails during execution
13
+ class TaskBuildError < StandardError; end
14
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'exceptions'
4
+
5
+ module Taski
6
+ # Reference class for task references
7
+ #
8
+ # Used to create lazy references to task classes by name,
9
+ # which is useful for dependency tracking and metaprogramming.
10
+ class Reference
11
+ # @param klass [String] The name of the class to reference
12
+ def initialize(klass)
13
+ @klass = klass
14
+ end
15
+
16
+ # Dereference to get the actual class object
17
+ # @return [Class] The referenced class
18
+ # @raise [TaskAnalysisError] If the constant cannot be resolved
19
+ def deref
20
+ Object.const_get(@klass)
21
+ rescue NameError => e
22
+ raise TaskAnalysisError, "Cannot resolve constant '#{@klass}': #{e.message}"
23
+ end
24
+
25
+ # Compare reference with another object
26
+ # @param other [Object] Object to compare with
27
+ # @return [Boolean] True if the referenced class equals the other object
28
+ def ==(other)
29
+ Object.const_get(@klass) == other
30
+ rescue NameError
31
+ false
32
+ end
33
+
34
+ # String representation of the reference
35
+ # @return [String] Reference representation
36
+ def inspect
37
+ "&#{@klass}"
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../exceptions'
4
+
5
+ module Taski
6
+ # Base Task class that provides the foundation for task framework
7
+ # This module contains the core constants and basic structure
8
+ class Task
9
+ # Constants for thread-local keys and method tracking
10
+ THREAD_KEY_SUFFIX = '_building'
11
+ TASKI_ANALYZING_DEFINE_KEY = :taski_analyzing_define
12
+ ANALYZED_METHODS = [:build, :clean].freeze
13
+
14
+ class << self
15
+ # === Hook Methods ===
16
+
17
+ # Hook called when build/clean methods are defined
18
+ # This triggers static analysis of dependencies
19
+ def method_added(method_name)
20
+ super
21
+ return unless ANALYZED_METHODS.include?(method_name)
22
+ # Only call if the method is available (loaded by dependency_resolver)
23
+ analyze_dependencies_at_definition if respond_to?(:analyze_dependencies_at_definition, true)
24
+ end
25
+
26
+ # Create a reference to a task class (can be used anywhere)
27
+ # @param klass [String] The class name to reference
28
+ # @return [Reference] A reference object
29
+ def ref(klass)
30
+ reference = Reference.new(klass)
31
+ # If we're in a define context, throw for dependency tracking
32
+ if Thread.current[TASKI_ANALYZING_DEFINE_KEY]
33
+ reference.tap { |ref| throw :unresolved, ref }
34
+ else
35
+ reference
36
+ end
37
+ end
38
+
39
+ # Get or create resolution state for define API
40
+ # @return [Hash] Resolution state hash
41
+ def __resolve__
42
+ @__resolve__ ||= {}
43
+ end
44
+ end
45
+
46
+ # === Instance Methods ===
47
+
48
+ # Build method that must be implemented by subclasses
49
+ # @raise [NotImplementedError] If not implemented by subclass
50
+ def build
51
+ raise NotImplementedError, "You must implement the build method in your task class"
52
+ end
53
+
54
+ # Clean method with default empty implementation
55
+ # Subclasses can override this method to implement cleanup logic
56
+ def clean
57
+ # Default implementation does nothing
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Taski
6
+ class Task
7
+ class << self
8
+ # === Define API ===
9
+ # Define lazy-evaluated values with dynamic dependency resolution
10
+
11
+ # Define a lazy-evaluated value using a block
12
+ # Use this API when dependencies change based on runtime conditions,
13
+ # environment-specific configurations, feature flags, or complex conditional logic
14
+ # @param name [Symbol] Name of the value
15
+ # @param block [Proc] Block that computes the value and determines dependencies at runtime
16
+ # @param options [Hash] Additional options
17
+ def define(name, block, **options)
18
+ @dependencies ||= []
19
+ @definitions ||= {}
20
+
21
+ # Create method that tracks dependencies on first call
22
+ create_tracking_method(name)
23
+
24
+ # Analyze dependencies by executing the block
25
+ dependencies = analyze_define_dependencies(block)
26
+
27
+ @dependencies += dependencies
28
+ @definitions[name] = { block:, options:, classes: dependencies }
29
+ end
30
+
31
+ private
32
+
33
+ # === Define API Implementation ===
34
+
35
+ # Create method that tracks dependencies for define API
36
+ # @param name [Symbol] Method name to create
37
+ def create_tracking_method(name)
38
+ class_eval(<<-RUBY, __FILE__, __LINE__ + 1)
39
+ def self.#{name}
40
+ __resolve__[__callee__] ||= false
41
+ if __resolve__[__callee__]
42
+ # already resolved
43
+ else
44
+ __resolve__[__callee__] = true
45
+ throw :unresolved, [self, __callee__]
46
+ end
47
+ end
48
+ RUBY
49
+ end
50
+
51
+ # Analyze dependencies in define block
52
+ # @param block [Proc] Block to analyze
53
+ # @return [Array<Hash>] Array of dependency information
54
+ def analyze_define_dependencies(block)
55
+ classes = []
56
+
57
+ # Set flag to indicate we're analyzing define dependencies
58
+ Thread.current[TASKI_ANALYZING_DEFINE_KEY] = true
59
+
60
+ loop do
61
+ klass, task = catch(:unresolved) do
62
+ block.call
63
+ nil
64
+ end
65
+
66
+ break if klass.nil?
67
+
68
+ classes << { klass:, task: }
69
+ end
70
+
71
+ # Reset resolution state
72
+ classes.each do |task_class|
73
+ task_class[:klass].instance_variable_set(:@__resolve__, {})
74
+ end
75
+
76
+ classes
77
+ ensure
78
+ Thread.current[TASKI_ANALYZING_DEFINE_KEY] = false
79
+ end
80
+
81
+ # Create methods for values defined with define API
82
+ def create_defined_methods
83
+ @definitions ||= {}
84
+ @definitions.each do |name, definition|
85
+ create_defined_method(name, definition) unless method_defined_for_define?(name)
86
+ end
87
+ end
88
+
89
+ # Create a single defined method (both class and instance)
90
+ # @param name [Symbol] Method name
91
+ # @param definition [Hash] Method definition information
92
+ def create_defined_method(name, definition)
93
+ # Class method with lazy evaluation
94
+ define_singleton_method(name) do
95
+ @__defined_values ||= {}
96
+ @__defined_values[name] ||= definition[:block].call
97
+ end
98
+
99
+ # Instance method that delegates to class method
100
+ define_method(name) do
101
+ @__defined_values ||= {}
102
+ @__defined_values[name] ||= self.class.send(name)
103
+ end
104
+
105
+ # Mark as defined for this resolution
106
+ mark_method_as_defined(name)
107
+ end
108
+
109
+ # Mark method as defined for this resolution cycle
110
+ # @param method_name [Symbol] Method name to mark
111
+ def mark_method_as_defined(method_name)
112
+ @__defined_for_resolve ||= Set.new
113
+ @__defined_for_resolve << method_name
114
+ end
115
+
116
+ # Check if method was already defined for define API
117
+ # @param method_name [Symbol] Method name to check
118
+ # @return [Boolean] True if already defined
119
+ def method_defined_for_define?(method_name)
120
+ @__defined_for_resolve ||= Set.new
121
+ @__defined_for_resolve.include?(method_name)
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require_relative '../dependency_analyzer'
5
+
6
+ module Taski
7
+ class Task
8
+ class << self
9
+ # === Dependency Resolution ===
10
+
11
+ # Resolve method for dependency graph (called by resolve_dependencies)
12
+ # @param queue [Array] Queue of tasks to process
13
+ # @param resolved [Array] Array of resolved tasks
14
+ # @return [self] Returns self for method chaining
15
+ def resolve(queue, resolved)
16
+ @dependencies ||= []
17
+
18
+ @dependencies.each do |task|
19
+ task_class = extract_class(task)
20
+
21
+ # Reorder in resolved list for correct priority
22
+ resolved.delete(task_class) if resolved.include?(task_class)
23
+ queue << task_class
24
+ end
25
+
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
+ # Create getter methods for defined values
33
+ create_defined_methods
34
+
35
+ self
36
+ end
37
+
38
+ # Resolve all dependencies in topological order with circular dependency detection
39
+ # @return [Array<Class>] Array of tasks in dependency order
40
+ def resolve_dependencies
41
+ queue = [self]
42
+ resolved = []
43
+ visited = Set.new
44
+ resolving = Set.new # Track currently resolving tasks
45
+
46
+ while queue.any?
47
+ task_class = queue.shift
48
+ next if visited.include?(task_class)
49
+
50
+ # Check for circular dependency
51
+ if resolving.include?(task_class)
52
+ raise CircularDependencyError, "Circular dependency detected involving #{task_class.name}"
53
+ end
54
+
55
+ resolving << task_class
56
+ visited << task_class
57
+ task_class.resolve(queue, resolved)
58
+ resolving.delete(task_class)
59
+ resolved << task_class unless resolved.include?(task_class)
60
+ end
61
+
62
+ resolved
63
+ end
64
+
65
+ # === Static Analysis ===
66
+
67
+ # Analyze dependencies when methods are defined
68
+ def analyze_dependencies_at_definition
69
+ dependencies = gather_static_dependencies
70
+ add_unique_dependencies(dependencies)
71
+ end
72
+
73
+ # Gather dependencies from build and clean methods
74
+ # @return [Array<Class>] Array of dependency classes
75
+ def gather_static_dependencies
76
+ build_deps = DependencyAnalyzer.analyze_method(self, :build)
77
+ clean_deps = DependencyAnalyzer.analyze_method(self, :clean)
78
+ (build_deps + clean_deps).uniq
79
+ end
80
+
81
+ # Add dependencies that don't already exist
82
+ # @param dep_classes [Array<Class>] Array of dependency classes
83
+ def add_unique_dependencies(dep_classes)
84
+ dep_classes.each do |dep_class|
85
+ next if dep_class == self || dependency_exists?(dep_class)
86
+ add_dependency(dep_class)
87
+ end
88
+ end
89
+
90
+ # Add a single dependency
91
+ # @param dep_class [Class] Dependency class to add
92
+ def add_dependency(dep_class)
93
+ @dependencies ||= []
94
+ @dependencies << { klass: dep_class }
95
+ end
96
+
97
+ # Check if dependency already exists
98
+ # @param dep_class [Class] Dependency class to check
99
+ # @return [Boolean] True if dependency exists
100
+ def dependency_exists?(dep_class)
101
+ (@dependencies || []).any? { |d| d[:klass] == dep_class }
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Taski
4
+ class Task
5
+ class << self
6
+ # === Exports API ===
7
+ # Export instance variables as class methods for static dependencies
8
+
9
+ # Export instance variables as both class and instance methods
10
+ # @param names [Array<Symbol>] Names of instance variables to export
11
+ def exports(*names)
12
+ @exports ||= []
13
+ @exports += names
14
+
15
+ names.each do |name|
16
+ next if respond_to?(name)
17
+
18
+ # Define class method to access exported value
19
+ define_singleton_method(name) do
20
+ ensure_instance_built.send(name)
21
+ end
22
+
23
+ # Define instance method getter
24
+ define_method(name) do
25
+ instance_variable_get("@#{name}")
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'monitor'
4
+
5
+ module Taski
6
+ class Task
7
+ class << self
8
+ # === Lifecycle Management ===
9
+
10
+ # Build this task and all its dependencies
11
+ def build
12
+ resolve_dependencies.reverse.each do |task_class|
13
+ task_class.ensure_instance_built
14
+ end
15
+ end
16
+
17
+ # Clean this task and all its dependencies in reverse order
18
+ def clean
19
+ resolve_dependencies.each do |task_class|
20
+ # Get existing instance or create new one for cleaning
21
+ instance = task_class.instance_variable_get(:@__task_instance) || task_class.new
22
+ instance.clean
23
+ end
24
+ end
25
+
26
+ # Reset task instance and cached data to prevent memory leaks
27
+ # @return [self] Returns self for method chaining
28
+ def reset!
29
+ build_monitor.synchronize do
30
+ @__task_instance = nil
31
+ @__defined_values = nil
32
+ @__defined_for_resolve = nil
33
+ clear_thread_local_state
34
+ end
35
+ self
36
+ end
37
+
38
+ # Refresh task state (currently just resets)
39
+ # @return [self] Returns self for method chaining
40
+ def refresh
41
+ reset!
42
+ end
43
+
44
+ # === Instance Management ===
45
+
46
+ # Ensure task instance is built (public because called from build)
47
+ # @return [Task] The built task instance
48
+ def ensure_instance_built
49
+ # Use double-checked locking pattern for thread safety
50
+ return @__task_instance if @__task_instance
51
+
52
+ build_monitor.synchronize do
53
+ # Check again after acquiring lock
54
+ return @__task_instance if @__task_instance
55
+
56
+ # Prevent infinite recursion using thread-local storage
57
+ thread_key = build_thread_key
58
+ if Thread.current[thread_key]
59
+ raise CircularDependencyError, "Circular dependency detected: #{self.name} is already being built"
60
+ end
61
+
62
+ Thread.current[thread_key] = true
63
+ begin
64
+ build_dependencies
65
+ @__task_instance = build_instance
66
+ ensure
67
+ Thread.current[thread_key] = false
68
+ end
69
+ end
70
+
71
+ @__task_instance
72
+ end
73
+
74
+ private
75
+
76
+ # === Core Helper Methods ===
77
+
78
+ # Get or create build monitor for thread safety
79
+ # @return [Monitor] Thread-safe monitor object
80
+ def build_monitor
81
+ @__build_monitor ||= Monitor.new
82
+ end
83
+
84
+ # Generate thread key for recursion detection
85
+ # @return [String] Thread key for this task
86
+ def build_thread_key
87
+ "#{self.name}#{THREAD_KEY_SUFFIX}"
88
+ end
89
+
90
+ # Build and configure task instance
91
+ # @return [Task] Built task instance
92
+ def build_instance
93
+ instance = self.new
94
+ begin
95
+ instance.build
96
+ instance
97
+ rescue => e
98
+ # Log the error but don't let it crash the entire system
99
+ warn "Taski: Failed to build #{self.name}: #{e.message}"
100
+ warn "Taski: #{e.backtrace.first}" if e.backtrace
101
+ raise TaskBuildError, "Failed to build task #{self.name}: #{e.message}"
102
+ end
103
+ end
104
+
105
+ # Clear thread-local state for this task
106
+ def clear_thread_local_state
107
+ Thread.current.keys.each do |key|
108
+ Thread.current[key] = nil if key.to_s.include?(build_thread_key)
109
+ end
110
+ end
111
+
112
+ # === Dependency Management ===
113
+
114
+ # Build all dependencies of this task
115
+ def build_dependencies
116
+ resolve_dependencies
117
+
118
+ (@dependencies || []).each do |dep|
119
+ dep_class = extract_class(dep)
120
+ next if dep_class == self
121
+
122
+ dep_class.ensure_instance_built if dep_class.respond_to?(:ensure_instance_built)
123
+ end
124
+ end
125
+
126
+ # Extract class from dependency hash
127
+ # @param dep [Hash] Dependency information
128
+ # @return [Class] The dependency class
129
+ def extract_class(dep)
130
+ klass = dep[:klass]
131
+ klass.is_a?(Reference) ? klass.deref : klass
132
+ end
133
+ end
134
+ end
135
+ end
data/lib/taski/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Taski
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end