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,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zeitwerk'
4
+
5
+ module Boxwerk
6
+ # Uses Zeitwerk's file system scanner and inflector to discover constants
7
+ # in a directory. Zeitwerk's autoload registration cannot be used directly
8
+ # inside Ruby::Box (autoloads register in the box where the code was
9
+ # defined, not where it's called), so we only use Zeitwerk for:
10
+ #
11
+ # - File discovery (respecting Zeitwerk conventions: hidden dirs, etc.)
12
+ # - Inflection (file names → constant names)
13
+ #
14
+ # The actual autoload registration is done by BoxManager using box.eval.
15
+ module ZeitwerkScanner
16
+ Entry = Data.define(:type, :cname, :full_path, :file, :parent, :dir)
17
+
18
+ # Scans a directory and returns an array of Entry structs describing
19
+ # the constants and namespaces found. Uses a temporary Zeitwerk::Loader
20
+ # for its FileSystem scanner and Inflector.
21
+ def self.scan(dir)
22
+ loader = Zeitwerk::Loader.new
23
+ loader.push_dir(dir)
24
+ inflector = loader.inflector
25
+ fs = Zeitwerk::Loader::FileSystem.new(loader)
26
+
27
+ entries = []
28
+ scan_dir(fs, inflector, dir, '', entries)
29
+ entries
30
+ end
31
+
32
+ # Registers autoloads in a Ruby::Box based on scan results.
33
+ # Implicit namespaces become Module.new, explicit namespaces are
34
+ # eagerly loaded so child autoloads can attach to them.
35
+ def self.register_autoloads(box, entries)
36
+ namespaces = entries.select { |e| e.type == :namespace }
37
+ files = entries.select { |e| e.type == :file }
38
+
39
+ # Deduplicate namespaces: when both lib/ and public/ contribute a
40
+ # namespace with the same full_path, prefer the explicit one (has a
41
+ # .rb file) so the module definition is loaded rather than replaced
42
+ # by an empty Module.new.
43
+ namespaces =
44
+ namespaces
45
+ .group_by(&:full_path)
46
+ .values
47
+ .map { |group| group.find { |ns| ns.file } || group.first }
48
+
49
+ # Phase 1: Set up namespaces
50
+ namespaces.each do |ns|
51
+ if ns.file
52
+ # Explicit namespace: autoload the .rb file
53
+ register_autoload(box, ns.parent, ns.cname, ns.file)
54
+ else
55
+ # Implicit namespace: create empty module
56
+ define_implicit_module(box, ns.parent, ns.cname, ns.full_path)
57
+ end
58
+ end
59
+
60
+ # Phase 2: Eagerly trigger explicit namespaces so children can attach
61
+ namespaces.each { |ns| box.eval(ns.full_path) if ns.file }
62
+
63
+ # Phase 3: Register file autoloads
64
+ files.each { |f| register_autoload(box, f.parent, f.cname, f.file) }
65
+ end
66
+
67
+ # Scans files in a collapsed directory, computing the parent namespace
68
+ # from the path between root_dir and the collapsed dir. E.g. if root_dir
69
+ # is lib/ and dir is lib/analytics/formatters/, files map to Analytics::*.
70
+ def self.scan_files_only(dir, root_dir: nil)
71
+ inflector = Zeitwerk::Inflector.new
72
+ entries = []
73
+
74
+ parent_cnames =
75
+ if root_dir && dir.start_with?("#{root_dir}/")
76
+ rel = dir.delete_prefix("#{root_dir}/")
77
+ rel.split('/')[0...-1].map { |p| inflector.camelize(p, root_dir) }
78
+ else
79
+ []
80
+ end
81
+
82
+ Dir
83
+ .glob(File.join(dir, '**', '*.rb'))
84
+ .sort
85
+ .each do |abspath|
86
+ relative = abspath.delete_prefix("#{dir}/").delete_suffix('.rb')
87
+ parts = relative.split('/')
88
+ file_cnames = parts.map { |part| inflector.camelize(part, dir) }
89
+ all_cnames = parent_cnames + file_cnames
90
+ full_path = all_cnames.join('::')
91
+ cname = all_cnames.last
92
+ parent = all_cnames[0...-1].join('::')
93
+
94
+ entries << Entry.new(
95
+ type: :file,
96
+ cname: cname,
97
+ full_path: full_path,
98
+ file: abspath,
99
+ parent: parent,
100
+ dir: nil,
101
+ )
102
+ end
103
+
104
+ entries
105
+ end
106
+
107
+ # Builds a file index (const_name → file_path) from scan entries.
108
+ # Used by ConstantResolver for dependency wiring.
109
+ def self.build_file_index(entries)
110
+ index = {}
111
+ entries.each { |e| index[e.full_path] = e.file if e.file }
112
+ index
113
+ end
114
+
115
+ class << self
116
+ private
117
+
118
+ def scan_dir(fs, inflector, dir, parent_path, entries)
119
+ fs.ls(dir) do |basename, abspath, ftype|
120
+ if ftype == :file
121
+ # Skip files that have a matching directory (explicit namespaces).
122
+ # These are already handled as namespace entries with their .rb file.
123
+ next if File.directory?(abspath.delete_suffix('.rb'))
124
+
125
+ cname = inflector.camelize(basename.delete_suffix('.rb'), dir)
126
+ full_path = parent_path.empty? ? cname : "#{parent_path}::#{cname}"
127
+ entries << Entry.new(
128
+ type: :file,
129
+ cname: cname,
130
+ full_path: full_path,
131
+ file: abspath,
132
+ parent: parent_path,
133
+ dir: nil,
134
+ )
135
+ elsif ftype == :directory
136
+ cname = inflector.camelize(basename, dir)
137
+ full_path = parent_path.empty? ? cname : "#{parent_path}::#{cname}"
138
+ rb_file = "#{abspath}.rb"
139
+ has_rb = File.exist?(rb_file)
140
+ entries << Entry.new(
141
+ type: :namespace,
142
+ cname: cname,
143
+ full_path: full_path,
144
+ file: has_rb ? rb_file : nil,
145
+ parent: parent_path,
146
+ dir: abspath,
147
+ )
148
+ scan_dir(fs, inflector, abspath, full_path, entries)
149
+ end
150
+ end
151
+ end
152
+
153
+ def register_autoload(box, parent, cname, file)
154
+ if parent.empty?
155
+ box.eval("autoload :#{cname}, #{file.inspect}")
156
+ else
157
+ box.eval("#{parent}.autoload(:#{cname}, #{file.inspect})")
158
+ end
159
+ end
160
+
161
+ def define_implicit_module(box, parent, cname, full_path)
162
+ if parent.empty?
163
+ box.eval("#{cname} = Module.new unless defined?(#{cname})")
164
+ else
165
+ box.eval(
166
+ "#{parent}.const_set(:#{cname}, Module.new) unless defined?(#{full_path})",
167
+ )
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
data/lib/boxwerk.rb CHANGED
@@ -1,9 +1,36 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'boxwerk/autoloader_mixin'
4
+ require_relative 'boxwerk/box_manager'
3
5
  require_relative 'boxwerk/cli'
4
- require_relative 'boxwerk/graph'
5
- require_relative 'boxwerk/loader'
6
+ require_relative 'boxwerk/constant_resolver'
7
+ require_relative 'boxwerk/gem_resolver'
8
+ require_relative 'boxwerk/gemfile_require_parser'
9
+ require_relative 'boxwerk/global_context'
6
10
  require_relative 'boxwerk/package'
7
- require_relative 'boxwerk/registry'
11
+ require_relative 'boxwerk/package_context'
12
+ require_relative 'boxwerk/package_resolver'
13
+ require_relative 'boxwerk/privacy_checker'
8
14
  require_relative 'boxwerk/setup'
9
15
  require_relative 'boxwerk/version'
16
+ require_relative 'boxwerk/zeitwerk_scanner'
17
+
18
+ # Boxwerk is a package isolation system for Ruby applications built on
19
+ # Ruby::Box. It loads each package in its own +Ruby::Box+, enforcing
20
+ # dependency and privacy boundaries declared in +package.yml+ files.
21
+ module Boxwerk
22
+ class << self
23
+ # Returns the {PackageContext} for the currently executing package.
24
+ # Returns +nil+ in the root box.
25
+ # @return [PackageContext, nil]
26
+ def package
27
+ nil
28
+ end
29
+
30
+ # Returns the {GlobalContext}.
31
+ # @return [GlobalContext]
32
+ def global
33
+ Ruby::Box.root.const_get(:BOXWERK_GLOBAL)
34
+ end
35
+ end
36
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: boxwerk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Cristofaro
@@ -9,23 +9,54 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: bundler
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '4.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '4.0'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: irb
14
28
  requirement: !ruby/object:Gem::Requirement
15
29
  requirements:
16
30
  - - "~>"
17
31
  - !ruby/object:Gem::Version
18
- version: '1.16'
32
+ version: '1.17'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.17'
40
+ - !ruby/object:Gem::Dependency
41
+ name: zeitwerk
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '2.7'
19
47
  type: :runtime
20
48
  prerelease: false
21
49
  version_requirements: !ruby/object:Gem::Requirement
22
50
  requirements:
23
51
  - - "~>"
24
52
  - !ruby/object:Gem::Version
25
- version: '1.16'
26
- description: Boxwerk is an experimental Ruby package system with Box-powered constant
27
- isolation. It is used at runtime to organize code into packages with explicit dependencies
28
- and strict constant access using Ruby::Box. Inspired by Packwerk.
53
+ version: '2.7'
54
+ description: Boxwerk is a tool for creating modular Ruby and Rails applications. It
55
+ organizes code into packages with clear boundaries and explicit dependencies, enforcing
56
+ them at runtime using Ruby::Box constant isolation. It reads standard Packwerk package.yml
57
+ files (without requiring Packwerk), providing per-package gem isolation, Zeitwerk-based
58
+ autoloading, monkey patch isolation between packages, and a CLI for running, testing,
59
+ and inspecting your modular application.
29
60
  email:
30
61
  - david@dtcristo.com
31
62
  executables:
@@ -33,19 +64,30 @@ executables:
33
64
  extensions: []
34
65
  extra_rdoc_files: []
35
66
  files:
67
+ - AGENTS.md
68
+ - ARCHITECTURE.md
36
69
  - CHANGELOG.md
37
70
  - LICENSE.txt
38
71
  - README.md
39
72
  - Rakefile
73
+ - TODO.md
74
+ - USAGE.md
40
75
  - exe/boxwerk
41
76
  - lib/boxwerk.rb
77
+ - lib/boxwerk/autoloader_mixin.rb
78
+ - lib/boxwerk/box_manager.rb
42
79
  - lib/boxwerk/cli.rb
43
- - lib/boxwerk/graph.rb
44
- - lib/boxwerk/loader.rb
80
+ - lib/boxwerk/constant_resolver.rb
81
+ - lib/boxwerk/gem_resolver.rb
82
+ - lib/boxwerk/gemfile_require_parser.rb
83
+ - lib/boxwerk/global_context.rb
45
84
  - lib/boxwerk/package.rb
46
- - lib/boxwerk/registry.rb
85
+ - lib/boxwerk/package_context.rb
86
+ - lib/boxwerk/package_resolver.rb
87
+ - lib/boxwerk/privacy_checker.rb
47
88
  - lib/boxwerk/setup.rb
48
89
  - lib/boxwerk/version.rb
90
+ - lib/boxwerk/zeitwerk_scanner.rb
49
91
  homepage: https://github.com/dtcristo/boxwerk
50
92
  licenses:
51
93
  - MIT
@@ -54,6 +96,7 @@ metadata:
54
96
  homepage_uri: https://github.com/dtcristo/boxwerk
55
97
  source_code_uri: https://github.com/dtcristo/boxwerk
56
98
  changelog_uri: https://github.com/dtcristo/boxwerk/blob/main/CHANGELOG.md
99
+ documentation_uri: https://dtcristo.github.io/boxwerk/
57
100
  rdoc_options: []
58
101
  require_paths:
59
102
  - lib
@@ -61,7 +104,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
61
104
  requirements:
62
105
  - - ">="
63
106
  - !ruby/object:Gem::Version
64
- version: 4.0.0
107
+ version: '4.0'
65
108
  required_rubygems_version: !ruby/object:Gem::Requirement
66
109
  requirements:
67
110
  - - ">="
@@ -70,5 +113,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
70
113
  requirements: []
71
114
  rubygems_version: 4.0.3
72
115
  specification_version: 4
73
- summary: Ruby package system with Box-powered constant isolation
116
+ summary: Ruby package system with Box-powered boundary enforcement
74
117
  test_files: []
data/lib/boxwerk/graph.rb DELETED
@@ -1,53 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Boxwerk
4
- # Graph builds a directed acyclic graph (DAG) of package dependencies.
5
- # Validates no circular dependencies and provides topological ordering for boot sequence.
6
- class Graph
7
- attr_reader :packages, :root
8
-
9
- def initialize(root_path)
10
- @root_path = root_path
11
- @packages = {}
12
- @root = load_package('root', root_path)
13
- resolve_dependencies(@root, [])
14
- end
15
-
16
- def topological_order
17
- visited = {}
18
- order = []
19
- @packages.each_value { |pkg| visit(pkg, visited, order, []) }
20
- order
21
- end
22
-
23
- private
24
-
25
- def load_package(name, path)
26
- return @packages[name] if @packages[name]
27
-
28
- @packages[name] = Package.new(name, path)
29
- end
30
-
31
- def resolve_dependencies(package, path)
32
- raise "Circular dependency: #{(path + [package.name]).join(' -> ')}" if path.include?(package.name)
33
-
34
- package.dependencies.each do |dep_path|
35
- full_path = File.join(@root_path, dep_path)
36
- raise "Package not found: #{dep_path}" unless File.directory?(full_path)
37
-
38
- resolve_dependencies(load_package(File.basename(dep_path), full_path), path + [package.name])
39
- end
40
- end
41
-
42
- def visit(package, visited, order, path)
43
- return if visited[package.name]
44
-
45
- visited[package.name] = true
46
- package.dependencies.each do |dep_path|
47
- dep_package = @packages[File.basename(dep_path)]
48
- visit(dep_package, visited, order, path + [package.name]) if dep_package
49
- end
50
- order << package
51
- end
52
- end
53
- end
@@ -1,149 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Boxwerk
4
- # Loader creates isolated Ruby::Box instances for packages and wires imports.
5
- # Lazily loads exports using Zeitwerk naming conventions and injects constants.
6
- class Loader
7
- class << self
8
- def boot_all(graph, registry)
9
- order = graph.topological_order
10
-
11
- order.each { |package| boot(package, graph, registry) }
12
- end
13
-
14
- def boot(package, graph, registry)
15
- return package if package.booted?
16
-
17
- package.box = Ruby::Box.new
18
- wire_imports(package.box, package, graph)
19
- registry.register(package.name, package)
20
-
21
- package
22
- end
23
-
24
- private
25
-
26
- def load_export(package, const_name)
27
- # Skip if already loaded (cached)
28
- return if package.loaded_exports.key?(const_name)
29
-
30
- lib_path = File.join(package.path, 'lib')
31
- return unless File.directory?(lib_path)
32
-
33
- file_path = find_file_for_constant(lib_path, const_name)
34
- unless file_path
35
- raise "Cannot find file for exported constant '#{const_name}' in package '#{package.name}'"
36
- end
37
-
38
- package.box.require(file_path)
39
- package.loaded_exports[const_name] = file_path
40
- end
41
-
42
- def find_file_for_constant(lib_path, const_name)
43
- if const_name.include?('::')
44
- nested_path = File.join(lib_path, "#{underscore(const_name)}.rb")
45
- return nested_path if File.exist?(nested_path)
46
-
47
- parts = const_name.split('::')
48
- parent_name = parts[0..-2].join('::')
49
- parent_file = File.join(lib_path, "#{underscore(parent_name)}.rb")
50
- return parent_file if File.exist?(parent_file)
51
- else
52
- conventional_path =
53
- File.join(lib_path, "#{underscore(const_name)}.rb")
54
- return conventional_path if File.exist?(conventional_path)
55
- end
56
-
57
- nil
58
- end
59
-
60
- def underscore(string)
61
- string
62
- .gsub(/::/, '/')
63
- .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
64
- .gsub(/([a-z\d])([A-Z])/, '\1_\2')
65
- .tr('-', '_')
66
- .downcase
67
- end
68
-
69
- def wire_imports(box, package, graph)
70
- package.imports.each do |import_item|
71
- path, config =
72
- (
73
- if import_item.is_a?(String)
74
- [import_item, nil]
75
- else
76
- [import_item.keys.first, import_item.values.first]
77
- end
78
- )
79
- dep_name = File.basename(path)
80
- dependency = graph.packages[dep_name]
81
-
82
- unless dependency
83
- raise "Cannot resolve dependency '#{path}' for package '#{package.name}'"
84
- end
85
- unless dependency.booted?
86
- raise "Dependency '#{dep_name}' not booted yet"
87
- end
88
-
89
- wire_import_strategy(box, package, path, config, dependency)
90
- end
91
- end
92
-
93
- def wire_import_strategy(box, package, path, config, dependency)
94
- case config
95
- when nil
96
- create_namespace(package, camelize(File.basename(path)), dependency)
97
- when String
98
- create_namespace(package, config, dependency)
99
- when Array
100
- config.each do |name|
101
- set_constant(package, name, get_constant(dependency, name))
102
- end
103
- when Hash
104
- config.each do |remote, local|
105
- set_constant(package, local, get_constant(dependency, remote))
106
- end
107
- end
108
- end
109
-
110
- def create_namespace(package, namespace_name, dependency)
111
- if dependency.exports.size == 1
112
- set_constant(
113
- package,
114
- namespace_name,
115
- get_constant(dependency, dependency.exports.first),
116
- )
117
- else
118
- mod = create_module(package.box, namespace_name)
119
- dependency.exports.each do |name|
120
- mod.const_set(name.to_sym, get_constant(dependency, name))
121
- end
122
- end
123
- end
124
-
125
- def get_constant(package, name)
126
- load_export(package, name)
127
- package.box.const_get(name.to_sym)
128
- end
129
-
130
- def set_constant(package, name, value)
131
- package.box.const_set(name.to_sym, value)
132
- end
133
-
134
- def create_module(box, name)
135
- if box.const_defined?(name.to_sym, false)
136
- return box.const_get(name.to_sym)
137
- end
138
-
139
- mod = Module.new
140
- box.const_set(name.to_sym, mod)
141
- mod
142
- end
143
-
144
- def camelize(string)
145
- string.split('_').map(&:capitalize).join
146
- end
147
- end
148
- end
149
- end
@@ -1,26 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Boxwerk
4
- # Registry tracks booted package instances to ensure each package boots only once.
5
- class Registry
6
- def initialize
7
- @registry = {}
8
- end
9
-
10
- def register(name, instance)
11
- @registry[name] = instance
12
- end
13
-
14
- def get(name)
15
- @registry[name]
16
- end
17
-
18
- def registered?(name)
19
- @registry.key?(name)
20
- end
21
-
22
- def clear!
23
- @registry = {}
24
- end
25
- end
26
- end