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.
@@ -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 "dagwood"
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
- class << self
11
- def subclasses
12
- @subclasses ||= []
13
- end
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 dotenv(path = ".env")
118
- require "dotenv"
119
- Dotenv.load(path) if File.exist?(path)
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
- raise Asgard::Error,
181
- "task '#{dep}' has #{required} required argument(s) and cannot be used as a dependency"
182
- end
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(method_name.to_s, pending_desc, pending_opts)
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 method_name.to_s.start_with?("_")
54
+ return super if private_method
209
55
 
210
- # Each element is a Symbol (sequential) or Array (parallel group).
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
- say self.class.header.join("\n\n") if self.class.header && command.nil?
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 self.class.footer.join("\n\n") if self.class.footer && command.nil?
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 signal_done(target)
288
- self.class._ran_mutex.synchronize do
289
- self.class._done.add(target)
290
- self.class._cond[target].broadcast
291
- end
292
- end
73
+ def tree
74
+ klass = self.class
75
+ header_text = klass.header
76
+ footer_text = klass.footer
293
77
 
294
- def run_dep(task)
295
- command = self.class.all_commands[task.to_s]
296
- invoke_command(command) if command
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