asgard 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.envrc +5 -0
- data/.loki +19 -23
- data/.reek.yml +112 -0
- data/.rubocop.yml +6 -0
- data/Archspec.rb +24 -0
- data/CHANGELOG.md +98 -2
- data/CLAUDE.md +4 -7
- data/README.md +93 -2
- data/bin/asgard +1 -1
- data/docs/api.md +130 -10
- data/docs/changelog.md +28 -0
- data/docs/dependencies.md +83 -2
- data/docs/getting-started.md +4 -2
- data/docs/helpers.md +84 -0
- data/docs/index.md +3 -3
- data/docs/options.md +51 -7
- data/docs/task-files.md +2 -2
- data/examples/bad.loki +63 -0
- data/examples/server_subcommands.loki +31 -22
- data/gem_tasks.loki +51 -0
- 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 +44 -222
- 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 +12 -6
- data/lib/asgard/tasks.rb +18 -6
- data/lib/asgard/version.rb +1 -1
- data/lib/asgard.rb +11 -1
- data/quality.loki +261 -0
- data/quality_rails.loki +46 -0
- data/xyzzy.loki +12 -0
- metadata +17 -17
- data/Rakefile +0 -101
data/lib/asgard/base.rb
CHANGED
|
@@ -1,173 +1,48 @@
|
|
|
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
|
-
|
|
15
|
+
extend Registry
|
|
16
|
+
extend DependencyGraph
|
|
17
|
+
extend TaskDSL
|
|
18
|
+
include Dispatch
|
|
14
19
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
def dotenv(path = ".env")
|
|
96
|
-
require "dotenv"
|
|
97
|
-
Dotenv.load(path) if File.exist?(path)
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
def default_task(meth = nil)
|
|
101
|
-
if meth && meth != :none && @_default_task_location
|
|
102
|
-
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}"
|
|
106
|
-
end
|
|
107
|
-
if meth && meth != :none
|
|
108
|
-
@_default_task_location = caller_locations(1, 1).first
|
|
109
|
-
@_default_task_name = meth
|
|
110
|
-
end
|
|
111
|
-
super
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
# Validate the full dep graph for cycles using Dagwood::DependencyGraph.
|
|
115
|
-
def validate_deps!
|
|
116
|
-
_check_orphaned_deps!
|
|
117
|
-
return if _deps.empty?
|
|
118
|
-
|
|
119
|
-
all_task_names = all_commands.keys.map(&:to_sym)
|
|
120
|
-
_check_undefined_deps!(all_task_names)
|
|
121
|
-
_check_dep_arities!
|
|
122
|
-
_build_and_sort_graph(all_task_names)
|
|
123
|
-
rescue TSort::Cyclic => e
|
|
124
|
-
raise Asgard::CircularDependencyError, e.message
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
private
|
|
128
|
-
|
|
129
|
-
def _check_orphaned_deps!
|
|
130
|
-
pending = Array(@_pending_deps)
|
|
131
|
-
return unless pending.any?
|
|
132
|
-
|
|
133
|
-
raise Asgard::Error,
|
|
134
|
-
"depends_on(#{pending.join(', ')}) declared without a following task definition"
|
|
20
|
+
class << self
|
|
21
|
+
def header(text = nil)
|
|
22
|
+
return @_header if text.nil?
|
|
23
|
+
(@_header ||= []) << text
|
|
135
24
|
end
|
|
136
25
|
|
|
137
|
-
def
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
raise Asgard::Error, "undefined task(s) in depends_on: #{undefined.sort.join(', ')}"
|
|
26
|
+
def footer(text = nil)
|
|
27
|
+
return @_footer if text.nil?
|
|
28
|
+
(@_footer ||= []).unshift(text)
|
|
142
29
|
end
|
|
143
30
|
|
|
144
|
-
def
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
meth = instance_method(dep.to_s)
|
|
148
|
-
required = meth.parameters.count { |type, _| type == :req }
|
|
149
|
-
next unless required.positive?
|
|
31
|
+
def method_added(method_name)
|
|
32
|
+
name = method_name.to_s
|
|
33
|
+
private_method = name.start_with?("_")
|
|
150
34
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
35
|
+
unless private_method
|
|
36
|
+
loc = instance_method(method_name).source_location
|
|
37
|
+
_method_log[method_name] << loc if loc
|
|
154
38
|
end
|
|
155
|
-
end
|
|
156
39
|
|
|
157
|
-
def _build_and_sort_graph(all_task_names)
|
|
158
|
-
full_graph = all_task_names.to_h { |task| [task, _deps.fetch(task, []).flatten] }
|
|
159
|
-
Dagwood::DependencyGraph.new(full_graph).order
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
public
|
|
163
|
-
|
|
164
|
-
def method_added(method_name)
|
|
165
40
|
if @_pending_single_desc && !no_commands?
|
|
166
41
|
pending_desc = @_pending_single_desc
|
|
167
42
|
pending_opts = @_pending_single_desc_opts || {}
|
|
168
43
|
@_pending_single_desc = nil
|
|
169
44
|
@_pending_single_desc_opts = nil
|
|
170
|
-
desc(
|
|
45
|
+
desc(name, pending_desc, pending_opts)
|
|
171
46
|
end
|
|
172
47
|
|
|
173
48
|
return super unless @usage
|
|
@@ -176,88 +51,35 @@ module Asgard
|
|
|
176
51
|
@_pending_deps = []
|
|
177
52
|
|
|
178
53
|
return super if pending.empty?
|
|
179
|
-
return super if
|
|
54
|
+
return super if private_method
|
|
180
55
|
|
|
181
|
-
|
|
182
|
-
_deps[method_name.to_sym] = pending.map { |d| Array(d).map(&:to_sym) }
|
|
56
|
+
_deps[method_name.to_sym] = _normalize_pending_deps(pending)
|
|
183
57
|
super
|
|
184
58
|
end
|
|
185
59
|
end
|
|
186
60
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
# body finishes. Threads that arrive at an already-running shared dep
|
|
193
|
-
# wait on its ConditionVariable rather than proceeding immediately,
|
|
194
|
-
# preventing the race where parallel tasks start before a shared dep
|
|
195
|
-
# has actually completed.
|
|
196
|
-
def invoke_command(command, *args)
|
|
197
|
-
$DEBUG = true if options[:debug]
|
|
198
|
-
$VERBOSE = true if options[:verbose]
|
|
199
|
-
target = command.name.to_sym
|
|
200
|
-
return unless acquire_run_token(target)
|
|
61
|
+
def help(command = nil, subcommand = false) # rubocop:disable Style/OptionalBooleanParameter
|
|
62
|
+
klass = self.class
|
|
63
|
+
top_level = command.nil?
|
|
64
|
+
header_text = klass.header
|
|
65
|
+
footer_text = klass.footer
|
|
201
66
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
signal_done(target)
|
|
207
|
-
end
|
|
208
|
-
end
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
private
|
|
212
|
-
|
|
213
|
-
def acquire_run_token(target)
|
|
214
|
-
self.class._ran_mutex.synchronize do
|
|
215
|
-
if self.class._done.include?(target)
|
|
216
|
-
false
|
|
217
|
-
elsif self.class._running.include?(target)
|
|
218
|
-
self.class._cond[target].wait(self.class._ran_mutex) until self.class._done.include?(target)
|
|
219
|
-
false
|
|
220
|
-
else
|
|
221
|
-
self.class._running.add(target)
|
|
222
|
-
true
|
|
223
|
-
end
|
|
224
|
-
end
|
|
225
|
-
end
|
|
226
|
-
|
|
227
|
-
def run_deps_for(target)
|
|
228
|
-
stages = self.class._deps[target]
|
|
229
|
-
return unless stages&.any?
|
|
230
|
-
|
|
231
|
-
groups = Dagwood::DependencyGraph.new(self.class._build_dep_graph(stages)).parallel_order
|
|
232
|
-
groups.each { |group| run_dep_group(group) }
|
|
233
|
-
end
|
|
234
|
-
|
|
235
|
-
def run_dep_group(group)
|
|
236
|
-
if group.size > 1
|
|
237
|
-
threads = group.map { |task| Thread.new { run_dep(task) } }
|
|
238
|
-
errors = []
|
|
239
|
-
threads.each { |t| begin; t.join; rescue => e; errors << e; end }
|
|
240
|
-
if errors.size == 1
|
|
241
|
-
raise errors.first
|
|
242
|
-
elsif errors.any?
|
|
243
|
-
errors.each { |e| warn "asgard: #{e.message}" }
|
|
244
|
-
raise Asgard::Error, "#{errors.size} parallel dependencies failed"
|
|
245
|
-
end
|
|
246
|
-
else
|
|
247
|
-
run_dep(group.first)
|
|
248
|
-
end
|
|
67
|
+
say header_text.join("\n\n") if header_text && top_level
|
|
68
|
+
say "\n"
|
|
69
|
+
super
|
|
70
|
+
say footer_text.join("\n\n") if footer_text && top_level
|
|
249
71
|
end
|
|
250
72
|
|
|
251
|
-
def
|
|
252
|
-
self.class
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
end
|
|
256
|
-
end
|
|
73
|
+
def tree
|
|
74
|
+
klass = self.class
|
|
75
|
+
header_text = klass.header
|
|
76
|
+
footer_text = klass.footer
|
|
257
77
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
|
261
83
|
end
|
|
262
84
|
end
|
|
263
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
|
|
@@ -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 =
|
|
21
|
-
return candidate if
|
|
22
|
-
parent =
|
|
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
|
|
@@ -27,11 +29,15 @@ module Kernel
|
|
|
27
29
|
end
|
|
28
30
|
module_function :loki_up
|
|
29
31
|
|
|
30
|
-
|
|
32
|
+
# +from+ is the call-site location used to resolve a relative +path+. It
|
|
33
|
+
# defaults to the immediate caller, but is exposed as a keyword so a
|
|
34
|
+
# wrapper (e.g. Asgard::Doctor's import tracer, prepended via `super`)
|
|
35
|
+
# can thread through the *real* caller instead of its own frame.
|
|
36
|
+
def import(path, from: caller_locations(1, 1).first)
|
|
31
37
|
path = path.to_s
|
|
32
38
|
raise ArgumentError, "import: path must end with .loki (got #{path.inspect})" unless path.end_with?(".loki")
|
|
33
39
|
unless File.absolute_path?(path)
|
|
34
|
-
caller_dir = File.dirname(
|
|
40
|
+
caller_dir = File.dirname(from.absolute_path)
|
|
35
41
|
path = File.expand_path(path, caller_dir)
|
|
36
42
|
end
|
|
37
43
|
paths = path =~ /[*?\[{]/ ? Dir.glob(path) : [path]
|
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,15 @@ class Tasks < Asgard::Base
|
|
|
14
21
|
default: false,
|
|
15
22
|
desc: "Enable verbose output ($VERBOSE = true)"
|
|
16
23
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
24
|
+
class_option :version,
|
|
25
|
+
type: :boolean,
|
|
26
|
+
default: false,
|
|
27
|
+
desc: "Show asgard version and exit"
|
|
28
|
+
no_negate :version
|
|
29
|
+
|
|
30
|
+
class_option :doctor,
|
|
31
|
+
type: :boolean,
|
|
32
|
+
default: false,
|
|
33
|
+
desc: "Diagnose .loki resolution, imports, and task definitions for the CWD, then exit"
|
|
34
|
+
no_negate :doctor
|
|
23
35
|
end
|
data/lib/asgard/version.rb
CHANGED