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.
- checksums.yaml +4 -4
- data/.envrc +5 -0
- data/.loki +14 -1
- data/.reek.yml +115 -0
- data/.rubocop.yml +6 -0
- data/Archspec.rb +24 -0
- data/CHANGELOG.md +72 -1
- data/CLAUDE.md +4 -7
- data/README.md +51 -1
- data/bin/asgard +1 -1
- data/doc_tasks.loki +21 -0
- data/docs/api.md +125 -6
- data/docs/changelog.md +23 -1
- data/docs/dependencies.md +94 -2
- data/docs/getting-started.md +4 -2
- data/docs/index.md +3 -3
- data/docs/options.md +16 -6
- data/docs/shell.md +30 -1
- data/docs/tasks.md +40 -0
- data/examples/bad.loki +63 -0
- data/examples/depends_on_block/bad/.loki +51 -0
- data/examples/depends_on_block/good/.loki +83 -0
- data/examples/kitchen_sink.loki +13 -0
- data/gem_tasks.loki +17 -1
- data/git.loki +13 -0
- data/lib/asgard/base/dependency_graph.rb +160 -0
- data/lib/asgard/base/dispatch.rb +160 -0
- data/lib/asgard/base/registry.rb +38 -0
- data/lib/asgard/base/task_dsl.rb +64 -0
- data/lib/asgard/base.rb +36 -250
- data/lib/asgard/doctor/report.rb +73 -0
- data/lib/asgard/doctor/task_sections.rb +67 -0
- data/lib/asgard/doctor.rb +150 -0
- data/lib/asgard/kernel_methods.rb +6 -2
- data/lib/asgard/shell.rb +21 -8
- data/lib/asgard/tasks.rb +6 -0
- data/lib/asgard/version.rb +1 -1
- data/lib/asgard.rb +9 -1
- data/quality.loki +218 -32
- data/quality_rails.loki +47 -0
- data/xyzzy.loki +12 -0
- metadata +18 -16
|
@@ -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
|