boxwerk 0.1.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/example/app.rb ADDED
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Example Boxwerk Application
4
+ # Run with: RUBY_BOX=1 boxwerk app.rb
5
+
6
+ puts '=' * 70
7
+ puts 'Boxwerk Application'
8
+ puts '=' * 70
9
+ puts ''
10
+
11
+ puts 'Creating invoice...'
12
+ invoice = Finance::Invoice.new(tax_rate: 0.15)
13
+ invoice.add_item('Web Development', 200_000) # $2000.00 in cents
14
+ invoice.add_item('Design Work', 150_000) # $1500.00 in cents
15
+ invoice.add_item('Consulting', 80_000) # $800.00 in cents
16
+
17
+ data = invoice.to_h
18
+
19
+ puts "\nInvoice Details:"
20
+ data[:items].each_with_index do |item, i|
21
+ amount_dollars = item[:amount] / 100.0
22
+ puts " #{i + 1}. #{item[:description]}: $#{format('%.2f', amount_dollars)}"
23
+ end
24
+ puts ''
25
+ puts " Subtotal: $#{format('%.2f', data[:subtotal] / 100.0)}"
26
+ puts " Tax (15%): $#{format('%.2f', data[:tax] / 100.0)}"
27
+ puts " Total: $#{format('%.2f', data[:total] / 100.0)}"
28
+ puts ''
29
+
30
+ puts 'Testing isolation...'
31
+ # Finance::Invoice should be available
32
+ begin
33
+ test_invoice = Finance::Invoice.new
34
+ puts ' ✓ Finance::Invoice accessible'
35
+ rescue NameError => e
36
+ puts " ✗ Finance::Invoice not accessible: #{e.message}"
37
+ end
38
+
39
+ # Finance::TaxCalculator should be available
40
+ begin
41
+ Finance::TaxCalculator
42
+ puts ' ✓ Finance::TaxCalculator accessible'
43
+ rescue NameError => e
44
+ puts " ✗ Finance::TaxCalculator not accessible: #{e.message}"
45
+ end
46
+
47
+ # UtilCalculator should NOT be available (transitive dependency)
48
+ begin
49
+ UtilCalculator.add(1, 2)
50
+ puts ' ✗ ERROR: UtilCalculator leaked from transitive dependency!'
51
+ rescue NameError
52
+ puts ' ✓ UtilCalculator not accessible (correct isolation)'
53
+ end
54
+
55
+ # Invoice should NOT be at top level (only in Finance namespace)
56
+ begin
57
+ Invoice.new
58
+ puts ' ✗ ERROR: Invoice available at top level!'
59
+ rescue NameError
60
+ puts ' ✓ Invoice only accessible via Finance namespace'
61
+ end
62
+
63
+ # Calculator should NOT be available (transitive dependency from util package)
64
+ begin
65
+ Calculator.add(1, 2)
66
+ puts ' ✗ ERROR: Calculator leaked from transitive dependency!'
67
+ rescue NameError
68
+ puts ' ✓ Calculator not accessible (correct isolation)'
69
+ end
70
+
71
+ # Geometry should NOT be available (transitive dependency from util package)
72
+ begin
73
+ Geometry.circle_area(5)
74
+ puts ' ✗ ERROR: Geometry leaked from transitive dependency!'
75
+ rescue NameError
76
+ puts ' ✓ Geometry not accessible (correct isolation)'
77
+ end
78
+
79
+ # Money gem SHOULD be accessible (gems are global, not isolated)
80
+ begin
81
+ test_money = Money.new(100, 'USD')
82
+ puts ' ✓ Money gem accessible (gems are global)'
83
+ rescue NameError => e
84
+ puts " ✗ ERROR: Money gem not accessible: #{e.message}"
85
+ end
86
+
87
+ puts ''
88
+ puts '=' * 70
89
+ puts '✓ Application completed successfully'
90
+ puts '=' * 70
91
+ puts ''
92
+ puts 'Boxwerk CLI setup process:'
93
+ puts ' 1. `boxwerk run app.rb` found root package.yml'
94
+ puts ' 2. Built dependency graph (util → finance → root)'
95
+ puts ' 3. Validated no circular dependencies'
96
+ puts ' 4. Booted packages in topological order (all in isolated boxes)'
97
+ puts ' 5. Executed app.rb in root package box with Finance imported'
98
+ puts ''
99
+ puts 'Key difference: ALL packages (including root) run in isolated boxes.'
100
+ puts 'The main Ruby process only contains gems and the Boxwerk runtime.'
101
+ puts ''
@@ -0,0 +1,6 @@
1
+ # Main Application Package
2
+
3
+ imports:
4
+ # Finance has multiple exports (Invoice, TaxCalculator)
5
+ # so this creates a Finance module containing both
6
+ - packages/finance
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Invoice represents a financial invoice with line items and tax calculation
4
+ # Uses the Money gem for precise currency handling
5
+ class Invoice
6
+ attr_reader :items, :tax_rate
7
+
8
+ def initialize(tax_rate: TaxCalculator::STANDARD_RATE)
9
+ @items = []
10
+ @tax_rate = tax_rate
11
+ end
12
+
13
+ def add_item(description, amount_cents)
14
+ @items << {
15
+ description: description,
16
+ amount: Money.new(amount_cents, 'USD'),
17
+ }
18
+ self
19
+ end
20
+
21
+ def subtotal
22
+ @items.reduce(Money.new(0, 'USD')) { |sum, item| sum + item[:amount] }
23
+ end
24
+
25
+ def tax
26
+ (subtotal * tax_rate).round
27
+ end
28
+
29
+ def total
30
+ subtotal + tax
31
+ end
32
+
33
+ def to_h
34
+ {
35
+ items:
36
+ @items.map do |item|
37
+ { description: item[:description], amount: item[:amount].cents }
38
+ end,
39
+ subtotal: subtotal.cents,
40
+ tax_rate: tax_rate,
41
+ tax: tax.cents,
42
+ total: total.cents,
43
+ }
44
+ end
45
+
46
+ def self.quick_invoice(amount_cents, tax_rate: TaxCalculator::STANDARD_RATE)
47
+ invoice = new(tax_rate: tax_rate)
48
+ invoice.add_item('Service', amount_cents)
49
+ invoice
50
+ end
51
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # TaxCalculator provides tax calculation utilities
4
+ # Uses the imported Util package for calculations
5
+ class TaxCalculator
6
+ STANDARD_RATE = 0.10 # 10%
7
+ LUXURY_RATE = 0.20 # 20%
8
+
9
+ def self.calculate_tax(amount, rate = STANDARD_RATE)
10
+ # Use UtilCalculator from selective import
11
+ UtilCalculator.multiply(amount, rate)
12
+ end
13
+
14
+ def self.calculate_total(amount, rate = STANDARD_RATE)
15
+ tax = calculate_tax(amount, rate)
16
+ # Use UtilCalculator.add from selective import
17
+ UtilCalculator.add(amount, tax)
18
+ end
19
+
20
+ def self.reverse_calculate(total_with_tax, rate = STANDARD_RATE)
21
+ # Calculate original amount from total including tax
22
+ # Formula: original = total / (1 + rate)
23
+ divisor = UtilCalculator.add(1, rate)
24
+ UtilCalculator.divide(total_with_tax, divisor)
25
+ end
26
+ end
@@ -0,0 +1,10 @@
1
+ # Finance Package
2
+
3
+ exports:
4
+ - Invoice
5
+ - TaxCalculator
6
+
7
+ imports:
8
+ # Import Calculator from util and rename it to UtilCalculator
9
+ - packages/util:
10
+ Calculator: UtilCalculator
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Calculator provides basic arithmetic operations
4
+ class Calculator
5
+ def self.add(a, b)
6
+ a + b
7
+ end
8
+
9
+ def self.subtract(a, b)
10
+ a - b
11
+ end
12
+
13
+ def self.multiply(a, b)
14
+ a * b
15
+ end
16
+
17
+ def self.divide(a, b)
18
+ raise ArgumentError, 'Cannot divide by zero' if b.zero?
19
+ a.to_f / b
20
+ end
21
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Geometry provides geometric calculations
4
+ class Geometry
5
+ PI = 3.14159265359
6
+
7
+ def self.circle_area(radius)
8
+ PI * radius * radius
9
+ end
10
+
11
+ def self.circle_circumference(radius)
12
+ 2 * PI * radius
13
+ end
14
+
15
+ def self.rectangle_area(width, height)
16
+ width * height
17
+ end
18
+
19
+ def self.rectangle_perimeter(width, height)
20
+ 2 * (width + height)
21
+ end
22
+
23
+ def self.triangle_area(base, height)
24
+ (base * height) / 2.0
25
+ end
26
+ end
@@ -0,0 +1,5 @@
1
+ # Util Package
2
+
3
+ exports:
4
+ - Calculator
5
+ - Geometry
data/exe/boxwerk ADDED
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Boxwerk CLI
5
+ # Usage: boxwerk <command> [args...]
6
+
7
+ # Check if Ruby::Box is available and enabled
8
+ unless ENV['RUBY_BOX'] == '1'
9
+ puts "Error: Boxwerk requires Ruby::Box to be enabled"
10
+ puts "Please set RUBY_BOX=1 environment variable"
11
+ exit 1
12
+ end
13
+
14
+ # Verify Ruby::Box is actually available in this Ruby version
15
+ unless defined?(Ruby::Box)
16
+ puts "Error: Ruby::Box is not available in this Ruby version"
17
+ puts "Boxwerk requires Ruby 4.0 or later with Box support"
18
+ exit 1
19
+ end
20
+
21
+ # Setup Bundler, require any gems and run Boxwerk - all in the root box
22
+ Ruby::Box.root.eval(<<~RUBY)
23
+ require 'bundler/setup'
24
+ # Application gems need to be required in the root box to be accessible in package boxes
25
+ Bundler.require
26
+ # Run the CLI to handle the invocation
27
+ Boxwerk::CLI.run(ARGV)
28
+ RUBY
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boxwerk
4
+ # CLI handles command-line execution of Boxwerk applications
5
+ module CLI
6
+ class << self
7
+ # Main entry point for CLI
8
+ # @param argv [Array<String>] Command line arguments
9
+ def run(argv)
10
+ if argv.empty?
11
+ print_usage
12
+ exit 1
13
+ end
14
+
15
+ command = argv[0]
16
+
17
+ case command
18
+ when 'run'
19
+ run_command(argv[1..-1])
20
+ when 'console'
21
+ console_command(argv[1..-1])
22
+ when 'help', '--help', '-h'
23
+ print_usage
24
+ exit 0
25
+ else
26
+ puts "Error: Unknown command '#{command}'"
27
+ puts ''
28
+ print_usage
29
+ exit 1
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def print_usage
36
+ puts 'Boxwerk - Ruby package system with Box-powered constant isolation'
37
+ puts ''
38
+ puts 'Usage: boxwerk <command> [args...]'
39
+ puts ''
40
+ puts 'Commands:'
41
+ puts ' run <script.rb> [args...] Run a script in the root package context'
42
+ puts ' console [irb-args...] Start an IRB console in the root package context'
43
+ puts ' help Show this help message'
44
+ puts ''
45
+ puts 'All packages are loaded and wired before the command executes.'
46
+ end
47
+
48
+ def run_command(args)
49
+ if args.empty?
50
+ puts 'Error: No script specified'
51
+ puts ''
52
+ puts 'Usage: boxwerk run <script.rb> [args...]'
53
+ exit 1
54
+ end
55
+
56
+ script_path = args[0]
57
+ script_args = args[1..-1] || []
58
+
59
+ # Verify script exists
60
+ unless File.exist?(script_path)
61
+ puts "Error: Script not found: #{script_path}"
62
+ exit 1
63
+ end
64
+
65
+ # Perform Boxwerk setup (find package.yml, build graph, boot packages)
66
+ graph = perform_setup
67
+
68
+ # Execute the script in the root package's box
69
+ root_package = graph.root
70
+ execute_in_box(root_package.box, script_path, script_args)
71
+ end
72
+
73
+ def console_command(args)
74
+ # Require IRB while we're still in root box context
75
+ require 'irb'
76
+
77
+ # Perform Boxwerk setup
78
+ graph = perform_setup
79
+
80
+ # Start IRB in the root package's box with provided args
81
+ root_package = graph.root
82
+ start_console_in_box(root_package.box, args)
83
+ end
84
+
85
+ def perform_setup
86
+ begin
87
+ Boxwerk::Setup.run!(start_dir: Dir.pwd)
88
+ rescue => e
89
+ puts "Error: #{e.message}"
90
+ exit 1
91
+ end
92
+ end
93
+
94
+ def execute_in_box(box, script_path, script_args)
95
+ # Set ARGV for the script using eval
96
+ box.eval("ARGV.replace(#{script_args.inspect})")
97
+
98
+ # Run the script in the isolated box
99
+ absolute_script_path = File.expand_path(script_path)
100
+ box.require(absolute_script_path)
101
+ end
102
+
103
+ def start_console_in_box(box, irb_args = [])
104
+ puts '=' * 70
105
+ puts 'Boxwerk Console'
106
+ puts '=' * 70
107
+ puts ''
108
+ puts 'All packages have been loaded and wired.'
109
+ puts 'You are in the root package context.'
110
+ puts ''
111
+ puts 'Type "exit" or press Ctrl+D to quit.'
112
+ puts '=' * 70
113
+ puts ''
114
+
115
+ # Start IRB in the box context.
116
+ # TODO: This is currently broken. IRB runs the in the root box context.
117
+ # This should be fixed by calling `require 'irb'` inside the box, but
118
+ # that currently crashes the VM.
119
+ # Set ARGV to the provided IRB args so they can be processed by IRB.
120
+ # Always add --noautocomplete to disable autocomplete (currently broken with Ruby::Box)
121
+ # TODO: Enable autocomplete when it's not broken.
122
+ irb_args_with_noautocomplete = ['--noautocomplete'] + irb_args
123
+ box.eval(<<~RUBY)
124
+ ARGV.replace(#{irb_args_with_noautocomplete.inspect})
125
+ IRB.start
126
+ RUBY
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Boxwerk
4
+ # Builds and validates the package dependency graph
5
+ class Graph
6
+ attr_reader :packages, :root
7
+
8
+ def initialize(root_path)
9
+ @root_path = root_path
10
+ @packages = {}
11
+ @root = load_package('root', root_path)
12
+ resolve_dependencies(@root, [])
13
+ validate!
14
+ end
15
+
16
+ # Returns packages in topological order (leaves first)
17
+ def topological_order
18
+ visited = {}
19
+ order = []
20
+
21
+ @packages.each_value { |package| visit(package, visited, order, []) }
22
+
23
+ order
24
+ end
25
+
26
+ private
27
+
28
+ # Validate that the graph is acyclic
29
+ def validate!
30
+ visited = {}
31
+ rec_stack = {}
32
+
33
+ @packages.each_value do |package|
34
+ if has_cycle?(package, visited, rec_stack, [])
35
+ raise 'Circular dependency detected in package graph'
36
+ end
37
+ end
38
+
39
+ true
40
+ end
41
+
42
+ def load_package(name, path)
43
+ return @packages[name] if @packages[name]
44
+
45
+ package = Package.new(name, path)
46
+ @packages[name] = package
47
+ package
48
+ end
49
+
50
+ def resolve_dependencies(package, path)
51
+ if path.include?(package.name)
52
+ cycle = (path + [package.name]).join(' -> ')
53
+ raise "Circular dependency detected: #{cycle}"
54
+ end
55
+
56
+ package.dependencies.each do |dep_path|
57
+ dep_name = File.basename(dep_path)
58
+ full_path = File.join(@root_path, dep_path)
59
+
60
+ unless File.directory?(full_path)
61
+ raise "Package not found: #{dep_path} (expected at #{full_path})"
62
+ end
63
+
64
+ dep_package = load_package(dep_name, full_path)
65
+ resolve_dependencies(dep_package, path + [package.name])
66
+ end
67
+ end
68
+
69
+ # DFS for topological sort
70
+ def visit(package, visited, order, path)
71
+ return if visited[package.name]
72
+
73
+ if path.include?(package.name)
74
+ raise "Circular dependency: #{(path + [package.name]).join(' -> ')}"
75
+ end
76
+
77
+ visited[package.name] = true
78
+
79
+ package.dependencies.each do |dep_path|
80
+ dep_name = File.basename(dep_path)
81
+ dep_package = @packages[dep_name]
82
+ visit(dep_package, visited, order, path + [package.name]) if dep_package
83
+ end
84
+
85
+ order << package
86
+ end
87
+
88
+ # Cycle detection
89
+ def has_cycle?(package, visited, rec_stack, path)
90
+ return false if visited[package.name]
91
+
92
+ return true if rec_stack[package.name]
93
+
94
+ visited[package.name] = true
95
+ rec_stack[package.name] = true
96
+
97
+ package.dependencies.each do |dep_path|
98
+ dep_name = File.basename(dep_path)
99
+ dep_package = @packages[dep_name]
100
+
101
+ if dep_package &&
102
+ has_cycle?(dep_package, visited, rec_stack, path + [package.name])
103
+ return true
104
+ end
105
+ end
106
+
107
+ rec_stack[package.name] = false
108
+ false
109
+ end
110
+ end
111
+ end