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.
data/lib/asgard/base.rb CHANGED
@@ -1,173 +1,48 @@
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
15
+ extend Registry
16
+ extend DependencyGraph
17
+ extend TaskDSL
18
+ include Dispatch
14
19
 
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
- 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 _check_undefined_deps!(all_task_names)
138
- undefined = _deps.values.flatten.uniq - all_task_names
139
- return unless undefined.any?
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 _check_dep_arities!
145
- _deps.each_value do |stages|
146
- stages.flatten.each do |dep|
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
- raise Asgard::Error,
152
- "task '#{dep}' has #{required} required argument(s) and cannot be used as a dependency"
153
- end
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(method_name.to_s, pending_desc, pending_opts)
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 method_name.to_s.start_with?("_")
54
+ return super if private_method
180
55
 
181
- # Each element is a Symbol (sequential) or Array (parallel group).
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
- no_commands do
188
- # Dispatch hook: resolves and runs all deps (in parallel where declared)
189
- # before executing the target command.
190
- #
191
- # Completion-based deduplication: a task is only marked done after its
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
- begin
203
- run_deps_for(target)
204
- command.run(self, *args)
205
- ensure
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 signal_done(target)
252
- self.class._ran_mutex.synchronize do
253
- self.class._done.add(target)
254
- self.class._cond[target].broadcast
255
- end
256
- end
73
+ def tree
74
+ klass = self.class
75
+ header_text = klass.header
76
+ footer_text = klass.footer
257
77
 
258
- def run_dep(task)
259
- command = self.class.all_commands[task.to_s]
260
- 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
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 = File.join(dir, name)
21
- return candidate if File.exist?(candidate)
22
- parent = File.dirname(dir)
22
+ candidate = dir + name
23
+ return candidate if candidate.exist?
24
+ parent = dir.parent
23
25
  break if parent == dir
24
26
  dir = parent
25
27
  end
@@ -27,11 +29,15 @@ module Kernel
27
29
  end
28
30
  module_function :loki_up
29
31
 
30
- def import(path)
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(caller_locations(1, 1).first.absolute_path)
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
- desc "--version", "Show asgard version"
18
- map "--version" => :_version
19
- def _version
20
- puts Asgard::VERSION
21
- exit
22
- end
24
+ class_option :version,
25
+ type: :boolean,
26
+ default: false,
27
+ desc: "Show asgard version and exit"
28
+ no_negate :version
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Asgard
4
- VERSION = "0.3.0"
4
+ VERSION = "0.3.2"
5
5
  end