asgard 0.3.1 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.envrc +5 -0
- data/.loki +14 -1
- data/.reek.yml +115 -0
- data/.rubocop.yml +6 -0
- data/Archspec.rb +24 -0
- data/CHANGELOG.md +72 -1
- data/CLAUDE.md +4 -7
- data/README.md +51 -1
- data/bin/asgard +1 -1
- data/doc_tasks.loki +21 -0
- data/docs/api.md +125 -6
- data/docs/changelog.md +23 -1
- data/docs/dependencies.md +94 -2
- data/docs/getting-started.md +4 -2
- data/docs/index.md +3 -3
- data/docs/options.md +16 -6
- data/docs/shell.md +30 -1
- data/docs/tasks.md +40 -0
- data/examples/bad.loki +63 -0
- data/examples/depends_on_block/bad/.loki +51 -0
- data/examples/depends_on_block/good/.loki +83 -0
- data/examples/kitchen_sink.loki +13 -0
- data/gem_tasks.loki +17 -1
- data/git.loki +13 -0
- data/lib/asgard/base/dependency_graph.rb +160 -0
- data/lib/asgard/base/dispatch.rb +160 -0
- data/lib/asgard/base/registry.rb +38 -0
- data/lib/asgard/base/task_dsl.rb +64 -0
- data/lib/asgard/base.rb +36 -250
- data/lib/asgard/doctor/report.rb +73 -0
- data/lib/asgard/doctor/task_sections.rb +67 -0
- data/lib/asgard/doctor.rb +150 -0
- data/lib/asgard/kernel_methods.rb +6 -2
- data/lib/asgard/shell.rb +21 -8
- data/lib/asgard/tasks.rb +6 -0
- data/lib/asgard/version.rb +1 -1
- data/lib/asgard.rb +9 -1
- data/quality.loki +218 -32
- data/quality_rails.loki +47 -0
- data/xyzzy.loki +12 -0
- metadata +18 -16
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** —
|
|
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 [
|
|
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
|
-
|
|
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
|
|
data/docs/getting-started.md
CHANGED
|
@@ -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,
|
|
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
|
|
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>--
|
|
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,
|
|
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`, [
|
|
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
|
|
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: "
|
|
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] #
|
|
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] #
|
|
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
|
|
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
|
data/examples/kitchen_sink.loki
CHANGED
|
@@ -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
|