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.
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boxwerk
4
+ # Resolves constants from dependency packages without namespace wrapping.
5
+ #
6
+ # When package A depends on package B, B's constants are accessible
7
+ # directly in A (e.g. Invoice, not Finance::Invoice). A const_missing
8
+ # handler searches all direct dependencies in order and resolves the
9
+ # first match. Privacy enforcement is applied per-dependency.
10
+ module ConstantResolver
11
+ # Installs a const_missing handler on the box that searches all
12
+ # dependency boxes for the requested constant. Each dependency entry
13
+ # contains: :box, :file_index, :public_constants, :private_constants,
14
+ # :package_name.
15
+ #
16
+ # +all_packages_ref+ is an optional hash with lazy references to
17
+ # :file_indexes, :packages, :root_path, :dep_names, and :self_name
18
+ # for producing helpful NameError hints.
19
+ # +package_name+ is the name of the current package (for error messages).
20
+ def self.install_dependency_resolver(
21
+ box,
22
+ deps_config,
23
+ all_packages_ref: nil,
24
+ package_name: nil
25
+ )
26
+ resolver =
27
+ build_resolver(
28
+ deps_config,
29
+ all_packages_ref: all_packages_ref,
30
+ package_name: package_name,
31
+ )
32
+ box.const_set(:BOXWERK_DEPENDENCY_RESOLVER, resolver)
33
+
34
+ # Define const_missing on Object within the box so that top-level
35
+ # constant lookups (e.g. Invoice) trigger the dependency search.
36
+ box.eval(<<~RUBY)
37
+ class Object
38
+ def self.const_missing(const_name)
39
+ BOXWERK_DEPENDENCY_RESOLVER.call(const_name)
40
+ end
41
+ end
42
+ RUBY
43
+ end
44
+
45
+ # Builds a resolver proc that searches dependencies for a constant.
46
+ def self.build_resolver(
47
+ deps_config,
48
+ all_packages_ref: nil,
49
+ package_name: nil
50
+ )
51
+ proc do |const_name|
52
+ name_str = const_name.to_s
53
+ found = false
54
+ value = nil
55
+
56
+ deps_config.each do |dep|
57
+ dep_box = dep[:box]
58
+ file_index = dep[:file_index]
59
+ public_constants = dep[:public_constants]
60
+ private_constants = dep[:private_constants]
61
+ pkg_name = dep[:package_name]
62
+
63
+ # Check if this dependency has the constant or a namespace
64
+ # matching it (e.g. "Menu" when file_index has "Menu::Item")
65
+ has_constant =
66
+ file_index.key?(name_str) ||
67
+ file_index.any? { |k, _| k.start_with?("#{name_str}::") } ||
68
+ (
69
+ begin
70
+ dep_box.const_get(const_name)
71
+ true
72
+ rescue NameError
73
+ false
74
+ end
75
+ )
76
+
77
+ next unless has_constant
78
+
79
+ # Check explicitly private constants
80
+ if private_constants && !private_constants.empty?
81
+ if private_constants.include?(name_str) ||
82
+ private_constants.any? { |pc| name_str.start_with?("#{pc}::") }
83
+ from = package_name ? " referenced from '#{package_name}'" : ''
84
+ raise NameError.new(
85
+ "private constant #{name_str}#{from} — #{name_str} is private to '#{pkg_name}'",
86
+ const_name,
87
+ )
88
+ end
89
+ end
90
+
91
+ # Check public constants whitelist (privacy enforcement).
92
+ # A namespace module (e.g. Menu) is allowed if any public
93
+ # constant lives under it (e.g. Menu::Item).
94
+ if public_constants
95
+ direct_match = public_constants.include?(name_str)
96
+ namespace_match =
97
+ public_constants.any? { |pc| pc.start_with?("#{name_str}::") }
98
+ unless direct_match || namespace_match
99
+ from = package_name ? " referenced from '#{package_name}'" : ''
100
+ raise NameError.new(
101
+ "private constant #{name_str}#{from} — #{name_str} is private to '#{pkg_name}'",
102
+ const_name,
103
+ )
104
+ end
105
+ end
106
+
107
+ # Resolve the constant from the dependency box
108
+ value =
109
+ begin
110
+ dep_box.const_get(const_name)
111
+ rescue NameError
112
+ file = file_index[name_str]
113
+ if file
114
+ dep_box.require(file)
115
+ dep_box.const_get(const_name)
116
+ else
117
+ # Namespace module — trigger autoload of a child constant
118
+ # so the module gets defined in the dependency box.
119
+ child_key =
120
+ file_index.keys.find { |k| k.start_with?("#{name_str}::") }
121
+ if child_key
122
+ child_file = file_index[child_key]
123
+ dep_box.require(child_file)
124
+ dep_box.const_get(const_name)
125
+ else
126
+ raise NameError.new(
127
+ "uninitialized constant #{name_str}",
128
+ const_name,
129
+ )
130
+ end
131
+ end
132
+ end
133
+
134
+ found = true
135
+ break
136
+ end
137
+
138
+ unless found
139
+ hint = find_hint(name_str, all_packages_ref)
140
+ msg =
141
+ if hint
142
+ visibility = hint[:private] ? 'private in' : 'defined in'
143
+ from = package_name ? ", not a dependency of '#{package_name}'" : ''
144
+ "uninitialized constant #{name_str} (#{visibility} '#{hint[:package_name]}'#{from})"
145
+ else
146
+ "uninitialized constant #{name_str}"
147
+ end
148
+ raise NameError.new(msg, const_name)
149
+ end
150
+
151
+ value
152
+ end
153
+ end
154
+
155
+ # Searches all packages (via lazy ref) for one whose file_index
156
+ # contains the given constant name. Returns a hash with :package_name
157
+ # and :private (boolean), or nil if not found.
158
+ #
159
+ # For packages that weren't booted (not in file_indexes), does a
160
+ # lightweight file-system scan so hints work with selective booting.
161
+ def self.find_hint(name_str, all_packages_ref)
162
+ return nil unless all_packages_ref
163
+
164
+ file_indexes = all_packages_ref[:file_indexes]
165
+ packages = all_packages_ref[:packages]
166
+ root_path = all_packages_ref[:root_path]
167
+ dep_names = all_packages_ref[:dep_names]
168
+ self_name = all_packages_ref[:self_name]
169
+
170
+ packages.each_value do |pkg|
171
+ next if pkg.name == self_name
172
+ next if dep_names.include?(pkg.name)
173
+
174
+ pkg_file_index = file_indexes[pkg.name]
175
+
176
+ if pkg_file_index
177
+ # Package was booted — use its file index directly
178
+ next unless pkg_file_index.key?(name_str) ||
179
+ pkg_file_index.any? { |k, _| k.start_with?("#{name_str}::") }
180
+ else
181
+ # Package was not booted — do a lightweight file-system scan
182
+ pkg_file_index = scan_package_for_hint(pkg, root_path)
183
+ next unless pkg_file_index.key?(name_str) ||
184
+ pkg_file_index.any? { |k, _| k.start_with?("#{name_str}::") }
185
+ end
186
+
187
+ pub_consts = PrivacyChecker.public_constants(pkg, root_path)
188
+ is_private =
189
+ if pub_consts
190
+ !pub_consts.include?(name_str) &&
191
+ !pub_consts.any? { |pc| pc.start_with?("#{name_str}::") }
192
+ else
193
+ false
194
+ end
195
+
196
+ return { package_name: pkg.name, private: is_private }
197
+ end
198
+ nil
199
+ end
200
+
201
+ # Lightweight file-system scan to build a constant → file map for a
202
+ # package without booting its box. Used for NameError hints.
203
+ #
204
+ # Does NOT use Zeitwerk::Loader (which registers dirs globally and
205
+ # would conflict if called twice for the same dir). Instead performs
206
+ # a plain Dir.glob walk with Zeitwerk::Inflector for naming.
207
+ def self.scan_package_for_hint(pkg, root_path)
208
+ pkg_dir =
209
+ if pkg.root?
210
+ root_path
211
+ else
212
+ File.join(root_path, pkg.name)
213
+ end
214
+
215
+ lib_dir = pkg.root? ? nil : File.join(pkg_dir, 'lib')
216
+ pub_dir = PrivacyChecker.public_path_for(pkg, root_path)
217
+
218
+ inflector = Zeitwerk::Inflector.new
219
+ result = {}
220
+
221
+ [lib_dir, pub_dir].compact.each do |dir|
222
+ next unless File.directory?(dir)
223
+
224
+ Dir.glob(File.join(dir, '**', '*.rb')).sort.each do |abspath|
225
+ relative = abspath.delete_prefix("#{dir}/").delete_suffix('.rb')
226
+ parts = relative.split('/')
227
+ cnames = parts.map { |part| inflector.camelize(part, abspath) }
228
+ full_path = cnames.join('::')
229
+ result[full_path] ||= abspath
230
+ end
231
+ end
232
+
233
+ result
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler'
4
+
5
+ module Boxwerk
6
+ # Resolves per-package gem dependencies from lockfiles.
7
+ #
8
+ # Parses lockfiles with Bundler::LockfileParser and resolves gem load paths
9
+ # for $LOAD_PATH isolation per box. Unlike Bundler itself, this resolver can
10
+ # find gems outside the current bundle by searching all gem directories.
11
+ #
12
+ # Gems are fully isolated per box — they do not leak across package
13
+ # boundaries. Cross-package version conflicts are harmless because each box
14
+ # has its own $LOAD_PATH and $LOADED_FEATURES snapshot. The only situation
15
+ # worth flagging is when a package defines a gem that is also in the root
16
+ # Gemfile at a different version: both versions end up in memory (the global
17
+ # version inherited at box creation, the package version loaded on demand).
18
+ class GemResolver
19
+ # Represents a resolved gem for a package: name, version, load paths,
20
+ # and autorequire directives from the Gemfile.
21
+ # autorequire: nil → transitive dependency (not in Gemfile)
22
+ # autorequire: :default → require the gem name (Gemfile entry, no require option)
23
+ # autorequire: [] → skip (require: false)
24
+ # autorequire: ["x/y"] → require each listed path
25
+ GemInfo = Struct.new(:name, :version, :load_paths, :autorequire, keyword_init: true)
26
+
27
+ attr_reader :root_path
28
+
29
+ def initialize(root_path)
30
+ @root_path = root_path
31
+ @all_specs = nil
32
+ @package_gems = {} # package name -> [GemInfo]
33
+ end
34
+
35
+ # Returns an array of load paths for a package's gem dependencies.
36
+ # Returns nil if the package has no gems.rb/Gemfile.
37
+ def resolve_for(package)
38
+ gems = gems_for(package)
39
+ return nil unless gems&.any?
40
+
41
+ gems.flat_map(&:load_paths).uniq
42
+ end
43
+
44
+ # Returns [GemInfo] for a package, cached after first resolution.
45
+ # Merges lockfile resolution with Gemfile autorequire directives.
46
+ def gems_for(package)
47
+ return @package_gems[package.name] if @package_gems.key?(package.name)
48
+
49
+ gemfile_path = find_gemfile(package)
50
+ unless gemfile_path
51
+ @package_gems[package.name] = nil
52
+ return nil
53
+ end
54
+
55
+ lockfile_path = find_lockfile(gemfile_path)
56
+ unless lockfile_path && File.exist?(lockfile_path)
57
+ pkg_label = package.root? ? '.' : package.name
58
+ warn "Boxwerk: No lockfile found for #{pkg_label}. Run 'boxwerk install' to install gems."
59
+ @package_gems[package.name] = nil
60
+ return nil
61
+ end
62
+
63
+ requires = parse_gemfile_requires(gemfile_path)
64
+ gems = resolve_gems_from_lockfile(lockfile_path)
65
+ gems.each do |g|
66
+ next unless requires.key?(g.name)
67
+ # :default means "require the gem name" (Gemfile entry with no require option).
68
+ # nil means transitive dependency (not in Gemfile) — skip auto-require.
69
+ g.autorequire = requires[g.name] || :default
70
+ end
71
+ @package_gems[package.name] = gems
72
+ gems
73
+ end
74
+
75
+ # Parses the Gemfile to extract autorequire directives.
76
+ # Returns { gem_name => autorequire_value } where value is:
77
+ # nil → require the gem name (default)
78
+ # [] → skip (require: false)
79
+ # ["path"] → require the listed paths
80
+ #
81
+ # Uses a lightweight DSL evaluator to avoid Bundler::Dsl side-effects.
82
+ def parse_gemfile_requires(gemfile_path)
83
+ parser = GemfileRequireParser.new
84
+ parser.eval_gemfile(gemfile_path)
85
+ parser.requires
86
+ end
87
+
88
+ # Checks for gem version conflicts between global and per-package gems.
89
+ # Returns an array of conflict descriptions (empty if none).
90
+ #
91
+ # Cross-package conflicts are NOT checked because gems are fully isolated
92
+ # per box — each box has its own $LOAD_PATH and $LOADED_FEATURES snapshot.
93
+ # Package A can safely use gem Z v2 while package B uses gem Z v1, even if
94
+ # A depends on B.
95
+ #
96
+ # The only conflict worth flagging is a **global override**: a package
97
+ # defines a gem that is also in the root Gemfile at a different version.
98
+ # Both versions load into memory (global inherited at box creation,
99
+ # package version loaded on demand), which wastes memory but is
100
+ # functionally correct.
101
+ def check_conflicts(package_resolver)
102
+ conflicts = []
103
+
104
+ root_gems = gems_for(package_resolver.root)
105
+ return conflicts unless root_gems&.any?
106
+
107
+ root_gem_map = root_gems.each_with_object({}) { |g, h| h[g.name] = g }
108
+
109
+ package_resolver.packages.each_value do |pkg|
110
+ next if pkg.root?
111
+
112
+ pkg_gems = gems_for(pkg)
113
+ next unless pkg_gems
114
+
115
+ pkg_gems.each do |gem_info|
116
+ root_gem = root_gem_map[gem_info.name]
117
+ next unless root_gem
118
+ next if root_gem.version == gem_info.version
119
+
120
+ conflicts << {
121
+ type: :global_override,
122
+ gem_name: gem_info.name,
123
+ package: pkg.name,
124
+ package_version: gem_info.version,
125
+ global_version: root_gem.version,
126
+ }
127
+ end
128
+ end
129
+
130
+ conflicts
131
+ end
132
+
133
+ private
134
+
135
+ def package_dir(package)
136
+ if package.root?
137
+ @root_path
138
+ else
139
+ File.join(@root_path, package.name)
140
+ end
141
+ end
142
+
143
+ def find_gemfile(package)
144
+ dir = package_dir(package)
145
+
146
+ gemfile = File.join(dir, 'gems.rb')
147
+ return gemfile if File.exist?(gemfile)
148
+
149
+ gemfile = File.join(dir, 'Gemfile')
150
+ return gemfile if File.exist?(gemfile)
151
+
152
+ nil
153
+ end
154
+
155
+ def find_lockfile(gemfile_path)
156
+ if gemfile_path.end_with?('gems.rb')
157
+ gemfile_path.sub('gems.rb', 'gems.locked')
158
+ else
159
+ "#{gemfile_path}.lock"
160
+ end
161
+ end
162
+
163
+ # Parses a lockfile and returns [GemInfo] with resolved load paths.
164
+ # Path gems (like `boxwerk`) that can't be found in gem dirs are included
165
+ # with empty load_paths so they appear in display output.
166
+ def resolve_gems_from_lockfile(lockfile_path)
167
+ lockfile_content = File.read(lockfile_path)
168
+ parser = Bundler::LockfileParser.new(lockfile_content)
169
+
170
+ seen = Set.new
171
+ gems = []
172
+ parser.specs.each do |spec|
173
+ # Deduplicate by name — lockfiles can have multiple platform-specific
174
+ # variants of the same gem (e.g. sqlite3 for x86_64-linux and arm64-darwin).
175
+ next unless seen.add?(spec.name)
176
+
177
+ paths = resolve_gem_paths(spec.name, spec.version.to_s)
178
+ gems << GemInfo.new(
179
+ name: spec.name,
180
+ version: spec.version.to_s,
181
+ load_paths: paths || [],
182
+ )
183
+ end
184
+
185
+ gems
186
+ end
187
+
188
+ # Resolves load paths for a specific gem version.
189
+ def resolve_gem_paths(name, version)
190
+ spec = find_gem_spec(name, version)
191
+ unless spec
192
+ unless name == 'boxwerk'
193
+ warn "Boxwerk: gem '#{name}' (#{version}) not installed, skipping"
194
+ end
195
+ return nil
196
+ end
197
+ collect_paths(spec)
198
+ end
199
+
200
+ # Finds a gem specification by searching all gem directories.
201
+ def find_gem_spec(name, version)
202
+ all_gem_specs.find { |s| s.name == name && s.version.to_s == version } ||
203
+ all_gem_specs.find { |s| s.name == name }
204
+ end
205
+
206
+ # Loads all gem specifications from all gem directories.
207
+ # Cached for the lifetime of this resolver.
208
+ def all_gem_specs
209
+ @all_specs ||=
210
+ begin
211
+ dirs =
212
+ Gem.path.flat_map do |p|
213
+ Dir.glob(File.join(p, 'specifications', '*.gemspec'))
214
+ end
215
+ dirs.map { |path| Gem::Specification.load(path) }.compact
216
+ end
217
+ end
218
+
219
+ # Recursively collects load paths for a gem and its runtime dependencies.
220
+ def collect_paths(spec, resolved = Set.new)
221
+ return [] if resolved.include?(spec.name)
222
+
223
+ resolved.add(spec.name)
224
+ paths = spec.full_require_paths.dup
225
+
226
+ spec.runtime_dependencies.each do |dep|
227
+ dep_spec = find_gem_spec(dep.name, dep.requirement.to_s.delete('= '))
228
+ dep_spec ||= all_gem_specs.find { |s| s.name == dep.name }
229
+ paths.concat(collect_paths(dep_spec, resolved)) if dep_spec
230
+ end
231
+
232
+ paths
233
+ end
234
+ end
235
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boxwerk
4
+ # Lightweight Gemfile parser that extracts autorequire directives.
5
+ #
6
+ # Evaluates a Gemfile in a sandbox that only captures `gem` calls,
7
+ # avoiding Bundler::Dsl side-effects. Handles:
8
+ # gem 'name' → nil (require the gem name)
9
+ # gem 'name', require: false → [] (skip)
10
+ # gem 'name', require: 'x' → ["x"] (require specific paths)
11
+ class GemfileRequireParser
12
+ attr_reader :requires
13
+
14
+ def initialize
15
+ @requires = {}
16
+ end
17
+
18
+ def eval_gemfile(path)
19
+ instance_eval(File.read(path), path)
20
+ end
21
+
22
+ private
23
+
24
+ def source(*); end
25
+ def ruby(*); end
26
+ def git_source(*); end
27
+ def platform(*); end
28
+ def platforms(*); end
29
+ def group(*); end
30
+ def install_if(*); end
31
+ def plugin(*); end
32
+
33
+ def gem(name, *args)
34
+ opts = args.last.is_a?(Hash) ? args.last : {}
35
+
36
+ if opts.key?(:require)
37
+ val = opts[:require]
38
+ @requires[name] =
39
+ case val
40
+ when false then []
41
+ when String then [val]
42
+ when Array then val
43
+ else nil
44
+ end
45
+ else
46
+ @requires[name] = nil
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boxwerk
4
+ # Runtime context for the root box, accessible via +Boxwerk.global+ from
5
+ # any box context (global boot, package boot, or application code).
6
+ class GlobalContext
7
+ # @return [GlobalContext::Autoloader] Autoloader configuration for the root box.
8
+ attr_reader :autoloader
9
+
10
+ def initialize(root_path)
11
+ @root_path = root_path
12
+ @autoloader = Autoloader.new(root_path)
13
+ @default_dirs = []
14
+ end
15
+
16
+ # Autoload configuration for the root box. Provides {AutoloaderMixin}'s
17
+ # +push_dir+, +collapse+, +ignore+, and +setup+ in +global/boot.rb+.
18
+ #
19
+ # Registrations are lazy (autoload entries only) until the framework
20
+ # eager-loads them after +global/boot.rb+ completes.
21
+ class Autoloader
22
+ include AutoloaderMixin
23
+
24
+ def initialize(root_path)
25
+ init_dirs
26
+ @root_path = root_path
27
+ @accumulated_entries = []
28
+ end
29
+
30
+ private
31
+
32
+ def do_setup(new_push, new_collapse)
33
+ all_entries = []
34
+
35
+ new_push.each do |dir|
36
+ abs_dir = File.expand_path(dir, @root_path)
37
+ next unless File.directory?(abs_dir)
38
+ all_entries.concat(ZeitwerkScanner.scan(abs_dir))
39
+ end
40
+
41
+ new_collapse.each do |dir|
42
+ abs_dir = File.expand_path(dir, @root_path)
43
+ next unless File.directory?(abs_dir)
44
+ all_entries.concat(ZeitwerkScanner.scan_files_only(abs_dir))
45
+ end
46
+
47
+ return if all_entries.empty?
48
+
49
+ ZeitwerkScanner.register_autoloads(Ruby::Box.root, all_entries)
50
+ @accumulated_entries.concat(all_entries)
51
+ end
52
+
53
+ # Eagerly requires all registered files so child boxes inherit constants.
54
+ # Called by Setup after global/boot.rb when eager_load_global is true.
55
+ def eager_load!
56
+ return if @accumulated_entries.empty?
57
+
58
+ root_box = Ruby::Box.root
59
+ @accumulated_entries.each { |e| root_box.require(e.file) if e.file }
60
+ end
61
+
62
+ # Dir info for the info command (accessed via GlobalContext#dir_info).
63
+ def dir_info
64
+ { autoload: @push_dirs.dup, collapse: @collapse_dirs.dup, ignore: @ignore_dirs.dup }
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ # Records a directory scanned by Setup (e.g. global/) for the info command.
71
+ def record_scanned_dir(rel_path)
72
+ @default_dirs << rel_path
73
+ end
74
+
75
+ # Returns combined dir info for the info command.
76
+ def dir_info
77
+ al_info = @autoloader.__send__(:dir_info)
78
+ {
79
+ autoload: @default_dirs + al_info[:autoload],
80
+ collapse: al_info[:collapse],
81
+ ignore: al_info[:ignore],
82
+ }
83
+ end
84
+ end
85
+ end