asgard 0.3.2 → 0.4.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/docs/dependencies.md CHANGED
@@ -236,9 +236,9 @@ Because `*.loki` files are loaded alphabetically when `import "*.loki"` is used,
236
236
 
237
237
  ---
238
238
 
239
- ## Dynamic Dependencies (Proc Form)
239
+ ## Dynamic Dependencies (Proc / Block Form)
240
240
 
241
- `depends_on` normally takes a fixed list, recorded the moment the `def` right after it is encountered — which is why load order matters, as above. Pass a `Proc` or lambda instead, and that list is computed *later*, after every `.loki` file has finished loading, rather than at the point `depends_on` itself is evaluated:
241
+ `depends_on` normally takes a fixed list, recorded the moment the `def` right after it is encountered — which is why load order matters, as above. Pass a `Proc` or lambda instead — or, equivalently, a block — and that list is computed *later*, after every `.loki` file has finished loading, rather than at the point `depends_on` itself is evaluated:
242
242
 
243
243
  ```ruby
244
244
  depends_on -> { [all_commands.keys.grep(/_check\z/).sort.map(&:to_sym)] }
@@ -246,22 +246,33 @@ desc "Run every *_check quality gate task in parallel"
246
246
  def quality
247
247
  # ...
248
248
  end
249
+
250
+ depends_on { [all_commands.keys.grep(/_check\z/).sort.map(&:to_sym)] }
251
+ desc "Same thing, written as a block"
252
+ def quality2
253
+ # ...
254
+ end
249
255
  ```
250
256
 
251
- This solves exactly the "load order matters" problem from the previous section: a plain array can only name tasks that already exist in `.loki` files loaded *before* this one. A Proc is resolved once every file has loaded, so it can safely reference a task defined in a file that hasn't been imported yet at the point `depends_on` is written — including one that only exists conditionally, e.g. a Rails-specific task file imported with `import "quality_rails.loki" if defined?(Rails)`.
257
+ This solves exactly the "load order matters" problem from the previous section: a plain array can only name tasks that already exist in `.loki` files loaded *before* this one. A Proc/block is resolved once every file has loaded, so it can safely reference a task defined in a file that hasn't been imported yet at the point `depends_on` is written — including one that only exists conditionally, e.g. a Rails-specific task file imported with `import "quality_rails.loki" if defined?(Rails)`. `depends_on` accepts task arguments *or* a block, never both — combining them raises `Asgard::Error`.
258
+
259
+ **Shape:** the Proc/block must return exactly what the plain-array form would receive as its splat arguments — an array of stages, each a `Symbol`/`String` (sequential) or an `Array` of `Symbol`/`String` (parallel group), nested no deeper than that. The example above returns `[[:a_check, :b_check, :c_check]]`: one stage, containing every matching task, all running in parallel — the same shape as `depends_on [:a_check, :b_check, :c_check]`. This shape is validated once the result comes back — a bad return value (wrong type, a stage that isn't a Symbol/String/Array, a leaf inside a parallel group that isn't a Symbol/String, or nesting more than one level deep) raises `Asgard::Error` naming the task and the offending value, instead of failing later with an opaque `NoMethodError`:
252
260
 
253
- **Shape:** the Proc must return exactly what the plain-array form would receive as its splat arguments — an array of stages, each a `Symbol` (sequential) or `Array` (parallel group). The example above returns `[[:a_check, :b_check, :c_check]]`: one stage, containing every matching task, all running in parallel — the same shape as `depends_on [:a_check, :b_check, :c_check]`.
261
+ ```bash
262
+ asgard quality
263
+ # asgard: depends_on proc/block for 'quality' returned invalid stage 123 (Integer); expected a Symbol, String, or Array of them
264
+ ```
254
265
 
255
- **When it runs:** once, when `validate_deps!` runs (right after the `.loki` chain finishes loading, before any task dispatches). The resolved result replaces the Proc in the dependency table, so cycle detection, undefined-task checks, and arity checks all run against the *resolved* list — a Proc that references an undefined task, or that itself introduces a cycle, is caught at startup exactly like a plain array would be:
266
+ **When it runs:** once, when `validate_deps!` runs (right after the `.loki` chain finishes loading, before any task dispatches). The resolved result replaces the Proc/block in the dependency table, so cycle detection, undefined-task checks, and arity checks all run against the *resolved* list — a Proc/block that references an undefined task, or that itself introduces a cycle, is caught at startup exactly like a plain array would be:
256
267
 
257
268
  ```bash
258
269
  asgard quality
259
270
  # asgard: undefined task(s) in depends_on: ghost_check
260
271
  ```
261
272
 
262
- **`self` inside the Proc:** since the Proc is written directly in a `class Tasks` body, it lexically captures that class as `self` — so it can call `all_commands`, `_deps`, or any other class-level method bare, without a `self.class.` prefix, even though it's actually invoked later from inside `validate_deps!`.
273
+ **`self` inside the Proc/block:** since it's written directly in a `class Tasks` body, it lexically captures that class as `self` — so it can call `all_commands`, `_deps`, or any other class-level method bare, without a `self.class.` prefix, even though it's actually invoked later from inside `validate_deps!`.
263
274
 
264
- **If the Proc raises**, the error is caught and re-raised as `Asgard::Error` naming the task it was declared for:
275
+ **If the Proc/block raises**, the error is caught and re-raised as `Asgard::Error` naming the task it was declared for:
265
276
 
266
277
  ```
267
278
  asgard: depends_on proc for 'quality' raised RuntimeError: boom
data/docs/index.md CHANGED
@@ -18,6 +18,7 @@
18
18
  <li><strong>Dotenv Support</strong> — load <code>.env</code> files into the environment with <code>dotenv</code></li>
19
19
  <li><strong>Auto-Discovery</strong> — <code>.loki</code> root marker searched from CWD upward through parent directories</li>
20
20
  <li><strong>Multi-File Tasks</strong> — split tasks across <code>*.loki</code> files loaded via <code>import</code></li>
21
+ <li><strong>Scheduled Tasks</strong> — run any task periodically under launchd (macOS) or systemd timers (Linux) with <code>schedule</code> and <code>asgard schedule install</code></li>
21
22
  <li><strong>Built-in Flags</strong> — <code>--version</code>, <code>--debug</code>, <code>--verbose</code>, and <code>--doctor</code> available on every task</li>
22
23
  </ul>
23
24
  </td>
data/docs/schedule.md ADDED
@@ -0,0 +1,101 @@
1
+ # Scheduled Tasks
2
+
3
+ Asgard can run any task on a schedule. You declare schedules in the project's own `.loki`, and `asgard schedule install` hands them to the platform's own scheduler: **launchd** on macOS, **systemd user timers** on Linux. Both schedulers run a calendar job that was missed while the machine slept as soon as it wakes.
4
+
5
+ Scheduling is built into the gem, so there is nothing to import or require.
6
+
7
+ ---
8
+
9
+ ## Declaring Schedules
10
+
11
+ Call `schedule` at class level inside `Tasks`:
12
+
13
+ ```ruby
14
+ # .loki
15
+ class Tasks
16
+ desc "daily_summary", "Summarize the day's work"
17
+ def daily_summary = sh "bin/summary"
18
+
19
+ schedule :daily_summary, at: "17:30", on: :weekdays
20
+ schedule :weekly_report, at: "16:00", on: :friday
21
+ schedule :sync, every: 3600 # seconds
22
+ schedule :backup, at: %w[02:00 14:00] # on: defaults to :daily
23
+
24
+ # The task's own options go in options:, split the way a shell would
25
+ # (quotes respected).
26
+ schedule :report, options: "--format md -v", at: "08:00", on: :weekdays
27
+ schedule :report, options: "--period week", at: "16:00", on: :friday
28
+ schedule :notify, options: "--msg 'backup done'", every: 86_400, as: "notify_daily"
29
+ end
30
+ ```
31
+
32
+ A declaration needs exactly one of `at:` or `every:`.
33
+
34
+ | Keyword | Meaning |
35
+ |---------|---------|
36
+ | `at:` | `"HH:MM"` (24-hour), or an Array of times |
37
+ | `on:` | `:daily` (default), `:weekdays`, `:weekends`, a day (`:friday`), or an Array of days |
38
+ | `every:` | Interval in seconds, or anything that responds to `in_seconds` (an ActiveSupport `Duration`) |
39
+ | `options:` | The task's own arguments, as a String (split shell-style) or an Array of words |
40
+ | `env:` | `{ "KEY" => "value" }`: literal environment variables added to the job |
41
+ | `as:` | The entry's name (letters, digits, `_ . -`) |
42
+
43
+ ### Entry Names
44
+
45
+ Every entry has a name, and the subcommands below take it as `NAME`. The name defaults to the task name. When the entry has `options:`, the default is a slug of the task plus its options (`:report, options: "--format md -v"` becomes `report-format-md-v`), so one task can be scheduled several times with different flags. `as:` overrides the default. Declaring two *different* entries with the same name raises an error.
46
+
47
+ ### Durations
48
+
49
+ Asgard doesn't depend on ActiveSupport. If you want `every: 3.minutes`, require it yourself at the top of your `.loki`:
50
+
51
+ ```ruby
52
+ require "active_support/core_ext/integer/time"
53
+
54
+ class Tasks
55
+ schedule :sync, every: 15.minutes
56
+ end
57
+ ```
58
+
59
+ ---
60
+
61
+ ## Managing Schedules
62
+
63
+ `schedule` is also a command with subcommands. They work the same way on both platforms:
64
+
65
+ ```bash
66
+ asgard schedule preview # print the job files install would write
67
+ asgard schedule install # load declared entries; drop entries no longer declared
68
+ asgard schedule list # installed entries, schedule, state, last exit status
69
+ asgard schedule stop NAME # stop one entry (stays stopped across reboots and installs)
70
+ asgard schedule start NAME # start a stopped entry, or install just this one
71
+ asgard schedule trigger NAME # run an installed entry now, under the scheduler
72
+ asgard schedule log NAME [-f] # print the entry's log (-f keeps following it)
73
+ asgard schedule remove # unload and delete all of this project's entries
74
+ ```
75
+
76
+ Re-run `asgard schedule install` after changing declarations or your `PATH`.
77
+
78
+ ---
79
+
80
+ ## How Jobs Run
81
+
82
+ Each entry runs `asgard <task> [options]` from the directory that holds `.loki`, so installing from any subdirectory gives the same result. The job gets the `PATH` that was current when you ran `install`, plus any `env:` variables.
83
+
84
+ If the project has a `.envrc`, the job runs under `direnv exec`, so API keys and other secrets load from `.envrc` at run time and are never copied into the job files. If `direnv` isn't on your `PATH`, `install` warns that `.envrc` will not be loaded.
85
+
86
+ Entries are scoped to the project (the name of the directory holding `.loki`), so `list`, `install` and `remove` only touch this project's jobs.
87
+
88
+ | | macOS (launchd) | Linux (systemd) |
89
+ |---|---|---|
90
+ | Job files | `~/Library/LaunchAgents/com.madbomber.asgard.<project>.<name>.plist` | `~/.config/systemd/user/asgard.<project>.<name>.{service,timer}` |
91
+ | Logs | `~/Library/Logs/asgard/` | `~/.local/state/asgard/` (honors `XDG_STATE_HOME`) |
92
+ | Stop | `launchctl disable` | `systemctl --user disable --now` |
93
+ | Caveats | runs only while you're logged in | runs only while you're logged in unless `loginctl enable-linger`; needs systemd 240+ |
94
+
95
+ Other platforms aren't supported. The `schedule` subcommands there exit with an error.
96
+
97
+ ---
98
+
99
+ ## Name Collisions
100
+
101
+ The built-in command is registered as `_schedule` and mapped to `schedule`, following Asgard's `_` convention for gem-owned tasks. If your `.loki` defines its own `schedule` task, `asgard schedule` still dispatches to the built-in command.
data/docs/shell.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Asgard provides two methods for running shell commands and scripts from within task bodies: `sh` for shell commands and heredocs, and `shebang` for polyglot scripts. Both are provided by `Asgard::Shell` and mixed into every `Tasks` instance.
4
4
 
5
- Both methods exit with the command's status code on failure — they do not raise Ruby exceptions.
5
+ Both methods exit with the command's status code on failure — they do not raise Ruby exceptions. `sh` also accepts `exec: true` to replace the asgard process outright instead of forking — see [Handing Off with `exec`](#handing-off-with-exec).
6
6
 
7
7
  ---
8
8
 
@@ -80,6 +80,35 @@ class Tasks
80
80
  end
81
81
  ```
82
82
 
83
+ ### Handing Off with `exec`
84
+
85
+ Pass `exec: true` to hand the command the asgard process itself instead of forking a child. Under the hood this calls `Kernel.exec` rather than `system`, which replaces the running ruby process image with the command — asgard doesn't stick around waiting on it.
86
+
87
+ Use this for a task's final, long-running command — a dev server, a REPL, anything meant to take over the terminal — so there's no idle ruby process sitting in memory alongside it, and Ctrl-C is handled directly by the command instead of unwinding back through asgard:
88
+
89
+ ```ruby
90
+ class Tasks
91
+ desc "Documentation server startup"
92
+ depends_on :doc_builder
93
+ def doc_server = sh "mkdocs serve", exec: true
94
+ end
95
+ ```
96
+
97
+ Since the process is replaced, nothing after the `sh` call ever runs, and `depends_on` chains upstream of it must already have finished (they have — dependencies run before the task body).
98
+
99
+ ```ruby
100
+ class Tasks
101
+ desc "Start a REPL — never returns to asgard"
102
+ def console
103
+ sh "bundle exec pry", exec: true
104
+ puts "unreachable"
105
+ end
106
+ end
107
+ ```
108
+
109
+ !!! note
110
+ `exec: true` only makes sense for a command meant to run for the lifetime of the process. Don't use it for a step with more work queued after it in the same task.
111
+
83
112
  ---
84
113
 
85
114
  ## `shebang` — Polyglot Scripts
data/docs/tasks.md CHANGED
@@ -206,6 +206,46 @@ Without `default_task`, running `asgard` with no arguments displays the help mes
206
206
 
207
207
  ---
208
208
 
209
+ ## Abbreviated Command Matching
210
+
211
+ Every task is a Thor command, and Thor resolves any unambiguous prefix of a command name to that command automatically — no Asgard code involved, and nothing to declare. Given:
212
+
213
+ ```ruby
214
+ class Tasks
215
+ desc "Check code style with RuboCop"
216
+ def rubocop_check = sh "bundle exec rubocop"
217
+
218
+ desc "Run the test suite"
219
+ def test_check = sh "bundle exec rake test"
220
+
221
+ desc "Run the test suite with verbose output"
222
+ def test_verbose = sh "bundle exec rake test -v"
223
+
224
+ desc "Deploy to production"
225
+ def deploy = sh "cap production deploy"
226
+
227
+ desc "Deploy to staging"
228
+ def deploy_staging = sh "cap staging deploy"
229
+ end
230
+ ```
231
+
232
+ ```bash
233
+ asgard r # same as: asgard rubocop_check — the only task starting with "r"
234
+ asgard test_c # same as: asgard test_check — enough of the name to be unique
235
+ asgard test # Ambiguous command test matches [test_check, test_verbose]
236
+ asgard deploy # runs deploy, not deploy_staging — see below
237
+ ```
238
+
239
+ Thor matches on a plain prefix (`command_name.start_with?(typed_string)`), so the shortest string that is still unique for your task set works. Two details worth knowing:
240
+
241
+ - **An exact full name always wins**, even if it's also a prefix of another task. `asgard deploy` runs `deploy` itself, never the ambiguous-prefix error, because `deploy` is a defined command — not merely a prefix of `deploy_staging`.
242
+ - **An ambiguous prefix produces a clean error listing every candidate** (`Ambiguous command X matches [...]`) rather than guessing or running the alphabetically-first match. Type enough of the name to disambiguate.
243
+
244
+ !!! tip
245
+ Because matching depends on every task name currently defined, a short prefix that's unique today can become ambiguous tomorrow when a new task with the same stem is added — e.g. adding `rubocop_fix` alongside `rubocop_check` turns `asgard r` from a clean match into `Ambiguous command r matches [rubocop_check, rubocop_fix]`. Use [`map`](#command-aliases) below for a short name you want to guarantee stays stable regardless of what other tasks get added later.
246
+
247
+ ---
248
+
209
249
  ## Command Aliases
210
250
 
211
251
  `map` creates short aliases for existing tasks:
@@ -0,0 +1,51 @@
1
+ # examples/depends_on_block/bad/.loki
2
+ # BAD example: depends_on block returning a mis-shaped result.
3
+ #
4
+ # Compare with ../good/.loki, which does this correctly:
5
+ #
6
+ # depends_on { [all_commands.keys.grep(/_check\z/).sort.map(&:to_sym)] }
7
+ # ^ ^
8
+ # one wrapping [ ] — this is "one stage, containing every matching
9
+ # task, all running in parallel."
10
+ #
11
+ # It's an easy typo to add a SECOND wrapping array, thinking the outer
12
+ # array is "the list of stages" and the inner one is "this stage's tasks"
13
+ # as two separate concerns:
14
+ #
15
+ # depends_on { [[all_commands.keys.grep(/_check\z/).sort.map(&:to_sym)]] }
16
+ # ^ ^
17
+ # that's one stage too many — the middle array ends up
18
+ # *inside* a stage as a dependency, not as the stage itself.
19
+ #
20
+ # depends_on's block/Proc result is validated the moment it resolves
21
+ # (during validate_deps!, before any task runs): every stage must be a
22
+ # Symbol/String (sequential) or an Array of Symbol/String (parallel group)
23
+ # — nesting one level deeper than that is rejected with Asgard::Error
24
+ # instead of failing later with a confusing NoMethodError.
25
+ #
26
+ # Run with (from this directory):
27
+ # asgard quality
28
+ #
29
+ # Expected output (validate_deps! aborts before quality — or any other
30
+ # task — ever runs):
31
+ # asgard: depends_on proc/block for 'quality' returned invalid dependency
32
+ # [:a_check, :b_check, :c_check] (Array) in a parallel group; expected a
33
+ # Symbol or String
34
+
35
+ class Tasks
36
+ desc "Run the first check"
37
+ def a_check = puts "running a_check ..."
38
+
39
+ desc "Run the second check"
40
+ def b_check = puts "running b_check ..."
41
+
42
+ desc "Run the third check"
43
+ def c_check = puts "running c_check ..."
44
+
45
+ # BUG: double-wrapped — [[ ... ]] instead of [ ... ]. See comment above.
46
+ depends_on { [[all_commands.keys.grep(/_check\z/).sort.map(&:to_sym)]] }
47
+ desc "Run every *_check task in parallel, then report"
48
+ def quality
49
+ puts "quality: all *_check tasks passed"
50
+ end
51
+ end
@@ -0,0 +1,83 @@
1
+ # examples/depends_on_block/good/.loki
2
+ # GOOD example: depends_on with a block instead of a Proc/lambda.
3
+ #
4
+ # A block behaves exactly like `-> { ... }` — it defers resolution to
5
+ # validate_deps! (once, after every .loki file has loaded) instead of
6
+ # resolving the moment depends_on is evaluated. This is the real pattern
7
+ # from this gem's own quality.loki: gather every task whose name ends in
8
+ # `_check`, without having to know their names or load order upfront.
9
+ #
10
+ # The block's return value is validated once it resolves: it must be an
11
+ # Array of stages, each a Symbol/String (sequential) or an Array of
12
+ # Symbol/String (parallel group) — see ../bad/.loki for what happens when
13
+ # that shape is wrong.
14
+ #
15
+ # `{ ... }` braces are used below for the short, single-line blocks (quality,
16
+ # full_cycle) — standard Ruby style reserves `do ... end` for a block that
17
+ # spans multiple statements, shown in smoke further down.
18
+ #
19
+ # Run with (from this directory):
20
+ # asgard quality
21
+ #
22
+ # Expected output:
23
+ # running a_check ...
24
+ # running b_check ...
25
+ # running c_check ...
26
+ # quality: all *_check tasks passed
27
+
28
+ class Tasks
29
+ desc "Run the first check"
30
+ def a_check = puts "running a_check ..."
31
+
32
+ desc "Run the second check"
33
+ def b_check = puts "running b_check ..."
34
+
35
+ desc "Run the third check"
36
+ def c_check = puts "running c_check ..."
37
+
38
+ # self inside the block is the Tasks class (it's written directly in the
39
+ # class body), so all_commands can be called bare, with no self.class.
40
+ # prefix — even though the block itself isn't invoked until later.
41
+ depends_on { [all_commands.keys.grep(/_check\z/).sort.map(&:to_sym)] }
42
+ desc "Run every *_check task in parallel, then report"
43
+ def quality
44
+ puts "quality: all *_check tasks passed"
45
+ end
46
+
47
+ # A block can also return a mix of sequential Symbols and parallel Arrays,
48
+ # exactly like the plain splat form: depends_on :setup, [:a_check, :b_check], :teardown
49
+ desc "Prepare fixtures before the checks run"
50
+ def setup = puts "setup: fixtures ready"
51
+
52
+ desc "Clean up fixtures after the checks run"
53
+ def teardown = puts "teardown: fixtures removed"
54
+
55
+ depends_on { [:setup, %i[a_check b_check], :teardown] }
56
+ desc "setup, then a_check+b_check in parallel, then teardown"
57
+ def full_cycle
58
+ puts "full_cycle: done"
59
+ end
60
+
61
+ # A block spanning multiple statements — local variables, some computation
62
+ # — reads more naturally as do...end than as one long `{ ... }` line.
63
+ # Whatever it does along the way, the last expression is still what gets
64
+ # returned and validated: here, [:setup, Array, :teardown], the same shape
65
+ # as full_cycle above.
66
+ #
67
+ # Gotcha: this block runs during validate_deps!, which happens *before*
68
+ # Thor parses this invocation's CLI options (see the Entry Point Flow in
69
+ # CLAUDE.md) — so checking `--verbose`/`--debug` in here would always see
70
+ # the pre-flag default, never what was actually passed on this run. Keep
71
+ # depends_on blocks limited to shaping the dependency graph itself.
72
+ depends_on do
73
+ all_checks = all_commands.keys.grep(/_check\z/).sort.map(&:to_sym)
74
+ slow_checks = %i[c_check]
75
+ fast_checks = all_checks - slow_checks
76
+
77
+ [:setup, fast_checks, :teardown]
78
+ end
79
+ desc "setup, then every *_check except the slow ones, then teardown"
80
+ def smoke
81
+ puts "smoke: done"
82
+ end
83
+ end
@@ -181,6 +181,19 @@ class Tasks
181
181
  end
182
182
  end
183
183
 
184
+ # ── Asgard: sh — run shell commands, single-line and multi-line heredoc ────
185
+ desc "Show environment info (single-line sh)"
186
+ def env_info = sh "echo Running #{@@app_name} on $(uname -s)"
187
+
188
+ desc "Bootstrap the local environment (multi-line sh / heredoc)"
189
+ def bootstrap
190
+ sh <<~SHELL
191
+ echo "App: #{@@app_name}"
192
+ echo "Ruby: $(ruby -v)"
193
+ echo "Date: $(date)"
194
+ SHELL
195
+ end
196
+
184
197
  # ── Thor: no_commands — public helper excluded from CLI and --help ──────────
185
198
  no_commands do
186
199
  def current_sha
@@ -17,13 +17,21 @@ module Asgard
17
17
  # depends_on [:build, :lint] # build and lint in parallel
18
18
  # depends_on :setup, [:build, :lint], :test # setup, then build+lint, then test
19
19
  #
20
- # A sole Proc/lambda defers resolution to validate_deps! (after every
21
- # .loki file has loaded), instead of now. It must return the same shape
22
- # the splat form above would: an array of stages, each a Symbol
23
- # (sequential) or Array (parallel group).
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).
24
25
  #
25
26
  # depends_on -> { [all_commands.keys.grep(/_check\z/).map(&:to_sym)] }
26
- def depends_on(*tasks)
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
+
27
35
  @_pending_deps = tasks
28
36
  end
29
37
 
@@ -65,7 +73,9 @@ module Asgard
65
73
  _deps.each do |task, stages_or_proc|
66
74
  next unless stages_or_proc.respond_to?(:call)
67
75
 
68
- _deps[task] = _stages_from(Array(_call_dep_proc(task, stages_or_proc)))
76
+ result = Array(_call_dep_proc(task, stages_or_proc))
77
+ _validate_dep_shape!(task, result)
78
+ _deps[task] = _stages_from(result)
69
79
  end
70
80
  end
71
81
 
@@ -75,6 +85,32 @@ module Asgard
75
85
  raise Asgard::Error, "depends_on proc for '#{task}' raised #{e.class}: #{e.message}"
76
86
  end
77
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
+
78
114
  def _check_orphaned_deps!
79
115
  pending = Array(@_pending_deps)
80
116
  return unless pending.any?
@@ -107,7 +143,7 @@ module Asgard
107
143
  # the order itself isn't otherwise used (execution order comes from the
108
144
  # stage groups each task's own depends_on declared).
109
145
  def _build_and_sort_graph(all_task_names)
110
- full_graph = all_task_names.to_h { |task| [task, _deps.fetch(task, []).flatten] }
146
+ full_graph = all_task_names.to_h { |task| [task, _deps.fetch(task) { [] }.flatten] }
111
147
  Graph.new(full_graph).tsort
112
148
  end
113
149
 
@@ -48,11 +48,10 @@ module Asgard
48
48
  here = caller_locations(1, 1).first if active
49
49
 
50
50
  if active && @_default_task_location
51
- # rubocop:disable Style/StderrPuts -- warn bypasses $stderr in Ruby 4.0, breaking capture_io in tests
51
+ # rubocop:disable-next Style/StderrPuts -- warn bypasses $stderr in Ruby 4.0, breaking capture_io in tests
52
52
  $stderr.puts "asgard: default_task :#{meth} at #{here.path}:#{here.lineno} " \
53
53
  "overrides default_task :#{@_default_task_name} set at " \
54
54
  "#{@_default_task_location.path}:#{@_default_task_location.lineno}"
55
- # rubocop:enable Style/StderrPuts
56
55
  end
57
56
  if active
58
57
  @_default_task_location = here
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Required first: Asgard::Schedule::Error subclasses Asgard::Error.
4
+ module Asgard
5
+ class Error < StandardError; end
6
+ class CircularDependencyError < Error; end
7
+ end