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,405 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boxwerk
4
+ class BoxManager
5
+ attr_reader :boxes, :gem_resolver, :file_indexes, :default_autoload_dirs, :package_dirs_info
6
+
7
+ def initialize(root_path)
8
+ @root_path = root_path
9
+ @boxes = {} # package name -> Ruby::Box
10
+ @file_indexes = {} # package name -> {const_name => abs_path}
11
+ @gem_resolver = GemResolver.new(root_path)
12
+ @default_autoload_dirs = {} # package name -> [relative dir strings]
13
+ @package_dirs_info = {} # package name -> { autoload: [...], collapse: [...], ignore: [...] }
14
+ end
15
+
16
+ # Boot all packages in topological order.
17
+ def boot_all(resolver, eager_load_packages: false)
18
+ resolver.topological_order.each do |package|
19
+ boot(package, resolver)
20
+ next unless eager_load_packages
21
+
22
+ box = @boxes[package.name]
23
+ file_index = @file_indexes[package.name] || {}
24
+ eager_load_box(box, file_index) if box
25
+ end
26
+ end
27
+
28
+ # Boot only the target package and its transitive dependencies.
29
+ # If the target (or any dep) has no enforce_dependencies, boot all
30
+ # packages since it may need to access constants from any of them.
31
+ def boot_package(target, resolver, eager_load_packages: false)
32
+ packages_to_boot = collect_transitive_deps(target, resolver, Set.new)
33
+ packages_to_boot << target unless packages_to_boot.include?(target)
34
+
35
+ # If any package in the set doesn't enforce dependencies, it can
36
+ # access constants from all packages — boot everything.
37
+ if packages_to_boot.any? { |p| !p.enforce_dependencies? }
38
+ packages_to_boot = resolver.topological_order.to_set
39
+ end
40
+
41
+ # Boot in dependency order (deps first)
42
+ ordered =
43
+ resolver.topological_order.select { |p| packages_to_boot.include?(p) }
44
+ ordered.each do |package|
45
+ boot(package, resolver)
46
+ next unless eager_load_packages
47
+
48
+ box = @boxes[package.name]
49
+ file_index = @file_indexes[package.name] || {}
50
+ eager_load_box(box, file_index) if box
51
+ end
52
+ end
53
+
54
+ # Boot a single package: create box, set up gems, scan with Zeitwerk,
55
+ # run boot.rb, register additional dirs, wire dependencies.
56
+ def boot(package, resolver)
57
+ return if @boxes.key?(package.name)
58
+
59
+ box = Ruby::Box.new
60
+ @boxes[package.name] = box
61
+
62
+ # Set up per-package gem load paths
63
+ setup_gem_load_paths(box, package)
64
+
65
+ # Auto-require gems declared in the package Gemfile
66
+ auto_require_gems(box, package)
67
+
68
+ # Scan default directories and register autoloads. Returns default dirs
69
+ # so they can be injected into the PackageContext autoloader.
70
+ file_index, default_dirs = scan_and_register(box, package)
71
+
72
+ # Set BOXWERK_PACKAGE constant in the box for Boxwerk.package access.
73
+ # Pass default_dirs so they appear in the autoloader's dir_info.
74
+ set_package_context(box, package, default_dirs: default_dirs)
75
+
76
+ # Run optional per-package boot.rb, then scan additional dirs
77
+ extra_index = run_package_boot(box, package, file_index)
78
+ file_index.merge!(extra_index) if extra_index
79
+
80
+ @file_indexes[package.name] = file_index
81
+
82
+ # Record all dir info for use by the info command
83
+ record_package_dirs(box, package)
84
+
85
+ # Wire dependency constants into this box
86
+ wire_dependency_constants(box, package, resolver)
87
+ end
88
+
89
+ private
90
+
91
+ def setup_gem_load_paths(box, package)
92
+ gem_paths = @gem_resolver.resolve_for(package)
93
+ return unless gem_paths&.any?
94
+
95
+ gem_paths.each { |path| box.eval("$LOAD_PATH.unshift(#{path.inspect})") }
96
+ end
97
+
98
+ # Auto-require gems based on Gemfile autorequire directives.
99
+ # Mirrors Bundler's default behavior: gems are required unless
100
+ # `require: false` is specified. Skips the root package since its
101
+ # gems are already loaded globally by Bundler. Only auto-requires
102
+ # gems explicitly declared in the Gemfile, not transitive dependencies.
103
+ def auto_require_gems(box, package)
104
+ return if package.root?
105
+
106
+ gems = @gem_resolver.gems_for(package)
107
+ return unless gems&.any?
108
+
109
+ gems.each do |gem_info|
110
+ next if gem_info.name == 'boxwerk'
111
+ next unless gem_info.autorequire.is_a?(Array) || gem_info.autorequire == :default
112
+
113
+ paths = gem_require_paths(gem_info)
114
+ next unless paths
115
+
116
+ paths.each do |path|
117
+ box.eval(<<~RUBY)
118
+ begin
119
+ require #{path.inspect}
120
+ rescue LoadError
121
+ end
122
+ RUBY
123
+ end
124
+ end
125
+ end
126
+
127
+ # Returns the list of require paths for a gem, or nil to skip.
128
+ def gem_require_paths(gem_info)
129
+ case gem_info.autorequire
130
+ when :default then [gem_info.name]
131
+ when [] then nil
132
+ else gem_info.autorequire
133
+ end
134
+ end
135
+
136
+ # Scans package directories with ZeitwerkScanner and registers autoloads
137
+ # in the box. Returns [file_index, default_dirs] where default_dirs is
138
+ # the list of relative dir strings scanned (e.g. ['lib/', 'public/']).
139
+ def scan_and_register(box, package)
140
+ all_entries = []
141
+ default_al_dirs = []
142
+
143
+ # Always compute public path for file scanning. Privacy enforcement
144
+ # controls access, not discovery — files in public/ are always scanned.
145
+ pub_path = PrivacyChecker.public_path_for(package, @root_path)
146
+
147
+ lib_path = package_lib_path(package)
148
+ if lib_path && File.directory?(lib_path)
149
+ default_al_dirs << 'lib/'
150
+ entries = ZeitwerkScanner.scan(lib_path)
151
+ # Exclude constants under public_path (scanned separately)
152
+ if pub_path && pub_path.start_with?(lib_path)
153
+ entries =
154
+ entries.reject do |e|
155
+ e.file&.start_with?(pub_path) || e.dir&.start_with?(pub_path)
156
+ end
157
+ end
158
+ all_entries.concat(entries)
159
+ end
160
+
161
+ # Scan public_path as a separate autoload root so that
162
+ # public/invoice.rb maps to Invoice (not Public::Invoice).
163
+ if pub_path && File.directory?(pub_path)
164
+ pub_rel = package.config['public_path'] || 'public/'
165
+ pub_rel = "#{pub_rel}/" unless pub_rel.end_with?('/')
166
+ default_al_dirs << pub_rel
167
+ all_entries.concat(ZeitwerkScanner.scan(pub_path))
168
+ end
169
+
170
+ ZeitwerkScanner.register_autoloads(box, all_entries)
171
+ @default_autoload_dirs[package.name] = default_al_dirs
172
+
173
+ [ZeitwerkScanner.build_file_index(all_entries), default_al_dirs]
174
+ end
175
+
176
+ # Runs the optional per-package boot.rb in the package's box context.
177
+ # Returns additional file index entries from configured autoload dirs.
178
+ def run_package_boot(box, package, file_index)
179
+ pkg_dir = package_dir(package)
180
+ boot_script = File.join(pkg_dir, 'boot.rb')
181
+ return nil unless File.exist?(boot_script)
182
+
183
+ # Retrieve autoloader from the PackageContext already set on the box
184
+ context = box.const_get(:BOXWERK_PACKAGE)
185
+ autoloader = context.autoloader
186
+
187
+ # Run boot.rb in the package's box
188
+ box.require(boot_script)
189
+
190
+ # Read back config and apply additional autoload dirs
191
+ apply_boot_config(box, package, autoloader, file_index)
192
+ end
193
+
194
+ # Reads autoload configuration from the PackageContext autoloader
195
+ # after boot.rb runs. All push_dir/collapse calls during boot.rb auto-
196
+ # called setup, so accumulated_file_index already contains their entries.
197
+ # This method handles namespace cleanup for collapse/ignore dirs and
198
+ # merges the accumulated file index.
199
+ def apply_boot_config(box, package, autoloader, file_index)
200
+ pkg_dir = package_dir(package)
201
+
202
+ # Remove intermediate namespaces for collapsed dirs (e.g. Analytics::Formatters)
203
+ autoloader.__send__(:all_collapse_dirs).each do |dir|
204
+ abs_dir = File.expand_path(dir, pkg_dir)
205
+ next unless File.directory?(abs_dir)
206
+ root_dir = autoloader.__send__(:find_root_for, abs_dir)
207
+ next unless root_dir
208
+ remove_namespace_for_dir(box, abs_dir, root_dir, file_index)
209
+ end
210
+
211
+ # Remove constants registered for ignored dirs
212
+ autoloader.__send__(:ignore_dirs).each do |dir|
213
+ abs_dir = File.expand_path(dir, pkg_dir)
214
+ next unless File.directory?(abs_dir)
215
+ root_dir = autoloader.__send__(:find_root_for, abs_dir)
216
+ remove_namespace_for_dir(box, abs_dir, root_dir, file_index)
217
+ end
218
+
219
+ accumulated = autoloader.__send__(:accumulated_file_index)
220
+ accumulated.empty? ? nil : accumulated
221
+ end
222
+
223
+ # Installs a const_missing handler on the box that searches dependency
224
+ # boxes for the requested constant. When enforce_dependencies is false,
225
+ # ALL packages are searchable (explicit deps first, then rest).
226
+ # Privacy rules are still enforced per-dependency.
227
+ def wire_dependency_constants(box, package, package_resolver)
228
+ deps_config = []
229
+
230
+ if package.enforce_dependencies?
231
+ # Only search explicit dependencies
232
+ search_packages = package_resolver.direct_dependencies(package)
233
+ else
234
+ # Search explicit deps first, then all remaining packages
235
+ explicit = package_resolver.direct_dependencies(package)
236
+ remaining =
237
+ package_resolver
238
+ .all_except(package)
239
+ .reject { |p| explicit.include?(p) }
240
+ search_packages = explicit + remaining
241
+ end
242
+
243
+ search_packages.each do |dep|
244
+ dep_box = @boxes[dep.name]
245
+ next unless dep_box
246
+
247
+ dep_file_index = @file_indexes[dep.name] || {}
248
+
249
+ pub_consts = PrivacyChecker.public_constants(dep, @root_path)
250
+ priv_consts =
251
+ (
252
+ if PrivacyChecker.enforces_privacy?(dep)
253
+ PrivacyChecker.private_constants_list(dep)
254
+ else
255
+ nil
256
+ end
257
+ )
258
+
259
+ deps_config << {
260
+ box: dep_box,
261
+ file_index: dep_file_index,
262
+ public_constants: pub_consts,
263
+ private_constants: priv_consts,
264
+ package_name: dep.name,
265
+ }
266
+ end
267
+
268
+ # Pass references for lazy hint lookup — other packages may not
269
+ # have been booted yet when this runs.
270
+ dep_names = search_packages.map(&:name).to_set
271
+ all_packages_ref = {
272
+ file_indexes: @file_indexes,
273
+ packages: package_resolver.packages,
274
+ root_path: @root_path,
275
+ dep_names: dep_names,
276
+ self_name: package.name,
277
+ }
278
+
279
+ # Always install a resolver so that NameError hints work even when
280
+ # there are no declared dependencies.
281
+ return if deps_config.empty? && package_resolver.packages.size <= 1
282
+
283
+ ConstantResolver.install_dependency_resolver(
284
+ box,
285
+ deps_config,
286
+ all_packages_ref: all_packages_ref,
287
+ package_name: package.name,
288
+ )
289
+ end
290
+
291
+ # Sets the BOXWERK_PACKAGE constant in the box with a PackageContext
292
+ # and overrides Boxwerk.package in the box to return it.
293
+ # default_dirs: relative autoload dir strings from scan_and_register
294
+ # (e.g. ['lib/', 'public/']), passed to the autoloader constructor.
295
+ def set_package_context(box, package, default_dirs: [])
296
+ pkg_dir = package_dir(package)
297
+ autoloader = PackageContext::Autoloader.new(pkg_dir, box: box, default_autoload_dirs: default_dirs)
298
+ context =
299
+ PackageContext.new(
300
+ name: package.name,
301
+ root_path: pkg_dir,
302
+ config: package.config,
303
+ autoloader: autoloader,
304
+ )
305
+ box.const_set(:BOXWERK_PACKAGE, context)
306
+
307
+ # Override Boxwerk.package in this box so it returns the box's
308
+ # own BOXWERK_PACKAGE. Monkey patch isolation ensures this only
309
+ # affects this box.
310
+ box.eval(<<~RUBY)
311
+ module Boxwerk
312
+ def self.package
313
+ BOXWERK_PACKAGE
314
+ end
315
+ end
316
+ RUBY
317
+ end
318
+
319
+ # Eager-loads all constants in a box by requiring every file in
320
+ # the file index.
321
+ def eager_load_box(box, file_index)
322
+ file_index.each_value do |file|
323
+ next unless file
324
+ box.require(file)
325
+ end
326
+ end
327
+
328
+ # Recursively collects all transitive dependencies of a package.
329
+ def collect_transitive_deps(package, resolver, visited)
330
+ deps = Set.new
331
+ package.dependencies.each do |dep_name|
332
+ next if visited.include?(dep_name)
333
+ visited.add(dep_name)
334
+ dep = resolver.packages[dep_name]
335
+ next unless dep
336
+ deps.add(dep)
337
+ deps.merge(collect_transitive_deps(dep, resolver, visited))
338
+ end
339
+ deps
340
+ end
341
+
342
+ def package_dir(package)
343
+ if package.root?
344
+ @root_path
345
+ else
346
+ File.join(@root_path, package.name)
347
+ end
348
+ end
349
+
350
+ # Records all autoload/collapse/ignore dirs for a package after boot.
351
+ # Used by the info command.
352
+ def record_package_dirs(box, package)
353
+ al = box.const_get(:BOXWERK_PACKAGE)&.autoloader rescue nil
354
+ pkg_dir = package_dir(package)
355
+ info = al ? al.__send__(:dir_info) : { autoload: [], collapse: [], ignore: [] }
356
+ @package_dirs_info[package.name] = {
357
+ autoload: info[:autoload].map { |d| normalize_for_info(d, pkg_dir) },
358
+ collapse: info[:collapse].map { |d| normalize_for_info(d, pkg_dir) },
359
+ ignore: info[:ignore].map { |d| normalize_for_info(d, pkg_dir) },
360
+ }
361
+ end
362
+
363
+ # Converts an absolute path to a relative dir string (with trailing slash),
364
+ # or returns the relative string as-is.
365
+ def normalize_for_info(dir, base_path)
366
+ if dir.start_with?('/')
367
+ rel = dir.delete_prefix("#{base_path}/")
368
+ rel == dir ? dir : "#{rel.chomp('/')}/"
369
+ else
370
+ "#{dir.chomp('/')}/"
371
+ end
372
+ end
373
+
374
+ def package_lib_path(package)
375
+ if package.root?
376
+ nil
377
+ else
378
+ File.join(@root_path, package.name, 'lib')
379
+ end
380
+ end
381
+
382
+ # Removes a directory's namespace constant (and its children) from the box.
383
+ # Used for collapse (removes intermediate namespace) and ignore (removes namespace).
384
+ # Also removes matching entries from file_index.
385
+ def remove_namespace_for_dir(box, abs_dir, root_dir, file_index)
386
+ inflector = Zeitwerk::Inflector.new
387
+ rel = abs_dir.delete_prefix("#{root_dir}/")
388
+ parts = rel.split('/')
389
+ ns_cnames = parts.map { |p| inflector.camelize(p, root_dir) }
390
+ parent_ns = ns_cnames[0...-1].join('::')
391
+ ns_cname = ns_cnames.last
392
+ ns_full = ns_cnames.join('::')
393
+
394
+ # Remove the constant from the box (works for both autoloads and defined constants)
395
+ if parent_ns.empty?
396
+ box.eval("remove_const(:#{ns_cname}) rescue nil")
397
+ else
398
+ box.eval("#{parent_ns}.send(:remove_const, :#{ns_cname}) rescue nil")
399
+ end
400
+
401
+ # Remove all file_index entries under this namespace
402
+ file_index.reject! { |k, _| k == ns_full || k.start_with?("#{ns_full}::") }
403
+ end
404
+ end
405
+ end