asgard 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b6045b69a572bacdc07c1ae149f2978000fb690c180cf536512caf4714345317
4
- data.tar.gz: defa5899f4e66143fe3118d835c60f128534075d2ce4a41652a0bbf54f61e790
3
+ metadata.gz: d5752f0e0d294dfb08b809b62b0fbcb4d26f281c6c6a9ddc4ca2b9ae9c310cfc
4
+ data.tar.gz: 918cbb04f6b285b0d96f674b963cd98c899f151159a73e8ccba72824c8c485f5
5
5
  SHA512:
6
- metadata.gz: 8d27eb2c214be342a64561c8f2287cd05aa48a30e411676dbd69b20f36ec9b706456d7c805c3bfa3a4b64897b8f79831a0a55b96c38b77e3981de551c8737750
7
- data.tar.gz: 6bbd01f8c90195fc9b7428dbe7b56721a70d9ffe4b881853497df84c68db7d96400cb279ab9006ff3b6841ab85bd5f54d6838f3d887469331dbe04873bd5dca5
6
+ metadata.gz: d4a32a84315d0367316ea813f5106d55e54c465f8d1ff1ca6958955fd8c23153483a0002a5396af63ad980ec37dacba6c2b3b311516901ff6bd0dc8ecd4dea37
7
+ data.tar.gz: 87b809a6e2900d22bec4cff5d9b133a4620bf6c2972312c02243bea916aa6cc01dcf668c3ea80786d710ddf39e7e5c3b773d35ec27577253b131a2e0463e1b39
data/.loki CHANGED
@@ -2,33 +2,22 @@
2
2
  # Asgard gem's own task file.
3
3
  # Task is pre-defined by the gem — just reopen it to add tasks.
4
4
 
5
- class Tasks
6
- @@gem_name ||= "asgard".freeze
7
-
8
- desc "Run the test suite"
9
- def test
10
- sh "bundle exec rake test"
11
- end
5
+ import "quality.loki"
6
+ import "gem_tasks.loki"
12
7
 
13
- desc "Run all quality gates (tests, RuboCop, Flog)"
14
- def quality
15
- sh "bundle exec rake quality"
16
- end
8
+ class Tasks
9
+ @@project ||= "asgard".freeze
10
+ @@project_desc ||= "CLI-task runner"
17
11
 
18
- desc "Build the gem package"
19
- def build
20
- sh "bundle exec rake build"
12
+ helper(:project_version) do
13
+ @@project_version ||= File.read("lib/#{@@project}/version.rb").match(/VERSION\s*=\s*"([^"]+)"/)[1].freeze
21
14
  end
22
15
 
23
- depends_on :test
24
- desc "Build and install gem locally"
25
- def install
26
- sh "bundle exec rake install"
27
- end
16
+ default_task :quality
28
17
 
29
- depends_on :quality
30
- desc "Release to RubyGems"
31
- def release
32
- sh "bundle exec rake release"
33
- end
18
+ header <<~HEAD
19
+ Porject: #{@@project} (v#{project_version}) - #{@@project_desc}
20
+ Root Dir: #{loki_up.parent}
21
+ Default task: #{default_task}
22
+ HEAD
34
23
  end
data/CHANGELOG.md CHANGED
@@ -5,10 +5,50 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [0.3.0] - Unreleased
8
+ ## [0.3.1] - Unreleased
9
9
 
10
10
  ### Added
11
11
 
12
+ - **`helper(name, &block)` DSL method on `Asgard::Base`** — defines a method available in both class context (e.g. inside `header` or `footer`) and as a private instance method inside task bodies, with a single declaration. Eliminates the manual `def self.name` + `no_commands { private def name = self.class.name }` boilerplate. Supports positional arguments, keyword arguments, default values, and block arguments.
13
+ ```ruby
14
+ class Tasks
15
+ @@project ||= "myapp".freeze
16
+
17
+ helper(:version) {
18
+ File.read("lib/myapp/version.rb").match(/VERSION\s*=\s*"([^"]+)"/)[1].freeze
19
+ }
20
+
21
+ header "#{@@project} v#{version}" # class context
22
+
23
+ desc "Show the current version"
24
+ def show_version = puts version # instance context
25
+ end
26
+ ```
27
+
28
+ - **`header(text)` and `footer(text)` DSL methods on `Asgard::Base`** — attach static text to the general help output. `header` lines are printed above the commands list; `footer` lines are printed below the options block. Multiple calls accumulate: each `header` call appends a line, each `footer` call prepends a line, so content from later-loaded files naturally wraps around content from earlier files. Neither appears when `asgard help <command>` is called for per-command detail.
29
+ ```ruby
30
+ class Tasks
31
+ header "my-project — build & release tasks"
32
+ footer "See https://example.com/docs for details"
33
+ end
34
+ ```
35
+ - **`no_negate(*names)` DSL method on `Asgard::Base`** — suppresses the `[--no-name]` and `[--skip-name]` negation variants from help output for boolean class options where negation is meaningless. Call it after the `class_option` declaration:
36
+ ```ruby
37
+ class_option :version, type: :boolean, default: false, desc: "Show version and exit"
38
+ no_negate :version
39
+ ```
40
+
41
+ ### Changed
42
+
43
+ - **`--version` reimplemented as a `class_option`** — the flag now appears in the "Options" section of `asgard help` alongside `--debug` and `--verbose`, rather than as a listed command. The `_version` method and its `map "--version" => :_version` registration have been removed. The actual early-exit behaviour still lives in `Asgard.run!` (before the `.loki` file is required), so `--version` works even when no `.loki` file exists. `no_negate :version` suppresses the spurious `[--no-version]` / `[--skip-version]` variants.
44
+ - **`loki_up` returns `Pathname` instead of `String`** — the return value is now a `Pathname` instance (or `nil` when not found). `Pathname` is accepted everywhere `loki_up`'s result is used: `import`, `dotenv`, `load`, and standard Ruby file methods all accept `Pathname` via `to_path` / `to_s`. Code that passes the result directly to those methods is unaffected; code that performs string operations on the path should call `.to_s` first.
45
+
46
+ ### Fixed
47
+
48
+ - **Help output showed `tasks` prefix before every command** — `asgard help` displayed `asgard tasks build` instead of `asgard build`. The cause was a keyword-vs-positional mismatch in the `Asgard::Base#help` override: declaring `subcommand: false` as a keyword argument caused Ruby's `super` to forward it as the hash `{subcommand: false}`. Thor's `banner` method received this hash as the positional `subcommand` argument, treated it as truthy, and prepended the class namespace (`tasks`) to every command name. Fixed by changing the override signature to match Thor's positional signature: `def help(command = nil, subcommand = false)`.
49
+
50
+ ### Added (continued)
51
+
12
52
  - **`loki_up(name = ".loki")` Kernel method** — searches `Dir.pwd` and each ancestor directory for a file with the given name; returns the absolute path of the first match or `nil`. Available everywhere in Ruby (task bodies, `.loki` files, top-level code) as a `module_function` on `Kernel`.
13
53
  - **`import(path)` Kernel method** — loads a `.loki` file (or a glob of `.loki` files) with `require`-like idempotency via `$LOADED_FEATURES`. Accepts a `String` or `Pathname`. Relative paths are resolved relative to the caller's file (like `require_relative`). Glob patterns (`*.loki`, `**/*.loki`) expand via `Dir.glob` and load all matches. Returns `true` if any file was newly loaded, `false` if all were already loaded or no glob matches were found. Raises `ArgumentError` if the path does not end with `.loki`.
14
54
  - **`import_up(name = ".loki")` Kernel method** — combines `loki_up` and `import`. For exact names, finds the first ancestor directory containing that file and loads it. For glob names, finds the first ancestor directory containing any matching files and loads them all — stopping at that level rather than aggregating across multiple ancestors. Returns `false` if nothing is found.
@@ -16,7 +56,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
16
56
  - **`env(name, default = nil)` Kernel method** — fetches a system environment variable by symbol or string name, upcasing the key automatically. `env(:port, "3000")` returns `"3000"` when `PORT` is unset; `env(:api_key)` raises `KeyError` when `API_KEY` is missing and no default is provided. Accepts both `env(:port)` and `env("PORT")` forms. Cleaner than `ENV['PORT']` in task bodies.
17
57
  - **Verbose/debug feedback for `import` and `import_up`** — when `verbose?` is true, each file loaded is printed to stderr. When `debug?` is true, already-loaded files are also reported (with an "already loaded" suffix), and `import_up` reports when a file is not found.
18
58
  - **RuboCop lint gate** — RuboCop is now a first-class quality gate alongside tests and Flog. Added `rubocop` to the Gemfile, a `.rubocop.yml` tuned for this codebase (Ruby 3.2 target, relaxed `Metrics` thresholds consistent with Flog as the primary complexity gate, `examples/` excluded), and `rake rubocop` / `rake rubocop_fix` tasks backed by a `tmp/rubocop_cache` directory for fast re-runs.
19
- - **Expanded `rake quality` task** — `quality` now runs three independent gates (tests + coverage, RuboCop, Flog) and prints a formatted pass/fail summary table after all gates complete, so every failure is visible in a single run rather than stopping at the first.
59
+ - **Expanded `rake quality` task** — `quality` now runs three independent gates (tests + coverage, RuboCop, Flog) in parallel using `depends_on [:test, :rubocop, :flog_check]`. Each gate captures its pass/fail result in an instance variable; output is suppressed on pass and filtered to failures only on fail, preventing interleaved output from concurrent subprocesses. A formatted pass/fail summary table is printed after all gates complete, so every failure is visible in a single run.
20
60
  - **`rake flog_check` task** — replaces the bare `flog lib/` call with a structured task that enforces per-method thresholds (warn ≥20, fail ≥50), lists warnings and failures in separate sections, and exits non-zero only when the failure threshold is breached.
21
61
  - **Single-argument `desc` shorthand** — `desc` now accepts one string (the description) with the usage string omitted. The usage defaults to the method name, eliminating the redundant first argument for the common case:
22
62
  ```ruby
data/README.md CHANGED
@@ -18,11 +18,12 @@
18
18
  - <strong>Concurrent Execution</strong> — parallel task groups run in native Ruby threads<br>
19
19
  - <strong>Subcommands</strong> — group related tasks under a named namespace<br>
20
20
  - <strong>Variables</strong> — shared configuration via Ruby class variables (<code>@@name</code>), visible across all tasks and subcommands<br>
21
+ - <strong>`helper` DSL</strong> — define a method once, available in both class-level DSL calls (<code>header</code>) and inside task instance methods<br>
21
22
  - <strong>Shell Helpers</strong> — <code>sh</code> for any shell command or heredoc; <code>shebang</code> for polyglot scripts<br>
22
23
  - <strong>Dotenv Support</strong> — load <code>.env</code> files into the environment with <code>dotenv</code><br>
23
24
  - <strong>Auto-Discovery</strong> — <code>.loki</code> root marker searched from CWD upward through parent directories<br>
24
25
  - <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
+ - <strong>Built-in Flags</strong> — <code>--debug</code>, <code>--verbose</code>, and <code>--version</code> built-in class options; header/footer DSL for static help text<br>
26
27
  </td>
27
28
  </tr>
28
29
  </table>
@@ -274,6 +275,40 @@ class Tasks
274
275
  end
275
276
  ```
276
277
 
278
+ ### The `helper` DSL method
279
+
280
+ Some values need to be available in both class context (e.g. inside `header`) and inside task instance methods. `helper` defines the method once in both contexts:
281
+
282
+ ```ruby
283
+ class Tasks
284
+ @@project ||= "myapp".freeze
285
+
286
+ helper(:version) {
287
+ File.read("lib/myapp/version.rb").match(/VERSION\s*=\s*"([^"]+)"/)[1].freeze
288
+ }
289
+
290
+ header "#{@@project} v#{version}" # class context
291
+
292
+ desc "Show the current version"
293
+ def show_version
294
+ puts version # instance context
295
+ end
296
+ end
297
+ ```
298
+
299
+ Without `helper`, achieving this requires two separate definitions:
300
+
301
+ ```ruby
302
+ def self.version = File.read(...).match(...)[1].freeze
303
+ no_commands { private def version = self.class.version }
304
+ ```
305
+
306
+ `helper` accepts positional arguments, keyword arguments, and blocks — any signature valid in a Ruby method definition:
307
+
308
+ ```ruby
309
+ helper(:tag) { |name, ver, prefix: "v"| "#{prefix}#{name}-#{ver}" }
310
+ ```
311
+
277
312
  Helpers can also be shared across multiple `.loki` files by extracting them into a plain Ruby file and loading it explicitly:
278
313
 
279
314
  ```ruby
@@ -299,6 +334,53 @@ end
299
334
 
300
335
  ---
301
336
 
337
+ ## Help header and footer
338
+
339
+ Add static text above and below the command list in `asgard help` output:
340
+
341
+ ```ruby
342
+ class Tasks
343
+ header "my-project — build & release tasks"
344
+ footer "See https://example.com/docs for details"
345
+ end
346
+ ```
347
+
348
+ Multiple calls accumulate. `header` appends each line (top to bottom); `footer` prepends each line (bottom to top), so content from a later-loaded `.loki` file sits closer to the commands:
349
+
350
+ ```ruby
351
+ # .loki
352
+ class Tasks
353
+ header "my-project"
354
+ footer "Maintainer: you@example.com"
355
+ end
356
+
357
+ import "*.loki"
358
+
359
+ # deploy.loki
360
+ class Tasks
361
+ header " deploy targets: staging, production"
362
+ footer "See runbook at wiki/deploy"
363
+ end
364
+ ```
365
+
366
+ ```
367
+ my-project
368
+ deploy targets: staging, production
369
+
370
+ Commands:
371
+ ...
372
+
373
+ Options:
374
+ ...
375
+
376
+ See runbook at wiki/deploy
377
+ Maintainer: you@example.com
378
+ ```
379
+
380
+ Header and footer text is only shown for the general `asgard help` page, not for `asgard help <command>`.
381
+
382
+ ---
383
+
302
384
  ## Options shared across all tasks
303
385
 
304
386
  `class_option` defines an option available to every task in the class:
@@ -566,7 +648,7 @@ end
566
648
  | Method | Description |
567
649
  |---|---|
568
650
  | `Asgard.run!(argv)` | Entry point — finds `.loki`, loads task files, starts CLI |
569
- | `Asgard.find_task_file` | Returns path to `.loki` searching from CWD upward, or nil |
651
+ | `Asgard.find_task_file` | Returns a `Pathname` to `.loki` searching from CWD upward, or `nil` |
570
652
 
571
653
  `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.
572
654
 
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
@@ -113,6 +113,9 @@ import_up "*.loki" # find the nearest ancestor with *.loki files
113
113
  |---|---|---|
114
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. |
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
121
  | `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. |
@@ -137,7 +140,7 @@ depends_on :setup, [:lint, :build], :test # setup, then lint+build concurrently
137
140
  |---|---|---|
138
141
  | `class_option :debug` | class option | `--debug` flag. Sets `$DEBUG = true` before any task runs. Boolean, default `false`. |
139
142
  | `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. |
143
+ | `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. |
141
144
  | `debug?` | Kernel module function | Returns `$DEBUG`. Available everywhere via `Kernel`. |
142
145
  | `verbose?` | Kernel module function | Returns `$VERBOSE`. Available everywhere via `Kernel`. |
143
146
 
data/docs/changelog.md CHANGED
@@ -8,6 +8,14 @@ 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
+ - **`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).
14
+
15
+ ### Changed
16
+
17
+ - **`quality` task** — all three gates (`test`, `rubocop`, `flog_check`) now run in parallel. Each gate captures its own pass/fail result; output is suppressed on pass and filtered to failures only on fail. A summary table is printed after all gates complete.
18
+
11
19
  ### Removed
12
20
 
13
21
  - **`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).
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/options.md CHANGED
@@ -71,19 +71,53 @@ 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: "Colorise output"
84
+ no_negate :color
85
+ end
86
+ ```
87
+
88
+ Help output before `no_negate`:
89
+
90
+ ```
91
+ [--color], [--no-color], [--skip-color] # Colorise output
92
+ ```
93
+
94
+ Help output after `no_negate`:
95
+
96
+ ```
97
+ [--color] # Colorise 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 three built-in `class_option` declarations — `--debug`, `--verbose`, and `--version` — 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
 
89
123
  ### `--debug`
@@ -173,8 +207,8 @@ asgard deploy production --verbose
173
207
  Methods whose names start with `_` are considered gem-owned in Asgard's naming convention. `run!` guards against invoking them directly from the CLI:
174
208
 
175
209
  ```bash
176
- asgard _version
177
- # asgard: unknown command '_version'
210
+ asgard _something
211
+ # asgard: unknown command '_something'
178
212
  ```
179
213
 
180
- If you define your own methods on `Tasks`, avoid the `_` prefix to prevent them from being silently blocked.
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.
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 |
@@ -1,29 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
  # Demonstrates Thor subcommands registered on the top-level Tasks class.
3
3
  #
4
- # The subcommand class inherits from Tasks so it has access to sh, shebang,
5
- # depends_on, and the built-in --debug/--verbose class options.
4
+ # Port state is persisted to .server.port so stop and restart always
5
+ # operate on the same port the server was started on.
6
6
  #
7
7
  # Usage:
8
- # asgard server # shows subcommand help
8
+ # asgard server # shows subcommand help
9
9
  # asgard server start
10
- # asgard server start 4000 --workers 4 --daemon
10
+ # asgard server start -p 8000
11
+ # asgard server start -p 4000 --workers 4 --daemon
12
+ # asgard server stop
11
13
  # asgard server stop --force
12
14
  # asgard server status
13
- # asgard server restart 4000
15
+ # asgard server restart # stops then starts on the persisted port
16
+
17
+ SERVER_PORT_FILE = "tmp/.server.port".freeze
14
18
 
15
19
  class ServerCommands < Tasks
16
- desc "start [PORT]", "Start the server on PORT (default: 3000)"
17
- option :daemon, aliases: "-d", type: :boolean, default: false, desc: "Run as a background daemon"
18
- option :workers, aliases: "-w", type: :numeric, default: 2, desc: "Number of worker processes"
20
+ default_task :help # This is the default value for the default_task
21
+
22
+ helper(:server_port) {
23
+ File.exist?(SERVER_PORT_FILE) ? File.read(SERVER_PORT_FILE).strip.to_i : 3000
24
+ }
25
+
26
+ desc "Start the server"
27
+ option :port, aliases: "-p", type: :numeric, default: 3000, desc: "Port to listen on"
28
+ option :daemon, aliases: "-d", type: :boolean, default: false, desc: "Run as a background daemon"
29
+ option :workers, aliases: "-w", type: :numeric, default: 2, desc: "Number of worker processes"
19
30
  option :log, type: :string, default: "log/server.log",
20
31
  banner: "FILE", desc: "Write logs to FILE"
21
- def start(port = "3000")
22
- puts "Starting server on :%s with %d workers%s" % [
23
- port,
24
- options[:workers],
25
- options[:daemon] ? " (daemon)" : ""
26
- ]
32
+ def start
33
+ FileUtils.mkdir_p("tmp")
34
+ File.write(SERVER_PORT_FILE, options[:port].to_s)
35
+ puts "Starting server on :#{options[:port]} with #{options[:workers]} workers#{options[:daemon] ? " (daemon)" : ""}..."
27
36
  end
28
37
 
29
38
  desc "Stop the running server"
@@ -31,22 +40,22 @@ class ServerCommands < Tasks
31
40
  option :wait, type: :numeric, default: 30, desc: "Seconds to wait for shutdown"
32
41
  def stop
33
42
  if options[:force]
34
- puts "Force-stopping server..."
43
+ puts "Force-stopping server on :#{server_port}..."
35
44
  else
36
- puts "Gracefully stopping server (timeout: #{options[:wait]}s)..."
45
+ puts "Gracefully stopping server on :#{server_port} (timeout: #{options[:wait] || 30}s)..."
37
46
  end
38
47
  end
39
48
 
40
49
  desc "Show server status and process info"
41
50
  def status
42
- puts "Checking server status..."
51
+ puts "Server is listening on :#{server_port}"
43
52
  end
44
53
 
45
- # depends_on works inside subcommand groups — stop runs before start
46
- depends_on :stop, :start
47
- desc "restart [PORT]", "Restart the server on PORT (stop, then start)"
48
- def restart(port = "3000")
49
- puts "Server restarted on port #{port}."
54
+ depends_on :stop
55
+ desc "Restart the server on the same port it was started on"
56
+ def restart
57
+ puts "Starting server on :#{server_port}..."
58
+ puts "Server restarted."
50
59
  end
51
60
  end
52
61
 
data/gem_tasks.loki ADDED
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+ # Gem lifecycle tasks — imported by .loki
3
+
4
+ class Tasks
5
+ desc "Build the gem package"
6
+ depends_on :quality
7
+ def build
8
+ sh "mkdir -p pkg"
9
+ sh "gem build asgard.gemspec"
10
+ sh "mv asgard-#{project_version}.gem pkg/"
11
+ end
12
+
13
+ desc "Build and install gem locally"
14
+ depends_on :build
15
+ def install
16
+ sh "gem install pkg/asgard-#{project_version}.gem"
17
+ end
18
+
19
+ desc "Release to RubyGems"
20
+ depends_on :quality
21
+ def release
22
+ tag = "v#{project_version}"
23
+ gem_file = "pkg/asgard-#{project_version}.gem"
24
+
25
+ abort "Working directory is not clean — commit or stash changes first." unless `git status --porcelain`.strip.empty?
26
+ abort "Tag #{tag} already exists." unless `git tag -l #{tag}`.strip.empty?
27
+
28
+ sh "mkdir -p pkg"
29
+ sh "gem build asgard.gemspec"
30
+ sh "mv asgard-#{project_version}.gem pkg/"
31
+ sh "git tag #{tag}"
32
+ sh "git push origin #{tag}"
33
+ sh "gem push #{gem_file}"
34
+ end
35
+ end
data/lib/asgard/base.rb CHANGED
@@ -92,17 +92,46 @@ module Asgard
92
92
  end
93
93
  end
94
94
 
95
+ # Suppress [--no-name] / [--skip-name] from help for boolean class options
96
+ # where negation is meaningless. Call after class_option declarations.
97
+ def no_negate(*names)
98
+ names.each do |name|
99
+ opt = class_options[name]
100
+ next unless opt
101
+ opt.define_singleton_method(:usage) do |padding = 0|
102
+ aliases_for_usage.ljust(padding) + "[#{switch_name}]"
103
+ end
104
+ end
105
+ end
106
+
107
+ def header(text = nil)
108
+ return @_header if text.nil?
109
+ (@_header ||= []) << text
110
+ end
111
+
112
+ def footer(text = nil)
113
+ return @_footer if text.nil?
114
+ (@_footer ||= []).unshift(text)
115
+ end
116
+
95
117
  def dotenv(path = ".env")
96
118
  require "dotenv"
97
119
  Dotenv.load(path) if File.exist?(path)
98
120
  end
99
121
 
122
+ def helper(name, &)
123
+ define_singleton_method(name, &)
124
+ no_commands { private define_method(name) { |*args, **kwargs, &blk| self.class.send(name, *args, **kwargs, &blk) } }
125
+ end
126
+
100
127
  def default_task(meth = nil)
101
128
  if meth && meth != :none && @_default_task_location
102
129
  here = caller_locations(1, 1).first
103
- warn "asgard: default_task :#{meth} at #{here.path}:#{here.lineno} " \
104
- "overrides default_task :#{@_default_task_name} set at " \
105
- "#{@_default_task_location.path}:#{@_default_task_location.lineno}"
130
+ # rubocop:disable Style/StderrPuts -- warn bypasses $stderr in Ruby 4.0, breaking capture_io in tests
131
+ $stderr.puts "asgard: default_task :#{meth} at #{here.path}:#{here.lineno} " \
132
+ "overrides default_task :#{@_default_task_name} set at " \
133
+ "#{@_default_task_location.path}:#{@_default_task_location.lineno}"
134
+ # rubocop:enable Style/StderrPuts
106
135
  end
107
136
  if meth && meth != :none
108
137
  @_default_task_location = caller_locations(1, 1).first
@@ -184,6 +213,13 @@ module Asgard
184
213
  end
185
214
  end
186
215
 
216
+ def help(command = nil, subcommand = false) # rubocop:disable Style/OptionalBooleanParameter
217
+ say self.class.header.join("\n\n") if self.class.header && command.nil?
218
+ say "\n"
219
+ super
220
+ say self.class.footer.join("\n\n") if self.class.footer && command.nil?
221
+ end
222
+
187
223
  no_commands do
188
224
  # Dispatch hook: resolves and runs all deps (in parallel where declared)
189
225
  # before executing the target command.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "pathname"
4
+
3
5
  module Kernel
4
6
  def debug? = $DEBUG
5
7
  def verbose? = $VERBOSE
@@ -15,11 +17,11 @@ module Kernel
15
17
  module_function :env
16
18
 
17
19
  def loki_up(name = ".loki")
18
- dir = Dir.pwd
20
+ dir = Pathname.new(Dir.pwd)
19
21
  loop do
20
- candidate = File.join(dir, name)
21
- return candidate if File.exist?(candidate)
22
- parent = File.dirname(dir)
22
+ candidate = dir + name
23
+ return candidate if candidate.exist?
24
+ parent = dir.parent
23
25
  break if parent == dir
24
26
  dir = parent
25
27
  end
data/lib/asgard/tasks.rb CHANGED
@@ -4,6 +4,13 @@
4
4
  # It is pre-defined by the gem so .loki files never need to declare a class.
5
5
  # Auxiliary *.loki files define modules which are imported into Tasks.
6
6
  class Tasks < Asgard::Base
7
+ header "\nasgard v#{Asgard::VERSION} The Mighty Thor and Loki working for you"
8
+
9
+ footer <<~FOOT
10
+ \nDocumentation ... https://madbomber.github.io/asgard
11
+ Github Repo ..... https://github.com/MadBomber/asgard\n
12
+ FOOT
13
+
7
14
  class_option :debug,
8
15
  type: :boolean,
9
16
  default: false,
@@ -14,10 +21,9 @@ class Tasks < Asgard::Base
14
21
  default: false,
15
22
  desc: "Enable verbose output ($VERBOSE = true)"
16
23
 
17
- desc "--version", "Show asgard version"
18
- map "--version" => :_version
19
- def _version
20
- puts Asgard::VERSION
21
- exit
22
- end
24
+ class_option :version,
25
+ type: :boolean,
26
+ default: false,
27
+ desc: "Show asgard version and exit"
28
+ no_negate :version
23
29
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Asgard
4
- VERSION = "0.3.0"
4
+ VERSION = "0.3.1"
5
5
  end
data/lib/asgard.rb CHANGED
@@ -19,6 +19,10 @@ module Asgard
19
19
  # Main entry point invoked by the asgard executable.
20
20
  def self.run!(argv)
21
21
  abort "asgard: unknown command '#{argv.first}'" if argv.first&.start_with?("_")
22
+ if argv.include?("--version")
23
+ puts Asgard::VERSION
24
+ exit
25
+ end
22
26
  task_file = find_task_file or abort "asgard: no .loki file found in #{Dir.pwd}"
23
27
  before = Asgard::Base.subclasses.dup
24
28
  load task_file
data/quality.loki ADDED
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+ # Quality gate tasks — imported by .loki
3
+
4
+ class Tasks
5
+ desc "Run the test suite"
6
+ def test
7
+ output = `bundle exec ruby -Ilib:test test/test_asgard.rb 2>&1`
8
+ @test_result = $?.success? ? :pass : :fail
9
+ if @test_result == :fail
10
+ lines = output.lines
11
+ start = lines.index { |l| l =~ /^\s+1\) / } || 0
12
+ finish = lines.rindex { |l| l =~ /\d+ runs,/ } || -1
13
+ print lines[start..finish].join
14
+ end
15
+ end
16
+
17
+ desc "Run all quality gates (tests, RuboCop, Flog) in parallel"
18
+ depends_on [:test, :rubocop, :flog_check]
19
+ def quality
20
+ results = { tests: @test_result, rubocop: @rubocop_result, flog: @flog_result }
21
+
22
+ if @flog_failures&.any?
23
+ puts "\nFlog failures (must be refactored):"
24
+ @flog_failures.each { |v| puts " #{v}" }
25
+ end
26
+
27
+ puts "\n#{"=" * 60}"
28
+ puts "Quality Summary"
29
+ puts "=" * 60
30
+ results.each { |gate, status| puts " [#{status == :pass ? "PASS" : "FAIL"}] #{gate}" }
31
+ puts "=" * 60
32
+
33
+ abort "\nQuality gate failed" if results.values.any?(:fail)
34
+ puts "\nAll quality gates passed."
35
+ end
36
+
37
+ desc "Check code style with RuboCop"
38
+ def rubocop
39
+ output = `RUBOCOP_CACHE_ROOT=tmp/rubocop_cache bundle exec rubocop 2>&1`
40
+ @rubocop_result = $?.success? ? :pass : :fail
41
+ if @rubocop_result == :fail
42
+ output.each_line { |l| print l if l =~ /:\d+:\d+: [CWEF]: / }
43
+ end
44
+ end
45
+
46
+ desc "Auto-correct RuboCop offenses"
47
+ def rubocop_fix
48
+ sh "RUBOCOP_CACHE_ROOT=tmp/rubocop_cache bundle exec rubocop -a"
49
+ end
50
+
51
+ desc "Check code complexity with Flog (warn >=20, fail >=50)"
52
+ def flog_check
53
+ require "flog"
54
+
55
+ method_warn = 20.0
56
+ method_fail = 50.0
57
+
58
+ flogger = Flog.new(all: true)
59
+ flogger.flog(*Dir.glob("lib/**/*.rb"))
60
+
61
+ warnings = []
62
+ @flog_failures = []
63
+
64
+ flogger.each_by_score do |method_name, score|
65
+ next if method_name.end_with?("#none")
66
+ if score > method_fail
67
+ @flog_failures << "#{"%.1f" % score}: #{method_name}"
68
+ elsif score > method_warn
69
+ warnings << "#{"%.1f" % score}: #{method_name}"
70
+ end
71
+ end
72
+
73
+ @flog_result = @flog_failures.empty? ? :pass : :fail
74
+ end
75
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: asgard
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dewayne VanHoozer
@@ -71,7 +71,6 @@ files:
71
71
  - COMMITS.md
72
72
  - LICENSE.txt
73
73
  - README.md
74
- - Rakefile
75
74
  - bin/asgard
76
75
  - bin/console
77
76
  - bin/setup
@@ -101,6 +100,7 @@ files:
101
100
  - examples/subdir/.loki
102
101
  - examples/subdir/import_demo.loki
103
102
  - examples/subdir/import_up_demo.loki
103
+ - gem_tasks.loki
104
104
  - lib/asgard.rb
105
105
  - lib/asgard/base.rb
106
106
  - lib/asgard/kernel_methods.rb
@@ -108,6 +108,7 @@ files:
108
108
  - lib/asgard/tasks.rb
109
109
  - lib/asgard/version.rb
110
110
  - mkdocs.yml
111
+ - quality.loki
111
112
  - sig/asgard.rbs
112
113
  homepage: https://github.com/madbomber/asgard
113
114
  licenses:
data/Rakefile DELETED
@@ -1,101 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bundler/gem_tasks"
4
- require "minitest/test_task"
5
-
6
- SIMPLECOV_PRELUDE = <<~RUBY
7
- require "simplecov"
8
- SimpleCov.start do
9
- add_filter "/test/"
10
- minimum_coverage 95
11
- end
12
- RUBY
13
-
14
- Minitest::TestTask.create do |t|
15
- t.test_prelude = SIMPLECOV_PRELUDE
16
- end
17
-
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"
25
- end
26
-
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