asgard 0.3.1 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.envrc +5 -0
- data/.loki +8 -1
- data/.reek.yml +112 -0
- data/.rubocop.yml +6 -0
- data/Archspec.rb +24 -0
- data/CHANGELOG.md +57 -1
- data/CLAUDE.md +4 -7
- data/README.md +10 -1
- data/bin/asgard +1 -1
- data/docs/api.md +122 -5
- data/docs/changelog.md +21 -1
- data/docs/dependencies.md +83 -2
- data/docs/getting-started.md +4 -2
- data/docs/index.md +3 -3
- data/docs/options.md +16 -6
- data/examples/bad.loki +63 -0
- data/gem_tasks.loki +17 -1
- data/git.loki +13 -0
- data/lib/asgard/base/dependency_graph.rb +124 -0
- data/lib/asgard/base/dispatch.rb +160 -0
- data/lib/asgard/base/registry.rb +38 -0
- data/lib/asgard/base/task_dsl.rb +65 -0
- data/lib/asgard/base.rb +36 -250
- data/lib/asgard/doctor/report.rb +73 -0
- data/lib/asgard/doctor/task_sections.rb +67 -0
- data/lib/asgard/doctor.rb +150 -0
- data/lib/asgard/kernel_methods.rb +6 -2
- data/lib/asgard/tasks.rb +6 -0
- data/lib/asgard/version.rb +1 -1
- data/lib/asgard.rb +7 -1
- data/quality.loki +218 -32
- data/quality_rails.loki +46 -0
- data/xyzzy.loki +12 -0
- metadata +15 -16
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Asgard
|
|
4
|
+
class Base < Thor
|
|
5
|
+
# Task-definition macros available inside .loki files: single-argument
|
|
6
|
+
# `desc`, `--no-x`/`--skip-x` suppression, `.env` loading, the `helper`
|
|
7
|
+
# DSL, and `default_task` override warnings.
|
|
8
|
+
module TaskDSL
|
|
9
|
+
# Allow single-argument desc: desc "Run the tests"
|
|
10
|
+
# The usage string defaults to the method name when the description is the only arg.
|
|
11
|
+
def desc(usage_or_desc, description = nil, options = {})
|
|
12
|
+
is_hash = description.is_a?(Hash)
|
|
13
|
+
if description.nil? || is_hash
|
|
14
|
+
options = description if is_hash
|
|
15
|
+
@_pending_single_desc = usage_or_desc
|
|
16
|
+
@_pending_single_desc_opts = options
|
|
17
|
+
else
|
|
18
|
+
@_pending_single_desc = nil
|
|
19
|
+
@_pending_single_desc_opts = nil
|
|
20
|
+
super
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Suppress [--no-name] / [--skip-name] from help for boolean class options
|
|
25
|
+
# where negation is meaningless. Call after class_option declarations.
|
|
26
|
+
def no_negate(*names)
|
|
27
|
+
names.each do |name|
|
|
28
|
+
opt = class_options[name]
|
|
29
|
+
next unless opt
|
|
30
|
+
opt.define_singleton_method(:usage) do |padding = 0|
|
|
31
|
+
aliases_for_usage.ljust(padding) + "[#{switch_name}]"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def dotenv(path = ".env")
|
|
37
|
+
require "dotenv"
|
|
38
|
+
Dotenv.load(path) if File.exist?(path)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def helper(name, &)
|
|
42
|
+
define_singleton_method(name, &)
|
|
43
|
+
no_commands { private define_method(name) { |*args, **kwargs, &blk| self.class.send(name, *args, **kwargs, &blk) } }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def default_task(meth = nil)
|
|
47
|
+
active = meth && meth != :none
|
|
48
|
+
here = caller_locations(1, 1).first if active
|
|
49
|
+
|
|
50
|
+
if active && @_default_task_location
|
|
51
|
+
# rubocop:disable Style/StderrPuts -- warn bypasses $stderr in Ruby 4.0, breaking capture_io in tests
|
|
52
|
+
$stderr.puts "asgard: default_task :#{meth} at #{here.path}:#{here.lineno} " \
|
|
53
|
+
"overrides default_task :#{@_default_task_name} set at " \
|
|
54
|
+
"#{@_default_task_location.path}:#{@_default_task_location.lineno}"
|
|
55
|
+
# rubocop:enable Style/StderrPuts
|
|
56
|
+
end
|
|
57
|
+
if active
|
|
58
|
+
@_default_task_location = here
|
|
59
|
+
@_default_task_name = meth
|
|
60
|
+
end
|
|
61
|
+
super
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
data/lib/asgard/base.rb
CHANGED
|
@@ -1,109 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "thor"
|
|
4
|
-
require "
|
|
4
|
+
require "tsort"
|
|
5
|
+
|
|
6
|
+
require_relative "base/registry"
|
|
7
|
+
require_relative "base/dependency_graph"
|
|
8
|
+
require_relative "base/task_dsl"
|
|
9
|
+
require_relative "base/dispatch"
|
|
5
10
|
|
|
6
11
|
module Asgard
|
|
7
12
|
class Base < Thor
|
|
8
13
|
include Asgard::Shell
|
|
9
14
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def inherited(subclass)
|
|
16
|
-
super
|
|
17
|
-
Asgard::Base.subclasses << subclass
|
|
18
|
-
subclass.instance_variable_set(:@_deps, {})
|
|
19
|
-
subclass.instance_variable_set(:@_pending_deps, [])
|
|
20
|
-
subclass.instance_variable_set(:@_pending_single_desc, nil)
|
|
21
|
-
subclass.instance_variable_set(:@_pending_single_desc_opts, nil)
|
|
22
|
-
subclass.instance_variable_set(:@_running, Set.new)
|
|
23
|
-
subclass.instance_variable_set(:@_done, Set.new)
|
|
24
|
-
subclass.instance_variable_set(:@_cond, Hash.new { |h, k| h[k] = ConditionVariable.new })
|
|
25
|
-
subclass.instance_variable_set(:@_ran_mutex, Mutex.new)
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def _deps
|
|
29
|
-
@_deps ||= {}
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def _running
|
|
33
|
-
@_running ||= Set.new
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def _done
|
|
37
|
-
@_done ||= Set.new
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def _cond
|
|
41
|
-
@_cond ||= Hash.new { |h, k| h[k] = ConditionVariable.new }
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def _ran_mutex
|
|
45
|
-
@_ran_mutex ||= Mutex.new
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
# Reset execution tracking for a fresh asgard invocation.
|
|
49
|
-
def _reset_ran!
|
|
50
|
-
_ran_mutex.synchronize do
|
|
51
|
-
@_running = Set.new
|
|
52
|
-
@_done = Set.new
|
|
53
|
-
@_cond = Hash.new { |h, k| h[k] = ConditionVariable.new }
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
# Translate stages into a DependencyGraph-compatible hash.
|
|
58
|
-
#
|
|
59
|
-
# stages: [[:one], [:two, :three], [:four]]
|
|
60
|
-
# → { one: [], two: [:one], three: [:one], four: [:two, :three] }
|
|
61
|
-
def _build_dep_graph(stages)
|
|
62
|
-
graph = {}
|
|
63
|
-
stages.each_with_index do |stage, i|
|
|
64
|
-
prev_stage = i.positive? ? stages[i - 1] : []
|
|
65
|
-
stage.each { |task| graph[task] = prev_stage.dup }
|
|
66
|
-
end
|
|
67
|
-
graph
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
# Declare dependencies for the next task.
|
|
71
|
-
# Bare symbols run sequentially; arrays within the splat run in parallel.
|
|
72
|
-
#
|
|
73
|
-
# depends_on :build # sequential
|
|
74
|
-
# depends_on :build, :lint # both sequential
|
|
75
|
-
# depends_on [:build, :lint] # build and lint in parallel
|
|
76
|
-
# depends_on :setup, [:build, :lint], :test # setup, then build+lint, then test
|
|
77
|
-
def depends_on(*tasks)
|
|
78
|
-
@_pending_deps = tasks
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
# Allow single-argument desc: desc "Run the tests"
|
|
82
|
-
# The usage string defaults to the method name when the description is the only arg.
|
|
83
|
-
def desc(usage_or_desc, description = nil, options = {})
|
|
84
|
-
if description.nil? || description.is_a?(Hash)
|
|
85
|
-
options = description if description.is_a?(Hash)
|
|
86
|
-
@_pending_single_desc = usage_or_desc
|
|
87
|
-
@_pending_single_desc_opts = options
|
|
88
|
-
else
|
|
89
|
-
@_pending_single_desc = nil
|
|
90
|
-
@_pending_single_desc_opts = nil
|
|
91
|
-
super
|
|
92
|
-
end
|
|
93
|
-
end
|
|
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
|
|
15
|
+
extend Registry
|
|
16
|
+
extend DependencyGraph
|
|
17
|
+
extend TaskDSL
|
|
18
|
+
include Dispatch
|
|
106
19
|
|
|
20
|
+
class << self
|
|
107
21
|
def header(text = nil)
|
|
108
22
|
return @_header if text.nil?
|
|
109
23
|
(@_header ||= []) << text
|
|
@@ -114,89 +28,21 @@ module Asgard
|
|
|
114
28
|
(@_footer ||= []).unshift(text)
|
|
115
29
|
end
|
|
116
30
|
|
|
117
|
-
def
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
end
|
|
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
|
-
|
|
127
|
-
def default_task(meth = nil)
|
|
128
|
-
if meth && meth != :none && @_default_task_location
|
|
129
|
-
here = caller_locations(1, 1).first
|
|
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
|
|
135
|
-
end
|
|
136
|
-
if meth && meth != :none
|
|
137
|
-
@_default_task_location = caller_locations(1, 1).first
|
|
138
|
-
@_default_task_name = meth
|
|
139
|
-
end
|
|
140
|
-
super
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
# Validate the full dep graph for cycles using Dagwood::DependencyGraph.
|
|
144
|
-
def validate_deps!
|
|
145
|
-
_check_orphaned_deps!
|
|
146
|
-
return if _deps.empty?
|
|
147
|
-
|
|
148
|
-
all_task_names = all_commands.keys.map(&:to_sym)
|
|
149
|
-
_check_undefined_deps!(all_task_names)
|
|
150
|
-
_check_dep_arities!
|
|
151
|
-
_build_and_sort_graph(all_task_names)
|
|
152
|
-
rescue TSort::Cyclic => e
|
|
153
|
-
raise Asgard::CircularDependencyError, e.message
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
private
|
|
157
|
-
|
|
158
|
-
def _check_orphaned_deps!
|
|
159
|
-
pending = Array(@_pending_deps)
|
|
160
|
-
return unless pending.any?
|
|
161
|
-
|
|
162
|
-
raise Asgard::Error,
|
|
163
|
-
"depends_on(#{pending.join(', ')}) declared without a following task definition"
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
def _check_undefined_deps!(all_task_names)
|
|
167
|
-
undefined = _deps.values.flatten.uniq - all_task_names
|
|
168
|
-
return unless undefined.any?
|
|
169
|
-
|
|
170
|
-
raise Asgard::Error, "undefined task(s) in depends_on: #{undefined.sort.join(', ')}"
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
def _check_dep_arities!
|
|
174
|
-
_deps.each_value do |stages|
|
|
175
|
-
stages.flatten.each do |dep|
|
|
176
|
-
meth = instance_method(dep.to_s)
|
|
177
|
-
required = meth.parameters.count { |type, _| type == :req }
|
|
178
|
-
next unless required.positive?
|
|
31
|
+
def method_added(method_name)
|
|
32
|
+
name = method_name.to_s
|
|
33
|
+
private_method = name.start_with?("_")
|
|
179
34
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
35
|
+
unless private_method
|
|
36
|
+
loc = instance_method(method_name).source_location
|
|
37
|
+
_method_log[method_name] << loc if loc
|
|
183
38
|
end
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
def _build_and_sort_graph(all_task_names)
|
|
187
|
-
full_graph = all_task_names.to_h { |task| [task, _deps.fetch(task, []).flatten] }
|
|
188
|
-
Dagwood::DependencyGraph.new(full_graph).order
|
|
189
|
-
end
|
|
190
39
|
|
|
191
|
-
public
|
|
192
|
-
|
|
193
|
-
def method_added(method_name)
|
|
194
40
|
if @_pending_single_desc && !no_commands?
|
|
195
41
|
pending_desc = @_pending_single_desc
|
|
196
42
|
pending_opts = @_pending_single_desc_opts || {}
|
|
197
43
|
@_pending_single_desc = nil
|
|
198
44
|
@_pending_single_desc_opts = nil
|
|
199
|
-
desc(
|
|
45
|
+
desc(name, pending_desc, pending_opts)
|
|
200
46
|
end
|
|
201
47
|
|
|
202
48
|
return super unless @usage
|
|
@@ -205,95 +51,35 @@ module Asgard
|
|
|
205
51
|
@_pending_deps = []
|
|
206
52
|
|
|
207
53
|
return super if pending.empty?
|
|
208
|
-
return super if
|
|
54
|
+
return super if private_method
|
|
209
55
|
|
|
210
|
-
|
|
211
|
-
_deps[method_name.to_sym] = pending.map { |d| Array(d).map(&:to_sym) }
|
|
56
|
+
_deps[method_name.to_sym] = _normalize_pending_deps(pending)
|
|
212
57
|
super
|
|
213
58
|
end
|
|
214
59
|
end
|
|
215
60
|
|
|
216
61
|
def help(command = nil, subcommand = false) # rubocop:disable Style/OptionalBooleanParameter
|
|
217
|
-
|
|
62
|
+
klass = self.class
|
|
63
|
+
top_level = command.nil?
|
|
64
|
+
header_text = klass.header
|
|
65
|
+
footer_text = klass.footer
|
|
66
|
+
|
|
67
|
+
say header_text.join("\n\n") if header_text && top_level
|
|
218
68
|
say "\n"
|
|
219
69
|
super
|
|
220
|
-
say
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
no_commands do
|
|
224
|
-
# Dispatch hook: resolves and runs all deps (in parallel where declared)
|
|
225
|
-
# before executing the target command.
|
|
226
|
-
#
|
|
227
|
-
# Completion-based deduplication: a task is only marked done after its
|
|
228
|
-
# body finishes. Threads that arrive at an already-running shared dep
|
|
229
|
-
# wait on its ConditionVariable rather than proceeding immediately,
|
|
230
|
-
# preventing the race where parallel tasks start before a shared dep
|
|
231
|
-
# has actually completed.
|
|
232
|
-
def invoke_command(command, *args)
|
|
233
|
-
$DEBUG = true if options[:debug]
|
|
234
|
-
$VERBOSE = true if options[:verbose]
|
|
235
|
-
target = command.name.to_sym
|
|
236
|
-
return unless acquire_run_token(target)
|
|
237
|
-
|
|
238
|
-
begin
|
|
239
|
-
run_deps_for(target)
|
|
240
|
-
command.run(self, *args)
|
|
241
|
-
ensure
|
|
242
|
-
signal_done(target)
|
|
243
|
-
end
|
|
244
|
-
end
|
|
245
|
-
end
|
|
246
|
-
|
|
247
|
-
private
|
|
248
|
-
|
|
249
|
-
def acquire_run_token(target)
|
|
250
|
-
self.class._ran_mutex.synchronize do
|
|
251
|
-
if self.class._done.include?(target)
|
|
252
|
-
false
|
|
253
|
-
elsif self.class._running.include?(target)
|
|
254
|
-
self.class._cond[target].wait(self.class._ran_mutex) until self.class._done.include?(target)
|
|
255
|
-
false
|
|
256
|
-
else
|
|
257
|
-
self.class._running.add(target)
|
|
258
|
-
true
|
|
259
|
-
end
|
|
260
|
-
end
|
|
261
|
-
end
|
|
262
|
-
|
|
263
|
-
def run_deps_for(target)
|
|
264
|
-
stages = self.class._deps[target]
|
|
265
|
-
return unless stages&.any?
|
|
266
|
-
|
|
267
|
-
groups = Dagwood::DependencyGraph.new(self.class._build_dep_graph(stages)).parallel_order
|
|
268
|
-
groups.each { |group| run_dep_group(group) }
|
|
269
|
-
end
|
|
270
|
-
|
|
271
|
-
def run_dep_group(group)
|
|
272
|
-
if group.size > 1
|
|
273
|
-
threads = group.map { |task| Thread.new { run_dep(task) } }
|
|
274
|
-
errors = []
|
|
275
|
-
threads.each { |t| begin; t.join; rescue => e; errors << e; end }
|
|
276
|
-
if errors.size == 1
|
|
277
|
-
raise errors.first
|
|
278
|
-
elsif errors.any?
|
|
279
|
-
errors.each { |e| warn "asgard: #{e.message}" }
|
|
280
|
-
raise Asgard::Error, "#{errors.size} parallel dependencies failed"
|
|
281
|
-
end
|
|
282
|
-
else
|
|
283
|
-
run_dep(group.first)
|
|
284
|
-
end
|
|
70
|
+
say footer_text.join("\n\n") if footer_text && top_level
|
|
285
71
|
end
|
|
286
72
|
|
|
287
|
-
def
|
|
288
|
-
self.class
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
end
|
|
292
|
-
end
|
|
73
|
+
def tree
|
|
74
|
+
klass = self.class
|
|
75
|
+
header_text = klass.header
|
|
76
|
+
footer_text = klass.footer
|
|
293
77
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
78
|
+
say header_text.join("\n\n") if header_text
|
|
79
|
+
say "\n"
|
|
80
|
+
super
|
|
81
|
+
say "\n"
|
|
82
|
+
say footer_text.join("\n\n") if footer_text
|
|
297
83
|
end
|
|
298
84
|
end
|
|
299
85
|
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Asgard
|
|
4
|
+
class Doctor
|
|
5
|
+
# Renders the full doctor report to stdout: findings, the Tasks-by-file
|
|
6
|
+
# listing, and the closing summary line.
|
|
7
|
+
module Report
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def print_report
|
|
11
|
+
divider = "=" * 60
|
|
12
|
+
puts "\nasgard doctor -- #{@dir}"
|
|
13
|
+
puts divider
|
|
14
|
+
@findings.each { |finding| puts format_finding(finding) }
|
|
15
|
+
print_task_sections
|
|
16
|
+
puts divider
|
|
17
|
+
puts summary_line
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def print_task_sections
|
|
21
|
+
return if @task_sections.nil? || @task_sections.empty?
|
|
22
|
+
|
|
23
|
+
multi_class = @task_sections.size > 1
|
|
24
|
+
@task_sections.each do |klass, file_map|
|
|
25
|
+
header = multi_class ? "Tasks by file (#{klass}):" : "Tasks by file:"
|
|
26
|
+
puts "\n#{header}"
|
|
27
|
+
file_map.each { |file, tasks| print_file_tasks(file, tasks) }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def print_file_tasks(file, tasks)
|
|
32
|
+
puts "\n #{relative_path(file)}"
|
|
33
|
+
return puts " (no tasks defined — imports only)" if tasks.empty?
|
|
34
|
+
|
|
35
|
+
width = tasks.map { |name, _, _| name.to_s.length }.max
|
|
36
|
+
tasks.each { |task| puts format_task_line(file, task, width) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def format_task_line(file, task, width)
|
|
40
|
+
name, line, status = task
|
|
41
|
+
text = " #{name.to_s.ljust(width)} #{relative_path(file)}:#{line}"
|
|
42
|
+
text += " #{status}" if status
|
|
43
|
+
return colorize(31, text) if status&.start_with?("OVERRIDDEN")
|
|
44
|
+
return colorize(33, text) if status
|
|
45
|
+
|
|
46
|
+
text
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def format_finding(finding)
|
|
50
|
+
message = finding.message
|
|
51
|
+
case finding.level
|
|
52
|
+
when :error then colorize(31, " [FAIL] #{message}")
|
|
53
|
+
when :warn then colorize(33, " [WARN] #{message}")
|
|
54
|
+
else colorize(36, " [INFO] #{message}")
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def summary_line
|
|
59
|
+
by_level = @findings.group_by(&:level)
|
|
60
|
+
errors = (by_level[:error]&.size || 0) + override_count
|
|
61
|
+
warns = by_level[:warn]&.size || 0
|
|
62
|
+
return colorize(32, "No problems found.") if errors.zero? && warns.zero?
|
|
63
|
+
return colorize(33, "No problems found (#{warns} warning(s)).") if errors.zero?
|
|
64
|
+
|
|
65
|
+
colorize(31, "#{errors} problem(s), #{warns} warning(s).")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def colorize(code, text)
|
|
69
|
+
"\e[#{code}m#{text}\e[0m"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Asgard
|
|
4
|
+
class Doctor
|
|
5
|
+
# Builds the "Tasks by file" data: every command grouped by the file
|
|
6
|
+
# it's defined in, with each definition annotated when a task name has
|
|
7
|
+
# more than one recorded location (the each/each_gem class of bug) —
|
|
8
|
+
# earlier ones OVERRIDDEN (dead), the last one the active redefinition.
|
|
9
|
+
module TaskSections
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def build_task_sections
|
|
13
|
+
@task_sections = []
|
|
14
|
+
return unless @new_subclasses
|
|
15
|
+
|
|
16
|
+
@task_sections = @new_subclasses.map { |klass| [klass, class_task_file_map(klass)] }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def class_task_file_map(klass)
|
|
20
|
+
file_map = Hash.new { |h, k| h[k] = [] }
|
|
21
|
+
@loaded_files.each { |f| file_map[f] }
|
|
22
|
+
|
|
23
|
+
commands = klass.all_commands.keys
|
|
24
|
+
klass._method_log.each do |name, locations|
|
|
25
|
+
next unless commands.include?(name.to_s)
|
|
26
|
+
|
|
27
|
+
locations.each_with_index do |(file, line), idx|
|
|
28
|
+
file_map[file] << [name, line, task_status(locations, idx)]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
file_map.each_value { |tasks| tasks.sort_by! { |t| t[1] } }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def task_status(locations, idx)
|
|
35
|
+
size = locations.size
|
|
36
|
+
if idx == size - 1
|
|
37
|
+
return nil if size == 1
|
|
38
|
+
|
|
39
|
+
prev_file, prev_line = locations[idx - 1]
|
|
40
|
+
"active — redefines #{relative_path(prev_file)}:#{prev_line}"
|
|
41
|
+
else
|
|
42
|
+
next_file, next_line = locations[idx + 1]
|
|
43
|
+
"OVERRIDDEN by #{relative_path(next_file)}:#{next_line} — never callable"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def override_count
|
|
48
|
+
return 0 unless @task_sections
|
|
49
|
+
|
|
50
|
+
@task_sections.sum do |_klass, file_map|
|
|
51
|
+
file_map.values.flatten(1).count { |t| t[2]&.start_with?("OVERRIDDEN") }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# +file+ is normally absolute (every path Doctor loads is expanded
|
|
56
|
+
# first), but a method's recorded source_location can be relative if it
|
|
57
|
+
# was defined by code invoked with a relative path (e.g. `ruby some.rb`
|
|
58
|
+
# rather than an absolute one) — display it verbatim rather than crash.
|
|
59
|
+
def relative_path(file)
|
|
60
|
+
path = Pathname.new(file)
|
|
61
|
+
return file unless path.absolute?
|
|
62
|
+
|
|
63
|
+
path.relative_path_from(Pathname.new(@dir)).to_s
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
require_relative "doctor/task_sections"
|
|
6
|
+
require_relative "doctor/report"
|
|
7
|
+
|
|
8
|
+
module Asgard
|
|
9
|
+
# Diagnoses .loki resolution, import chains, and task definitions for a
|
|
10
|
+
# directory — invoked via `asgard --doctor`. Deliberately bypasses the
|
|
11
|
+
# normal Tasks boot sequence (see Asgard.run!) so it can still report
|
|
12
|
+
# findings when that sequence would otherwise abort the whole process
|
|
13
|
+
# (a broken file, a circular dependency, a silently overridden task).
|
|
14
|
+
class Doctor
|
|
15
|
+
include TaskSections
|
|
16
|
+
include Report
|
|
17
|
+
|
|
18
|
+
Finding = Struct.new(:level, :message, keyword_init: true)
|
|
19
|
+
|
|
20
|
+
def initialize(dir = Dir.pwd)
|
|
21
|
+
@dir = File.expand_path(dir)
|
|
22
|
+
@findings = []
|
|
23
|
+
@imports = []
|
|
24
|
+
@loaded_files = []
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def run
|
|
28
|
+
report_markers
|
|
29
|
+
load_chain
|
|
30
|
+
report_imports
|
|
31
|
+
build_task_sections
|
|
32
|
+
report_dependencies
|
|
33
|
+
print_report
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Every ".loki" marker from +dir+ up to the filesystem root, nearest
|
|
37
|
+
# first — mirrors loki_up's walk, but collects every match instead of
|
|
38
|
+
# stopping at the first (so shadowed ancestor markers can be reported).
|
|
39
|
+
def self.ancestor_markers(dir)
|
|
40
|
+
markers = []
|
|
41
|
+
current = Pathname.new(dir)
|
|
42
|
+
loop do
|
|
43
|
+
candidate = current + ".loki"
|
|
44
|
+
markers << candidate.to_s if candidate.exist?
|
|
45
|
+
parent = current.parent
|
|
46
|
+
break if parent == current
|
|
47
|
+
|
|
48
|
+
current = parent
|
|
49
|
+
end
|
|
50
|
+
markers
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# method_name => [[file, line], ...] entries defined more than once —
|
|
54
|
+
# same file or different, a later `def` always silently overrides an
|
|
55
|
+
# earlier one with the same name.
|
|
56
|
+
def self.duplicate_methods(method_log)
|
|
57
|
+
method_log.each_with_object({}) do |(name, locations), acc|
|
|
58
|
+
acc[name] = locations if locations.size > 1
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def report_markers
|
|
65
|
+
markers = self.class.ancestor_markers(@dir)
|
|
66
|
+
if markers.empty?
|
|
67
|
+
@findings << Finding.new(level: :error, message: "no .loki file found in #{@dir} or any ancestor")
|
|
68
|
+
return
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
@marker = markers.first
|
|
72
|
+
@findings << Finding.new(level: :info, message: "using .loki marker: #{@marker}")
|
|
73
|
+
markers[1..].each do |shadowed|
|
|
74
|
+
@findings << Finding.new(level: :warn, message: "shadowed marker (never reached): #{shadowed}")
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def load_chain
|
|
79
|
+
return unless @marker
|
|
80
|
+
|
|
81
|
+
Kernel.prepend(ImportTracer)
|
|
82
|
+
ImportTracer.doctor = self
|
|
83
|
+
@loaded_files << @marker
|
|
84
|
+
|
|
85
|
+
before = Asgard::Base.subclasses.dup
|
|
86
|
+
load @marker
|
|
87
|
+
@new_subclasses = (Asgard::Base.subclasses - before + [Tasks]).uniq
|
|
88
|
+
rescue StandardError, ScriptError => e
|
|
89
|
+
@findings << Finding.new(level: :error, message: "load failed: #{e.class}: #{e.message}")
|
|
90
|
+
ensure
|
|
91
|
+
ImportTracer.doctor = nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def report_imports
|
|
95
|
+
@imports.each { |message| @findings << Finding.new(level: :info, message: message) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def report_dependencies
|
|
99
|
+
return unless @new_subclasses
|
|
100
|
+
|
|
101
|
+
@new_subclasses.each do |klass|
|
|
102
|
+
klass.validate_deps!
|
|
103
|
+
rescue Asgard::Error => e
|
|
104
|
+
@findings << Finding.new(level: :error, message: "#{klass} dependency graph: #{e.message}")
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def record_import(message, added = [])
|
|
109
|
+
@imports << message
|
|
110
|
+
@loaded_files.concat(added)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Prepended onto Kernel for the process lifetime of `asgard --doctor`
|
|
114
|
+
# (Asgard.run! exits right after Doctor#run, so it's never unprepended)
|
|
115
|
+
# to observe every import/import_up call, whether typed at the top
|
|
116
|
+
# level of a .loki file or reached indirectly through another import.
|
|
117
|
+
module ImportTracer
|
|
118
|
+
class << self
|
|
119
|
+
attr_accessor :doctor
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def import(path)
|
|
123
|
+
loc = caller_locations(1, 1).first
|
|
124
|
+
before = $LOADED_FEATURES.dup
|
|
125
|
+
result = super(path, from: loc)
|
|
126
|
+
added = $LOADED_FEATURES - before
|
|
127
|
+
ImportTracer.doctor&.send(:record_import, ImportTracer.trace_message("import", path, added), added) unless
|
|
128
|
+
loc&.absolute_path&.end_with?("kernel_methods.rb")
|
|
129
|
+
result
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def import_up(name = ".loki")
|
|
133
|
+
before = $LOADED_FEATURES.dup
|
|
134
|
+
result = super
|
|
135
|
+
added = $LOADED_FEATURES - before
|
|
136
|
+
ImportTracer.doctor&.send(:record_import, ImportTracer.trace_message("import_up", name, added), added)
|
|
137
|
+
result
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def self.trace_message(verb, arg, added)
|
|
141
|
+
inspected = arg.inspect
|
|
142
|
+
return "#{verb} #{inspected} -- nothing new (not found or already loaded)" if added.empty?
|
|
143
|
+
|
|
144
|
+
"#{verb} #{inspected} -> #{added.join(', ')}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private :import, :import_up
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|