asgard 0.1.2 → 0.3.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.
data/lib/asgard/base.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "thor"
4
- require "set"
5
4
  require "dagwood"
6
5
 
7
6
  module Asgard
@@ -16,23 +15,30 @@ module Asgard
16
15
  def inherited(subclass)
17
16
  super
18
17
  Asgard::Base.subclasses << subclass
19
- subclass.instance_variable_set(:@_deps, {})
20
- subclass.instance_variable_set(:@_vars, {})
21
- subclass.instance_variable_set(:@_pending_deps, [])
22
- subclass.instance_variable_set(:@_ran_tasks, Set.new)
23
- subclass.instance_variable_set(:@_ran_mutex, Mutex.new)
18
+ subclass.instance_variable_set(:@_deps, {})
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)
24
26
  end
25
27
 
26
28
  def _deps
27
29
  @_deps ||= {}
28
30
  end
29
31
 
30
- def _vars
31
- @_vars ||= {}
32
+ def _running
33
+ @_running ||= Set.new
32
34
  end
33
35
 
34
- def _ran_tasks
35
- @_ran_tasks ||= Set.new
36
+ def _done
37
+ @_done ||= Set.new
38
+ end
39
+
40
+ def _cond
41
+ @_cond ||= Hash.new { |h, k| h[k] = ConditionVariable.new }
36
42
  end
37
43
 
38
44
  def _ran_mutex
@@ -41,7 +47,11 @@ module Asgard
41
47
 
42
48
  # Reset execution tracking for a fresh asgard invocation.
43
49
  def _reset_ran!
44
- _ran_mutex.synchronize { @_ran_tasks = Set.new }
50
+ _ran_mutex.synchronize do
51
+ @_running = Set.new
52
+ @_done = Set.new
53
+ @_cond = Hash.new { |h, k| h[k] = ConditionVariable.new }
54
+ end
45
55
  end
46
56
 
47
57
  # Translate stages into a DependencyGraph-compatible hash.
@@ -51,7 +61,7 @@ module Asgard
51
61
  def _build_dep_graph(stages)
52
62
  graph = {}
53
63
  stages.each_with_index do |stage, i|
54
- prev_stage = i > 0 ? stages[i - 1] : []
64
+ prev_stage = i.positive? ? stages[i - 1] : []
55
65
  stage.each { |task| graph[task] = prev_stage.dup }
56
66
  end
57
67
  graph
@@ -68,41 +78,100 @@ module Asgard
68
78
  @_pending_deps = tasks
69
79
  end
70
80
 
71
- def var(name, value = nil, &block)
72
- value = block if block_given?
73
- _vars[name.to_sym] = value
74
- no_commands do
75
- define_method(name) do
76
- v = self.class._vars[name.to_sym]
77
- v.respond_to?(:call) ? v.call : v
78
- end
81
+ # Allow single-argument desc: desc "Run the tests"
82
+ # The usage string defaults to the method name when the description is the only arg.
83
+ def desc(usage_or_desc, description = nil, options = {})
84
+ if description.nil? || description.is_a?(Hash)
85
+ options = description if description.is_a?(Hash)
86
+ @_pending_single_desc = usage_or_desc
87
+ @_pending_single_desc_opts = options
88
+ else
89
+ @_pending_single_desc = nil
90
+ @_pending_single_desc_opts = nil
91
+ super
79
92
  end
80
93
  end
81
94
 
82
- def import(mod)
83
- include mod
84
- end
85
-
86
95
  def dotenv(path = ".env")
87
96
  require "dotenv"
88
97
  Dotenv.load(path) if File.exist?(path)
89
98
  end
90
99
 
100
+ def default_task(meth = nil)
101
+ if meth && meth != :none && @_default_task_location
102
+ here = caller_locations(1, 1).first
103
+ warn "asgard: default_task :#{meth} at #{here.path}:#{here.lineno} " \
104
+ "overrides default_task :#{@_default_task_name} set at " \
105
+ "#{@_default_task_location.path}:#{@_default_task_location.lineno}"
106
+ end
107
+ if meth && meth != :none
108
+ @_default_task_location = caller_locations(1, 1).first
109
+ @_default_task_name = meth
110
+ end
111
+ super
112
+ end
113
+
91
114
  # Validate the full dep graph for cycles using Dagwood::DependencyGraph.
92
115
  def validate_deps!
116
+ _check_orphaned_deps!
93
117
  return if _deps.empty?
94
118
 
95
- all_tasks = all_commands.keys.map(&:to_sym)
96
- full_graph = all_tasks.each_with_object({}) do |task, hash|
97
- hash[task] = _deps.fetch(task, []).flatten
119
+ all_task_names = all_commands.keys.map(&:to_sym)
120
+ _check_undefined_deps!(all_task_names)
121
+ _check_dep_arities!
122
+ _build_and_sort_graph(all_task_names)
123
+ rescue TSort::Cyclic => e
124
+ raise Asgard::CircularDependencyError, e.message
125
+ end
126
+
127
+ private
128
+
129
+ def _check_orphaned_deps!
130
+ pending = Array(@_pending_deps)
131
+ return unless pending.any?
132
+
133
+ raise Asgard::Error,
134
+ "depends_on(#{pending.join(', ')}) declared without a following task definition"
135
+ end
136
+
137
+ def _check_undefined_deps!(all_task_names)
138
+ undefined = _deps.values.flatten.uniq - all_task_names
139
+ return unless undefined.any?
140
+
141
+ raise Asgard::Error, "undefined task(s) in depends_on: #{undefined.sort.join(', ')}"
142
+ end
143
+
144
+ def _check_dep_arities!
145
+ _deps.each_value do |stages|
146
+ stages.flatten.each do |dep|
147
+ meth = instance_method(dep.to_s)
148
+ required = meth.parameters.count { |type, _| type == :req }
149
+ next unless required.positive?
150
+
151
+ raise Asgard::Error,
152
+ "task '#{dep}' has #{required} required argument(s) and cannot be used as a dependency"
153
+ end
98
154
  end
155
+ end
99
156
 
157
+ def _build_and_sort_graph(all_task_names)
158
+ full_graph = all_task_names.to_h { |task| [task, _deps.fetch(task, []).flatten] }
100
159
  Dagwood::DependencyGraph.new(full_graph).order
101
- rescue TSort::Cyclic => e
102
- raise Asgard::CircularDependencyError, e.message
103
160
  end
104
161
 
162
+ public
163
+
105
164
  def method_added(method_name)
165
+ if @_pending_single_desc && !no_commands?
166
+ pending_desc = @_pending_single_desc
167
+ pending_opts = @_pending_single_desc_opts || {}
168
+ @_pending_single_desc = nil
169
+ @_pending_single_desc_opts = nil
170
+ desc(method_name.to_s, pending_desc, pending_opts)
171
+ end
172
+
173
+ return super unless @usage
174
+
106
175
  pending = Array(@_pending_deps).dup
107
176
  @_pending_deps = []
108
177
 
@@ -117,42 +186,78 @@ module Asgard
117
186
 
118
187
  no_commands do
119
188
  # Dispatch hook: resolves and runs all deps (in parallel where declared)
120
- # before executing the target command. Thread-safe deduplication via
121
- # the class-level _ran_tasks set ensures each task runs at most once.
189
+ # before executing the target command.
190
+ #
191
+ # Completion-based deduplication: a task is only marked done after its
192
+ # body finishes. Threads that arrive at an already-running shared dep
193
+ # wait on its ConditionVariable rather than proceeding immediately,
194
+ # preventing the race where parallel tasks start before a shared dep
195
+ # has actually completed.
122
196
  def invoke_command(command, *args)
123
197
  $DEBUG = true if options[:debug]
124
198
  $VERBOSE = true if options[:verbose]
125
199
  target = command.name.to_sym
200
+ return unless acquire_run_token(target)
126
201
 
127
- should_run = self.class._ran_mutex.synchronize do
128
- next false if self.class._ran_tasks.include?(target)
129
- self.class._ran_tasks.add(target)
130
- true
202
+ begin
203
+ run_deps_for(target)
204
+ command.run(self, *args)
205
+ ensure
206
+ signal_done(target)
131
207
  end
132
- return unless should_run
133
-
134
- stages = self.class._deps[target]
135
- if stages&.any?
136
- graph = self.class._build_dep_graph(stages)
137
- groups = Dagwood::DependencyGraph.new(graph).parallel_order
138
-
139
- groups.each do |group|
140
- if group.size > 1
141
- threads = group.map { |task| Thread.new { _run_dep(task) } }
142
- threads.each(&:join)
143
- else
144
- _run_dep(group.first)
145
- end
146
- end
208
+ end
209
+ end
210
+
211
+ private
212
+
213
+ def acquire_run_token(target)
214
+ self.class._ran_mutex.synchronize do
215
+ if self.class._done.include?(target)
216
+ false
217
+ elsif self.class._running.include?(target)
218
+ self.class._cond[target].wait(self.class._ran_mutex) until self.class._done.include?(target)
219
+ false
220
+ else
221
+ self.class._running.add(target)
222
+ true
147
223
  end
224
+ end
225
+ end
226
+
227
+ def run_deps_for(target)
228
+ stages = self.class._deps[target]
229
+ return unless stages&.any?
148
230
 
149
- command.run(self, *args)
231
+ groups = Dagwood::DependencyGraph.new(self.class._build_dep_graph(stages)).parallel_order
232
+ groups.each { |group| run_dep_group(group) }
233
+ end
234
+
235
+ def run_dep_group(group)
236
+ if group.size > 1
237
+ threads = group.map { |task| Thread.new { run_dep(task) } }
238
+ errors = []
239
+ threads.each { |t| begin; t.join; rescue => e; errors << e; end }
240
+ if errors.size == 1
241
+ raise errors.first
242
+ elsif errors.any?
243
+ errors.each { |e| warn "asgard: #{e.message}" }
244
+ raise Asgard::Error, "#{errors.size} parallel dependencies failed"
245
+ end
246
+ else
247
+ run_dep(group.first)
150
248
  end
249
+ end
151
250
 
152
- def _run_dep(task)
153
- command = self.class.all_commands[task.to_s]
154
- invoke_command(command) if command
251
+ def signal_done(target)
252
+ self.class._ran_mutex.synchronize do
253
+ self.class._done.add(target)
254
+ self.class._cond[target].broadcast
155
255
  end
156
256
  end
257
+
258
+ def run_dep(task)
259
+ command = self.class.all_commands[task.to_s]
260
+ invoke_command(command) if command
261
+ end
157
262
  end
158
263
  end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kernel
4
+ def debug? = $DEBUG
5
+ def verbose? = $VERBOSE
6
+ module_function :debug?, :verbose?
7
+
8
+ # Fetch an environment variable by symbol or string name.
9
+ # The name is converted to an uppercase string automatically.
10
+ # Raises KeyError when the variable is missing and no default is given.
11
+ def env(name, default = nil)
12
+ key = name.to_s.upcase
13
+ default.nil? ? ENV.fetch(key) : ENV.fetch(key, default)
14
+ end
15
+ module_function :env
16
+
17
+ def loki_up(name = ".loki")
18
+ dir = Dir.pwd
19
+ loop do
20
+ candidate = File.join(dir, name)
21
+ return candidate if File.exist?(candidate)
22
+ parent = File.dirname(dir)
23
+ break if parent == dir
24
+ dir = parent
25
+ end
26
+ nil
27
+ end
28
+ module_function :loki_up
29
+
30
+ def import(path)
31
+ path = path.to_s
32
+ raise ArgumentError, "import: path must end with .loki (got #{path.inspect})" unless path.end_with?(".loki")
33
+ unless File.absolute_path?(path)
34
+ caller_dir = File.dirname(caller_locations(1, 1).first.absolute_path)
35
+ path = File.expand_path(path, caller_dir)
36
+ end
37
+ paths = path =~ /[*?\[{]/ ? Dir.glob(path) : [path]
38
+ loaded = paths.map do |p|
39
+ if $LOADED_FEATURES.include?(p)
40
+ warn "import: skip #{p} (already loaded)" if debug?
41
+ next false
42
+ end
43
+ warn "import: #{p}" if verbose? || debug?
44
+ load p
45
+ $LOADED_FEATURES << p
46
+ true
47
+ end
48
+ loaded.any?
49
+ end
50
+ module_function :import
51
+
52
+ def import_up(name = ".loki")
53
+ if name =~ /[*?\[{]/
54
+ dir = Dir.pwd
55
+ loop do
56
+ matches = Dir.glob(File.join(dir, name))
57
+ unless matches.empty?
58
+ warn "import_up: #{name} → #{dir}" if verbose? || debug?
59
+ return matches.map { |p| import(p) }.any?
60
+ end
61
+ parent = File.dirname(dir)
62
+ break if parent == dir
63
+ dir = parent
64
+ end
65
+ warn "import_up: #{name} not found" if debug?
66
+ return false
67
+ end
68
+ path = loki_up(name)
69
+ unless path
70
+ warn "import_up: #{name} not found" if debug?
71
+ return false
72
+ end
73
+ warn "import_up: #{name} → #{path}" if verbose? || debug?
74
+ import path
75
+ end
76
+ module_function :import_up
77
+ end
data/lib/asgard/shell.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'English'
3
4
  require "tempfile"
4
5
 
5
6
  module Asgard
@@ -12,12 +13,12 @@ module Asgard
12
13
  $stdout.puts script unless silent
13
14
 
14
15
  success = if script.include?("\n")
15
- system("bash", "-c", script)
16
- else
17
- system(script)
18
- end
16
+ system("bash", "-c", script)
17
+ else
18
+ system(script)
19
+ end
19
20
 
20
- exit($?.exitstatus) unless success
21
+ exit($CHILD_STATUS.exitstatus) unless success
21
22
  end
22
23
 
23
24
  # Write +script+ to a tempfile and execute it with +interpreter+.
@@ -32,11 +33,13 @@ module Asgard
32
33
  }
33
34
  ext = extensions.fetch(interpreter.to_sym, ".tmp")
34
35
 
36
+ $stdout.puts script unless silent
37
+
35
38
  Tempfile.create(["asgard_", ext]) do |f|
36
39
  f.write(script)
37
40
  f.flush
38
41
  system(interpreter.to_s, f.path)
39
- exit($?.exitstatus) unless $?.success?
42
+ exit($CHILD_STATUS.exitstatus) unless $CHILD_STATUS.success?
40
43
  end
41
44
  end
42
45
  end
data/lib/asgard/tasks.rb CHANGED
@@ -20,9 +20,4 @@ class Tasks < Asgard::Base
20
20
  puts Asgard::VERSION
21
21
  exit
22
22
  end
23
-
24
- private
25
-
26
- def debug? = $DEBUG
27
- def verbose? = $VERBOSE
28
23
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Asgard
4
- VERSION = "0.1.2"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/asgard.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "asgard/version"
4
+ require_relative "asgard/kernel_methods"
4
5
  require_relative "asgard/shell"
5
6
  require_relative "asgard/base"
6
7
  require_relative "asgard/tasks"
@@ -12,34 +13,22 @@ module Asgard
12
13
  # Search the current directory and its ancestors for a .loki task file.
13
14
  # Returns the path string, or nil if not found.
14
15
  def self.find_task_file
15
- dir = Dir.pwd
16
- loop do
17
- candidate = File.join(dir, ".loki")
18
- return candidate if File.exist?(candidate)
19
- parent = File.dirname(dir)
20
- break if parent == dir
21
- dir = parent
22
- end
23
- nil
24
- end
25
-
26
- # Load all *.loki files from dir in alphabetical order.
27
- # Each file typically reopens class Tasks to add tasks.
28
- # The .loki entry point is excluded — it is loaded separately by run!.
29
- def self.load_loki(dir)
30
- Dir.glob(File.join(dir, "*.loki")).sort.each { |f| load f }
16
+ loki_up
31
17
  end
32
18
 
33
19
  # Main entry point invoked by the asgard executable.
34
20
  def self.run!(argv)
35
21
  abort "asgard: unknown command '#{argv.first}'" if argv.first&.start_with?("_")
36
22
  task_file = find_task_file or abort "asgard: no .loki file found in #{Dir.pwd}"
37
- load_loki(File.dirname(task_file))
23
+ before = Asgard::Base.subclasses.dup
38
24
  load task_file
39
- Tasks.validate_deps!
25
+ newly_defined = Asgard::Base.subclasses - before
26
+ (newly_defined + [Tasks]).uniq.each(&:validate_deps!)
40
27
  Tasks._reset_ran!
41
28
  Tasks.start(argv)
42
29
  rescue CircularDependencyError => e
43
30
  abort "asgard: circular dependency — #{e.message}"
31
+ rescue Error => e
32
+ abort "asgard: #{e.message}"
44
33
  end
45
34
  end
data/mkdocs.yml ADDED
@@ -0,0 +1,164 @@
1
+ # MkDocs Configuration for Asgard Documentation
2
+ site_name: Asgard
3
+ site_description: Thor-based Ruby task runner with dependency graphs and concurrent execution
4
+ site_author: Dewayne VanHoozer
5
+ site_url: https://madbomber.github.io/asgard
6
+ copyright: Copyright &copy; 2026 Dewayne VanHoozer
7
+
8
+ # Repository information
9
+ repo_name: madbomber/asgard
10
+ repo_url: https://github.com/MadBomber/asgard
11
+ edit_uri: edit/main/docs/
12
+
13
+ # Configuration
14
+ theme:
15
+ name: material
16
+
17
+ # Color scheme
18
+ palette:
19
+ - scheme: default
20
+ primary: indigo
21
+ accent: amber
22
+ toggle:
23
+ icon: material/brightness-7
24
+ name: Switch to dark mode
25
+
26
+ - scheme: slate
27
+ primary: indigo
28
+ accent: amber
29
+ toggle:
30
+ icon: material/brightness-4
31
+ name: Switch to light mode
32
+
33
+ # Typography
34
+ font:
35
+ text: Roboto
36
+ code: Roboto Mono
37
+
38
+ # Logo and icon
39
+ icon:
40
+ repo: fontawesome/brands/github
41
+ logo: material/shield
42
+
43
+ # Theme features
44
+ features:
45
+ - navigation.instant
46
+ - navigation.tracking
47
+ - navigation.tabs
48
+ - navigation.tabs.sticky
49
+ - navigation.path
50
+ - navigation.indexes
51
+ - navigation.top
52
+ - navigation.footer
53
+ - toc.follow
54
+ - search.suggest
55
+ - search.highlight
56
+ - search.share
57
+ - header.autohide
58
+ - content.code.copy
59
+ - content.code.annotate
60
+ - content.tabs.link
61
+ - content.tooltips
62
+ - content.action.edit
63
+ - content.action.view
64
+
65
+ # Plugins
66
+ plugins:
67
+ - search:
68
+ separator: '[\s\-,:!=\[\]()"`/]+|\.(?!\d)|&[lg]t;|(?!\b)(?=[A-Z][a-z])'
69
+ - tags
70
+
71
+ # Extensions
72
+ markdown_extensions:
73
+ - abbr
74
+ - admonition
75
+ - attr_list
76
+ - def_list
77
+ - footnotes
78
+ - md_in_html
79
+ - tables
80
+ - toc:
81
+ permalink: true
82
+ title: On this page
83
+
84
+ - pymdownx.arithmatex:
85
+ generic: true
86
+ - pymdownx.betterem:
87
+ smart_enable: all
88
+ - pymdownx.caret
89
+ - pymdownx.critic
90
+ - pymdownx.details
91
+ - pymdownx.emoji:
92
+ emoji_generator: !!python/name:material.extensions.emoji.to_svg
93
+ emoji_index: !!python/name:material.extensions.emoji.twemoji
94
+ - pymdownx.highlight:
95
+ anchor_linenums: true
96
+ line_spans: __span
97
+ pygments_lang_class: true
98
+ - pymdownx.inlinehilite
99
+ - pymdownx.keys
100
+ - pymdownx.magiclink:
101
+ repo_url_shorthand: true
102
+ user: madbomber
103
+ repo: asgard
104
+ normalize_issue_symbols: true
105
+ - pymdownx.mark
106
+ - pymdownx.smartsymbols
107
+ - pymdownx.snippets:
108
+ check_paths: true
109
+ - pymdownx.superfences:
110
+ custom_fences:
111
+ - name: mermaid
112
+ class: mermaid
113
+ format: !!python/name:pymdownx.superfences.fence_code_format
114
+ - pymdownx.tabbed:
115
+ alternate_style: true
116
+ - pymdownx.tasklist:
117
+ custom_checkbox: true
118
+ - pymdownx.tilde
119
+
120
+ # Extra CSS
121
+ extra_css:
122
+ - assets/css/custom.css
123
+
124
+ # Social media and extra configuration
125
+ extra:
126
+ social:
127
+ - icon: fontawesome/brands/github
128
+ link: https://github.com/MadBomber/asgard
129
+ name: Asgard on GitHub
130
+ - icon: fontawesome/solid/gem
131
+ link: https://rubygems.org/gems/asgard
132
+ name: Asgard on RubyGems
133
+
134
+ analytics:
135
+ feedback:
136
+ title: Was this page helpful?
137
+ ratings:
138
+ - icon: material/emoticon-happy-outline
139
+ name: This page was helpful
140
+ data: 1
141
+ note: Thanks for your feedback!
142
+ - icon: material/emoticon-sad-outline
143
+ name: This page could be improved
144
+ data: 0
145
+ note: Thanks for your feedback! Help us improve by creating an issue.
146
+
147
+ # Navigation
148
+ nav:
149
+ - Home: index.md
150
+ - Getting Started: getting-started.md
151
+ - Tasks:
152
+ - Defining Tasks: tasks.md
153
+ - Dependencies: dependencies.md
154
+ - Variables: variables.md
155
+ - Helper Methods: helpers.md
156
+ - CLI:
157
+ - Options & Flags: options.md
158
+ - Subcommands: subcommands.md
159
+ - Shell Helpers: shell.md
160
+ - Environment: environment.md
161
+ - Task Files: task-files.md
162
+ - API Reference: api.md
163
+ - Examples: examples.md
164
+ - Changelog: changelog.md