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.
data/lib/boxwerk/cli.rb CHANGED
@@ -1,27 +1,40 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'rbconfig'
4
+ require 'stringio'
5
+
3
6
  module Boxwerk
4
- # CLI parses commands and delegates to Setup for package management.
5
- # Handles run, console, and help commands.
6
7
  module CLI
7
8
  class << self
8
- def run(argv)
9
+ attr_accessor :exe_path
10
+
11
+ def run(argv, exe_path: nil)
12
+ @exe_path = exe_path
9
13
  if argv.empty?
10
14
  print_usage
11
15
  exit 1
12
16
  end
13
17
 
14
18
  case argv[0]
19
+ when 'exec'
20
+ exec_command(argv[1..])
15
21
  when 'run'
16
- run_command(argv[1..-1])
22
+ run_command(argv[1..])
17
23
  when 'console'
18
- console_command(argv[1..-1])
24
+ console_command(argv[1..])
25
+ when 'info'
26
+ info_command
27
+ when 'install'
28
+ install_command
19
29
  when 'help', '--help', '-h'
20
30
  print_usage
21
31
  exit 0
32
+ when 'version', '--version', '-v'
33
+ puts "boxwerk #{Boxwerk::VERSION}"
34
+ exit 0
22
35
  else
23
- puts "Error: Unknown command '#{argv[0]}'"
24
- puts ''
36
+ $stderr.puts "Error: Unknown command '#{argv[0]}'"
37
+ $stderr.puts ''
25
38
  print_usage
26
39
  exit 1
27
40
  end
@@ -30,69 +43,795 @@ module Boxwerk
30
43
  private
31
44
 
32
45
  def print_usage
33
- puts 'Boxwerk - Ruby package system with Box-powered constant isolation'
46
+ puts "boxwerk #{Boxwerk::VERSION} Runtime package isolation for Ruby"
34
47
  puts ''
35
- puts 'Usage: boxwerk <command> [args...]'
48
+ puts 'Usage: boxwerk <command> [options] [args...]'
36
49
  puts ''
37
50
  puts 'Commands:'
38
- puts ' run <script.rb> [args...] Run a script in the root package context'
39
- puts ' console [irb-args...] Start an IRB console in the root package context'
51
+ puts ' exec <command> [args...] Execute a command in the boxed environment'
52
+ puts ' run <script.rb> [args...] Run a Ruby script in a package box'
53
+ puts ' console [irb-args...] Start an IRB console in a package box'
54
+ puts ' RUBY_BOX=1 info Boot and show runtime autoload structure'
55
+ puts ' install Install gems for all packages'
40
56
  puts ' help Show this help message'
57
+ puts ' version Show version'
58
+ puts ''
59
+ puts 'Options:'
60
+ puts ' -p, --package <name> Run in a specific package box (default: .)'
61
+ puts ' -a, --all Run exec for all packages sequentially'
62
+ puts ' -g, --global Run in the global context (no package)'
63
+ puts ' --package-paths <paths> Comma-separated package path globs'
64
+ puts ' --[no-]eager-load-global Toggle global eager loading'
65
+ puts ' --[no-]eager-load-packages Toggle package eager loading'
66
+ puts ''
67
+ puts 'Examples:'
68
+ puts ' boxwerk run main.rb'
69
+ puts ' boxwerk exec rake test'
70
+ puts ' boxwerk exec --package packs/util rake test'
71
+ puts ' boxwerk exec --all rake test'
72
+ puts ' boxwerk console'
73
+ puts ' boxwerk console --global'
74
+ puts ''
75
+ puts 'Setup:'
76
+ puts ' gem install boxwerk Install boxwerk'
77
+ puts ' RUBY_BOX=1 boxwerk run main.rb Run your app'
78
+ puts ''
79
+ puts ' # Or with Bundler:'
80
+ puts ' bundle install Install gems (including boxwerk)'
81
+ puts ' bundle binstubs boxwerk Create bin/boxwerk binstub'
82
+ puts ' RUBY_BOX=1 bin/boxwerk run main.rb Run your app'
83
+ puts ''
84
+ puts 'Requires: Ruby 4.0+ with RUBY_BOX=1 for exec/run/console commands'
85
+ end
86
+
87
+ # Parses --package/-p, --all/-a, and --global/-g flags from args, returning
88
+ # { package: name_or_nil, all: bool, global: bool, remaining: [...] }.
89
+ def parse_package_flag(args)
90
+ package_name = nil
91
+ all = false
92
+ global = false
93
+ config = {}
94
+ remaining = []
95
+ i = 0
96
+
97
+ while i < args.length
98
+ case args[i]
99
+ when '--package', '-p'
100
+ package_name = args[i + 1]
101
+ unless package_name
102
+ $stderr.puts 'Error: --package requires a package name'
103
+ exit 1
104
+ end
105
+ i += 2
106
+ when '--all', '-a'
107
+ all = true
108
+ i += 1
109
+ when '--global', '-g'
110
+ global = true
111
+ i += 1
112
+ when '--package-paths'
113
+ value = args[i + 1]
114
+ unless value
115
+ $stderr.puts 'Error: --package-paths requires a value'
116
+ exit 1
117
+ end
118
+ config['package_paths'] = value.split(',').map(&:strip)
119
+ i += 2
120
+ when '--eager-load-global'
121
+ config['eager_load_global'] = true
122
+ i += 1
123
+ when '--no-eager-load-global'
124
+ config['eager_load_global'] = false
125
+ i += 1
126
+ when '--eager-load-packages'
127
+ config['eager_load_packages'] = true
128
+ i += 1
129
+ when '--no-eager-load-packages'
130
+ config['eager_load_packages'] = false
131
+ i += 1
132
+ else
133
+ remaining = args[i..]
134
+ break
135
+ end
136
+ end
137
+
138
+ {
139
+ package: package_name,
140
+ all: all,
141
+ global: global,
142
+ config: config,
143
+ remaining: remaining,
144
+ }
145
+ end
146
+
147
+ # Resolves the target box for a command given parsed flags.
148
+ def resolve_target_box(result, package_name)
149
+ if package_name
150
+ normalized = Package.normalize(package_name)
151
+ box = result[:box_manager].boxes[normalized]
152
+ unless box
153
+ $stderr.puts "Error: Unknown package '#{package_name}'"
154
+ $stderr.puts "Available packages: #{result[:resolver].packages.keys.join(', ')}"
155
+ exit 1
156
+ end
157
+ box
158
+ else
159
+ result[:box_manager].boxes[result[:resolver].root.name]
160
+ end
161
+ end
162
+
163
+ # Execute a Ruby command (gem binstub) in the boxed environment.
164
+ def exec_command(args)
165
+ parsed = parse_package_flag(args)
166
+
167
+ if parsed[:remaining].empty?
168
+ $stderr.puts 'Error: No command specified'
169
+ $stderr.puts ''
170
+ $stderr.puts 'Usage: boxwerk exec [-p <package>] <command> [args...]'
171
+ exit 1
172
+ end
173
+
174
+ command = parsed[:remaining][0]
175
+ command_args = parsed[:remaining][1..] || []
176
+
177
+ if parsed[:all]
178
+ # Boot all for --all (each subprocess boots its own target)
179
+ result = perform_setup
180
+ root_path = Setup.send(:find_root, Dir.pwd)
181
+ failed = []
182
+
183
+ result[:resolver].topological_order.each do |pkg|
184
+ label = pkg.root? ? '.' : pkg.name
185
+ pkg_name = pkg.root? ? '.' : pkg.name
186
+ puts "==> #{label}"
187
+ env = { 'RUBY_BOX' => '1', 'BUNDLE_GEMFILE' => nil }
188
+ success =
189
+ system(
190
+ env,
191
+ RbConfig.ruby,
192
+ @exe_path,
193
+ 'exec',
194
+ '-p',
195
+ pkg_name,
196
+ command,
197
+ *command_args,
198
+ chdir: root_path,
199
+ )
200
+ failed << label unless success
201
+ puts ''
202
+ end
203
+
204
+ unless failed.empty?
205
+ $stderr.puts "Failed in: #{failed.join(', ')}"
206
+ exit 1
207
+ end
208
+ else
209
+ # Selective boot: only target + deps (global boots all)
210
+ target_packages = resolve_boot_targets(parsed)
211
+ result =
212
+ perform_setup(packages: target_packages, config: parsed[:config])
213
+
214
+ if parsed[:global]
215
+ box = Ruby::Box.root
216
+ install_global_resolver(result)
217
+ else
218
+ target_pkg =
219
+ (
220
+ if parsed[:package]
221
+ result[:resolver].packages[parsed[:package]]
222
+ else
223
+ nil
224
+ end
225
+ )
226
+ box = resolve_target_box(result, parsed[:package])
227
+ install_resolver_on_ruby_root(result, target_package: target_pkg)
228
+
229
+ if parsed[:package] && parsed[:package] != '.'
230
+ root_path = Setup.send(:find_root, Dir.pwd)
231
+ pkg_dir = File.join(root_path, parsed[:package])
232
+ Dir.chdir(pkg_dir)
233
+ end
234
+ end
235
+ run_command_in_box(
236
+ result,
237
+ box,
238
+ command,
239
+ command_args,
240
+ pkg_dir: Dir.pwd,
241
+ )
242
+ end
41
243
  end
42
244
 
43
245
  def run_command(args)
44
- if args.empty?
45
- puts 'Error: No script specified'
46
- puts ''
47
- puts 'Usage: boxwerk run <script.rb> [args...]'
246
+ parsed = parse_package_flag(args)
247
+
248
+ if parsed[:remaining].empty?
249
+ $stderr.puts 'Error: No script specified'
250
+ $stderr.puts ''
251
+ $stderr.puts 'Usage: boxwerk run [-p <package>] <script.rb> [args...]'
48
252
  exit 1
49
253
  end
50
254
 
51
- script_path = args[0]
255
+ script_path = parsed[:remaining][0]
52
256
  unless File.exist?(script_path)
53
- puts "Error: Script not found: #{script_path}"
257
+ $stderr.puts "Error: Script not found: #{script_path}"
54
258
  exit 1
55
259
  end
56
260
 
57
- graph = perform_setup
58
- execute_in_box(graph.root.box, script_path, args[1..-1] || [])
261
+ target_packages = resolve_boot_targets(parsed)
262
+ result =
263
+ perform_setup(packages: target_packages, config: parsed[:config])
264
+ if parsed[:global]
265
+ box = Ruby::Box.root
266
+ install_global_resolver(result)
267
+ else
268
+ target_pkg =
269
+ (
270
+ if parsed[:package]
271
+ result[:resolver].packages[parsed[:package]]
272
+ else
273
+ nil
274
+ end
275
+ )
276
+ box = resolve_target_box(result, parsed[:package])
277
+ install_resolver_on_ruby_root(result, target_package: target_pkg)
278
+ end
279
+ execute_in_box(box, script_path, parsed[:remaining][1..] || [])
59
280
  end
60
281
 
61
282
  def console_command(args)
62
- require 'irb'
63
- graph = perform_setup
64
- start_console_in_box(graph.root.box, args)
283
+ parsed = parse_package_flag(args)
284
+
285
+ target_packages = resolve_boot_targets(parsed)
286
+ result =
287
+ perform_setup(packages: target_packages, config: parsed[:config])
288
+ if parsed[:global]
289
+ install_global_resolver(result)
290
+ pkg_label = 'global'
291
+ else
292
+ target_pkg =
293
+ (
294
+ if parsed[:package]
295
+ result[:resolver].packages[parsed[:package]]
296
+ else
297
+ nil
298
+ end
299
+ )
300
+ install_resolver_on_ruby_root(result, target_package: target_pkg)
301
+ pkg_label = parsed[:package] || '.'
302
+ end
303
+ # IRB runs in Ruby::Box.root with a composite resolver that provides
304
+ # the target package's constants. This works around a Ruby 4.0.1 GC
305
+ # crash when running IRB directly in child boxes.
306
+ start_console_in_box(Ruby::Box.root, parsed[:remaining], pkg_label)
307
+ end
308
+
309
+ BOXWERK_CONFIG_DEFAULTS = {
310
+ 'package_paths' => ['**/'],
311
+ 'eager_load_global' => true,
312
+ 'eager_load_packages' => false,
313
+ }.freeze
314
+
315
+ def info_command
316
+ # Boot the application, suppressing stdout from boot scripts
317
+ result = nil
318
+ orig_stdout = $stdout
319
+ $stdout = StringIO.new
320
+ begin
321
+ result = perform_setup
322
+ ensure
323
+ $stdout = orig_stdout
324
+ end
325
+
326
+ root_path = result[:root_path]
327
+ resolver = result[:resolver]
328
+ box_manager = result[:box_manager]
329
+ config = BOXWERK_CONFIG_DEFAULTS.merge(resolver.boxwerk_config)
330
+ eager_global = config.fetch('eager_load_global', true)
331
+ eager_packages = config.fetch('eager_load_packages', false)
332
+ gem_resolver = GemResolver.new(root_path)
333
+
334
+ puts "boxwerk #{Boxwerk::VERSION}"
335
+ puts ''
336
+
337
+ # Config — always shown with defaults filled in
338
+ puts 'Config'
339
+ puts ''
340
+ config.each { |k, v| puts " #{k}: #{v.inspect}" }
341
+ puts ''
342
+
343
+ # Dependency Graph
344
+ puts 'Dependency Graph'
345
+ puts ''
346
+ print_dependency_tree(resolver)
347
+ puts ''
348
+
349
+ # Global section
350
+ global = Boxwerk.global
351
+ global_boot = File.join(root_path, 'global', 'boot.rb')
352
+ global_dir_info = global&.__send__(:dir_info) || {}
353
+ global_autoload_dirs =
354
+ (global_dir_info[:autoload] || []).map { |d| normalize_dir_display(d, root_path) }
355
+ global_collapse_dirs =
356
+ (global_dir_info[:collapse] || []).map { |d| normalize_dir_display(d, root_path) }
357
+ global_ignore_dirs =
358
+ (global_dir_info[:ignore] || []).map { |d| normalize_dir_display(d, root_path) }
359
+ root_gems =
360
+ gem_resolver.gems_for(resolver.root)&.select { |g| !g.autorequire.nil? }
361
+ global_has_content =
362
+ File.exist?(global_boot) || global_autoload_dirs.any? || root_gems&.any?
363
+
364
+ if global_has_content
365
+ puts 'Global'
366
+ puts ''
367
+ puts " boot: global/boot.rb" if File.exist?(global_boot)
368
+ autoload_label = eager_global ? 'eager_load' : 'autoload'
369
+ print_dir_section(' ', autoload_label, global_autoload_dirs)
370
+ print_dir_section(' ', 'collapse', global_collapse_dirs)
371
+ print_dir_section(' ', 'ignore', global_ignore_dirs)
372
+ if root_gems&.any?
373
+ print_inline_or_multiline(' ', 'gems', root_gems.map { |g| "#{g.name} (#{g.version})" })
374
+ end
375
+ puts ''
376
+ end
377
+
378
+ # Packages — root (.) first, then others
379
+ puts 'Packages'
380
+ puts ''
381
+ root_first = [resolver.root] + resolver.topological_order.reject(&:root?)
382
+ root_first.each do |pkg|
383
+ print_package_info(pkg, box_manager, gem_resolver, root_path, eager_packages)
384
+ end
385
+
386
+ # Gem conflicts
387
+ conflicts = gem_resolver.check_conflicts(resolver)
388
+ if conflicts.any?
389
+ puts 'Gem Conflicts'
390
+ puts ''
391
+ conflicts.each do |c|
392
+ puts " ⚠ #{c[:gem_name]}: #{c[:package_version]} in #{c[:package]} " \
393
+ "vs #{c[:global_version]} in root (both loaded into memory)"
394
+ end
395
+ puts ''
396
+ end
397
+ end
398
+
399
+ def install_command
400
+ root_path = Setup.send(:find_root, Dir.pwd)
401
+
402
+ resolver = PackageResolver.new(root_path)
403
+ has_gemfile = false
404
+
405
+ # Install root (global) gems first, then packages in dependency order
406
+ ordered =
407
+ [resolver.root] + resolver.topological_order.reject(&:root?)
408
+ ordered.each do |pkg|
409
+ pkg_dir = pkg.root? ? root_path : File.join(root_path, pkg.name)
410
+ gemfile =
411
+ %w[gems.rb Gemfile].find { |f| File.exist?(File.join(pkg_dir, f)) }
412
+ next unless gemfile
413
+
414
+ has_gemfile = true
415
+ label = pkg.root? ? 'global gems' : "gems for #{pkg.name}"
416
+ print "Installing #{label}..."
417
+ $stdout.flush
418
+ Dir.chdir(pkg_dir) do
419
+ # Clear Bundler env vars so each package uses its own Gemfile,
420
+ # not the parent process's BUNDLE_GEMFILE or BUNDLE_PATH.
421
+ success =
422
+ Bundler.with_unbundled_env do
423
+ system('bundle', 'install', '--retry', '3', '--quiet')
424
+ end
425
+ unless success
426
+ $stderr.puts " Error: bundle install failed in #{pkg.root? ? '.' : pkg.name}"
427
+ exit 1
428
+ end
429
+ end
430
+ puts 'Done!'
431
+ puts ''
432
+ end
433
+
434
+ puts 'No packages with a Gemfile or gems.rb found.' unless has_gemfile
65
435
  end
66
436
 
67
- def perform_setup
68
- Boxwerk::Setup.run!(start_dir: Dir.pwd)
437
+ # Determines which packages to boot based on parsed flags.
438
+ # Returns nil for --all or --global (boot all), or an array
439
+ # of target Package objects for selective booting.
440
+ def resolve_boot_targets(parsed)
441
+ return nil if parsed[:all] || parsed[:global]
442
+
443
+ # For a specific package, resolve it from package.yml discovery
444
+ # to get the Package object. Setup.run will boot it + deps.
445
+ if parsed[:package]
446
+ root_path = Setup.send(:find_root, Dir.pwd)
447
+ resolver = PackageResolver.new(root_path)
448
+ normalized = Package.normalize(parsed[:package])
449
+ pkg = resolver.packages[normalized]
450
+ unless pkg
451
+ $stderr.puts "Error: Unknown package '#{parsed[:package]}'"
452
+ $stderr.puts "Available packages: #{resolver.packages.keys.join(', ')}"
453
+ exit 1
454
+ end
455
+ [pkg]
456
+ else
457
+ # Default: boot root package + deps
458
+ nil
459
+ end
460
+ end
461
+
462
+ def perform_setup(packages: nil, config: {})
463
+ Boxwerk::Setup.run(
464
+ start_dir: Dir.pwd,
465
+ packages: packages,
466
+ config: config,
467
+ )
69
468
  rescue => e
70
- puts "Error: #{e.message}"
469
+ $stderr.puts "Error: #{e.message}"
71
470
  exit 1
72
471
  end
73
472
 
74
- def execute_in_box(box, script_path, script_args)
473
+ # Runs a command (binstub or script) in a box.
474
+ # Falls back to running as a shell command in pkg_dir if no
475
+ # binstub or gem binary is found.
476
+ def run_command_in_box(result, box, command, command_args, pkg_dir: nil)
477
+ if command.end_with?('.rb') || File.exist?(command)
478
+ execute_in_box(box, command, command_args)
479
+ else
480
+ # Check for project-level bin/<command> first, then gem binstubs
481
+ project_bin = File.join(Dir.pwd, 'bin', command)
482
+ if File.exist?(project_bin)
483
+ execute_in_box(box, project_bin, command_args)
484
+ else
485
+ bin_path = find_bin_path(command)
486
+ if bin_path
487
+ execute_in_box(box, bin_path, command_args, use_load: true)
488
+ else
489
+ # Fall back to shell command in the package directory
490
+ dir = pkg_dir || Dir.pwd
491
+ success = system(command, *command_args, chdir: dir)
492
+ exit(success ? 0 : 1)
493
+ end
494
+ end
495
+ end
496
+ end
497
+
498
+ def execute_in_box(box, script_path, script_args, use_load: false)
499
+ expanded = File.expand_path(script_path)
75
500
  box.eval("ARGV.replace(#{script_args.inspect})")
76
- box.require(File.expand_path(script_path))
501
+ if use_load
502
+ # Eval file content directly rather than using load, because
503
+ # load creates a new file scope where inherited DSL methods
504
+ # (e.g. Rake's task) may not be visible in Ruby::Box.
505
+ content = File.read(expanded)
506
+ box.eval(content)
507
+ else
508
+ # Use eval with __dir__ set so relative paths resolve
509
+ # correctly (e.g. project binstubs using File.expand_path).
510
+ content = File.read(expanded)
511
+ dir = File.dirname(expanded)
512
+ wrapped = "__dir__ = #{dir.inspect}\n" + content
513
+ box.eval(wrapped)
514
+ end
77
515
  end
78
516
 
79
- def start_console_in_box(box, irb_args = [])
80
- puts '=' * 70
81
- puts 'Boxwerk Console'
82
- puts '=' * 70
83
- puts ''
84
- puts 'All packages have been loaded and wired.'
85
- puts 'You are in the root package context.'
86
- puts ''
87
- puts 'Type "exit" or press Ctrl+D to quit.'
88
- puts '=' * 70
517
+ # Installs a resolver on Ruby::Box.root that searches ALL packages.
518
+ # Used for --global mode so the global context can resolve any constant.
519
+ def install_global_resolver(result)
520
+ boxes = result[:box_manager].boxes
521
+ file_indexes = result[:box_manager].instance_variable_get(:@file_indexes)
522
+
523
+ all_boxes =
524
+ result[:resolver]
525
+ .packages
526
+ .values
527
+ .filter_map do |pkg|
528
+ box = boxes[pkg.name]
529
+ next unless box
530
+ { box: box, file_index: file_indexes[pkg.name] || {} }
531
+ end
532
+
533
+ composite =
534
+ proc do |const_name|
535
+ name_str = const_name.to_s
536
+ found = false
537
+ value = nil
538
+
539
+ all_boxes.each do |entry|
540
+ box = entry[:box]
541
+ file_index = entry[:file_index]
542
+
543
+ has_constant =
544
+ file_index.key?(name_str) ||
545
+ file_index.any? { |k, _| k.start_with?("#{name_str}::") } ||
546
+ (
547
+ begin
548
+ box.const_get(const_name)
549
+ true
550
+ rescue NameError
551
+ false
552
+ end
553
+ )
554
+
555
+ next unless has_constant
556
+
557
+ value =
558
+ begin
559
+ box.const_get(const_name)
560
+ rescue NameError
561
+ file = file_index[name_str]
562
+ if file
563
+ box.require(file)
564
+ box.const_get(const_name)
565
+ else
566
+ child_key =
567
+ file_index.keys.find do |k|
568
+ k.start_with?("#{name_str}::")
569
+ end
570
+ if child_key
571
+ box.require(file_index[child_key])
572
+ box.const_get(const_name)
573
+ else
574
+ next
575
+ end
576
+ end
577
+ end
578
+
579
+ found = true
580
+ break
581
+ end
582
+
583
+ unless found
584
+ raise NameError.new(
585
+ "uninitialized constant #{name_str}",
586
+ const_name,
587
+ )
588
+ end
589
+ value
590
+ end
591
+
592
+ ruby_root = Ruby::Box.root
593
+ if ruby_root.const_defined?(:BOXWERK_DEPENDENCY_RESOLVER)
594
+ ruby_root.send(:remove_const, :BOXWERK_DEPENDENCY_RESOLVER)
595
+ end
596
+ ruby_root.const_set(:BOXWERK_DEPENDENCY_RESOLVER, composite)
597
+ ruby_root.eval(<<~RUBY)
598
+ class Object
599
+ def self.const_missing(const_name)
600
+ BOXWERK_DEPENDENCY_RESOLVER.call(const_name)
601
+ end
602
+ end
603
+ RUBY
604
+ end
605
+
606
+ # Installs a dependency resolver on Ruby::Box.root for the given
607
+ # package. Gems loaded via Bundler.require run in the root box (where
608
+ # their methods were defined). When those gems call load() (e.g. rake
609
+ # loading a Rakefile), the loaded files execute in the root box too.
610
+ # This method ensures const_missing is available there so that package
611
+ # constants can be resolved.
612
+ #
613
+ # When target_package is specified, the resolver also searches the
614
+ # target package's own box for its internal constants. This enables
615
+ # per-package testing where test files (loaded by rake in Ruby::Box.root)
616
+ # need access to the pack's own constants.
617
+ def install_resolver_on_ruby_root(result, target_package: nil)
618
+ target_pkg = target_package || result[:resolver].root
619
+ target_box = result[:box_manager].boxes[target_pkg.name]
620
+
621
+ # Delegate constant resolution from Ruby::Box.root to the target box.
622
+ # The target box already has its own const_missing (dependency resolver)
623
+ # for cross-package lookup. We check own constants first via
624
+ # const_get (which doesn't trigger const_missing recursion), then
625
+ # fall through to the dependency resolver.
626
+ own_box = target_box
627
+ dep_resolver =
628
+ begin
629
+ target_box.const_get(:BOXWERK_DEPENDENCY_RESOLVER)
630
+ rescue NameError
631
+ nil
632
+ end
633
+
634
+ # File index for the target package (for autoload-style loading)
635
+ file_index = result[:box_manager].file_indexes[target_pkg.name] || {}
636
+
637
+ composite =
638
+ proc do |const_name|
639
+ name_str = const_name.to_s
640
+ # Try own box's already-defined constants first
641
+ resolved =
642
+ begin
643
+ own_box.const_get(const_name)
644
+ rescue NameError
645
+ nil
646
+ end
647
+
648
+ if resolved
649
+ resolved
650
+ else
651
+ # Try loading from file index (autoload entries)
652
+ file = file_index[name_str]
653
+ if file
654
+ own_box.require(file)
655
+ own_box.const_get(const_name)
656
+ elsif dep_resolver
657
+ # Delegate to the target box's dependency resolver
658
+ dep_resolver.call(const_name)
659
+ else
660
+ raise NameError.new(
661
+ "uninitialized constant #{name_str}",
662
+ const_name,
663
+ )
664
+ end
665
+ end
666
+ end
667
+
668
+ ruby_root = Ruby::Box.root
669
+ if ruby_root.const_defined?(:BOXWERK_DEPENDENCY_RESOLVER)
670
+ ruby_root.send(:remove_const, :BOXWERK_DEPENDENCY_RESOLVER)
671
+ end
672
+ ruby_root.const_set(:BOXWERK_DEPENDENCY_RESOLVER, composite)
673
+ ruby_root.eval(<<~RUBY)
674
+ class Object
675
+ def self.const_missing(const_name)
676
+ BOXWERK_DEPENDENCY_RESOLVER.call(const_name)
677
+ end
678
+ end
679
+ RUBY
680
+ end
681
+
682
+ # Resolves a command name to its gem executable path. Iterates gem
683
+ # specs directly to avoid Bundler's Gem.bin_path hook which prints
684
+ # warnings when the gem name doesn't match the executable name
685
+ # (e.g. "rails" executable is in the "railties" gem).
686
+ def find_bin_path(command)
687
+ Gem::Specification.each do |spec|
688
+ spec.executables.each do |exe|
689
+ return spec.bin_file(exe) if exe == command
690
+ end
691
+ end
692
+ nil
693
+ end
694
+
695
+ def start_console_in_box(box, irb_args = [], pkg_label = '.')
696
+ puts "boxwerk #{Boxwerk::VERSION} console (#{pkg_label})"
89
697
  puts ''
90
698
 
91
699
  box.eval(<<~RUBY)
700
+ require 'irb'
92
701
  ARGV.replace(#{(['--noautocomplete'] + irb_args).inspect})
93
702
  IRB.start
94
703
  RUBY
95
704
  end
705
+
706
+ # Renders a dependency tree like:
707
+ # .
708
+ # ├── packs/finance
709
+ # │ └── packs/util
710
+ # └── packs/greeting
711
+ def print_dependency_tree(resolver)
712
+ root = resolver.root
713
+ puts root.name
714
+ print_tree_children(root.dependencies, resolver, '')
715
+ end
716
+
717
+ def print_tree_children(dep_names, resolver, prefix, ancestry = Set.new)
718
+ dep_names.each_with_index do |dep_name, i|
719
+ last = (i == dep_names.length - 1)
720
+ connector = last ? '└── ' : '├── '
721
+
722
+ if ancestry.include?(dep_name)
723
+ puts "#{prefix}#{connector}#{dep_name} (circular)"
724
+ else
725
+ puts "#{prefix}#{connector}#{dep_name}"
726
+ pkg = resolver.packages[dep_name]
727
+ if pkg && pkg.dependencies.any?
728
+ child_prefix = prefix + (last ? ' ' : '│ ')
729
+ print_tree_children(
730
+ pkg.dependencies,
731
+ resolver,
732
+ child_prefix,
733
+ ancestry | [dep_name],
734
+ )
735
+ end
736
+ end
737
+ end
738
+ end
739
+
740
+ def print_package_info(pkg, box_manager, gem_resolver, root_path, eager_packages)
741
+ label = pkg.root? ? ' .' : " #{pkg.name}"
742
+ puts label
743
+
744
+ flags = []
745
+ flags << 'dependencies' if pkg.enforce_dependencies?
746
+ flags << 'privacy' if pkg.config['enforce_privacy']
747
+ if flags.any?
748
+ print_inline_or_multiline(' ', 'enforcements', flags)
749
+ else
750
+ puts ' enforcements: none'
751
+ end
752
+
753
+ deps = pkg.dependencies
754
+ if deps.any?
755
+ print_inline_or_multiline(' ', 'dependencies', deps)
756
+ else
757
+ puts ' dependencies: none'
758
+ end
759
+
760
+ pkg_dir = pkg.root? ? root_path : File.join(root_path, pkg.name)
761
+ puts " boot: boot.rb" if File.exist?(File.join(pkg_dir, 'boot.rb'))
762
+
763
+ # Autoload dirs from box_manager dir info
764
+ dirs = box_manager.package_dirs_info[pkg.name] || {}
765
+ autoload_dirs = dirs[:autoload] || []
766
+ collapse_dirs = dirs[:collapse] || []
767
+ ignore_dirs = dirs[:ignore] || []
768
+
769
+ autoload_label = eager_packages ? 'eager_load' : 'autoload'
770
+ print_dir_section(' ', autoload_label, autoload_dirs)
771
+ print_dir_section(' ', 'collapse', collapse_dirs)
772
+ print_dir_section(' ', 'ignore', ignore_dirs)
773
+
774
+ # pack_public sigil constants
775
+ pack_public = PrivacyChecker.pack_public_constants(pkg, root_path)
776
+ if pack_public&.any?
777
+ print_inline_or_multiline(' ', 'pack_public', pack_public.sort)
778
+ end
779
+
780
+ private_consts = PrivacyChecker.private_constants_list(pkg)
781
+ if private_consts.any?
782
+ print_inline_or_multiline(' ', 'private constants', private_consts.sort)
783
+ end
784
+
785
+ # Direct gems — last; root gems shown in Global section
786
+ unless pkg.root?
787
+ gems = gem_resolver.gems_for(pkg)
788
+ direct_gems = gems&.select { |g| !g.autorequire.nil? }
789
+ if direct_gems&.any?
790
+ print_inline_or_multiline(' ', 'gems', direct_gems.map { |g| "#{g.name} (#{g.version})" })
791
+ end
792
+ end
793
+
794
+ puts ''
795
+ end
796
+
797
+ # Normalizes a dir path for display: relative to base if possible,
798
+ # otherwise absolute. Always has a trailing slash.
799
+ def normalize_dir_display(dir, base)
800
+ expanded = File.expand_path(dir, base)
801
+ rel =
802
+ if expanded.start_with?("#{base}/")
803
+ expanded.delete_prefix("#{base}/")
804
+ else
805
+ expanded
806
+ end
807
+ rel.end_with?('/') ? rel : "#{rel}/"
808
+ end
809
+
810
+ # Prints a labeled section with items, inline if one item, multiline if many.
811
+ # indent: e.g. " "
812
+ # label: e.g. "enforcements"
813
+ # items: array of strings
814
+ def print_inline_or_multiline(indent, label, items)
815
+ if items.length == 1
816
+ puts "#{indent}#{label}: #{items.first}"
817
+ else
818
+ puts "#{indent}#{label}:"
819
+ items.each { |item| puts "#{indent} #{item}" }
820
+ end
821
+ end
822
+
823
+ # Prints a dir section (autoload, collapse, ignore).
824
+ # Dirs are already normalized relative strings.
825
+ def print_dir_section(indent, label, dirs)
826
+ return if dirs.empty?
827
+
828
+ if dirs.length == 1
829
+ puts "#{indent}#{label}: #{dirs.first}"
830
+ else
831
+ puts "#{indent}#{label}:"
832
+ dirs.each { |dir| puts "#{indent} #{dir}" }
833
+ end
834
+ end
96
835
  end
97
836
  end
98
837
  end