asgard 0.5.1 → 0.6.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.
Files changed (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +179 -99
  3. data/README.md +141 -46
  4. data/bin/asgard +10 -0
  5. data/lib/asgard/base/dispatch.rb +257 -31
  6. data/lib/asgard/base/registry.rb +7 -3
  7. data/lib/asgard/base.rb +140 -9
  8. data/lib/asgard/declarations.rb +21 -0
  9. data/lib/asgard/doctor/task_sections.rb +11 -3
  10. data/lib/asgard/doctor.rb +12 -8
  11. data/lib/asgard/jobs/commands.rb +152 -0
  12. data/lib/asgard/jobs/declaration.rb +149 -0
  13. data/lib/asgard/jobs/launcher.rb +109 -0
  14. data/lib/asgard/jobs/state.rb +190 -0
  15. data/lib/asgard/jobs/stopper.rb +141 -0
  16. data/lib/asgard/jobs.rb +25 -0
  17. data/lib/asgard/kernel_methods.rb +19 -14
  18. data/lib/asgard/leading_flag.rb +10 -0
  19. data/lib/asgard/pid.rb +130 -0
  20. data/lib/asgard/schedule/commands.rb +39 -11
  21. data/lib/asgard/schedule/declaration.rb +139 -12
  22. data/lib/asgard/schedule/job_files.rb +24 -0
  23. data/lib/asgard/schedule/launchd.rb +55 -18
  24. data/lib/asgard/schedule/systemd.rb +69 -27
  25. data/lib/asgard/schedule/table.rb +34 -16
  26. data/lib/asgard/schedule.rb +49 -9
  27. data/lib/asgard/shell.rb +372 -44
  28. data/lib/asgard/state_dir.rb +84 -0
  29. data/lib/asgard/tasks.rb +8 -0
  30. data/lib/asgard/version.rb +1 -1
  31. data/lib/asgard/watch/changes.rb +26 -1
  32. data/lib/asgard/watch/commands.rb +52 -12
  33. data/lib/asgard/watch/daemon.rb +334 -43
  34. data/lib/asgard/watch/declaration.rb +135 -40
  35. data/lib/asgard/watch/dispatcher.rb +61 -10
  36. data/lib/asgard/watch/mailbox.rb +50 -0
  37. data/lib/asgard/watch/reporter.rb +50 -34
  38. data/lib/asgard/watch/spawn.rb +41 -16
  39. data/lib/asgard/watch/state.rb +93 -27
  40. data/lib/asgard/watch/worker.rb +44 -9
  41. data/lib/asgard/watch.rb +4 -11
  42. data/lib/asgard.rb +80 -14
  43. metadata +167 -186
  44. data/.envrc +0 -6
  45. data/.github/workflows/deploy-github-pages.yml +0 -52
  46. data/.loki +0 -39
  47. data/.reek.yml +0 -166
  48. data/.rubocop.yml +0 -163
  49. data/Archspec.rb +0 -24
  50. data/CLAUDE.md +0 -139
  51. data/COMMITS.md +0 -196
  52. data/bin/console +0 -11
  53. data/bin/setup +0 -8
  54. data/doc_tasks.loki +0 -21
  55. data/docs/api.md +0 -368
  56. data/docs/assets/css/custom.css +0 -93
  57. data/docs/assets/images/asgard.jpg +0 -0
  58. data/docs/changelog.md +0 -1
  59. data/docs/dependencies.md +0 -313
  60. data/docs/environment.md +0 -129
  61. data/docs/examples.md +0 -194
  62. data/docs/getting-started.md +0 -181
  63. data/docs/helpers.md +0 -262
  64. data/docs/index.md +0 -87
  65. data/docs/options.md +0 -224
  66. data/docs/schedule.md +0 -106
  67. data/docs/shell.md +0 -237
  68. data/docs/subcommands.md +0 -181
  69. data/docs/task-files.md +0 -407
  70. data/docs/tasks.md +0 -326
  71. data/docs/variables.md +0 -338
  72. data/docs/watch.md +0 -123
  73. data/examples/.env +0 -4
  74. data/examples/.envrc +0 -3
  75. data/examples/.loki +0 -24
  76. data/examples/bad.loki +0 -63
  77. data/examples/concurrent.loki +0 -58
  78. data/examples/confirm.loki +0 -48
  79. data/examples/db_subcommands.loki +0 -74
  80. data/examples/depends_on_block/bad/.loki +0 -51
  81. data/examples/depends_on_block/good/.loki +0 -83
  82. data/examples/do.loki +0 -45
  83. data/examples/env_usage.loki +0 -27
  84. data/examples/kitchen_sink.loki +0 -210
  85. data/examples/server_subcommands.loki +0 -65
  86. data/examples/subdir/.loki +0 -12
  87. data/examples/subdir/import_demo.loki +0 -14
  88. data/examples/subdir/import_up_demo.loki +0 -18
  89. data/examples/watcher_examples/.envrc +0 -3
  90. data/examples/watcher_examples/.loki +0 -54
  91. data/examples/watcher_examples/README.md +0 -112
  92. data/examples/watcher_examples/bin/hello +0 -3
  93. data/examples/watcher_examples/data/records.json +0 -1
  94. data/examples/watcher_examples/docs/drafts/ideas.md +0 -5
  95. data/examples/watcher_examples/docs/index.md +0 -4
  96. data/examples/watcher_examples/lib/greeter.rb +0 -7
  97. data/gem_tasks.loki +0 -51
  98. data/git.loki +0 -13
  99. data/mkdocs.yml +0 -166
  100. data/quality.loki +0 -266
  101. data/quality_rails.loki +0 -47
  102. data/xyzzy.loki +0 -12
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "English"
4
+
3
5
  module Asgard
4
6
  class Base < Thor
5
7
  # Task execution engine: resolves and runs declared dependencies (in
@@ -15,6 +17,69 @@ module Asgard
15
17
  base.extend(ClassMethods)
16
18
  end
17
19
 
20
+ # A finished task's outcome: :failed when its run ended in an error.
21
+ def self.run_state(target, done:, failed:)
22
+ return :failed if failed.key?(target)
23
+
24
+ done.include?(target) ? :done : :free
25
+ end
26
+
27
+ # A group's errors without the ones that only echo another failure:
28
+ # a thread that waited on a failed shared dependency. When nothing
29
+ # else is left, the echoes are all there is to report.
30
+ def self.primary_failures(errors)
31
+ primary = errors.reject { |_, error| error.is_a?(DepFailed) }
32
+ primary.empty? ? errors : primary
33
+ end
34
+
35
+ # A task that is already running further up +chain+ (the tasks this
36
+ # thread is inside, outermost first) and is asked for again can never
37
+ # finish: `def a = invoke(:b)` with `def b = invoke(:a)`. It would
38
+ # wait on itself forever, so name the cycle instead.
39
+ #
40
+ # Each entry, +target+ included, is [class, task name]: a task of the
41
+ # same name in another class is a different task. A subcommand
42
+ # group's `build` runs inside the `build` that dispatches to it, and
43
+ # `jobs restart` runs a project task that may itself be `restart`.
44
+ def self.check_cycle!(chain, target)
45
+ start = chain.index(target)
46
+ return unless start
47
+
48
+ raise Asgard::CircularDependencyError, [*chain[start..], target].map(&:last).join(" -> ")
49
+ end
50
+
51
+ # The options a dependency runs with. They start as the typed task's
52
+ # (+typed+), so an option the dependency does not declare passes
53
+ # through unchanged. The dependency's own defaults (+declared+, class
54
+ # options included) apply over those. Last, each option the user
55
+ # actually gave (+raw+, read against +typed_declared+, the typed
56
+ # task's declarations) reaches the dependency when it declares an
57
+ # option of that name. Words are read only as the typed task means
58
+ # them: a short alias that names something else in the dependency
59
+ # (-f for --file, not --force) never reaches it. A required option
60
+ # of the dependency is not enforced, as it was not before.
61
+ def self.dep_options(declared, typed_declared, raw, typed)
62
+ defaults = Thor::Options.new(declared, {}, false, true).parse([])
63
+ given = given_options(typed_declared, raw).slice(*declared.values.map(&:human_name))
64
+ typed.merge(defaults).merge(given).freeze
65
+ end
66
+
67
+ # Only the options +raw+ spells out, by name, with no defaults filled
68
+ # in: an Array of command-line words read against +declared+, or the
69
+ # Hash a caller passed to new.
70
+ def self.given_options(declared, raw)
71
+ return raw.to_h.transform_keys(&:to_s) unless raw.is_a?(Array)
72
+
73
+ bare = declared.transform_values { without_default(it) }
74
+ Thor::Options.new(bare, {}, false, true).parse(raw).to_h
75
+ end
76
+
77
+ # A copy of +option+ that parses the same words but has no default.
78
+ def self.without_default(option)
79
+ Thor::Option.new(option.name, type: option.type, aliases: option.aliases, enum: option.enum,
80
+ repeatable: option.repeatable, lazy_default: option.lazy_default)
81
+ end
82
+
18
83
  module ClassMethods
19
84
  def _running
20
85
  @_running ||= Set.new
@@ -28,6 +93,13 @@ module Asgard
28
93
  @_cond ||= Hash.new { |h, k| h[k] = ConditionVariable.new }
29
94
  end
30
95
 
96
+ # Tasks whose body raised or exited, keyed by name, with the error
97
+ # that ended them: a thread that waited on one re-raises instead of
98
+ # running its own body on a prerequisite that never finished.
99
+ def _failed
100
+ @_failed ||= {}
101
+ end
102
+
31
103
  # Completed tasks' return values, keyed by task name — lets a task
32
104
  # already run by one thread hand its result to another thread that
33
105
  # shares the dependency, without either touching `self`.
@@ -46,6 +118,7 @@ module Asgard
46
118
  @_done = Set.new
47
119
  @_cond = Hash.new { |h, k| h[k] = ConditionVariable.new }
48
120
  @_results = {}
121
+ @_failed = {}
49
122
  end
50
123
  end
51
124
  end
@@ -56,14 +129,26 @@ module Asgard
56
129
  $DEBUG = true if options[:debug]
57
130
  $VERBOSE = true if options[:verbose]
58
131
  target = command.name.to_sym
59
- return cached_result(target) unless acquire_run_token(target)
132
+ Dispatch.check_cycle!(_task_frames.map { [it.owner, it.task] }, [self.class, target])
133
+ case _acquire_run_token(target)
134
+ when :done then return _cached_result(target)
135
+ when :failed then raise DepFailed.new(target, self.class._failed[target])
136
+ end
60
137
 
61
138
  result = nil
139
+ error = nil
62
140
  begin
63
- resolved_deps = run_deps_for(target)
64
- result = with_dep_results(resolved_deps) { command.run(self, *) }
141
+ result = _in_task_frame(target) do
142
+ resolved_deps = _run_deps_for(target)
143
+ _with_dep_results(resolved_deps) { command.run(self, *) }
144
+ end
145
+ rescue Exception => e # rubocop:disable Lint/RescueException -- an exit or interrupt ends the task too
146
+ # Captured here, not read from $! in an ensure: $! still holds a
147
+ # caller's exception when this runs inside its rescue block.
148
+ error = e
149
+ raise
65
150
  ensure
66
- signal_done(target, result)
151
+ _signal_done(target, result, error)
67
152
  end
68
153
  result
69
154
  end
@@ -78,9 +163,41 @@ module Asgard
78
163
  dep_results[task.to_sym]
79
164
  end
80
165
 
166
+ # One running task, from its dependencies through its body: the class
167
+ # it belongs to, its name and how many background jobs it has started
168
+ # so far. Frames live on
169
+ # a thread-local stack, so a task running as a dependency in a
170
+ # parallel stage sees itself, not the task the user typed. A parallel
171
+ # stage's thread starts with a copy of its parent's stack, which is
172
+ # how a cycle through that stage is still seen (see check_cycle!).
173
+ TaskFrame = Struct.new(:owner, :task, :jobs)
174
+
175
+ # The task whose body is running on this thread, or nil outside one
176
+ # (a helper called at .loki top level).
177
+ def current_task = _task_frames.last&.task
178
+
179
+ # The next sequence number for a background job of the current task:
180
+ # 1 on the first call of each run of the body, then 2, 3, ...
181
+ def _next_job_sequence
182
+ frame = _task_frames.last
183
+ raise Asgard::Error, "no task is running on this thread" unless frame
184
+
185
+ frame.jobs += 1
186
+ end
187
+
81
188
  private
82
189
 
83
- def with_dep_results(results)
190
+ def _task_frames = Thread.current[:asgard_task_frames] ||= []
191
+
192
+ def _in_task_frame(task)
193
+ frames = _task_frames
194
+ frames.push(TaskFrame.new(self.class, task, 0))
195
+ yield
196
+ ensure
197
+ frames.pop
198
+ end
199
+
200
+ def _with_dep_results(results)
84
201
  thread = Thread.current
85
202
  previous = thread[:asgard_dep_results]
86
203
  thread[:asgard_dep_results] = results
@@ -89,71 +206,180 @@ module Asgard
89
206
  thread[:asgard_dep_results] = previous
90
207
  end
91
208
 
92
- def cached_result(target)
209
+ def _cached_result(target)
93
210
  klass = self.class
94
211
  klass._ran_mutex.synchronize { klass._results[target] }
95
212
  end
96
213
 
97
- def acquire_run_token(target)
214
+ # :run when this thread is to run +target+, :done when another
215
+ # already ran it, :failed when that run raised or exited. A thread
216
+ # that finds it running waits for the outcome.
217
+ def _acquire_run_token(target)
98
218
  klass = self.class
99
219
  mutex = klass._ran_mutex
100
220
  done = klass._done
101
221
  running = klass._running
102
222
 
103
223
  mutex.synchronize do
104
- if done.include?(target)
105
- false
106
- elsif running.include?(target)
224
+ if running.include?(target) && !done.include?(target)
107
225
  klass._cond[target].wait(mutex) until done.include?(target)
108
- false
109
- else
110
- running.add(target)
111
- true
112
226
  end
227
+ next Dispatch.run_state(target, done:, failed: klass._failed) if done.include?(target)
228
+
229
+ running.add(target)
230
+ :run
113
231
  end
114
232
  end
115
233
 
116
- def run_deps_for(target)
234
+ def _run_deps_for(target)
117
235
  stages = self.class._deps[target]
118
236
  return {} unless stages&.any?
119
237
 
120
- stages.each_with_object({}) { |group, acc| acc.merge!(run_dep_group(group)) }
238
+ stages.each_with_object({}) { |group, acc| acc.merge!(_run_dep_group(group)) }
121
239
  end
122
240
 
123
- def run_dep_group(group)
124
- return { group.first => run_dep(group.first) } unless group.size > 1
241
+ def _run_dep_group(group)
242
+ return { group.first => _run_dep(group.first) } unless group.size > 1
125
243
 
126
- threads = group.map { |task| [task, Thread.new { run_dep(task) }] }
127
- errors = []
244
+ threads = group.map { |task| [task, _dep_thread(task)] }
245
+ errors = {}
128
246
  results = {}
247
+ # Join every thread before raising, so one failure never ends the
248
+ # process while the other tasks' subprocesses run on, orphaned.
129
249
  threads.each do |task, t|
130
250
  results[task] = t.value
131
- rescue => e
132
- errors << e
251
+ rescue StandardError, ScriptError => e
252
+ errors[task] = e
133
253
  end
134
254
 
135
- if errors.size == 1
136
- raise errors.first
137
- elsif errors.any?
138
- errors.each { |e| warn "asgard: #{e.message}" }
139
- raise Asgard::Error, "#{errors.size} parallel dependencies failed"
140
- end
255
+ errors = Dispatch.primary_failures(errors)
256
+ _raise_dep_failure(errors.values.first) if errors.size == 1
257
+ _raise_group_failure(errors) if errors.any?
141
258
 
142
259
  results
143
260
  end
144
261
 
145
- def signal_done(target, result)
262
+ # A SystemExit that ended a dependency thread (a failing `sh`, an
263
+ # `abort`), carried out of the thread as an ordinary error. Ruby
264
+ # re-raises an unhandled SystemExit from any thread in the main
265
+ # thread, which would end the process in the middle of the join.
266
+ class DepExit < StandardError
267
+ attr_reader :status
268
+
269
+ def initialize(status)
270
+ @status = status
271
+ super("exit #{status}")
272
+ end
273
+ end
274
+
275
+ # Raised in a thread that waited on +task+ when +task+ failed. Its
276
+ # +error+ is the failure itself, which some other thread reports, so
277
+ # the joiner counts only that one (see primary_failures).
278
+ class DepFailed < StandardError
279
+ attr_reader :task, :error
280
+
281
+ def initialize(task, error)
282
+ @task = task
283
+ @error = error
284
+ super("dependency '#{task}' failed")
285
+ end
286
+ end
287
+
288
+ # Ruby's own report would print the full backtrace to stderr before
289
+ # the error reaches asgard's handler; the joiner re-raises it anyway.
290
+ # Each parallel task runs on its own instance (same options), so two
291
+ # tasks in one stage never race on an instance variable; results
292
+ # pass between tasks through dep_result, not through self.
293
+ def _dep_thread(task)
294
+ instance = _parallel_instance
295
+ frames = _task_frames.dup
296
+ Thread.new do
297
+ thread = Thread.current
298
+ thread.report_on_exception = false
299
+ thread[:asgard_task_frames] = frames
300
+ instance.__send__(:_run_dep, task)
301
+ rescue SystemExit => e
302
+ raise DepExit, e.status
303
+ end
304
+ end
305
+
306
+ # Built as Thor built this instance: the same command line and the
307
+ # same config, which names the typed command. Thor then reads the
308
+ # words against that command's options, as it did the first time,
309
+ # so check_unknown_options! knows them. The task the copy runs reads
310
+ # its own options from the same words (see dep_options).
311
+ def _parallel_instance
312
+ config = _initializer_config
313
+ typed = config[:current_command]
314
+ config = config.merge(command_options: typed.options) if typed
315
+ instance = self.class.new([], _raw_options, config)
316
+ instance.options = options
317
+ instance
318
+ end
319
+
320
+ # The options this instance was created with, before parsing: the
321
+ # command line's words, or the Hash a caller passed to new.
322
+ def _raw_options = @_initializer[1]
323
+
324
+ # The config Thor created this instance with; its :current_command
325
+ # is the task that was typed (or invoked).
326
+ def _initializer_config = @_initializer[2] || {}
327
+
328
+ # What the command line's words are read against: the class options
329
+ # plus the typed task's own.
330
+ def _typed_declared
331
+ typed = _initializer_config[:current_command]
332
+ declared = self.class.class_options
333
+ typed ? declared.merge(typed.options) : declared
334
+ end
335
+
336
+ # Runs the block with +opts+ as this instance's options.
337
+ def _with_options(opts)
338
+ previous = options
339
+ self.options = opts
340
+ yield
341
+ ensure
342
+ self.options = previous
343
+ end
344
+
345
+ def _dep_options_for(command)
346
+ Dispatch.dep_options(self.class.class_options.merge(command.options), _typed_declared, _raw_options, options)
347
+ end
348
+
349
+ # One failure: a dependency that exited ends asgard with its status,
350
+ # as it would have on its own; anything else propagates unchanged.
351
+ def _raise_dep_failure(error)
352
+ error = error.error while error.is_a?(DepFailed) && error.error
353
+ case error
354
+ when DepExit, SystemExit then exit(error.status)
355
+ else raise(error)
356
+ end
357
+ end
358
+
359
+ # Every script in the group stopped because asgard was signalled:
360
+ # that is the one failure, so it exits as asgard would on its own.
361
+ def _raise_group_failure(errors)
362
+ signalled = Shell::Children.exit_status
363
+ exit(signalled) if signalled
364
+ errors.each { |task, e| $stderr.puts "asgard: dependency '#{task}' failed: #{_describe_failure(e)}" } # rubocop:disable Style/StderrPuts
365
+ raise Asgard::Error, "parallel dependencies failed: #{errors.keys.join(', ')}"
366
+ end
367
+
368
+ def _describe_failure(error) = error.is_a?(DepExit) ? error.message : "#{error.class}: #{error.message}"
369
+
370
+ def _signal_done(target, result, error = nil)
146
371
  klass = self.class
147
372
  klass._ran_mutex.synchronize do
148
373
  klass._done.add(target)
149
374
  klass._results[target] = result
375
+ klass._failed[target] = error if error
150
376
  klass._cond[target].broadcast
151
377
  end
152
378
  end
153
379
 
154
- def run_dep(task)
380
+ def _run_dep(task)
155
381
  command = self.class.all_commands[task.to_s]
156
- invoke_command(command) if command
382
+ _with_options(_dep_options_for(command)) { invoke_command(command) } if command
157
383
  end
158
384
  end
159
385
  end
@@ -7,13 +7,16 @@ module Asgard
7
7
  # --doctor`) uses to detect a silently-overridden `def`, same file or
8
8
  # across files.
9
9
  module Registry
10
- def subclasses
11
- @subclasses ||= []
10
+ # Every subclass of Asgard::Base, at any depth, in definition order.
11
+ # Not `subclasses`: that is Ruby's own Class#subclasses (direct
12
+ # subclasses only), which a .loki may well call.
13
+ def _asgard_subclasses
14
+ @_asgard_subclasses ||= []
12
15
  end
13
16
 
14
17
  def inherited(subclass)
15
18
  super
16
- Asgard::Base.subclasses << subclass
19
+ Asgard::Base._asgard_subclasses << subclass
17
20
  subclass.instance_variable_set(:@_deps, {})
18
21
  subclass.instance_variable_set(:@_method_log, Hash.new { |h, k| h[k] = [] })
19
22
  subclass.instance_variable_set(:@_pending_deps, [])
@@ -23,6 +26,7 @@ module Asgard
23
26
  subclass.instance_variable_set(:@_done, Set.new)
24
27
  subclass.instance_variable_set(:@_cond, Hash.new { |h, k| h[k] = ConditionVariable.new })
25
28
  subclass.instance_variable_set(:@_ran_mutex, Mutex.new)
29
+ subclass.instance_variable_set(:@_failed, {})
26
30
  end
27
31
 
28
32
  # Every source_location a method name has ever been defined at, in
data/lib/asgard/base.rb CHANGED
@@ -17,11 +17,45 @@ module Asgard
17
17
  extend TaskDSL
18
18
  include Dispatch
19
19
 
20
+ THOR_LIB = File.dirname(Thor.method(:subcommand).source_location.first)
21
+ ASGARD_LIB = File.expand_path("..", __dir__)
22
+
23
+ # Names Thor's machinery relies on: its reserved words plus the readers
24
+ # dispatch calls on every instance, which Thor itself does not guard.
25
+ # A task or helper by one of these names breaks every command, with
26
+ # nothing in the failure pointing at the cause.
27
+ RESERVED_NAMES = (Thor::THOR_RESERVED_WORDS + %w[args parent_options]).map(&:to_sym).freeze
28
+
29
+ # The helpers lib/asgard/kernel_methods.rb adds to Kernel.
30
+ KERNEL_HELPERS = %i[debug? verbose? env changes loki_up import import_up].freeze
31
+
32
+ # Asgard's own helpers a task body calls (sh, env, dep_result, ...)
33
+ # and the methods dispatch runs every task through. A task or helper
34
+ # by one of these names would silently replace it.
35
+ ASGARD_HELPERS = [Shell, Dispatch].flat_map { |mod| mod.instance_methods(false) + mod.private_instance_methods(false) }
36
+ .concat(KERNEL_HELPERS).uniq.freeze
37
+
20
38
  class << self
21
39
  # A usage error (missing argument, unknown command or option) exits 1,
22
40
  # so CI and `asgard do` sequences see the failure. Thor's default is 0.
23
41
  def exit_on_failure? = true
24
42
 
43
+ # A subcommand group's help lists only the group's own commands. The
44
+ # tasks it inherits from Tasks still run, but Thor would list them
45
+ # too, under the class's default namespace (doc_commands for
46
+ # DocCommands), burying the group's own.
47
+ def printable_commands(all = true, subcommand = false) # rubocop:disable Style/OptionalBooleanParameter
48
+ super(all && !subcommand, subcommand)
49
+ end
50
+
51
+ # `subcommand "doc", DocCommands` names the group doc, so `tree` shows
52
+ # it as doc rather than Thor's doc_commands. A group that set its own
53
+ # namespace keeps it.
54
+ def subcommand(name, klass)
55
+ klass.namespace(name) if klass.namespace == Thor::Util.namespace_from_thor_class(klass)
56
+ super
57
+ end
58
+
25
59
  def header(text = nil)
26
60
  return @_header if text.nil?
27
61
  (@_header ||= []) << text
@@ -32,22 +66,119 @@ module Asgard
32
66
  (@_footer ||= []).unshift(text)
33
67
  end
34
68
 
69
+ # A method Thor generates (a `subcommand` dispatcher, a subcommand
70
+ # class's string-eval'd `help`) reports a source_location inside Thor.
71
+ # Credit it to the first caller frame outside Thor and asgard — the
72
+ # .loki line that called `subcommand` — so Doctor lists it there.
73
+ def definition_site(loc, callers)
74
+ return loc unless thor_generated?(loc.first)
75
+
76
+ site = callers.find { |frame| user_frame?(frame.path) }
77
+ site ? [site.path, site.lineno] : loc
78
+ end
79
+
80
+ def thor_generated?(path)
81
+ path.start_with?("(eval", THOR_LIB)
82
+ end
83
+
84
+ def user_frame?(path)
85
+ !thor_generated?(path) && !path.start_with?(ASGARD_LIB)
86
+ end
87
+
88
+ HIDDEN_DESC = "(hidden task: defined without a desc)"
89
+
90
+ # A public method a .loki defines without a desc is a hidden task:
91
+ # it runs from the command line and as a dependency, and help leaves
92
+ # it out. Not one: a method that already has a desc pending, anything
93
+ # inside no_commands (helpers), non-public methods, the gem's own
94
+ # _-prefixed names, and a name Thor already has a command for (help).
95
+ # Nor a method every object already has (to_s, inspect,
96
+ # respond_to_missing?): see object_method?.
97
+ def implicit_hidden_task?(method_name)
98
+ name = method_name.to_s
99
+ !@usage && !no_commands? && !name.start_with?("_") && !object_method?(method_name) &&
100
+ public_method_defined?(method_name) && !all_commands.key?(name)
101
+ end
102
+
103
+ # A method Thor (and so Object) already defines: a public one
104
+ # (to_s, inspect, display), or a private one that is not a Kernel
105
+ # module function (method_missing, respond_to_missing?). Kernel's
106
+ # module functions (test, format, system) stay free to be task names.
107
+ def object_method?(method_name)
108
+ Thor.public_method_defined?(method_name) ||
109
+ (Thor.private_method_defined?(method_name) && !Kernel.respond_to?(method_name))
110
+ end
111
+
112
+ # The task `asgard help NAME` describes: a built-in group's mapped
113
+ # name ("jobs") is its registered subcommand ("_jobs"); anything else
114
+ # is NAME unchanged.
115
+ def help_target(command)
116
+ mapped = map[command.to_s]&.to_s
117
+ subcommands.include?(mapped) ? mapped : command
118
+ end
119
+
120
+ def _check_reserved_name!(method_name)
121
+ thor = RESERVED_NAMES.include?(method_name)
122
+ return unless thor || ASGARD_HELPERS.include?(method_name)
123
+
124
+ file, line = instance_method(method_name).source_location
125
+ remove_method(method_name) # leave the original in place, so the class still dispatches
126
+ what = thor ? "a name Thor reserves" : "an asgard helper"
127
+ raise Asgard::Error, "'#{method_name}' at #{file}:#{line} is #{what} and cannot be a task or helper; rename it"
128
+ end
129
+
130
+ # `private def helper = ...` makes the method private only after
131
+ # method_added has seen it public and made it a hidden task, and Ruby
132
+ # has no hook for the change (overriding `private` would break its
133
+ # no-argument form in every subclass). So whenever the command table
134
+ # is read (dispatch, help, doctor), a hidden task whose method is no
135
+ # longer public is dropped from it.
136
+ def all_commands
137
+ super.tap { _drop_non_public_hidden_tasks(it) }
138
+ end
139
+
140
+ # Removes from +all+ (and this class's own table) each hidden task,
141
+ # one defined without a desc, whose method is private or protected,
142
+ # with any depends_on attached to it.
143
+ def _drop_non_public_hidden_tasks(all)
144
+ stale = all.select { |name, command| command.description == HIDDEN_DESC && !public_method_defined?(name) }
145
+ stale.each_key do |name|
146
+ all.delete(name)
147
+ commands.delete(name)
148
+ _deps.delete(name.to_sym)
149
+ end
150
+ end
151
+
152
+ # A redefined to_s or inspect with no desc of its own: not a task, and
153
+ # not worth Thor's "without usage or description" warning either.
154
+ def plain_object_method?(method_name) = !@usage && !@_pending_single_desc && object_method?(method_name)
155
+
156
+ # Turns a pending single-argument `desc "text"` into Thor's desc for
157
+ # the method just defined.
158
+ def _apply_pending_desc(name)
159
+ return unless @_pending_single_desc && !no_commands?
160
+
161
+ pending_desc = @_pending_single_desc
162
+ pending_opts = @_pending_single_desc_opts || {}
163
+ @_pending_single_desc = nil
164
+ @_pending_single_desc_opts = nil
165
+ desc(name, pending_desc, pending_opts)
166
+ end
167
+
35
168
  def method_added(method_name)
169
+ _check_reserved_name!(method_name)
170
+ return no_commands { super } if plain_object_method?(method_name)
171
+
36
172
  name = method_name.to_s
37
173
  private_method = name.start_with?("_")
38
174
 
39
175
  unless private_method
40
176
  loc = instance_method(method_name).source_location
41
- _method_log[method_name] << loc if loc
177
+ _method_log[method_name] << definition_site(loc, caller_locations) if loc
42
178
  end
43
179
 
44
- if @_pending_single_desc && !no_commands?
45
- pending_desc = @_pending_single_desc
46
- pending_opts = @_pending_single_desc_opts || {}
47
- @_pending_single_desc = nil
48
- @_pending_single_desc_opts = nil
49
- desc(name, pending_desc, pending_opts)
50
- end
180
+ _apply_pending_desc(name)
181
+ desc(name, HIDDEN_DESC, hide: true) if implicit_hidden_task?(method_name)
51
182
 
52
183
  return super unless @usage
53
184
 
@@ -70,7 +201,7 @@ module Asgard
70
201
 
71
202
  say header_text.join("\n\n") if header_text && top_level
72
203
  say "\n"
73
- super
204
+ super(klass.help_target(command), subcommand)
74
205
  say footer_text.join("\n\n") if footer_text && top_level
75
206
  end
76
207
 
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Asgard
4
+ # What `schedule` and `watch` share: a registry of declared entries,
5
+ # keyed by entry name.
6
+ module Declarations
7
+ module_function
8
+
9
+ # Stores +spec+ (a Hash with a :name) in +declarations+ and returns it.
10
+ # An identical entry already there is a no-op (a .loki file loaded
11
+ # twice); a different one under the same name is an error that names
12
+ # +kind+ ("schedule", "watch").
13
+ def record(declarations, spec, kind)
14
+ name = spec[:name]
15
+ taken = declarations[name]
16
+ raise ArgumentError, "#{kind}: #{name.inspect} is already declared; give one a distinct as: name" if taken && taken != spec
17
+
18
+ declarations[name] = spec
19
+ end
20
+ end
21
+ end
@@ -7,6 +7,14 @@ module Asgard
7
7
  # more than one recorded location (the each/each_gem class of bug) —
8
8
  # earlier ones OVERRIDDEN (dead), the last one the active redefinition.
9
9
  module TaskSections
10
+ # A hidden task (one defined without a desc) says so, after any
11
+ # override note.
12
+ def self.with_hidden(status, hidden)
13
+ return status unless hidden
14
+
15
+ [status, "hidden (no desc; not shown by help)"].compact.join("; ")
16
+ end
17
+
10
18
  private
11
19
 
12
20
  def build_task_sections
@@ -20,12 +28,12 @@ module Asgard
20
28
  file_map = Hash.new { |h, k| h[k] = [] }
21
29
  @loaded_files.each { |f| file_map[f] }
22
30
 
23
- commands = klass.all_commands.keys
31
+ commands = klass.all_commands
24
32
  klass._method_log.each do |name, locations|
25
- next unless commands.include?(name.to_s)
33
+ command = commands[name.to_s] or next
26
34
 
27
35
  locations.each_with_index do |(file, line), idx|
28
- file_map[file] << [name, line, task_status(locations, idx)]
36
+ file_map[file] << [name, line, TaskSections.with_hidden(task_status(locations, idx), command.hidden?)]
29
37
  end
30
38
  end
31
39
  file_map.each_value { |tasks| tasks.sort_by! { |t| t[1] } }