asgard 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md CHANGED
@@ -1,10 +1,33 @@
1
1
  # Asgard
2
2
 
3
- A Ruby task runner built on [Thor](https://github.com/rails/thor) for argument handling and [Dagwood](https://github.com/rewindio/dagwood) for dependency ordering.
4
-
5
- The name comes from Norse mythology: **Thor** is the CLI framework, **Asgard** is the realm where tasks live, and the task file is named **loki** — because Loki holds all the tricks.
6
-
7
- > **Asgard is a wrapper around [Thor](https://github.com/rails/thor).** Anything Thor can do — subcommands, typed options, argument validation, shell completion — is available inside a `.loki` file. Familiarity with Thor's DSL will make you immediately productive with Asgard.
3
+ > [!INFO]
4
+ > See the [CHANGELOG](CHANGELOG.md) for the latest changes. The [examples directory](examples/) contains working `.loki` files demonstrating the full feature set.
5
+
6
+ <br>
7
+ <table>
8
+ <tr>
9
+ <td width="40%" align="center" valign="top">
10
+ <img src="docs/assets/images/asgard.jpg" alt="Asgard"><br>
11
+ <em>"Loki writes the tricks. Asgard runs them."</em>
12
+ </td>
13
+ <td width="60%" valign="top">
14
+ <strong>Key Features</strong><br>
15
+
16
+ - <strong>Thor-Powered CLI</strong> — every Thor DSL feature available inside <code>.loki</code> task files<br>
17
+ - <strong>Task Dependencies</strong> — sequential, parallel, and mixed dependency graphs via <code>depends_on</code><br>
18
+ - <strong>Concurrent Execution</strong> — parallel task groups run in native Ruby threads<br>
19
+ - <strong>Subcommands</strong> — group related tasks under a named namespace<br>
20
+ - <strong>Variables</strong> — shared configuration via Ruby class variables (<code>@@name</code>), visible across all tasks and subcommands<br>
21
+ - <strong>Shell Helpers</strong> — <code>sh</code> for any shell command or heredoc; <code>shebang</code> for polyglot scripts<br>
22
+ - <strong>Dotenv Support</strong> — load <code>.env</code> files into the environment with <code>dotenv</code><br>
23
+ - <strong>Auto-Discovery</strong> — <code>.loki</code> root marker searched from CWD upward through parent directories<br>
24
+ - <strong>Multi-File Tasks</strong> — split tasks across <code>*.loki</code> files, loaded via <code>import</code> from your <code>.loki</code><br>
25
+ - <strong>Built-in Flags</strong> — <code>--debug</code> and <code>--verbose</code> available on every task; <code>--version</code> at the top level<br>
26
+ </td>
27
+ </tr>
28
+ </table>
29
+
30
+ <p>Asgard is a <a href="https://github.com/rails/thor">Thor</a>-based task runner for Ruby projects. Define tasks in <code>.loki</code> files, declare dependencies between them, and let Asgard handle ordering and concurrent execution. Anything Thor can do — subcommands, typed options, argument validation — is available inside a <code>.loki</code> file.</p>
8
31
 
9
32
  ## Installation
10
33
 
@@ -28,8 +51,8 @@ Every `.loki` file defines tasks as methods inside `class Tasks`. The `Tasks` cl
28
51
 
29
52
  ```ruby
30
53
  class Tasks
31
- desc "hello", "Say hello"
32
- def hello = sh 'echo "Hello, World!"'
54
+ desc "Say hello"
55
+ def hello = puts "Hello, World!"
33
56
  end
34
57
  ```
35
58
 
@@ -44,7 +67,7 @@ Declare positional parameters directly in the method signature. Document them in
44
67
  ```ruby
45
68
  class Tasks
46
69
  desc "hello NAME", "Say hello to NAME"
47
- def hello(name = "World") = sh "echo 'Hello, #{name}!'"
70
+ def hello(name = "World") = puts "Hello, #{name}!"
48
71
  end
49
72
  ```
50
73
 
@@ -55,7 +78,7 @@ asgard hello Alice
55
78
 
56
79
  ### A task with a formal argument declaration
57
80
 
58
- Use `argument` for richer metadata — type checking, enums, and help text:
81
+ Use `argument` for richer metadata — type checking, enums, and help text. **Warning: `argument` is a class-level declaration that applies to every task in the class**, not just the one below it. It is best suited for single-command CLIs or when every task genuinely shares the same positional input. In multi-task files, prefer method signature parameters instead.
59
82
 
60
83
  ```ruby
61
84
  class Tasks
@@ -65,7 +88,7 @@ class Tasks
65
88
  desc: "Name to greet"
66
89
 
67
90
  desc "hello NAME", "Say hello to NAME"
68
- def hello = sh "echo 'Hello, #{name}!'"
91
+ def hello = puts "Hello, #{name}!"
69
92
  end
70
93
  ```
71
94
 
@@ -80,7 +103,7 @@ class Tasks
80
103
  method_option :count, aliases: "-n", type: :numeric, default: 1, desc: "Repeat N times"
81
104
  def hello(name = "World")
82
105
  message = options[:shout] ? "HELLO, #{name.upcase}!" : "Hello, #{name}!"
83
- options[:count].times { sh "echo '#{message}'" }
106
+ options[:count].times { puts message }
84
107
  end
85
108
  end
86
109
  ```
@@ -105,7 +128,7 @@ class Tasks
105
128
  method_option :count, aliases: "-n", type: :numeric, default: 1, desc: "Repeat N times"
106
129
  def hello(name = "World")
107
130
  message = options[:shout] ? "HELLO, #{name.upcase}!" : "Hello, #{name}!"
108
- options[:count].times { sh "echo '#{message}'" }
131
+ options[:count].times { puts message }
109
132
  end
110
133
  end
111
134
  ```
@@ -124,15 +147,15 @@ Bare symbols run one after another in the order declared:
124
147
 
125
148
  ```ruby
126
149
  class Tasks
127
- desc "build", "Compile the project"
150
+ desc "Compile the project"
128
151
  def build = sh "rake build"
129
152
 
130
153
  depends_on :build
131
- desc "test", "Run the test suite"
154
+ desc "Run the test suite"
132
155
  def test = sh "rake test"
133
156
 
134
157
  depends_on :test
135
- desc "release", "Publish the gem"
158
+ desc "Publish the gem"
136
159
  def release = sh "bundle exec rake release"
137
160
  end
138
161
  ```
@@ -147,15 +170,15 @@ Wrap symbols in an array to declare they can run concurrently. Asgard waits for
147
170
 
148
171
  ```ruby
149
172
  class Tasks
150
- desc "lint", "Check code style"
173
+ desc "Check code style"
151
174
  def lint = sh "bundle exec rubocop"
152
175
 
153
- desc "typecheck", "Run type checks"
176
+ desc "Run type checks"
154
177
  def typecheck = sh "bundle exec srb tc"
155
178
 
156
179
  # lint and typecheck run in parallel, test waits for both
157
180
  depends_on [:lint, :typecheck]
158
- desc "test", "Run the test suite"
181
+ desc "Run the test suite"
159
182
  def test = sh "bundle exec rake test"
160
183
  end
161
184
  ```
@@ -170,16 +193,16 @@ Mix bare symbols (sequential) and arrays (parallel) in a single `depends_on` cal
170
193
 
171
194
  ```ruby
172
195
  class Tasks
173
- desc "setup", "Install dependencies"; def setup = sh "bundle install"
174
- desc "lint", "Check code style"; def lint = sh "bundle exec rubocop"
175
- desc "build", "Compile assets"; def build = sh "rake assets:precompile"
176
- desc "test", "Run tests"; def test = sh "bundle exec rake test"
177
- desc "notify", "Post to Slack"; def notify = sh "curl $SLACK_WEBHOOK -d '{\"text\":\"done\"}'"
196
+ desc "Install dependencies"; def setup = sh "bundle install"
197
+ desc "Check code style"; def lint = sh "bundle exec rubocop"
198
+ desc "Compile assets"; def build = sh "rake assets:precompile"
199
+ desc "Run tests"; def test = sh "bundle exec rake test"
200
+ desc "Post to Slack"; def notify = sh "curl $SLACK_WEBHOOK -d '{\"text\":\"done\"}'"
178
201
 
179
202
  # setup first, then lint+build in parallel, then test, then notify
180
203
  depends_on :setup, [:lint, :build], :test, :notify
181
- desc "ci", "Full CI pipeline"
182
- def ci = sh "echo 'CI complete'"
204
+ desc "Full CI pipeline"
205
+ def ci = puts "CI complete"
183
206
  end
184
207
  ```
185
208
 
@@ -201,18 +224,24 @@ asgard ci executes:
201
224
 
202
225
  ## Variables
203
226
 
204
- `var` declares a named value available to all tasks as a method. Pass a lambda for lazy evaluation it is called once on first use:
227
+ Shared configuration values are declared as Ruby class variables (`@@name`) at the top of the class body. Use `||=` so the first declaration wins when multiple `.loki` files reopen `Tasks`, and `.freeze` to prevent mutation:
205
228
 
206
229
  ```ruby
207
230
  class Tasks
208
- var :app, "myapp"
209
- var :version, -> { `git describe --tags`.strip }
231
+ @@app ||= "myapp".freeze
232
+ @@max_jobs ||= 4
233
+
234
+ desc "Create a release tag"
235
+ def tag = sh "git tag #{@@app}-#{version}"
236
+
237
+ private
210
238
 
211
- desc "tag", "Create a release tag"
212
- def tag = sh "git tag #{app}-#{version}"
239
+ def version = `git describe --tags`.strip
213
240
  end
214
241
  ```
215
242
 
243
+ Class variables are visible in all task instance methods and in subcommand subclasses — unlike class instance variables (`@name`), which are not accessible inside instance methods.
244
+
216
245
  ---
217
246
 
218
247
  ## Helper methods
@@ -221,13 +250,13 @@ Private methods are callable from any task in the same class but are never regis
221
250
 
222
251
  ```ruby
223
252
  class Tasks
224
- desc "build", "Compile and package"
253
+ desc "Compile and package"
225
254
  def build
226
255
  compile("src")
227
256
  package(version)
228
257
  end
229
258
 
230
- desc "release", "Build and publish"
259
+ desc "Build and publish"
231
260
  def release
232
261
  build
233
262
  sh "gem push pkg/myapp-#{version}.gem"
@@ -263,7 +292,7 @@ require_relative "shared/helpers"
263
292
  class Tasks
264
293
  include BuildHelpers
265
294
 
266
- desc "build", "Compile the project"
295
+ desc "Compile the project"
267
296
  def build = compile("src")
268
297
  end
269
298
  ```
@@ -297,7 +326,7 @@ end
297
326
 
298
327
  ```ruby
299
328
  class Tasks
300
- desc "setup", "Bootstrap the development environment"
329
+ desc "Bootstrap the development environment"
301
330
  def setup
302
331
  sh <<~SHELL
303
332
  brew install redis postgresql
@@ -307,7 +336,7 @@ class Tasks
307
336
  SHELL
308
337
  end
309
338
 
310
- desc "analyze", "Run Python data analysis"
339
+ desc "Run Python data analysis"
311
340
  def analyze
312
341
  shebang :python3, <<~PYTHON
313
342
  import json
@@ -316,7 +345,7 @@ class Tasks
316
345
  PYTHON
317
346
  end
318
347
 
319
- desc "bundle_assets", "Build frontend assets with esbuild"
348
+ desc "Build frontend assets with esbuild"
320
349
  def bundle_assets
321
350
  shebang :node, <<~JS
322
351
  const esbuild = require("esbuild")
@@ -328,28 +357,45 @@ end
328
357
 
329
358
  Supported interpreters: `:python3`, `:python`, `:node`, `:ruby`, `:perl`, `:bash`, `:sh`. Any other symbol is passed directly to `system` with a `.tmp` extension.
330
359
 
331
- Pass `silent: true` to suppress the command echo:
360
+ Pass `silent: true` to both `sh` and `shebang` to suppress the script echo:
332
361
 
333
362
  ```ruby
334
- def build = sh "rake build", silent: true
363
+ def build = sh "rake build", silent: true
364
+ def analyze = shebang :python3, <<~PY, silent: true
365
+ import json
366
+ print(json.load(open("data.json")))
367
+ PY
335
368
  ```
336
369
 
337
370
  ---
338
371
 
339
372
  ## Environment variables
340
373
 
341
- `dotenv` loads a `.env` file into the environment before tasks run:
374
+ `dotenv` loads a `.env` file into the environment before tasks run. Use the `env` Kernel method to read environment variables inside task bodies — it accepts a symbol or string and upcases the key automatically:
342
375
 
343
376
  ```ruby
344
377
  class Tasks
345
378
  dotenv # loads .env
346
379
  dotenv ".env.local" # or a specific file
347
380
 
348
- desc "check", "Print the app name from .env"
349
- def check = sh "echo $APP_NAME"
381
+ desc "Start the server"
382
+ def start
383
+ sh "puma -p #{env(:port, '3000')} -e #{env(:rack_env, 'development')}"
384
+ end
385
+
386
+ desc "Deploy the app"
387
+ def deploy
388
+ sh "cap #{env(:deploy_target)} deploy" # raises KeyError if DEPLOY_TARGET is unset
389
+ end
350
390
  end
351
391
  ```
352
392
 
393
+ | Call | Behaviour |
394
+ |------|-----------|
395
+ | `env(:port, "3000")` | Returns `"3000"` when `PORT` is unset |
396
+ | `env(:api_key)` | Raises `KeyError` when `API_KEY` is missing |
397
+ | `env("DATABASE_URL")` | String name works too — always upcased |
398
+
353
399
  ---
354
400
 
355
401
  ## Command aliases
@@ -362,7 +408,7 @@ class Tasks
362
408
  map "--v" => "version"
363
409
  map "t" => "test"
364
410
 
365
- desc "version", "Print the version"
411
+ desc "Print the version"
366
412
  def version = puts Asgard::VERSION
367
413
  end
368
414
  ```
@@ -375,10 +421,10 @@ Group related tasks under a common name using Thor's `subcommand` method. Define
375
421
 
376
422
  ```ruby
377
423
  class DeployCommands < Tasks
378
- desc "staging", "Deploy to staging"
424
+ desc "Deploy to staging"
379
425
  def staging = sh "cap staging deploy"
380
426
 
381
- desc "production", "Deploy to production"
427
+ desc "Deploy to production"
382
428
  def production = sh "cap production deploy"
383
429
  end
384
430
 
@@ -394,20 +440,20 @@ asgard deploy staging
394
440
  asgard deploy production
395
441
  ```
396
442
 
397
- Subcommand tasks have all the same access to helper methods like `sh`, `shebang`, `depends_on`, `var`, and the built-in `--debug`/`--verbose` class options as normal tasks.
443
+ Subcommand tasks have all the same access to `sh`, `shebang`, `depends_on`, and the built-in `--debug`/`--verbose` class options as normal tasks. `@@` class variables declared on `Tasks` are also visible in subcommand subclasses.
398
444
 
399
445
  `depends_on` only works within a subcommand group exactly as it does at the top level:
400
446
 
401
447
  ```ruby
402
448
  class DBCommands < Tasks
403
- desc "migrate", "Run pending migrations"
449
+ desc "Run pending migrations"
404
450
  def migrate = sh "rails db:migrate"
405
451
 
406
- desc "seed", "Load seed data"
452
+ desc "Load seed data"
407
453
  def seed = sh "rails db:seed"
408
454
 
409
455
  depends_on :migrate, :seed
410
- desc "reset", "Migrate then seed"
456
+ desc "Migrate then seed"
411
457
  def reset = puts "Done."
412
458
  end
413
459
 
@@ -443,7 +489,7 @@ Common `method_option` keys: `aliases`, `type`, `default`, `required`, `desc`, `
443
489
 
444
490
  ## Task files
445
491
 
446
- Asgard searches the current directory and its ancestors for a `.loki` file. That file marks the project root. All `*.loki` files in the same directory are auto-loaded alphabetically before `.loki` is loaded.
492
+ Asgard searches the current directory and its ancestors for a `.loki` file. That file marks the project root. Additional `*.loki` files are loaded only when your `.loki` file explicitly calls `import`.
447
493
 
448
494
  ### Single file
449
495
 
@@ -458,16 +504,23 @@ Split tasks across files — each reopens `class Tasks`:
458
504
 
459
505
  ```
460
506
  myproject/
461
- .loki ← entry point, can be empty
507
+ .loki ← entry point; calls import to load task files
462
508
  build.loki
463
509
  deploy.loki
464
510
  test.loki
465
511
  ```
466
512
 
513
+ The `.loki` entry file must call `import` to load the other task files. It also marks the project root for auto-discovery:
514
+
515
+ ```ruby
516
+ # .loki
517
+ import "*.loki"
518
+ ```
519
+
467
520
  ```ruby
468
521
  # build.loki
469
522
  class Tasks
470
- desc "build", "Compile the project"
523
+ desc "Compile the project"
471
524
  def build = sh "rake build"
472
525
  end
473
526
  ```
@@ -476,7 +529,7 @@ end
476
529
  # test.loki
477
530
  class Tasks
478
531
  depends_on :build
479
- desc "test", "Run the test suite"
532
+ desc "Run the test suite"
480
533
  def test = sh "bundle exec rake test"
481
534
  end
482
535
  ```
@@ -485,21 +538,21 @@ end
485
538
  # deploy.loki
486
539
  class Tasks
487
540
  depends_on :test
488
- desc "deploy", "Deploy to production"
541
+ desc "Deploy to production"
489
542
  def deploy = sh "cap production deploy"
490
543
  end
491
544
  ```
492
545
 
493
- The `.loki` entry point can be completely empty — it only needs to exist to mark the project root.
546
+ In a single-file project, `.loki` can be completely empty — its presence alone marks the project root. In a multi-file project, add at least an `import` call.
494
547
 
495
548
  ### Explicit loading
496
549
 
497
- Load any Ruby or `.loki` file manually from `.loki`:
550
+ Load any Ruby or `.loki` file manually from `.loki`. Use `require_relative` for plain Ruby; use `import` for `.loki` files (it enforces the extension and is idempotent):
498
551
 
499
552
  ```ruby
500
553
  # .loki
501
554
  require_relative "shared/helpers"
502
- require_relative "ci.loki"
555
+ import "ci.loki"
503
556
 
504
557
  class Tasks
505
558
  # additional tasks
@@ -514,9 +567,8 @@ end
514
567
  |---|---|
515
568
  | `Asgard.run!(argv)` | Entry point — finds `.loki`, loads task files, starts CLI |
516
569
  | `Asgard.find_task_file` | Returns path to `.loki` searching from CWD upward, or nil |
517
- | `Asgard.load_loki(dir)` | Loads all `*.loki` files in dir alphabetically |
518
570
 
519
- `run!` handles its own errors — a missing `.loki` or a circular dependency both produce a clean one-line message and exit 1.
571
+ `run!` handles its own errors — a missing `.loki`, a circular dependency, or a `depends_on` that names a task that doesn't exist all produce a clean one-line message and exit 1.
520
572
 
521
573
  ---
522
574
 
data/Rakefile CHANGED
@@ -3,7 +3,7 @@
3
3
  require "bundler/gem_tasks"
4
4
  require "minitest/test_task"
5
5
 
6
- SIMPLECOV_PRELUDE = <<~RUBY.freeze
6
+ SIMPLECOV_PRELUDE = <<~RUBY
7
7
  require "simplecov"
8
8
  SimpleCov.start do
9
9
  add_filter "/test/"
@@ -15,8 +15,87 @@ Minitest::TestTask.create do |t|
15
15
  t.test_prelude = SIMPLECOV_PRELUDE
16
16
  end
17
17
 
18
- task quality: :test do
19
- sh "flog lib/"
18
+ task default: :test
19
+
20
+ RUBOCOP_ENV = { "RUBOCOP_CACHE_ROOT" => "tmp/rubocop_cache" }.freeze
21
+
22
+ desc "Check code style with RuboCop"
23
+ task :rubocop do
24
+ sh RUBOCOP_ENV, "bundle exec rubocop"
20
25
  end
21
26
 
22
- task default: :test
27
+ desc "Auto-correct RuboCop offenses"
28
+ task :rubocop_fix do
29
+ sh RUBOCOP_ENV, "bundle exec rubocop -a"
30
+ end
31
+
32
+ desc "Check code complexity with Flog (warn >=20, fail >=50)"
33
+ task :flog_check do
34
+ require "flog"
35
+
36
+ # Target to work toward; methods above this are warned but don't fail the gate.
37
+ METHOD_WARN = 20.0
38
+ # Current baseline floor — established from first run. Reduce incrementally.
39
+ METHOD_FAIL = 50.0
40
+
41
+ flogger = Flog.new(all: true)
42
+ flogger.flog(*Dir.glob("lib/**/*.rb"))
43
+
44
+ warnings = []
45
+ failures = []
46
+
47
+ flogger.each_by_score do |method, score|
48
+ next if method.end_with?("#none")
49
+ if score > METHOD_FAIL
50
+ failures << "#{"%.1f" % score}: #{method}"
51
+ elsif score > METHOD_WARN
52
+ warnings << "#{"%.1f" % score}: #{method}"
53
+ end
54
+ end
55
+
56
+ unless warnings.empty?
57
+ puts "\nFlog warnings (#{METHOD_WARN}–#{METHOD_FAIL}) — target for future refactoring:"
58
+ warnings.each { |v| puts " #{v}" }
59
+ end
60
+
61
+ if failures.empty?
62
+ puts "\nFlog: no methods exceed the failure threshold (>=#{METHOD_FAIL})"
63
+ else
64
+ puts "\nFlog failures (>=#{METHOD_FAIL}) — must be refactored:"
65
+ failures.each { |v| puts " #{v}" }
66
+ $stdout.flush
67
+ abort "\nFlog quality gate failed: #{failures.size} method(s) exceed #{METHOD_FAIL}"
68
+ end
69
+ end
70
+
71
+ desc "Run all quality checks: tests (with coverage), RuboCop, and Flog"
72
+ task :quality do
73
+ results = {}
74
+
75
+ puts "\n#{"=" * 60}"
76
+ puts "Quality Gate: Tests + Coverage"
77
+ puts "=" * 60
78
+ results[:tests] = system("bundle exec rake test") ? :pass : :fail
79
+
80
+ puts "\n#{"=" * 60}"
81
+ puts "Quality Gate: RuboCop"
82
+ puts "=" * 60
83
+ results[:rubocop] = system(RUBOCOP_ENV, "bundle exec rubocop") ? :pass : :fail
84
+
85
+ puts "\n#{"=" * 60}"
86
+ puts "Quality Gate: Flog Complexity"
87
+ puts "=" * 60
88
+ results[:flog] = system("bundle exec rake flog_check") ? :pass : :fail
89
+
90
+ puts "\n#{"=" * 60}"
91
+ puts "Quality Summary"
92
+ puts "=" * 60
93
+ results.each do |gate, status|
94
+ icon = status == :pass ? "PASS" : "FAIL"
95
+ puts " [#{icon}] #{gate}"
96
+ end
97
+ puts "=" * 60
98
+
99
+ abort "\nQuality gate failed" if results.values.any?(:fail)
100
+ puts "\nAll quality gates passed."
101
+ end