boxwerk 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,42 +1,94 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'yaml'
4
-
5
3
  module Boxwerk
6
- # Package represents a single package loaded from package.yml.
7
- # Tracks exports, imports, dependencies, and the isolated Ruby::Box instance.
4
+ # Data class representing a package. Reads the standard package.yml format.
8
5
  class Package
9
- attr_reader :name, :path, :exports, :imports, :loaded_exports
10
- attr_accessor :box
6
+ attr_reader :name, :config
11
7
 
12
- def initialize(name, path)
8
+ def initialize(name:, config: {})
13
9
  @name = name
14
- @path = path
15
- @box = nil
16
- @exports = []
17
- @imports = []
18
- @loaded_exports = {}
19
-
20
- load_config
10
+ @config = config
21
11
  end
22
12
 
23
- def booted?
24
- !@box.nil?
13
+ def root?
14
+ @name == '.'
25
15
  end
26
16
 
27
17
  def dependencies
28
- @imports.map { |item| item.is_a?(String) ? item : item.keys.first }
18
+ @config['dependencies'] || []
19
+ end
20
+
21
+ def enforce_dependencies?
22
+ @config['enforce_dependencies'] == true
23
+ end
24
+
25
+ def ==(other)
26
+ other.is_a?(Package) && name == other.name
29
27
  end
30
28
 
31
- private
29
+ alias eql? ==
30
+
31
+ def hash
32
+ name.hash
33
+ end
34
+
35
+ def to_s
36
+ name
37
+ end
32
38
 
33
- def load_config
34
- config_path = File.join(@path, 'package.yml')
35
- return unless File.exist?(config_path)
39
+ def inspect
40
+ "#<Boxwerk::Package #{name}>"
41
+ end
42
+
43
+ # Loads a Package from a package.yml file.
44
+ # The name is the relative path from root_path to the package directory.
45
+ def self.from_yml(yml_path, root_path:)
46
+ config = YAML.safe_load_file(yml_path) || {}
47
+ pkg_dir = File.dirname(yml_path)
48
+ name =
49
+ if File.expand_path(pkg_dir) == File.expand_path(root_path)
50
+ '.'
51
+ else
52
+ relative_path(pkg_dir, root_path)
53
+ end
54
+
55
+ # Normalize dependency names declared in the package.yml
56
+ if config['dependencies']
57
+ config = config.dup
58
+ config['dependencies'] = config['dependencies'].map { |d| normalize(d) }
59
+ end
60
+
61
+ new(name: name, config: config)
62
+ end
63
+
64
+ # Creates an implicit root package that depends on all other packages.
65
+ # No enforcement is enabled.
66
+ def self.implicit_root(all_package_names)
67
+ new(
68
+ name: '.',
69
+ config: {
70
+ 'enforce_dependencies' => false,
71
+ 'enforce_privacy' => false,
72
+ 'dependencies' => all_package_names.reject { |n| n == '.' },
73
+ },
74
+ )
75
+ end
76
+
77
+ # Normalizes a package name: strips leading ./ and trailing /.
78
+ # Allows users to write packs/loyalty/, ./packs/loyalty, or packs/loyalty
79
+ # interchangeably in package.yml dependencies and CLI --package flag.
80
+ def self.normalize(name)
81
+ name = name.to_s.strip
82
+ name = name.sub(%r{\A\./}, '')
83
+ name = name.sub(%r{/\z}, '')
84
+ name
85
+ end
36
86
 
37
- config = YAML.load_file(config_path)
38
- @exports = config['exports'] || []
39
- @imports = config['imports'] || []
87
+ def self.relative_path(path, base)
88
+ Pathname
89
+ .new(File.expand_path(path))
90
+ .relative_path_from(Pathname.new(File.expand_path(base)))
91
+ .to_s
40
92
  end
41
93
  end
42
94
  end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boxwerk
4
+ # Runtime context for the current package, accessible via +Boxwerk.package+.
5
+ # Available during +boot.rb+ execution and throughout the application.
6
+ class PackageContext
7
+ # @return [String] The package name (e.g. +"packs/orders"+ or +"."+ for root).
8
+ attr_reader :name
9
+
10
+ # @return [String] Absolute path to the package directory.
11
+ attr_reader :root_path
12
+
13
+ # @return [PackageContext::Autoloader] Autoloader configuration for this package.
14
+ attr_reader :autoloader
15
+
16
+ def initialize(name:, root_path:, config:, autoloader:)
17
+ @name = name
18
+ @root_path = root_path
19
+ @config = config.freeze
20
+ @autoloader = autoloader
21
+ end
22
+
23
+ # @return [Boolean] Whether this is the root package.
24
+ def root?
25
+ @name == '.'
26
+ end
27
+
28
+ # @return [Hash] Frozen package configuration from +package.yml+.
29
+ def config
30
+ @config
31
+ end
32
+
33
+ # Autoload configuration for a package box. Provides {AutoloaderMixin}'s
34
+ # +push_dir+, +collapse+, +ignore+, and +setup+ in +boot.rb+.
35
+ #
36
+ # Registration happens immediately when +push_dir+ or +collapse+ is called,
37
+ # making constants available for the rest of the boot script without an
38
+ # explicit +setup+ call.
39
+ class Autoloader
40
+ include AutoloaderMixin
41
+
42
+ def initialize(root_path, box:, default_autoload_dirs: [])
43
+ init_dirs
44
+ @root_path = root_path
45
+ @box = box
46
+ @default_autoload_dirs = default_autoload_dirs
47
+ end
48
+
49
+ private
50
+
51
+ def do_setup(new_push, new_collapse)
52
+ return unless @box
53
+
54
+ all_entries = []
55
+
56
+ new_push.each do |dir|
57
+ abs_dir = File.expand_path(dir, @root_path)
58
+ next unless File.directory?(abs_dir)
59
+ all_entries.concat(ZeitwerkScanner.scan(abs_dir))
60
+ end
61
+
62
+ new_collapse.each do |dir|
63
+ abs_dir = File.expand_path(dir, @root_path)
64
+ next unless File.directory?(abs_dir)
65
+ root_dir = find_root_for(abs_dir)
66
+ all_entries.concat(ZeitwerkScanner.scan_files_only(abs_dir, root_dir: root_dir))
67
+ end
68
+
69
+ return if all_entries.empty?
70
+
71
+ ZeitwerkScanner.register_autoloads(@box, all_entries)
72
+ file_index = ZeitwerkScanner.build_file_index(all_entries)
73
+ @accumulated_file_index ||= {}
74
+ @accumulated_file_index.merge!(file_index)
75
+ end
76
+
77
+ # Returns the absolute root autoload dir that contains abs_dir, or nil.
78
+ def find_root_for(abs_dir)
79
+ (@default_autoload_dirs + @push_dirs)
80
+ .map { |d| File.expand_path(d, @root_path) }
81
+ .find { |root| abs_dir.start_with?("#{root}/") }
82
+ end
83
+
84
+ # All user-configured collapse dirs (for BoxManager namespace cleanup).
85
+ def all_collapse_dirs = @collapse_dirs.dup
86
+
87
+ # All user-configured ignore dirs (for BoxManager namespace cleanup).
88
+ def ignore_dirs = @ignore_dirs.dup
89
+
90
+ # File index accumulated from all auto-setup calls (for BoxManager).
91
+ def accumulated_file_index = @accumulated_file_index || {}
92
+
93
+ # Dir info for BoxManager#record_package_dirs (used by info command).
94
+ def dir_info
95
+ {
96
+ autoload: @default_autoload_dirs + @push_dirs,
97
+ collapse: @collapse_dirs.dup,
98
+ ignore: @ignore_dirs.dup,
99
+ }
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'pathname'
5
+
6
+ module Boxwerk
7
+ # Discovers packages by scanning for package.yml files and provides
8
+ # ordering for boot. Supports boxwerk.yml configuration for package_paths.
9
+ class PackageResolver
10
+ attr_reader :packages, :root
11
+
12
+ def initialize(root_path, config_overrides: {})
13
+ @root_path = File.expand_path(root_path)
14
+ @packages = {}
15
+ @config = load_boxwerk_config.merge(config_overrides)
16
+
17
+ discover_packages
18
+ end
19
+
20
+ # Returns packages in boot order. Dependencies come before dependents
21
+ # when possible. Circular dependencies are allowed — strongly connected
22
+ # components are grouped together. Packages with enforce_dependencies
23
+ # disabled are treated as depending on all other packages for ordering.
24
+ def topological_order
25
+ visited = {}
26
+ order = []
27
+ @packages.each_value { |pkg| visit(pkg, visited, order, Set.new) }
28
+ order
29
+ end
30
+
31
+ # Returns the direct dependency Package objects for a given package.
32
+ def direct_dependencies(package)
33
+ package.dependencies.filter_map { |dep_name| @packages[dep_name] }
34
+ end
35
+
36
+ # Returns all packages except the given one.
37
+ def all_except(package)
38
+ @packages.values.reject { |p| p.name == package.name }
39
+ end
40
+
41
+ # Boxwerk configuration from boxwerk.yml.
42
+ def boxwerk_config
43
+ @config
44
+ end
45
+
46
+ private
47
+
48
+ def load_boxwerk_config
49
+ yml_path = File.join(@root_path, 'boxwerk.yml')
50
+ if File.exist?(yml_path)
51
+ YAML.safe_load_file(yml_path) || {}
52
+ else
53
+ {}
54
+ end
55
+ end
56
+
57
+ def discover_packages
58
+ yml_paths = find_package_ymls
59
+
60
+ yml_paths.each do |yml_path|
61
+ package = Package.from_yml(yml_path, root_path: @root_path)
62
+ @packages[package.name] = package
63
+ @root = package if package.root?
64
+ end
65
+
66
+ # Implicit root package if no package.yml at root
67
+ unless @root
68
+ @root = Package.implicit_root(@packages.keys)
69
+ @packages['.'] = @root
70
+ end
71
+ end
72
+
73
+ def find_package_ymls
74
+ ymls = []
75
+
76
+ # Check for root package.yml
77
+ root_yml = File.join(@root_path, 'package.yml')
78
+ ymls << root_yml if File.exist?(root_yml)
79
+
80
+ # Use package_paths from boxwerk.yml (default: ["**/"])
81
+ package_paths = @config['package_paths'] || ['**/']
82
+
83
+ package_paths.each do |pattern|
84
+ glob = File.join(@root_path, pattern, 'package.yml')
85
+ Dir
86
+ .glob(glob)
87
+ .each do |path|
88
+ next if path == root_yml
89
+ ymls << path
90
+ end
91
+ end
92
+
93
+ ymls.uniq
94
+ end
95
+
96
+ def visit(package, visited, order, in_stack)
97
+ return if visited[package.name]
98
+
99
+ visited[package.name] = true
100
+ in_stack.add(package.name)
101
+
102
+ # When enforce_dependencies is false, treat as depending on all
103
+ # other packages for boot ordering purposes.
104
+ deps =
105
+ if package.enforce_dependencies?
106
+ package.dependencies
107
+ else
108
+ @packages.keys.reject { |n| n == package.name }
109
+ end
110
+
111
+ deps.each do |dep_name|
112
+ dep = @packages[dep_name]
113
+ next unless dep
114
+ next if in_stack.include?(dep_name)
115
+ visit(dep, visited, order, in_stack)
116
+ end
117
+
118
+ in_stack.delete(package.name)
119
+ order << package
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zeitwerk'
4
+
5
+ module Boxwerk
6
+ # Enforces privacy rules at runtime.
7
+ # Reads enforce_privacy, public_path, and private_constants from package.yml.
8
+ # Files with `# pack_public: true` sigil are treated as public.
9
+ module PrivacyChecker
10
+ DEFAULT_PUBLIC_PATH = 'public/'
11
+ PUBLICIZED_SIGIL_REGEX = /#.*pack_public:\s*true/
12
+
13
+ class << self
14
+ # Returns true if the package enforces privacy.
15
+ def enforces_privacy?(package)
16
+ setting = package.config['enforce_privacy']
17
+ [true, 'strict'].include?(setting)
18
+ end
19
+
20
+ # Returns the public path for a package (absolute).
21
+ def public_path_for(package, root_path)
22
+ user_path = package.config['public_path']
23
+ relative =
24
+ if user_path
25
+ user_path.end_with?('/') ? user_path : "#{user_path}/"
26
+ else
27
+ DEFAULT_PUBLIC_PATH
28
+ end
29
+
30
+ if package.root?
31
+ File.join(root_path, relative)
32
+ else
33
+ File.join(root_path, package.name, relative)
34
+ end
35
+ end
36
+
37
+ # Returns the set of public constant names for a package.
38
+ # These are derived from files in the public_path using Ruby naming conventions.
39
+ def public_constants(package, root_path)
40
+ return nil unless enforces_privacy?(package)
41
+
42
+ pub_path = public_path_for(package, root_path)
43
+ constants = Set.new
44
+
45
+ if File.directory?(pub_path)
46
+ Dir
47
+ .glob(File.join(pub_path, '**', '*.rb'))
48
+ .each do |file|
49
+ const_name = constant_name_from_path(file, pub_path)
50
+ constants.add(const_name) if const_name
51
+ end
52
+ end
53
+
54
+ # Also scan all package files for pack_public: true sigil
55
+ package_lib = package_lib_path(package, root_path)
56
+ if package_lib && File.directory?(package_lib)
57
+ Dir
58
+ .glob(File.join(package_lib, '**', '*.rb'))
59
+ .each do |file|
60
+ if publicized_file?(file)
61
+ const_name = constant_name_from_path(file, package_lib)
62
+ constants.add(const_name) if const_name
63
+ end
64
+ end
65
+ end
66
+
67
+ constants
68
+ end
69
+
70
+ # Returns the set of constants with pack_public: true sigil in package lib/.
71
+ # These are individual files opted-in to public access without a public_path dir.
72
+ def pack_public_constants(package, root_path)
73
+ lib = package_lib_path(package, root_path)
74
+ return nil unless lib && File.directory?(lib)
75
+
76
+ constants = Set.new
77
+ Dir
78
+ .glob(File.join(lib, '**', '*.rb'))
79
+ .each do |file|
80
+ if publicized_file?(file)
81
+ const_name = constant_name_from_path(file, lib)
82
+ constants.add(const_name) if const_name
83
+ end
84
+ end
85
+ constants.empty? ? nil : constants
86
+ end
87
+
88
+ # Returns the set of explicitly private constant names.
89
+ # Strips leading :: prefix.
90
+ def private_constants_list(package)
91
+ (package.config['private_constants'] || [])
92
+ .map { |name| name.start_with?('::') ? name[2..] : name }
93
+ .to_set
94
+ end
95
+
96
+ # Checks if a constant is accessible from outside the package.
97
+ # Returns true if accessible, false if blocked by privacy.
98
+ def accessible?(
99
+ const_name,
100
+ package,
101
+ root_path,
102
+ public_constants_cache: nil
103
+ )
104
+ return true unless enforces_privacy?(package)
105
+
106
+ # Check explicitly private constants
107
+ privates = private_constants_list(package)
108
+ return false if privates.include?(const_name.to_s)
109
+ if privates.any? { |pc| const_name.to_s.start_with?("#{pc}::") }
110
+ return false
111
+ end
112
+
113
+ # Check if constant is in the public set
114
+ pub_consts =
115
+ public_constants_cache || public_constants(package, root_path)
116
+ return true if pub_consts.nil? # privacy not enforced
117
+
118
+ # If public_path has files, check against them
119
+ # If public_path is empty/doesn't exist but enforce_privacy is on,
120
+ # all constants are private (no public API defined)
121
+ pub_consts.include?(const_name.to_s)
122
+ end
123
+
124
+ private
125
+
126
+ # Derives a constant name from a file path relative to a base directory.
127
+ # Uses Zeitwerk's inflector for Ruby naming conventions.
128
+ def constant_name_from_path(file_path, base_path)
129
+ normalized_base = base_path.end_with?('/') ? base_path : "#{base_path}/"
130
+ relative = file_path.delete_prefix(normalized_base).delete_suffix('.rb')
131
+ return nil if relative.empty?
132
+
133
+ inflector = Zeitwerk::Inflector.new
134
+ relative
135
+ .split('/')
136
+ .map { |part| inflector.camelize(part, base_path) }
137
+ .join('::')
138
+ end
139
+
140
+ # Checks if a file contains the pack_public: true sigil in first 5 lines.
141
+ def publicized_file?(file_path)
142
+ File
143
+ .foreach(file_path)
144
+ .first(5)
145
+ .any? { |line| line.match?(PUBLICIZED_SIGIL_REGEX) }
146
+ rescue Errno::ENOENT
147
+ false
148
+ end
149
+
150
+ def package_lib_path(package, root_path)
151
+ if package.root?
152
+ nil
153
+ else
154
+ File.join(root_path, package.name, 'lib')
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
data/lib/boxwerk/setup.rb CHANGED
@@ -1,41 +1,90 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Boxwerk
4
- # Setup finds the root package, builds the dependency graph, and boots all packages.
5
- # Orchestrates the entire Boxwerk initialization process.
6
4
  module Setup
7
5
  class << self
8
- def run!(start_dir: Dir.pwd)
9
- root_path = find_package_yml(start_dir)
10
- raise 'Cannot find package.yml in current directory or ancestors' unless root_path
6
+ def run(start_dir: Dir.pwd, packages: nil, config: {})
7
+ root_path = find_root(start_dir)
11
8
 
12
- graph = Boxwerk::Graph.new(root_path)
13
- registry = Boxwerk::Registry.new
14
- Boxwerk::Loader.boot_all(graph, registry)
9
+ resolver = Boxwerk::PackageResolver.new(root_path, config_overrides: config)
10
+ config = resolver.boxwerk_config
11
+ eager_load_global = config.fetch('eager_load_global', true)
12
+ eager_load_packages = config.fetch('eager_load_packages', false)
15
13
 
16
- @graph = graph
14
+ # Create GlobalContext and expose it as Boxwerk.global before boot.rb runs
15
+ global_context = GlobalContext.new(root_path)
16
+ root_box = Ruby::Box.root
17
+ root_box.__send__(:remove_const, :BOXWERK_GLOBAL) if root_box.const_defined?(:BOXWERK_GLOBAL, false)
18
+ root_box.const_set(:BOXWERK_GLOBAL, global_context)
19
+
20
+ # Run global boot in root box (after gems, before package boxes).
21
+ run_global_boot(root_path, eager_load: eager_load_global, global_context: global_context)
22
+
23
+ # Eager-load all Zeitwerk-managed constants in root box so child
24
+ # boxes inherit fully resolved constants (not pending autoloads).
25
+ eager_load_zeitwerk if eager_load_global
26
+
27
+ @box_manager = Boxwerk::BoxManager.new(root_path)
28
+ if packages
29
+ packages.each do |pkg|
30
+ @box_manager.boot_package(
31
+ pkg,
32
+ resolver,
33
+ eager_load_packages: eager_load_packages,
34
+ )
35
+ end
36
+ else
37
+ @box_manager.boot_all(
38
+ resolver,
39
+ eager_load_packages: eager_load_packages,
40
+ )
41
+ end
42
+
43
+ check_gem_conflicts(@box_manager.gem_resolver, resolver)
44
+
45
+ @resolver = resolver
17
46
  @booted = true
18
- graph
47
+
48
+ { resolver: resolver, box_manager: @box_manager, root_path: root_path }
19
49
  end
20
50
 
21
- def graph
22
- @graph
51
+ def resolver
52
+ @resolver
53
+ end
54
+
55
+ def box_manager
56
+ @box_manager
23
57
  end
24
58
 
25
59
  def booted?
26
60
  @booted || false
27
61
  end
28
62
 
29
- def reset!
30
- @graph = nil
63
+ def root_box
64
+ return nil unless @box_manager && @resolver&.root
65
+
66
+ @box_manager.boxes[@resolver.root.name]
67
+ end
68
+
69
+ def reset
70
+ @resolver = nil
71
+ @box_manager = nil
31
72
  @booted = false
73
+ if defined?(Ruby::Box)
74
+ root_box = Ruby::Box.root
75
+ root_box.__send__(:remove_const, :BOXWERK_GLOBAL) if root_box.const_defined?(:BOXWERK_GLOBAL, false)
76
+ end
32
77
  end
33
78
 
34
79
  private
35
80
 
36
- def find_package_yml(start_dir)
81
+ # Finds the project root directory. Walks up the directory tree
82
+ # looking for boxwerk.yml or package.yml. Falls back to start_dir
83
+ # if neither is found (implicit root).
84
+ def find_root(start_dir)
37
85
  current = File.expand_path(start_dir)
38
86
  loop do
87
+ return current if File.exist?(File.join(current, 'boxwerk.yml'))
39
88
  return current if File.exist?(File.join(current, 'package.yml'))
40
89
 
41
90
  parent = File.dirname(current)
@@ -43,7 +92,66 @@ module Boxwerk
43
92
 
44
93
  current = parent
45
94
  end
46
- nil
95
+
96
+ # Fall back to CWD as implicit root
97
+ File.expand_path(start_dir)
98
+ end
99
+
100
+ # Runs the optional global boot in the root box. The global/
101
+ # directory is autoloaded and global/boot.rb is required in the root
102
+ # box. These run after global gems are loaded but before package
103
+ # boxes are created, so definitions here are inherited by all boxes.
104
+ #
105
+ # Root-level boot.rb is NOT handled here — it runs in the root
106
+ # package box via BoxManager (like any other package boot.rb).
107
+ def run_global_boot(root_path, eager_load: true, global_context: nil)
108
+ root_box = Ruby::Box.root
109
+ global_dir = File.join(root_path, 'global')
110
+ global_boot = File.join(global_dir, 'boot.rb')
111
+ global_entries = nil
112
+
113
+ # Always register lazy autoloads for global/ dir so constants are
114
+ # accessible in boot.rb (autoload fires on access in root box context).
115
+ # This works regardless of eager_load_global so global constants can
116
+ # be used in boot.rb even when eager loading is disabled.
117
+ if File.directory?(global_dir)
118
+ global_context&.__send__(:record_scanned_dir, 'global/')
119
+ entries = ZeitwerkScanner.scan(global_dir)
120
+ global_entries = entries.reject { |e| e.file == global_boot }
121
+ ZeitwerkScanner.register_autoloads(root_box, global_entries) if global_entries.any?
122
+ end
123
+
124
+ # Run global/boot.rb in root box (always, regardless of eager_load)
125
+ root_box.require(global_boot) if File.exist?(global_boot)
126
+
127
+ # After boot.rb, register lazy autoloads for any dirs added via
128
+ # Boxwerk.global.autoloader.push_dir during boot.
129
+ global_context&.autoloader&.setup
130
+
131
+ # Eager-load AFTER boot.rb so boot scripts run first.
132
+ if eager_load
133
+ global_entries&.each { |e| root_box.require(e.file) if e.file }
134
+ global_context&.autoloader&.__send__(:eager_load!)
135
+ end
136
+ end
137
+
138
+ def check_gem_conflicts(gem_resolver, package_resolver)
139
+ conflicts = gem_resolver.check_conflicts(package_resolver)
140
+ conflicts.each do |c|
141
+ warn "Boxwerk: gem '#{c[:gem_name]}' is #{c[:package_version]} in #{c[:package]} " \
142
+ "but #{c[:global_version]} in global gems — both versions will be loaded into memory"
143
+ end
144
+ end
145
+
146
+ # Eager-load all Zeitwerk-managed constants (gem autoloads) in the root
147
+ # box. Child boxes created via Ruby::Box.new inherit a snapshot of the
148
+ # root box's constants. Zeitwerk autoloads are lazy — without eager
149
+ # loading, child boxes inherit pending autoload entries that may not
150
+ # resolve correctly across box boundaries.
151
+ def eager_load_zeitwerk
152
+ Ruby::Box.root.eval(<<~RUBY)
153
+ Zeitwerk::Loader.eager_load_all if defined?(Zeitwerk)
154
+ RUBY
47
155
  end
48
156
  end
49
157
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Boxwerk
4
- VERSION = '0.2.0'
4
+ VERSION = '0.3.0'
5
5
  end