asgard 0.3.0 → 0.3.2

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/api.md CHANGED
@@ -11,7 +11,7 @@ These class methods are defined on the `Asgard` module itself.
11
11
  | Method | Signature | Description |
12
12
  |---|---|---|
13
13
  | `run!` | `Asgard.run!(argv)` | Main entry point. Finds `.loki`, loads all task files, validates the dependency graph, and dispatches via Thor. Handles its own errors: missing `.loki` and circular dependencies both produce a clean one-line message and `exit 1`. |
14
- | `find_task_file` | `Asgard.find_task_file → String, nil` | Searches `Dir.pwd` and each ancestor directory for a `.loki` file. Returns the absolute path string of the first match, or `nil` if none is found. |
14
+ | `find_task_file` | `Asgard.find_task_file → Pathname, nil` | Searches `Dir.pwd` and each ancestor directory for a `.loki` file. Returns a `Pathname` of the first match, or `nil` if none is found. |
15
15
 
16
16
  ### `run!` Details
17
17
 
@@ -35,7 +35,7 @@ These methods are defined as `module_function` on `Kernel` and are therefore ava
35
35
 
36
36
  | Method | Signature | Returns | Description |
37
37
  |---|---|---|---|
38
- | `loki_up` | `loki_up(name = ".loki") → String, nil` | Absolute path or `nil` | Searches `Dir.pwd` and each ancestor directory for a file named `name`. Returns the first match's absolute path, or `nil` if not found. Exact filenames only — does not expand globs. |
38
+ | `loki_up` | `loki_up(name = ".loki") → Pathname, nil` | `Pathname` or `nil` | Searches `Dir.pwd` and each ancestor directory for a file named `name`. Returns a `Pathname` for the first match, or `nil` if not found. Exact filenames only — does not expand globs. |
39
39
  | `import` | `import(path) → true, false` | `true` if any file newly loaded | Loads one `.loki` file or a glob of `.loki` files. Relative paths resolve relative to the caller's file (like `require_relative`). Idempotent via `$LOADED_FEATURES`. Raises `ArgumentError` if `path` does not end with `.loki`. Raises `LoadError` if a non-glob path does not exist. |
40
40
  | `import_up` | `import_up(name = ".loki") → true, false` | `true` if any file newly loaded | Combines `loki_up` and `import`. Walks ancestors to find the file or glob match, then loads it. Returns `false` if nothing is found. |
41
41
  | `debug?` | `debug? → true, false` | `$DEBUG` | Returns the current value of `$DEBUG`. Set to `true` by `--debug` on the CLI or directly via `$DEBUG = true`. |
@@ -53,11 +53,11 @@ loki_up(".env") # find the nearest .env file up the tree
53
53
  loki_up("VERSION") # find a VERSION file in CWD or any ancestor
54
54
  ```
55
55
 
56
- Returns an absolute path string or `nil`. Does not load the file.
56
+ Returns a `Pathname` or `nil`. Does not load the file. `Pathname` is accepted by `import`, `dotenv`, `load`, and standard Ruby file methods — no `.to_s` conversion needed in common usage.
57
57
 
58
58
  ```ruby
59
59
  if (path = loki_up("gem_tasks.loki"))
60
- import path
60
+ import path # Pathname accepted directly
61
61
  end
62
62
 
63
63
  # Pass the located .env to dotenv — works from any subdirectory
@@ -111,13 +111,62 @@ 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. |
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). |
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
+ | `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
+ | `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
+ | `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. |
116
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. |
117
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. |
118
- | `validate_deps!` | `Tasks.validate_deps!` | Build and topologically sort the full dependency graph using Dagwood. Raises `Asgard::CircularDependencyError` on cycles. Called by `run!` at startup. |
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. |
119
122
  | `_reset_ran!` | `Tasks._reset_ran!` | Clear the per-invocation task deduplication set. Called by `run!` before dispatching. Thread-safe via Mutex. |
120
123
 
124
+ ### `header` and `footer` Accumulation
125
+
126
+ Both `header` and `footer` accumulate across multiple calls and across multiple imported `.loki` files, but they accumulate in opposite directions by design.
127
+
128
+ **`header` appends** — each call adds to the bottom of the header block:
129
+
130
+ ```ruby
131
+ # .loki
132
+ import "quality.loki"
133
+ import "gem_tasks.loki"
134
+
135
+ class Tasks
136
+ header "Project: myapp" # line 1
137
+ header "Root: #{loki_up.parent}" # line 2
138
+ end
139
+ ```
140
+
141
+ Result in `asgard help`:
142
+ ```
143
+ Project: myapp
144
+ Root: /home/user/myapp
145
+ ```
146
+
147
+ **`footer` prepends** — each call inserts at the top of the footer block:
148
+
149
+ ```ruby
150
+ class Tasks
151
+ footer "Github: https://github.com/org/myapp" # ends up second
152
+ footer "Docs: https://myapp.example.com" # ends up first
153
+ end
154
+ ```
155
+
156
+ Result in `asgard help`:
157
+ ```
158
+ Docs: https://myapp.example.com
159
+ Github: https://github.com/org/myapp
160
+ ```
161
+
162
+ **Why the asymmetry?** When task files are split across multiple `.loki` files, the base `.loki` is loaded first and any imported files are loaded after. With `header`, earlier-loaded content appears first (the project banner stays at the top). With `footer`, later-loaded content appears first — this allows an imported file to inject a note that appears above the base footer rather than after it.
163
+
164
+ In practice: if only one file calls `header` and one file calls `footer`, the direction doesn't matter. The difference is visible only when multiple `.loki` files both call `header` or both call `footer`.
165
+
166
+ Neither `header` nor `footer` appears when running per-command help (`asgard help <task>`).
167
+
168
+ ---
169
+
121
170
  ### `depends_on` Argument Shapes
122
171
 
123
172
  ```ruby
@@ -125,8 +174,14 @@ depends_on :build # single sequential dep
125
174
  depends_on :clean, :build # two sequential deps
126
175
  depends_on [:lint, :typecheck] # lint and typecheck run in parallel
127
176
  depends_on :setup, [:lint, :build], :test # setup, then lint+build concurrently, then test
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.
180
+ depends_on -> { [all_commands.keys.grep(/_check\z/).map(&:to_sym)] }
128
181
  ```
129
182
 
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.
184
+
130
185
  ---
131
186
 
132
187
  ## `Tasks` Built-ins
@@ -137,12 +192,79 @@ depends_on :setup, [:lint, :build], :test # setup, then lint+build concurrently
137
192
  |---|---|---|
138
193
  | `class_option :debug` | class option | `--debug` flag. Sets `$DEBUG = true` before any task runs. Boolean, default `false`. |
139
194
  | `class_option :verbose` | class option | `--verbose` flag. Sets `$VERBOSE = true` before any task runs. Boolean, default `false`. |
140
- | `_version` | private task method | Implements `--version`. Prints `Asgard::VERSION` and exits. Registered via `map "--version" => :_version`. Uses `_` prefix convention. |
195
+ | `class_option :version` | class option | `--version` flag. Handled by `Asgard.run!` before the `.loki` file is loaded — prints `Asgard::VERSION` and exits. `no_negate :version` suppresses the `[--no-version]` / `[--skip-version]` help entries. |
196
+ | `class_option :doctor` | class option | `--doctor` flag. Handled by `Asgard.run!` before the `.loki` file is loaded — runs `Asgard::Doctor.new.run` and exits. `no_negate :doctor` suppresses the `[--no-doctor]` / `[--skip-doctor]` help entries. |
141
197
  | `debug?` | Kernel module function | Returns `$DEBUG`. Available everywhere via `Kernel`. |
142
198
  | `verbose?` | Kernel module function | Returns `$VERBOSE`. Available everywhere via `Kernel`. |
143
199
 
144
200
  ---
145
201
 
202
+ ## `Asgard::Doctor`
203
+
204
+ `asgard --doctor` diagnoses `.loki` resolution, import chains, and task definitions for the current directory. It deliberately bypasses the normal `Tasks` boot sequence used by `run!`, so it can still report findings in situations that would otherwise abort the whole process — a broken `.loki` file, a circular or undefined dependency, or a task silently redefined by a later `def`.
205
+
206
+ | Method | Signature | Description |
207
+ |---|---|---|
208
+ | `new` | `Asgard::Doctor.new(dir = Dir.pwd)` | Builds a doctor scoped to `dir`. |
209
+ | `run` | `doctor.run` | Runs the full diagnostic pass and prints a report to stdout. Does not raise or exit — callers (like `Asgard.run!`) decide what to do afterward. |
210
+ | `ancestor_markers` | `Asgard::Doctor.ancestor_markers(dir) → Array<String>` | Every `.loki` marker from `dir` up to the filesystem root, nearest first. Unlike `loki_up`, it collects every match instead of stopping at the first, so shadowed ancestor markers can be reported. |
211
+ | `duplicate_methods` | `Asgard::Doctor.duplicate_methods(method_log) → Hash` | Given an `Asgard::Base` subclass's `_method_log`, returns the subset of entries defined at more than one location — same file or different — which is exactly what a silent `def` override looks like. |
212
+
213
+ Findings fall into three levels: `:info` (what was found — the marker used, each import's result), `:warn` (shadowed markers that are never reached), and `:error` (load failures, redefined methods, dependency graph problems).
214
+
215
+ After the findings, the report prints a **Tasks by file** section: every Thor command, grouped by the `.loki` file it's defined in, as `relative/path:line` — a format an editor can jump straight to. A task name defined at more than one location gets every definition annotated inline: the earlier one(s) are marked `OVERRIDDEN by <file>:<line> — never callable`, and the winning (last) definition is marked `active — redefines <file>:<line>`. This is the exact class of bug the flag was built to catch — a later `def` silently replacing an earlier one, with no error anywhere else in the toolchain. The report ends with a one-line summary and a count of problems/warnings, where each overridden task counts as one problem.
216
+
217
+ ```bash
218
+ asgard --doctor
219
+ ```
220
+
221
+ The asgard repo ships its own live example of this: `xyzzy.loki` defines a `xyzzy` task, and the top-level `.loki` (which imports it) reopens `Tasks` and defines `xyzzy` again — so the one from `xyzzy.loki` is silently dead. Clone the repo and run `asgard --doctor` from its root to see this for yourself:
222
+
223
+ ```
224
+ asgard doctor -- /path/to/asgard
225
+ ============================================================
226
+ [INFO] using .loki marker: /path/to/asgard/.loki
227
+ [INFO] import "quality.loki" -> /path/to/asgard/quality.loki
228
+ [INFO] import "gem_tasks.loki" -> /path/to/asgard/gem_tasks.loki
229
+ [INFO] import "git.loki" -> /path/to/asgard/git.loki
230
+ [INFO] import "xyzzy.loki" -> /path/to/asgard/xyzzy.loki
231
+
232
+ Tasks by file:
233
+
234
+ .loki
235
+ xyzzy .loki:15 active — redefines xyzzy.loki:6
236
+
237
+ quality.loki
238
+ test quality.loki:6
239
+ test_verbose quality.loki:15
240
+ quality quality.loki:21
241
+ rubocop quality.loki:34
242
+ rubocop_fix quality.loki:43
243
+ flog_check quality.loki:48
244
+ flay_check quality.loki:82
245
+ reek quality.loki:104
246
+
247
+ gem_tasks.loki
248
+ console gem_tasks.loki:6
249
+ build gem_tasks.loki:16
250
+ install gem_tasks.loki:24
251
+ release gem_tasks.loki:31
252
+
253
+ git.loki
254
+ push git.loki:6
255
+ pull git.loki:9
256
+ fetch git.loki:12
257
+
258
+ xyzzy.loki
259
+ xyzzy xyzzy.loki:6 OVERRIDDEN by .loki:15 — never callable
260
+ ============================================================
261
+ 1 problem(s), 1 warning(s).
262
+ ```
263
+
264
+ The warning is a shadowed ancestor `.loki` marker one directory further up the tree — unrelated to the override, and something you may or may not see depending on what's above the repo on your own machine.
265
+
266
+ ---
267
+
146
268
  ## `Asgard::Base` Internal Class Methods
147
269
 
148
270
  These are implementation details exposed for extensibility. Prefer the DSL methods above in normal use.
@@ -154,7 +276,6 @@ These are implementation details exposed for extensibility. Prefer the DSL metho
154
276
  | `_running` | `Set` of task name symbols currently executing (started but not yet finished). |
155
277
  | `_cond` | Hash of `ConditionVariable` objects keyed by task name; threads wait here when a dep is in-flight. |
156
278
  | `_ran_mutex` | `Mutex` protecting `_done`, `_running`, and `_cond` for thread-safe access. |
157
- | `_build_dep_graph(stages)` | Translates the stage array (from `_deps`) into a Dagwood-compatible hash. |
158
279
 
159
280
  ---
160
281
 
@@ -164,7 +285,7 @@ These are implementation details exposed for extensibility. Prefer the DSL metho
164
285
 
165
286
  1. Sets `$DEBUG` / `$VERBOSE` from `options` if the corresponding flags are present.
166
287
  2. Tries to acquire a run token (`acquire_run_token`): if the task is already in `_done`, returns immediately (skip); if it is in `_running`, waits on the `_cond` ConditionVariable until it finishes, then returns (skip); otherwise adds the task to `_running` and continues.
167
- 3. Resolves dependency stages from `_deps`, builds the Dagwood graph, and executes groups (parallel groups in threads, sequential groups one at a time).
288
+ 3. Resolves dependency stages from `_deps` already the parallel-group execution plan `depends_on` built — and executes each group in order (parallel groups in threads, sequential groups one at a time).
168
289
  4. Calls `command.run(self, *args)` to execute the task itself.
169
290
  5. In an `ensure` block, adds the task to `_done` and broadcasts on its `_cond` to wake any waiting threads.
170
291
 
@@ -194,7 +315,6 @@ end
194
315
  | Gem | Version | Purpose |
195
316
  |---|---|---|
196
317
  | [thor](https://github.com/rails/thor) | `~> 1.0` | CLI framework; provides the full task DSL |
197
- | [dagwood](https://rubygems.org/gems/dagwood) | `~> 1.0` | DAG library for dependency graph resolution and topological sort |
198
318
  | [dotenv](https://github.com/bkeepers/dotenv) | `~> 3.0` | `.env` file loading |
199
319
 
200
320
  ---
data/docs/changelog.md CHANGED
@@ -8,8 +8,36 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). Asg
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ### Added
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).
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-form).
26
+
27
+ ### Changed
28
+
29
+ - **`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.
30
+ - **`test`, `rubocop`, `reek` renamed to `test_check`, `rubocop_check`, `reek_check`** — consistency with the other gates is what makes `quality`'s automatic discovery possible.
31
+ - **`release` task** — prompts for confirmation unless `-y`/`--yes` is passed.
32
+ - **`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.
33
+
34
+ ### Fixed
35
+
36
+ - **`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.
37
+
11
38
  ### Removed
12
39
 
40
+ - **`reek_baseline` / `ensure_quality_dir` tasks and `.quality/`** — superseded by precise per-method `exclude:` entries in `.reek.yml` (see the Reek gate entry above).
13
41
  - **`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).
14
42
 
15
43
  ## [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,43 @@ 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 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, 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
+
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)`.
252
+
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]`.
254
+
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:
256
+
257
+ ```bash
258
+ asgard quality
259
+ # asgard: undefined task(s) in depends_on: ghost_check
260
+ ```
261
+
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!`.
263
+
264
+ **If the Proc raises**, the error is caught and re-raised as `Asgard::Error` naming the task it was declared for:
265
+
266
+ ```
267
+ asgard: depends_on proc for 'quality' raised RuntimeError: boom
268
+ ```
269
+
270
+ !!! tip
271
+ 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
272
 
192
273
  ---
193
274
 
@@ -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/helpers.md CHANGED
@@ -75,6 +75,90 @@ end
75
75
 
76
76
  ---
77
77
 
78
+ ## The `helper` DSL Method
79
+
80
+ `helper` is an Asgard DSL method that defines a helper available in **both class context and instance context** with a single declaration. It is the right tool when a value needs to be used inside a `header` or `footer` call (which execute at class load time) and also inside task instance methods.
81
+
82
+ ### The problem it solves
83
+
84
+ Thor task methods run as instance methods. Class-level DSL calls like `header` and `footer` run as class methods. A plain `def` only creates an instance method, so it cannot be called inside `header`. Conversely, `def self.name` only creates a class method, so it cannot be called inside a task body without `self.class.name`.
85
+
86
+ The manual workaround is verbose:
87
+
88
+ ```ruby
89
+ class Tasks
90
+ @@project ||= "myapp".freeze
91
+
92
+ # class method for header/footer
93
+ def self.version
94
+ @@version ||= File.read("lib/myapp/version.rb").match(/VERSION\s*=\s*"([^"]+)"/)[1].freeze
95
+ end
96
+
97
+ # private instance method delegating to the class method
98
+ no_commands do
99
+ private def version = self.class.version
100
+ end
101
+
102
+ header "#{@@project} v#{version}"
103
+
104
+ desc "Show version"
105
+ def show_version = puts version
106
+ end
107
+ ```
108
+
109
+ `helper` replaces those six lines with one:
110
+
111
+ ```ruby
112
+ class Tasks
113
+ @@project ||= "myapp".freeze
114
+
115
+ helper(:version) {
116
+ @@version ||= File.read("lib/myapp/version.rb").match(/VERSION\s*=\s*"([^"]+)"/)[1].freeze
117
+ }
118
+
119
+ header "#{@@project} v#{version}"
120
+
121
+ desc "Show version"
122
+ def show_version = puts version
123
+ end
124
+ ```
125
+
126
+ ### Arguments
127
+
128
+ `helper` supports any argument signature valid in a Ruby method definition: positional, keyword, default values, and blocks.
129
+
130
+ ```ruby
131
+ # No arguments
132
+ helper(:project_root) { loki_up.parent.to_s }
133
+
134
+ # Positional arguments
135
+ helper(:gem_path) { |name| "lib/#{name}/version.rb" }
136
+
137
+ # Positional with default
138
+ helper(:tag_prefix) { |sep = "-"| "#{@@project}#{sep}" }
139
+
140
+ # Positional and keyword arguments
141
+ helper(:format_version) { |name, version, prefix: "v", separator: "-"|
142
+ "#{prefix}#{name}#{separator}#{version}"
143
+ }
144
+ ```
145
+
146
+ Wrong argument counts or unknown keyword names raise the same `ArgumentError` Ruby raises for any method call — no special error handling needed.
147
+
148
+ ### Visibility
149
+
150
+ `helper`-defined methods are:
151
+
152
+ - **Excluded from `asgard help`** — they never appear as commands
153
+ - **Blocked from CLI invocation** — cannot be called directly from the command line
154
+ - **Private on the instance side** — not accessible from outside the class
155
+
156
+ ### When to use `helper`
157
+
158
+ Use `helper` when the value or computation must be available in both a class-level DSL call (`header`, `footer`, a `@@var` initializer) and inside task instance methods. For helpers that are only needed inside task bodies, a plain `private` method is simpler.
159
+
160
+ ---
161
+
78
162
  ## Choosing Between `private` and `no_commands`
79
163
 
80
164
  | | `private` | `no_commands` |
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
 
@@ -71,21 +71,65 @@ end
71
71
 
72
72
  Both `deploy` and `migrate` automatically accept `--dry-run` and `--env`.
73
73
 
74
+ ### Boolean class options and `no_negate`
75
+
76
+ Thor automatically generates `[--no-name]` and `[--skip-name]` help entries alongside every boolean `class_option`. When negation is meaningful — `--dry-run` paired with `--no-dry-run` — this is useful. When negation is meaningless, call `no_negate` immediately after the declaration to remove the extra variants from the help output:
77
+
78
+ ```ruby
79
+ class Tasks
80
+ class_option :color,
81
+ type: :boolean,
82
+ default: true,
83
+ desc: "Colorize output"
84
+ no_negate :color
85
+ end
86
+ ```
87
+
88
+ Help output before `no_negate`:
89
+
90
+ ```
91
+ [--color], [--no-color], [--skip-color] # Colorize output
92
+ ```
93
+
94
+ Help output after `no_negate`:
95
+
96
+ ```
97
+ [--color] # Colorize output
98
+ ```
99
+
100
+ `no_negate` accepts multiple option names in a single call:
101
+
102
+ ```ruby
103
+ no_negate :color, :version, :emoji
104
+ ```
105
+
106
+ It has no effect on runtime behaviour — `--no-color` still works on the CLI; only the help display is affected.
107
+
74
108
  ---
75
109
 
76
110
  ## Built-in Flags
77
111
 
78
- `Tasks` ships with three built-in class options and a version flag:
112
+ `Tasks` ships with four built-in `class_option` declarations `--debug`, `--verbose`, `--version`, and `--doctor` — all visible in the Options section of `asgard help`.
79
113
 
80
114
  ### `--version`
81
115
 
82
- Prints `Asgard::VERSION` and exits. Implemented as the `_version` method with the `_` prefix convention (gem-owned, blocked from direct CLI invocation):
116
+ A `class_option :version` of type `:boolean`. Prints `Asgard::VERSION` and exits. Handled by `Asgard.run!` before the `.loki` file is loaded, so it works even in a directory without a `.loki` file. `no_negate :version` suppresses the `[--no-version]` / `[--skip-version]` variants (see [Boolean class options and `no_negate`](#boolean-class-options-and-no_negate) above):
83
117
 
84
118
  ```bash
85
119
  asgard --version
86
- # 0.1.2
120
+ # 0.3.0
87
121
  ```
88
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
+
89
133
  ### `--debug`
90
134
 
91
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`):
@@ -173,8 +217,8 @@ asgard deploy production --verbose
173
217
  Methods whose names start with `_` are considered gem-owned in Asgard's naming convention. `run!` guards against invoking them directly from the CLI:
174
218
 
175
219
  ```bash
176
- asgard _version
177
- # asgard: unknown command '_version'
220
+ asgard _something
221
+ # asgard: unknown command '_something'
178
222
  ```
179
223
 
180
- If you define your own methods on `Tasks`, avoid the `_` prefix to prevent them from being silently blocked.
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/task-files.md CHANGED
@@ -93,7 +93,7 @@ import("gem_tasks.loki") ? "loaded now" : "already loaded"
93
93
 
94
94
  ## Finding Files with `loki_up`
95
95
 
96
- `loki_up(name = ".loki")` searches `Dir.pwd` and each ancestor directory for a file with the given name, returning its absolute path or `nil`. It does **not** load the file — it only finds it.
96
+ `loki_up(name = ".loki")` searches `Dir.pwd` and each ancestor directory for a file with the given name, returning a `Pathname` or `nil`. It does **not** load the file — it only finds it.
97
97
 
98
98
  Despite the name, `loki_up` is not limited to `.loki` files — it will locate any file by name. This makes it useful for finding shared config files, `.env` files, or any other resource that lives somewhere up the directory tree:
99
99
 
@@ -401,7 +401,7 @@ end
401
401
 
402
402
  | Method | Finds? | Loads? | Glob? | Ancestor search? |
403
403
  |---|---|---|---|---|
404
- | `loki_up(name)` | Yes | No | No | Yes |
404
+ | `loki_up(name)` | Yes (`Pathname`) | No | No | Yes |
405
405
  | `import(path)` | No | Yes | Yes | No |
406
406
  | `import_up(name)` | Yes | Yes | Yes | Yes |
407
407
  | Asgard's `run!` | Yes | `.loki` only | No | Yes |