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.
data/docs/changelog.md CHANGED
@@ -10,14 +10,36 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). Asg
10
10
 
11
11
  ### Added
12
12
 
13
+ - **`--doctor` built-in CLI flag** — diagnoses `.loki` resolution, import chains, and task definitions for the current directory. Handled directly in `Asgard.run!` (same pattern as `--version`), so it works even when a broken file, a circular/undefined dependency, or a silently redefined task would otherwise abort the whole process. Backed by the new `Asgard::Doctor` class. Includes a "Tasks by file" listing — every command grouped by the file it's defined in, as `file:line`, with any silently-overridden task annotated inline (`OVERRIDDEN by ...` / `active — redefines ...`). See [API Reference](api.md#asgarddoctor).
13
14
  - **`helper` DSL method** — defines a method available in both class context (e.g. inside `header`) and instance context (inside task methods) with a single declaration. Eliminates the manual `def self.name` + `no_commands { private def name = self.class.name }` boilerplate. Supports positional arguments, keyword arguments, and block arguments. See [Helper Methods](helpers.md).
15
+ - **Flay and Reek quality gates** — `flay_check` checks for structural duplication (mass ≥ 150); `reek` checks code smells. Reviewed findings are grandfathered precisely, per method and detector, via `reek --todo`-generated `exclude:` entries in `.reek.yml` — a genuinely new smell still fails the gate even at an already-reviewed method.
16
+ - **`test_verbose`, `console` tasks** — verbose test output and an IRB console with the gem loaded.
17
+ - **`git.loki`** — per-repo `push`/`pull`/`fetch` tasks.
18
+ - **`typos_check` / `typos_fix` tasks** — spell-checking via the external `typos` CLI (`brew install typos-cli`). Reports `SKIP` (not a failure) if `typos` isn't installed, with a one-line install hint.
19
+ - **`fasterer_check` task** — performance-idiom suggestions from the `fasterer` gem, reported as `WARN` (non-blocking).
20
+ - **`SKIP` / `WARN` quality-gate statuses** — alongside `PASS`/`FAIL`; only `FAIL` blocks `quality`.
21
+ - Every quality gate now writes full detail to a `<gate>_output.txt` file (gitignored) and prints just a one-line summary to stdout.
22
+ - **`asgard tree`** now shows the project header/footer, matching `asgard help`.
23
+ - **`bundler_audit_check` task** — `bundle-audit check --update` against `Gemfile.lock`; `FAIL` on any known vulnerability.
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-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}/`.
14
28
 
15
29
  ### Changed
16
30
 
17
- - **`quality` task** — all three gates (`test`, `rubocop`, `flog_check`) now run in parallel. Each gate captures its own pass/fail result; output is suppressed on pass and filtered to failures only on fail. A summary table is printed after all gates complete.
31
+ - **`quality` task** — discovers every `*_check` task at run time (via a `depends_on` Proc) rather than a fixed list, and runs them all in parallel with a colorized PASS/FAIL/WARN/SKIP summary and tally.
32
+ - **`test`, `rubocop`, `reek` renamed to `test_check`, `rubocop_check`, `reek_check`** — consistency with the other gates is what makes `quality`'s automatic discovery possible.
33
+ - **`release` task** — prompts for confirmation unless `-y`/`--yes` is passed.
34
+ - **`Asgard::Base` and `Asgard::Doctor` split into mixins** — `lib/asgard/base/{registry,dependency_graph,task_dsl,dispatch}.rb` and `lib/asgard/doctor/{task_sections,report}.rb`. No behavior change; drops both classes' Reek `TooManyMethods`/`TooManyInstanceVariables` warnings to zero.
35
+
36
+ ### Fixed
37
+
38
+ - **`bin/asgard`** — switched from `require "asgard"` to `require_relative "../lib/asgard"` so the executable always loads the library shipped alongside it, instead of whatever `asgard` gem happens to be installed separately.
18
39
 
19
40
  ### Removed
20
41
 
42
+ - **`reek_baseline` / `ensure_quality_dir` tasks and `.quality/`** — superseded by precise per-method `exclude:` entries in `.reek.yml` (see the Reek gate entry above).
21
43
  - **`var` DSL method** — replaced by native Ruby class variables. Use `@@name ||= "value".freeze` in the class body. Class variables are visible in all task instance methods and in subcommand subclasses, making them the correct tool for shared configuration in a Thor-based task runner. See [Variables](variables.md).
22
44
 
23
45
  ## [0.2.0] — 2026-05-29
data/docs/dependencies.md CHANGED
@@ -145,9 +145,54 @@ When `asgard ci` runs, `setup` executes once even though both `test` and `lint`
145
145
 
146
146
  ---
147
147
 
148
+ ## Transitive Dependencies
149
+
150
+ When a dependency has its own dependencies, Asgard resolves them recursively before running the dependent task. The deduplication set ensures each task runs at most once regardless of how many paths lead to it.
151
+
152
+ Consider this graph:
153
+
154
+ ```ruby
155
+ class Tasks
156
+ desc "Fetch gems"
157
+ def setup = sh "bundle install"
158
+
159
+ depends_on :setup
160
+ desc "Compile assets"
161
+ def build = sh "rake assets:precompile"
162
+
163
+ desc "Check code style"
164
+ def lint = sh "bundle exec rubocop"
165
+
166
+ depends_on :build, :lint, :setup
167
+ desc "Run the full pipeline"
168
+ def ci = puts "Done."
169
+ end
170
+ ```
171
+
172
+ `ci` declares three sequential dependencies: `build`, `lint`, `setup`. But `build` itself depends on `setup`. The effective execution order is:
173
+
174
+ ```
175
+ setup ← run as build's prerequisite
176
+
177
+ build
178
+
179
+ lint
180
+
181
+ (setup skipped — already done)
182
+
183
+ ci
184
+ ```
185
+
186
+ `setup` runs once — on its first encounter as `build`'s prerequisite. When `ci`'s own stage for `setup` is reached, the deduplication set skips it.
187
+
188
+ !!! tip
189
+ When a task is both a transitive dependency and a direct dependency, declare it only where it logically belongs — as a prerequisite of the task that needs it. Declaring it redundantly at the top level is harmless (deduplication handles it) but adds noise.
190
+
191
+ ---
192
+
148
193
  ## Circular Dependency Detection
149
194
 
150
- Asgard validates the full dependency graph using [Dagwood](https://rubygems.org/gems/dagwood) before any task runs. A circular dependency produces a clean error and exits:
195
+ Asgard validates the full dependency graph using stdlib [TSort](https://docs.ruby-lang.org/en/master/TSort.html) before any task runs. A circular dependency produces a clean error and exits:
151
196
 
152
197
  ```ruby
153
198
  class Tasks
@@ -187,7 +232,54 @@ class Tasks
187
232
  end
188
233
  ```
189
234
 
190
- When `--auto-load` is used, `*.loki` files are loaded alphabetically, so `build.loki` loads before `test.loki`. If you need to control load order, use explicit `require_relative` from `.loki`.
235
+ Because `*.loki` files are loaded alphabetically when `import "*.loki"` is used, `build.loki` loads before `test.loki`. If you need to control load order precisely, use explicit `import` calls with full filenames rather than a glob.
236
+
237
+ ---
238
+
239
+ ## Dynamic Dependencies (Proc / Block Form)
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 — 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
+
243
+ ```ruby
244
+ depends_on -> { [all_commands.keys.grep(/_check\z/).sort.map(&:to_sym)] }
245
+ desc "Run every *_check quality gate task in parallel"
246
+ def quality
247
+ # ...
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
255
+ ```
256
+
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`:
260
+
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
+ ```
265
+
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:
267
+
268
+ ```bash
269
+ asgard quality
270
+ # asgard: undefined task(s) in depends_on: ghost_check
271
+ ```
272
+
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!`.
274
+
275
+ **If the Proc/block raises**, the error is caught and re-raised as `Asgard::Error` naming the task it was declared for:
276
+
277
+ ```
278
+ asgard: depends_on proc for 'quality' raised RuntimeError: boom
279
+ ```
280
+
281
+ !!! tip
282
+ Reach for this only when the dependency list genuinely can't be known until every file has loaded — like "every task whose name ends in `_check`," discovered across several `.loki` files. For a fixed, known-upfront list, the plain array form is simpler and reads just as clearly.
191
283
 
192
284
  ---
193
285
 
@@ -138,24 +138,26 @@ myproject/
138
138
  qa.loki ← test and lint tasks
139
139
  ```
140
140
 
141
- Each `*.loki` file reopens `class Tasks`. To load them, pass `--auto-load` to the `asgard` command they are loaded alphabetically before `.loki`. See [Task Files](task-files.md) for full details.
141
+ Each `*.loki` file reopens `class Tasks`. To load them, call `import "*.loki"` at the top of `.loki` — files are loaded in alphabetical order. See [Task Files](task-files.md) for full details.
142
142
 
143
143
  ---
144
144
 
145
145
  ## Built-in Flags
146
146
 
147
- Every task automatically has three flags available, defined as `class_option` on `Tasks`:
147
+ Every task automatically has four flags available, defined as `class_option` on `Tasks`:
148
148
 
149
149
  | Flag | Description |
150
150
  |---|---|
151
151
  | `--version` | Print the Asgard version and exit |
152
152
  | `--debug` | Set `$DEBUG = true` before the task runs |
153
153
  | `--verbose` | Set `$VERBOSE = true` before the task runs |
154
+ | `--doctor` | Diagnose `.loki` resolution, imports, and task definitions for the CWD, then exit |
154
155
 
155
156
  ```bash
156
157
  asgard --version
157
158
  asgard hello --debug
158
159
  asgard hello --verbose
160
+ asgard --doctor
159
161
  ```
160
162
 
161
163
  Inside a task body, use the `debug?` and `verbose?` predicates:
data/docs/index.md CHANGED
@@ -18,7 +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>Built-in Flags</strong> — <code>--version</code>, <code>--debug</code>, and <code>--verbose</code> available on every task</li>
21
+ <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
22
  </ul>
23
23
  </td>
24
24
  </tr>
@@ -72,7 +72,7 @@ The full Thor DSL is available: `desc`, `method_option`, `class_option`, `long_d
72
72
  | [Subcommands](subcommands.md) | Grouping tasks under a namespace |
73
73
  | [Shell Helpers](shell.md) | `sh`, `shebang`, and supported interpreters |
74
74
  | [Environment](environment.md) | Loading `.env` files with `dotenv` |
75
- | [Task Files](task-files.md) | `.loki` root marker, `--auto-load`, multi-file layout |
75
+ | [Task Files](task-files.md) | `.loki` root marker, `import`, multi-file layout |
76
76
  | [API Reference](api.md) | Module methods, DSL methods, error classes |
77
77
  | [Examples](examples.md) | Working `.loki` files for every feature |
78
78
  | [Changelog](changelog.md) | Release history |
@@ -82,4 +82,4 @@ The full Thor DSL is available: `desc`, `method_option`, `class_option`, `long_d
82
82
  ## Requirements
83
83
 
84
84
  - Ruby >= 3.2.0
85
- - Dependencies: [thor](https://github.com/rails/thor) `~> 1.0`, [dagwood](https://rubygems.org/gems/dagwood) `~> 1.0`, [dotenv](https://github.com/bkeepers/dotenv) `~> 3.0`
85
+ - Dependencies: [thor](https://github.com/rails/thor) `~> 1.0`, [dotenv](https://github.com/bkeepers/dotenv) `~> 3.0`
data/docs/options.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Options & Flags
2
2
 
3
- Asgard tasks use the full Thor option system. Options declared with `method_option` (alias: `option`) apply to a single task. Options declared with `class_option` apply to every task in the class. Asgard ships with three built-in `class_option` declarations on `Tasks`: `--debug`, `--verbose`, and `--version`.
3
+ Asgard tasks use the full Thor option system. Options declared with `method_option` (alias: `option`) apply to a single task. Options declared with `class_option` apply to every task in the class. Asgard ships with four built-in `class_option` declarations on `Tasks`: `--debug`, `--verbose`, `--version`, and `--doctor`.
4
4
 
5
5
  ---
6
6
 
@@ -80,7 +80,7 @@ class Tasks
80
80
  class_option :color,
81
81
  type: :boolean,
82
82
  default: true,
83
- desc: "Colorise output"
83
+ desc: "Colorize output"
84
84
  no_negate :color
85
85
  end
86
86
  ```
@@ -88,13 +88,13 @@ end
88
88
  Help output before `no_negate`:
89
89
 
90
90
  ```
91
- [--color], [--no-color], [--skip-color] # Colorise output
91
+ [--color], [--no-color], [--skip-color] # Colorize output
92
92
  ```
93
93
 
94
94
  Help output after `no_negate`:
95
95
 
96
96
  ```
97
- [--color] # Colorise output
97
+ [--color] # Colorize output
98
98
  ```
99
99
 
100
100
  `no_negate` accepts multiple option names in a single call:
@@ -109,7 +109,7 @@ It has no effect on runtime behaviour — `--no-color` still works on the CLI; o
109
109
 
110
110
  ## Built-in Flags
111
111
 
112
- `Tasks` ships with three built-in `class_option` declarations — `--debug`, `--verbose`, and `--version` — all visible in the Options section of `asgard help`.
112
+ `Tasks` ships with four built-in `class_option` declarations — `--debug`, `--verbose`, `--version`, and `--doctor` — all visible in the Options section of `asgard help`.
113
113
 
114
114
  ### `--version`
115
115
 
@@ -120,6 +120,16 @@ asgard --version
120
120
  # 0.3.0
121
121
  ```
122
122
 
123
+ ### `--doctor`
124
+
125
+ A `class_option :doctor` of type `:boolean`. Diagnoses `.loki` resolution, import chains, and task definitions for the current directory, then exits. Handled by `Asgard.run!` before the `.loki` file is loaded (same pattern as `--version`) — deliberately so, since it needs to keep working in the exact situations that would otherwise abort `run!`: a broken `.loki` file, a circular or undefined dependency, or a task silently redefined by a later `def`. The report includes a "Tasks by file" listing — every command grouped by the file it's defined in, as `file:line` — with any silently-overridden task called out inline, right where it's defined. `no_negate :doctor` suppresses the `[--no-doctor]` / `[--skip-doctor]` variants:
126
+
127
+ ```bash
128
+ asgard --doctor
129
+ ```
130
+
131
+ See [`Asgard::Doctor` in the API Reference](api.md#asgarddoctor) for the full breakdown of what it checks.
132
+
123
133
  ### `--debug`
124
134
 
125
135
  A `class_option :debug` of type `:boolean`. When passed, sets `$DEBUG = true` before the task body runs (via the `invoke_command` hook in `Asgard::Base`):
@@ -211,4 +221,4 @@ asgard _something
211
221
  # asgard: unknown command '_something'
212
222
  ```
213
223
 
214
- If you define your own methods on `Tasks`, avoid the `_` prefix to prevent them from being blocked. Built-in `class_option` declarations (like `--version`, `--debug`, `--verbose`) do not use the `_` prefix because they are options, not commands.
224
+ If you define your own methods on `Tasks`, avoid the `_` prefix to prevent them from being blocked. Built-in `class_option` declarations (like `--version`, `--debug`, `--verbose`, `--doctor`) do not use the `_` prefix because they are options, not commands.
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:
data/examples/bad.loki ADDED
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+ # Demonstrates review.md Critical Issue #1: parallel depends_on tasks that
3
+ # write to shared instance state on `self` race each other.
4
+ #
5
+ # quality.loki's *_check tasks each write a *different* named ivar
6
+ # (@test_result, @rubocop_result, ...), so MRI's GVL happens to hide the
7
+ # race in practice — every write lands on a distinct slot. This file
8
+ # isolates the actual hazard by having 4 parallel workers read-modify-write
9
+ # the SAME @hits counter, the way any real *_check task would if it
10
+ # accumulated into one shared results structure instead of one ivar each.
11
+ #
12
+ # `@hits = @hits + 1` is not one atomic operation — it's a read, an add,
13
+ # then a write — and MRI can switch threads between those steps. Thread.pass
14
+ # inside the loop forces exactly those switches, so the lost-update race
15
+ # shows up reliably instead of "most of the time."
16
+ #
17
+ # Run with:
18
+ # asgard bad_race
19
+ #
20
+ # Expected @hits: 4000 (4 workers x 1000 increments each)
21
+ # Actual: consistently lower — proof of the lost-update race described in
22
+ # review.md item #1.
23
+
24
+ BAD_RACE_WORKERS = 4
25
+ BAD_RACE_REPS = 1000
26
+
27
+ class Tasks
28
+ desc "Increment the shared @hits counter, unsynchronized (worker)"
29
+ def racer_1 = bump_shared_counter
30
+
31
+ desc "Increment the shared @hits counter, unsynchronized (worker)"
32
+ def racer_2 = bump_shared_counter
33
+
34
+ desc "Increment the shared @hits counter, unsynchronized (worker)"
35
+ def racer_3 = bump_shared_counter
36
+
37
+ desc "Increment the shared @hits counter, unsynchronized (worker)"
38
+ def racer_4 = bump_shared_counter
39
+
40
+ depends_on [:racer_1, :racer_2, :racer_3, :racer_4]
41
+ desc "Show the lost-update race in @hits"
42
+ def bad_race
43
+ expected = BAD_RACE_WORKERS * BAD_RACE_REPS
44
+ puts "expected @hits == #{expected}, got #{@hits}"
45
+
46
+ if @hits == expected
47
+ puts "no corruption this run — the race is timing-dependent, rerun a few times"
48
+ else
49
+ puts "DATA RACE CONFIRMED: #{expected - @hits} update(s) lost"
50
+ end
51
+ end
52
+
53
+ no_commands do
54
+ def bump_shared_counter
55
+ @hits ||= 0
56
+ BAD_RACE_REPS.times do
57
+ current = @hits # read
58
+ Thread.pass # force a context switch before the write lands
59
+ @hits = current + 1 # write — another thread's read may already be stale
60
+ end
61
+ end
62
+ end
63
+ end
@@ -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
data/gem_tasks.loki CHANGED
@@ -2,6 +2,15 @@
2
2
  # Gem lifecycle tasks — imported by .loki
3
3
 
4
4
  class Tasks
5
+ desc "Open IRB console with the gem loaded"
6
+ def console
7
+ if File.exist?("bin/console")
8
+ sh "bin/console"
9
+ else
10
+ sh "bundle exec irb -Ilib -r #{@@project}"
11
+ end
12
+ end
13
+
5
14
  desc "Build the gem package"
6
15
  depends_on :quality
7
16
  def build
@@ -16,12 +25,19 @@ class Tasks
16
25
  sh "gem install pkg/asgard-#{project_version}.gem"
17
26
  end
18
27
 
19
- desc "Release to RubyGems"
28
+ desc "release", "Release gem to RubyGems (runs quality gate first)"
29
+ option :yes, aliases: "-y", type: :boolean, default: false, desc: "Skip confirmation prompt"
20
30
  depends_on :quality
21
31
  def release
22
32
  tag = "v#{project_version}"
23
33
  gem_file = "pkg/asgard-#{project_version}.gem"
24
34
 
35
+ unless options[:yes]
36
+ print "Release #{@@project} v#{project_version} to RubyGems? [y/N] "
37
+ $stdout.flush
38
+ return puts "Aborted." unless $stdin.gets.strip.downcase == "y"
39
+ end
40
+
25
41
  abort "Working directory is not clean — commit or stash changes first." unless `git status --porcelain`.strip.empty?
26
42
  abort "Tag #{tag} already exists." unless `git tag -l #{tag}`.strip.empty?
27
43
 
data/git.loki ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+ # Per-repo git tasks — imported by .loki
3
+
4
+ class Tasks
5
+ desc "Push the current branch to its remote"
6
+ def push = sh "git push"
7
+
8
+ desc "Pull (fast-forward only) from the remote"
9
+ def pull = sh "git pull --ff-only"
10
+
11
+ desc "Fetch from the remote"
12
+ def fetch = sh "git fetch"
13
+ end