asgard 0.3.2 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8cec41525d2a749cb83bcd59a480fb4bbffa80e468ca4a35c453376a16c84640
4
- data.tar.gz: 997da94f3b7bf4fadedaff8d78741ff2748b00fb7107b27402dc50cf73f897df
3
+ metadata.gz: 0f88b550d323b26b075a45f6a7d6e8781b7d54d062c1f724de25b2d253d2f3e4
4
+ data.tar.gz: 5c2a05803003793e14ad08f0453d0a549cb6f2f0202b31c38c600303a539d66b
5
5
  SHA512:
6
- metadata.gz: d189e34cd3733b41e436460367130fa19451bf3938b693f1c5663a1268fab41f87866063e2c0c3a185a69dfda238aa8a5e6a253de9bbac0e2c5e97e6c9d2fa6e
7
- data.tar.gz: d6fcbee1e4302dce37d7ea39cf22019ae0322f3f200e4ac5421ac247d7facca205e38cdb1f905ab20781142472b0010c46ff966dbba83be918a7fbabd49bebbd
6
+ metadata.gz: d9f2d214246e0960b2a2a4ca790818a8f57edfd45c17f0fca6edf1bcf486cc096d0331818d899c5ca929f9fcec404329744403a0f03aa2fe0faafb70b13ae172
7
+ data.tar.gz: e35a52164438b5734cdf86ee4f7210e2169edb391e8478cddd1548698691d3bddea5d703ef113e34bcdd1caeb0041d9851ec94b8ee7840b9115a0ca821f03f49
data/.loki CHANGED
@@ -3,10 +3,16 @@
3
3
  # Task is pre-defined by the gem — just reopen it to add tasks.
4
4
 
5
5
  import "quality.loki"
6
- import "quality_rails.loki" if defined?(Rails)
6
+ # asgard runs as a separate process outside the app it's checking, so
7
+ # `defined?(Rails)` never sees the app's Rails constant. RAILS_ROOT — set in
8
+ # a Rails repo's own .envrc (`export RAILS_ROOT=$RR`, after RR is defined)
9
+ # — is the signal instead.
10
+ import "quality_rails.loki" if ENV["RAILS_ROOT"]
7
11
 
8
12
  import "gem_tasks.loki"
9
13
  import "git.loki"
14
+ import "doc_tasks.loki"
15
+
10
16
  import "xyzzy.loki" # An example for the --doctor flag
11
17
 
12
18
  class Tasks
data/.reek.yml CHANGED
@@ -75,6 +75,8 @@ detectors:
75
75
  - Asgard::Base::Registry#inherited # inherited(subclass) configuring subclass is the whole point
76
76
  - Asgard::Base::DependencyGraph#_normalize_pending_deps # small transform of its own argument
77
77
  - Asgard::Base::DependencyGraph#_call_dep_proc # rescue => e; e.class/e.message is inherent
78
+ - Asgard::Base::DependencyGraph#_validate_dep_stage! # pure shape check of its own argument
79
+ - Asgard::Base::DependencyGraph#_validate_dep_leaf! # pure shape check of its own argument
78
80
  - Asgard::Doctor::TaskSections#class_task_file_map
79
81
  - Asgard::Doctor::TaskSections#relative_path
80
82
  - Asgard::Doctor::TaskSections#task_status
@@ -104,6 +106,7 @@ detectors:
104
106
  - Asgard::Base::DependencyGraph#_build_dep_graph # genuinely pure, but tightly coupled to this graph model
105
107
  - Asgard::Base::Dispatch#dep_results # thread-local by design, not self — that's the whole point
106
108
  - Asgard::Base::Dispatch#with_dep_results # same: stashes results on Thread.current, not self
109
+ - Asgard::Shell#shell_argv # extracted pure on purpose so it's testable without invoking system/exec
107
110
 
108
111
  exclude_paths:
109
112
  - test
data/CHANGELOG.md CHANGED
@@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [0.3.2] - Unreleased
8
+ ## [0.3.3] - 2026-08-27
9
9
 
10
10
  ### Added
11
11
 
@@ -159,6 +159,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
159
159
 
160
160
  - **`examples/bad.loki`** — a worked demonstration of the race the fix above addresses: 4 parallel workers read-modify-write a shared `@hits` counter directly (the anti-pattern `quality.loki` used to have), reliably losing updates. Kept as a contrast example for what `dep_result`/`dep_results` is for.
161
161
 
162
+ ### Added (continued 5)
163
+
164
+ - **`sh(script, exec: true)`** — hands the command the asgard process itself via `Kernel.exec` instead of forking. For a task's final, long-running command (a dev server, a REPL) this replaces the ruby process outright, so nothing sits resident in memory behind it and Ctrl-C is handled directly by the command instead of unwinding back through asgard. `doc_tasks.loki`'s `doc_server` task (`sh "mkdocs serve", exec: true`) is the motivating example. See [Shell Helpers](docs/shell.md#handing-off-with-exec).
165
+ - **`bootstrap` and `env_info` tasks in `kitchen_sink.loki`** — demonstrate `sh` with a multi-line heredoc (routed through `bash -c`) and a single-line command, respectively.
166
+
167
+ ### Fixed (continued 3)
168
+
169
+ - **Ctrl-C during a running `sh` command printed a raw `Interrupt` backtrace** — SIGINT hits the whole foreground process group, so asgard's own ruby process raised `Interrupt` independently of whatever the shelled-out command did with the signal, and it went uncaught, unwinding through Thor and printing a stack trace before exiting. `Asgard.run!` now rescues `Interrupt` and exits with the conventional 130 status.
170
+
171
+ ### Added (continued 6)
172
+
173
+ - **`depends_on` accepts a block in addition to a Proc/lambda** — `depends_on { ... }` (or `depends_on do ... end` for a block spanning multiple statements) defers resolution to `validate_deps!` exactly like the existing sole-Proc/lambda form; the two are interchangeable. `depends_on` still accepts task arguments *or* a block, never both — combining them raises `Asgard::Error`. See [Dynamic Dependencies](docs/dependencies.md#dynamic-dependencies-proc-block-form).
174
+ - **The resolved Proc/lambda/block result is now shape-validated** — once `validate_deps!` calls it, the return value must be an `Array` of stages, each a `Symbol`/`String` (sequential) or an `Array` of `Symbol`/`String` (parallel group), nested no deeper than that. A bad shape (wrong type, an invalid stage, a non-Symbol/String leaf, or nesting more than one level deep) now raises `Asgard::Error` naming the task and the offending value, instead of failing later with an opaque `NoMethodError`.
175
+ - **`examples/depends_on_block/good/` and `examples/depends_on_block/bad/`** — two self-contained example projects (each its own `.loki` root, isolated from the main `examples/` tree) demonstrating the block form: `good/` covers single-line `{ ... }`, `do...end`, and a mixed sequential+parallel shape; `bad/` demonstrates the double-wrapped-array mistake that the new shape validation catches, with the exact `Asgard::Error` message it produces.
176
+
162
177
  ## [0.2.0] - 2026-05-29
163
178
 
164
179
  ### Changed
data/README.md CHANGED
@@ -221,14 +221,28 @@ asgard ci executes:
221
221
  ci
222
222
  ```
223
223
 
224
- `depends_on` also accepts a `Proc`/lambda instead of a fixed list, resolved once every `.loki` file has finished loading rather than immediately — useful when the list can't be known upfront, e.g. "every task whose name ends in `_check`," discovered across several files:
224
+ `depends_on` also accepts a `Proc`/lambda (or, equivalently, a block) instead of a fixed list, resolved once every `.loki` file has finished loading rather than immediately — useful when the list can't be known upfront, e.g. "every task whose name ends in `_check`," discovered across several files:
225
225
 
226
226
  ```ruby
227
227
  depends_on -> { [all_commands.keys.grep(/_check\z/).map(&:to_sym)] }
228
228
  def quality = puts "running every *_check task..."
229
+
230
+ depends_on { [all_commands.keys.grep(/_check\z/).map(&:to_sym)] }
231
+ def quality2 = puts "same, as a block..."
232
+
233
+ # do...end for a block spanning multiple statements — braces are for
234
+ # single-line blocks like the two above. The last expression is still what
235
+ # gets returned and validated.
236
+ depends_on do
237
+ checks = all_commands.keys.grep(/_check\z/).sort.map(&:to_sym)
238
+ slow = %i[c_check]
239
+
240
+ [checks - slow]
241
+ end
242
+ def quality3 = puts "same, minus the slow checks, as a multi-line block..."
229
243
  ```
230
244
 
231
- See [Dependencies](https://madbomber.github.io/asgard/dependencies/#dynamic-dependencies-proc-form) for the full explanation.
245
+ See [Dependencies](https://madbomber.github.io/asgard/dependencies/#dynamic-dependencies-proc-block-form) for the full explanation.
232
246
 
233
247
  ---
234
248
 
@@ -489,6 +503,33 @@ end
489
503
 
490
504
  ---
491
505
 
506
+ ## Abbreviated command matching
507
+
508
+ Every task is a Thor command, so you don't have to type the full name — Thor resolves any unambiguous prefix automatically, with no extra code:
509
+
510
+ ```ruby
511
+ class Tasks
512
+ desc "Compile the project"
513
+ def build = sh "rake build"
514
+
515
+ desc "Deploy to production"
516
+ def deploy = sh "cap production deploy"
517
+
518
+ desc "Deploy to staging"
519
+ def deploy_staging = sh "cap staging deploy"
520
+ end
521
+ ```
522
+
523
+ ```bash
524
+ asgard b # same as: asgard build — only task starting with "b"
525
+ asgard depl # Ambiguous command depl matches [deploy, deploy_staging]
526
+ asgard deploy # runs deploy — an exact match always wins, even over a shorter ambiguous prefix
527
+ ```
528
+
529
+ This is Thor's own dispatch behavior, not an Asgard feature — it applies to every task in every `.loki` file automatically. When a prefix matches more than one task, Thor lists the candidates instead of guessing; type enough of the name to disambiguate, or use `map` (below) to pin a short name that stays stable even if a later-added task would otherwise make it ambiguous.
530
+
531
+ ---
532
+
492
533
  ## Command aliases
493
534
 
494
535
  `map` creates alternative names for a task:
data/doc_tasks.loki ADDED
@@ -0,0 +1,21 @@
1
+ # dev/doc_tasks.loki
2
+
3
+ # TODO: create common tasks for documentation management
4
+ class Tasks
5
+
6
+ if File.exist?(ENV['RR']+'/.config/tocer/configuration.yml')
7
+ desc "Management table of contents"
8
+ def tocer = sh "tocer help"
9
+ end
10
+
11
+
12
+ if File.exist?(ENV['RR']+'/mkdocs.yml')
13
+ desc "Documentation builder"
14
+ def doc_builder = sh "mkdocs build"
15
+
16
+ desc "Documentation server startup"
17
+ depends_on :doc_builder
18
+ def doc_server = sh "mkdocs serve", exec: true
19
+ end
20
+
21
+ end
data/docs/api.md CHANGED
@@ -111,12 +111,12 @@ import_up "*.loki" # find the nearest ancestor with *.loki files
111
111
 
112
112
  | Method | Signature | Description |
113
113
  |---|---|---|
114
- | `depends_on` | `depends_on(*tasks)` | Declare prerequisites for the next `def`. Bare symbols run sequentially; arrays within the splat run as a parallel group. A sole `Proc`/lambda defers resolution to `validate_deps!` (after every `.loki` file has loaded) instead of resolving immediately — see [Dynamic Dependencies](dependencies.md#dynamic-dependencies-proc-form). |
114
+ | `depends_on` | `depends_on(*tasks, &block)` | Declare prerequisites for the next `def`. Bare symbols run sequentially; arrays within the splat run as a parallel group. A sole `Proc`/lambda, or a block in place of the splat args, defers resolution to `validate_deps!` (after every `.loki` file has loaded) instead of resolving immediately — see [Dynamic Dependencies](dependencies.md#dynamic-dependencies-proc-block-form). Passing both task arguments and a block raises `Asgard::Error`. |
115
115
  | `dotenv` | `dotenv(path = ".env")` | Load the specified `.env` file into `ENV` using the dotenv gem. Silently skipped if the file does not exist. Called at class-load time. |
116
116
  | `header` | `header(text)` | Append a line of text shown above the commands list in `asgard help`. Each call adds another line. No-op for per-command help. |
117
117
  | `footer` | `footer(text)` | Prepend a line of text shown below the options block in `asgard help`. Each call inserts above the previous lines. No-op for per-command help. |
118
118
  | `no_negate` | `no_negate(*names)` | Suppress `[--no-name]` / `[--skip-name]` help entries for one or more boolean class options. Call after the `class_option` declaration. |
119
- | `sh` | `sh(script, silent: false)` | Instance method. Run a shell command or multiline heredoc. Single-line → `system(script)`; multiline → `system("bash", "-c", script)`. Exits with the command's status on failure. |
119
+ | `sh` | `sh(script, silent: false, exec: false)` | Instance method. Run a shell command or multiline heredoc. Single-line → `system(script)`; multiline → `system("bash", "-c", script)`. Exits with the command's status on failure. With `exec: true`, replaces the asgard process via `Kernel.exec` instead of forking — see [Shell Helpers](shell.md#handing-off-with-exec). |
120
120
  | `shebang` | `shebang(interpreter, script, silent: false)` | Instance method. Write `script` to a tempfile and execute it with `interpreter`. See the [Shell Helpers](shell.md) page for the full interpreter table. |
121
121
  | `validate_deps!` | `Tasks.validate_deps!` | Build and topologically sort the full dependency graph using stdlib `TSort`. Raises `Asgard::CircularDependencyError` on cycles. Called by `run!` at startup. |
122
122
  | `_reset_ran!` | `Tasks._reset_ran!` | Clear the per-invocation task deduplication set. Called by `run!` before dispatching. Thread-safe via Mutex. |
@@ -175,12 +175,14 @@ depends_on :clean, :build # two sequential deps
175
175
  depends_on [:lint, :typecheck] # lint and typecheck run in parallel
176
176
  depends_on :setup, [:lint, :build], :test # setup, then lint+build concurrently, then test
177
177
 
178
- # A sole Proc/lambda defers resolution to validate_deps!, after every .loki
179
- # file has loaded — must return the same shape the splat form above would.
178
+ # A sole Proc/lambda (or, equivalently, a block) defers resolution to
179
+ # validate_deps!, after every .loki file has loaded — must return the same
180
+ # shape the splat form above would; the shape is validated when it resolves.
180
181
  depends_on -> { [all_commands.keys.grep(/_check\z/).map(&:to_sym)] }
182
+ depends_on { [all_commands.keys.grep(/_check\z/).map(&:to_sym)] }
181
183
  ```
182
184
 
183
- See [Dynamic Dependencies](dependencies.md#dynamic-dependencies-proc-form) for the full explanation of the Proc form — why it exists, when it runs, and how errors are reported.
185
+ See [Dynamic Dependencies](dependencies.md#dynamic-dependencies-proc-block-form) for the full explanation of the Proc/block form — why it exists, when it runs, and how errors are reported.
184
186
 
185
187
  ---
186
188
 
data/docs/changelog.md CHANGED
@@ -22,7 +22,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). Asg
22
22
  - **`asgard tree`** now shows the project header/footer, matching `asgard help`.
23
23
  - **`bundler_audit_check` task** — `bundle-audit check --update` against `Gemfile.lock`; `FAIL` on any known vulnerability.
24
24
  - **`quality_rails.loki`** — imported only when `Rails` is defined; ships `brakeman_check` as a Rails security-scan example. No wiring needed — `quality` discovers it automatically (see `depends_on` below).
25
- - **`depends_on` accepts a Proc/lambda**, not just a fixed list — resolved once, in `validate_deps!`, after every `.loki` file has loaded, instead of immediately. Solves "load order matters" for a dependency list that can't be known upfront, e.g. every task ending in `_check` across several files. See [Dynamic Dependencies](dependencies.md#dynamic-dependencies-proc-form).
25
+ - **`depends_on` accepts a Proc/lambda**, not just a fixed list — resolved once, in `validate_deps!`, after every `.loki` file has loaded, instead of immediately. Solves "load order matters" for a dependency list that can't be known upfront, e.g. every task ending in `_check` across several files. See [Dynamic Dependencies](dependencies.md#dynamic-dependencies-proc-block-form).
26
+ - **`depends_on` also accepts a block** — `depends_on { ... }` or `depends_on do ... end`, interchangeable with the Proc/lambda form above. Task arguments and a block can't be combined; doing so raises `Asgard::Error`.
27
+ - **The Proc/lambda/block result is now shape-validated** once resolved — it must be an `Array` of `Symbol`/`String` (sequential) or `Array` of `Symbol`/`String` (parallel group) stages, nested no deeper. A bad shape raises a clear `Asgard::Error` naming the task and the offending value instead of a raw `NoMethodError`. See `examples/depends_on_block/{good,bad}/`.
26
28
 
27
29
  ### Changed
28
30
 
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/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
data/lib/asgard/shell.rb CHANGED
@@ -8,17 +8,21 @@ module Asgard
8
8
  # Run a shell script. Multiline strings are passed to bash -c; single-line
9
9
  # strings are passed to system directly. Exits with the command's status
10
10
  # code on failure.
11
- def sh(script, silent: false)
11
+ #
12
+ # Pass exec: true to replace the current process instead of forking —
13
+ # useful for a task's final, long-running command (e.g. a dev server)
14
+ # so the asgard/ruby process doesn't sit resident in memory alongside it.
15
+ def sh(script, silent: false, exec: false)
12
16
  script = script.strip
13
17
  $stdout.puts script unless silent
18
+ argv = shell_argv(script)
14
19
 
15
- success = if script.include?("\n")
16
- system("bash", "-c", script)
17
- else
18
- system(script)
19
- end
20
-
21
- exit($CHILD_STATUS.exitstatus) unless success
20
+ if exec
21
+ $stdout.flush
22
+ Kernel.exec(*argv)
23
+ else
24
+ exit($CHILD_STATUS.exitstatus) unless system(*argv)
25
+ end
22
26
  end
23
27
 
24
28
  # Write +script+ to a tempfile and execute it with +interpreter+.
@@ -42,5 +46,14 @@ module Asgard
42
46
  exit($CHILD_STATUS.exitstatus) unless $CHILD_STATUS.success?
43
47
  end
44
48
  end
49
+
50
+ private
51
+
52
+ # The argv passed to system/exec for +script+: multi-line scripts run
53
+ # through `bash -c` so assignments carry across lines; single-line
54
+ # scripts run directly.
55
+ def shell_argv(script)
56
+ script.include?("\n") ? ["bash", "-c", script] : [script]
57
+ end
45
58
  end
46
59
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Asgard
4
- VERSION = "0.3.2"
4
+ VERSION = "0.3.3"
5
5
  end
data/lib/asgard.rb CHANGED
@@ -40,5 +40,7 @@ module Asgard
40
40
  abort "asgard: circular dependency — #{e.message}"
41
41
  rescue Error => e
42
42
  abort "asgard: #{e.message}"
43
+ rescue Interrupt
44
+ exit(130)
43
45
  end
44
46
  end
data/quality_rails.loki CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
- # Rails-specific quality gate tasks — imported by .loki only when the Rails
3
- # constant is defined.
2
+ # Rails-specific quality gate tasks — imported by .loki only when RAILS_ROOT
3
+ # is set. asgard runs as a separate process outside the app it's checking,
4
+ # so `defined?(Rails)` never sees the app's Rails constant.
4
5
  #
5
6
  # `quality` (in quality.loki) discovers every *_check task at run time by
6
7
  # introspecting Tasks.all_commands, rather than a fixed depends_on list — so
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: asgard
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.3.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dewayne VanHoozer
@@ -62,6 +62,7 @@ files:
62
62
  - bin/asgard
63
63
  - bin/console
64
64
  - bin/setup
65
+ - doc_tasks.loki
65
66
  - docs/api.md
66
67
  - docs/assets/css/custom.css
67
68
  - docs/assets/images/asgard.jpg
@@ -83,6 +84,8 @@ files:
83
84
  - examples/bad.loki
84
85
  - examples/concurrent.loki
85
86
  - examples/db_subcommands.loki
87
+ - examples/depends_on_block/bad/.loki
88
+ - examples/depends_on_block/good/.loki
86
89
  - examples/env_usage.loki
87
90
  - examples/kitchen_sink.loki
88
91
  - examples/server_subcommands.loki