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.
- checksums.yaml +4 -4
- data/AGENTS.md +24 -0
- data/ARCHITECTURE.md +264 -0
- data/CHANGELOG.md +59 -11
- data/README.md +56 -174
- data/Rakefile +46 -3
- data/TODO.md +317 -0
- data/USAGE.md +505 -0
- data/exe/boxwerk +55 -14
- data/lib/boxwerk/autoloader_mixin.rb +65 -0
- data/lib/boxwerk/box_manager.rb +405 -0
- data/lib/boxwerk/cli.rb +776 -37
- data/lib/boxwerk/constant_resolver.rb +236 -0
- data/lib/boxwerk/gem_resolver.rb +235 -0
- data/lib/boxwerk/gemfile_require_parser.rb +50 -0
- data/lib/boxwerk/global_context.rb +85 -0
- data/lib/boxwerk/package.rb +76 -24
- data/lib/boxwerk/package_context.rb +103 -0
- data/lib/boxwerk/package_resolver.rb +122 -0
- data/lib/boxwerk/privacy_checker.rb +159 -0
- data/lib/boxwerk/setup.rb +124 -16
- data/lib/boxwerk/version.rb +1 -1
- data/lib/boxwerk/zeitwerk_scanner.rb +172 -0
- data/lib/boxwerk.rb +30 -3
- metadata +54 -11
- data/lib/boxwerk/graph.rb +0 -53
- data/lib/boxwerk/loader.rb +0 -149
- data/lib/boxwerk/registry.rb +0 -26
|
@@ -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
|