asgard 0.3.1 → 0.3.3

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,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Asgard
4
+ class Base < Thor
5
+ # Dependency declaration (`depends_on`) and full-graph validation
6
+ # (`validate_deps!`), backed by stdlib TSort for cycle detection.
7
+ module DependencyGraph
8
+ def _deps
9
+ @_deps ||= {}
10
+ end
11
+
12
+ # Declare dependencies for the next task.
13
+ # Bare symbols run sequentially; arrays within the splat run in parallel.
14
+ #
15
+ # depends_on :build # sequential
16
+ # depends_on :build, :lint # both sequential
17
+ # depends_on [:build, :lint] # build and lint in parallel
18
+ # depends_on :setup, [:build, :lint], :test # setup, then build+lint, then test
19
+ #
20
+ # A sole Proc/lambda, or a block in place of the splat args, defers
21
+ # resolution to validate_deps! (after every .loki file has loaded),
22
+ # instead of now. It must return the same shape the splat form above
23
+ # would: an array of stages, each a Symbol (sequential) or Array
24
+ # (parallel group).
25
+ #
26
+ # depends_on -> { [all_commands.keys.grep(/_check\z/).map(&:to_sym)] }
27
+ # depends_on { [all_commands.keys.grep(/_check\z/).map(&:to_sym)] }
28
+ def depends_on(*tasks, &block)
29
+ if block
30
+ raise Asgard::Error, "depends_on accepts either task arguments or a block, not both" if tasks.any?
31
+
32
+ tasks = [block]
33
+ end
34
+
35
+ @_pending_deps = tasks
36
+ end
37
+
38
+ # Validate the full dep graph for cycles using stdlib TSort.
39
+ def validate_deps!
40
+ _check_orphaned_deps!
41
+ return if _deps.empty?
42
+
43
+ _resolve_lazy_deps!
44
+ all_task_names = all_commands.keys.map(&:to_sym)
45
+ _check_undefined_deps!(all_task_names)
46
+ _check_dep_arities!
47
+ _build_and_sort_graph(all_task_names)
48
+ rescue TSort::Cyclic => e
49
+ raise Asgard::CircularDependencyError, e.message
50
+ end
51
+
52
+ private
53
+
54
+ # Normalizes depends_on's raw splat args into a _deps-ready value: the
55
+ # sole Proc/lambda unresolved (see #depends_on), or a concrete stage
56
+ # array.
57
+ def _normalize_pending_deps(pending)
58
+ sole = pending.first
59
+ return sole if pending.size == 1 && sole.respond_to?(:call)
60
+
61
+ _stages_from(pending)
62
+ end
63
+
64
+ # Each element is a Symbol (sequential) or Array (parallel group).
65
+ def _stages_from(list)
66
+ list.map { |d| Array(d).map(&:to_sym) }
67
+ end
68
+
69
+ # Replaces any Proc-valued _deps entry (see #depends_on) with its
70
+ # resolved stage array. Runs once, after the full .loki chain has
71
+ # loaded, so the Proc can safely reference tasks defined in any file.
72
+ def _resolve_lazy_deps!
73
+ _deps.each do |task, stages_or_proc|
74
+ next unless stages_or_proc.respond_to?(:call)
75
+
76
+ result = Array(_call_dep_proc(task, stages_or_proc))
77
+ _validate_dep_shape!(task, result)
78
+ _deps[task] = _stages_from(result)
79
+ end
80
+ end
81
+
82
+ def _call_dep_proc(task, dep_proc)
83
+ dep_proc.call
84
+ rescue StandardError => e
85
+ raise Asgard::Error, "depends_on proc for '#{task}' raised #{e.class}: #{e.message}"
86
+ end
87
+
88
+ # A resolved proc/lambda/block result must be an Array of stages, each
89
+ # either a Symbol/String (sequential) or an Array of Symbol/String
90
+ # (parallel group) — no deeper nesting, no other leaf types.
91
+ def _validate_dep_shape!(task, result)
92
+ result.each { |stage| _validate_dep_stage!(task, stage) }
93
+ end
94
+
95
+ def _validate_dep_stage!(task, stage)
96
+ case stage
97
+ when Symbol, String then nil
98
+ when Array then stage.each { |leaf| _validate_dep_leaf!(task, leaf) }
99
+ else
100
+ raise Asgard::Error,
101
+ "depends_on proc/block for '#{task}' returned invalid stage #{stage.inspect} " \
102
+ "(#{stage.class}); expected a Symbol, String, or Array of them"
103
+ end
104
+ end
105
+
106
+ def _validate_dep_leaf!(task, leaf)
107
+ return if leaf.is_a?(Symbol) || leaf.is_a?(String)
108
+
109
+ raise Asgard::Error,
110
+ "depends_on proc/block for '#{task}' returned invalid dependency #{leaf.inspect} " \
111
+ "(#{leaf.class}) in a parallel group; expected a Symbol or String"
112
+ end
113
+
114
+ def _check_orphaned_deps!
115
+ pending = Array(@_pending_deps)
116
+ return unless pending.any?
117
+
118
+ raise Asgard::Error,
119
+ "depends_on(#{pending.join(', ')}) declared without a following task definition"
120
+ end
121
+
122
+ def _check_undefined_deps!(all_task_names)
123
+ undefined = _deps.values.flatten.uniq - all_task_names
124
+ return unless undefined.any?
125
+
126
+ raise Asgard::Error, "undefined task(s) in depends_on: #{undefined.sort.join(', ')}"
127
+ end
128
+
129
+ def _check_dep_arities!
130
+ _deps.each_value do |stages|
131
+ stages.flatten.each do |dep|
132
+ meth = instance_method(dep.to_s)
133
+ required = meth.parameters.count { |type, _| type == :req }
134
+ next unless required.positive?
135
+
136
+ raise Asgard::Error,
137
+ "task '#{dep}' has #{required} required argument(s) and cannot be used as a dependency"
138
+ end
139
+ end
140
+ end
141
+
142
+ # Runs a full topological sort purely to raise TSort::Cyclic on a cycle;
143
+ # the order itself isn't otherwise used (execution order comes from the
144
+ # stage groups each task's own depends_on declared).
145
+ def _build_and_sort_graph(all_task_names)
146
+ full_graph = all_task_names.to_h { |task| [task, _deps.fetch(task) { [] }.flatten] }
147
+ Graph.new(full_graph).tsort
148
+ end
149
+
150
+ # Minimal TSort-able wrapper around a task => dependency-list Hash.
151
+ Graph = Struct.new(:edges) do
152
+ include TSort
153
+
154
+ def tsort_each_node(&) = edges.each_key(&)
155
+ def tsort_each_child(node, &) = edges.fetch(node).each(&)
156
+ end
157
+ private_constant :Graph
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Asgard
4
+ class Base < Thor
5
+ # Task execution engine: resolves and runs declared dependencies (in
6
+ # parallel where declared) before the target command runs.
7
+ #
8
+ # Completion-based deduplication: a task is only marked done after its
9
+ # body finishes. Threads that arrive at an already-running shared dep
10
+ # wait on its ConditionVariable rather than proceeding immediately,
11
+ # preventing the race where parallel tasks start before a shared dep
12
+ # has actually completed.
13
+ module Dispatch
14
+ def self.included(base)
15
+ base.extend(ClassMethods)
16
+ end
17
+
18
+ module ClassMethods
19
+ def _running
20
+ @_running ||= Set.new
21
+ end
22
+
23
+ def _done
24
+ @_done ||= Set.new
25
+ end
26
+
27
+ def _cond
28
+ @_cond ||= Hash.new { |h, k| h[k] = ConditionVariable.new }
29
+ end
30
+
31
+ # Completed tasks' return values, keyed by task name — lets a task
32
+ # already run by one thread hand its result to another thread that
33
+ # shares the dependency, without either touching `self`.
34
+ def _results
35
+ @_results ||= {}
36
+ end
37
+
38
+ def _ran_mutex
39
+ @_ran_mutex ||= Mutex.new
40
+ end
41
+
42
+ # Reset execution tracking for a fresh asgard invocation.
43
+ def _reset_ran!
44
+ _ran_mutex.synchronize do
45
+ @_running = Set.new
46
+ @_done = Set.new
47
+ @_cond = Hash.new { |h, k| h[k] = ConditionVariable.new }
48
+ @_results = {}
49
+ end
50
+ end
51
+ end
52
+
53
+ # Dispatch hook: resolves and runs all deps (in parallel where declared)
54
+ # before executing the target command.
55
+ def invoke_command(command, *)
56
+ $DEBUG = true if options[:debug]
57
+ $VERBOSE = true if options[:verbose]
58
+ target = command.name.to_sym
59
+ return cached_result(target) unless acquire_run_token(target)
60
+
61
+ result = nil
62
+ begin
63
+ resolved_deps = run_deps_for(target)
64
+ result = with_dep_results(resolved_deps) { command.run(self, *) }
65
+ ensure
66
+ signal_done(target, result)
67
+ end
68
+ result
69
+ end
70
+
71
+ # The results of +target+'s direct dependencies, keyed by task name —
72
+ # available to a task body while it runs, e.g. `dep_result(:test_check)`.
73
+ def dep_results
74
+ Thread.current[:asgard_dep_results] || {}
75
+ end
76
+
77
+ def dep_result(task)
78
+ dep_results[task.to_sym]
79
+ end
80
+
81
+ private
82
+
83
+ def with_dep_results(results)
84
+ thread = Thread.current
85
+ previous = thread[:asgard_dep_results]
86
+ thread[:asgard_dep_results] = results
87
+ yield
88
+ ensure
89
+ thread[:asgard_dep_results] = previous
90
+ end
91
+
92
+ def cached_result(target)
93
+ klass = self.class
94
+ klass._ran_mutex.synchronize { klass._results[target] }
95
+ end
96
+
97
+ def acquire_run_token(target)
98
+ klass = self.class
99
+ mutex = klass._ran_mutex
100
+ done = klass._done
101
+ running = klass._running
102
+
103
+ mutex.synchronize do
104
+ if done.include?(target)
105
+ false
106
+ elsif running.include?(target)
107
+ klass._cond[target].wait(mutex) until done.include?(target)
108
+ false
109
+ else
110
+ running.add(target)
111
+ true
112
+ end
113
+ end
114
+ end
115
+
116
+ def run_deps_for(target)
117
+ stages = self.class._deps[target]
118
+ return {} unless stages&.any?
119
+
120
+ stages.each_with_object({}) { |group, acc| acc.merge!(run_dep_group(group)) }
121
+ end
122
+
123
+ def run_dep_group(group)
124
+ return { group.first => run_dep(group.first) } unless group.size > 1
125
+
126
+ threads = group.map { |task| [task, Thread.new { run_dep(task) }] }
127
+ errors = []
128
+ results = {}
129
+ threads.each do |task, t|
130
+ results[task] = t.value
131
+ rescue => e
132
+ errors << e
133
+ end
134
+
135
+ if errors.size == 1
136
+ raise errors.first
137
+ elsif errors.any?
138
+ errors.each { |e| warn "asgard: #{e.message}" }
139
+ raise Asgard::Error, "#{errors.size} parallel dependencies failed"
140
+ end
141
+
142
+ results
143
+ end
144
+
145
+ def signal_done(target, result)
146
+ klass = self.class
147
+ klass._ran_mutex.synchronize do
148
+ klass._done.add(target)
149
+ klass._results[target] = result
150
+ klass._cond[target].broadcast
151
+ end
152
+ end
153
+
154
+ def run_dep(task)
155
+ command = self.class.all_commands[task.to_s]
156
+ invoke_command(command) if command
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Asgard
4
+ class Base < Thor
5
+ # Tracks every Asgard::Base subclass ever defined, and every method
6
+ # name's full source_location history — the data Doctor (`asgard
7
+ # --doctor`) uses to detect a silently-overridden `def`, same file or
8
+ # across files.
9
+ module Registry
10
+ def subclasses
11
+ @subclasses ||= []
12
+ end
13
+
14
+ def inherited(subclass)
15
+ super
16
+ Asgard::Base.subclasses << subclass
17
+ subclass.instance_variable_set(:@_deps, {})
18
+ subclass.instance_variable_set(:@_method_log, Hash.new { |h, k| h[k] = [] })
19
+ subclass.instance_variable_set(:@_pending_deps, [])
20
+ subclass.instance_variable_set(:@_pending_single_desc, nil)
21
+ subclass.instance_variable_set(:@_pending_single_desc_opts, nil)
22
+ subclass.instance_variable_set(:@_running, Set.new)
23
+ subclass.instance_variable_set(:@_done, Set.new)
24
+ subclass.instance_variable_set(:@_cond, Hash.new { |h, k| h[k] = ConditionVariable.new })
25
+ subclass.instance_variable_set(:@_ran_mutex, Mutex.new)
26
+ end
27
+
28
+ # Every source_location a method name has ever been defined at, in
29
+ # definition order — so a later `def` silently overriding an earlier
30
+ # one (same name, different file) is still visible after the fact.
31
+ # Doctor is the consumer; asgard itself doesn't otherwise care once
32
+ # the last definition wins.
33
+ def _method_log
34
+ @_method_log ||= Hash.new { |h, k| h[k] = [] }
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Asgard
4
+ class Base < Thor
5
+ # Task-definition macros available inside .loki files: single-argument
6
+ # `desc`, `--no-x`/`--skip-x` suppression, `.env` loading, the `helper`
7
+ # DSL, and `default_task` override warnings.
8
+ module TaskDSL
9
+ # Allow single-argument desc: desc "Run the tests"
10
+ # The usage string defaults to the method name when the description is the only arg.
11
+ def desc(usage_or_desc, description = nil, options = {})
12
+ is_hash = description.is_a?(Hash)
13
+ if description.nil? || is_hash
14
+ options = description if is_hash
15
+ @_pending_single_desc = usage_or_desc
16
+ @_pending_single_desc_opts = options
17
+ else
18
+ @_pending_single_desc = nil
19
+ @_pending_single_desc_opts = nil
20
+ super
21
+ end
22
+ end
23
+
24
+ # Suppress [--no-name] / [--skip-name] from help for boolean class options
25
+ # where negation is meaningless. Call after class_option declarations.
26
+ def no_negate(*names)
27
+ names.each do |name|
28
+ opt = class_options[name]
29
+ next unless opt
30
+ opt.define_singleton_method(:usage) do |padding = 0|
31
+ aliases_for_usage.ljust(padding) + "[#{switch_name}]"
32
+ end
33
+ end
34
+ end
35
+
36
+ def dotenv(path = ".env")
37
+ require "dotenv"
38
+ Dotenv.load(path) if File.exist?(path)
39
+ end
40
+
41
+ def helper(name, &)
42
+ define_singleton_method(name, &)
43
+ no_commands { private define_method(name) { |*args, **kwargs, &blk| self.class.send(name, *args, **kwargs, &blk) } }
44
+ end
45
+
46
+ def default_task(meth = nil)
47
+ active = meth && meth != :none
48
+ here = caller_locations(1, 1).first if active
49
+
50
+ if active && @_default_task_location
51
+ # rubocop:disable-next Style/StderrPuts -- warn bypasses $stderr in Ruby 4.0, breaking capture_io in tests
52
+ $stderr.puts "asgard: default_task :#{meth} at #{here.path}:#{here.lineno} " \
53
+ "overrides default_task :#{@_default_task_name} set at " \
54
+ "#{@_default_task_location.path}:#{@_default_task_location.lineno}"
55
+ end
56
+ if active
57
+ @_default_task_location = here
58
+ @_default_task_name = meth
59
+ end
60
+ super
61
+ end
62
+ end
63
+ end
64
+ end