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,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Boxwerk
|
|
4
|
+
# Resolves constants from dependency packages without namespace wrapping.
|
|
5
|
+
#
|
|
6
|
+
# When package A depends on package B, B's constants are accessible
|
|
7
|
+
# directly in A (e.g. Invoice, not Finance::Invoice). A const_missing
|
|
8
|
+
# handler searches all direct dependencies in order and resolves the
|
|
9
|
+
# first match. Privacy enforcement is applied per-dependency.
|
|
10
|
+
module ConstantResolver
|
|
11
|
+
# Installs a const_missing handler on the box that searches all
|
|
12
|
+
# dependency boxes for the requested constant. Each dependency entry
|
|
13
|
+
# contains: :box, :file_index, :public_constants, :private_constants,
|
|
14
|
+
# :package_name.
|
|
15
|
+
#
|
|
16
|
+
# +all_packages_ref+ is an optional hash with lazy references to
|
|
17
|
+
# :file_indexes, :packages, :root_path, :dep_names, and :self_name
|
|
18
|
+
# for producing helpful NameError hints.
|
|
19
|
+
# +package_name+ is the name of the current package (for error messages).
|
|
20
|
+
def self.install_dependency_resolver(
|
|
21
|
+
box,
|
|
22
|
+
deps_config,
|
|
23
|
+
all_packages_ref: nil,
|
|
24
|
+
package_name: nil
|
|
25
|
+
)
|
|
26
|
+
resolver =
|
|
27
|
+
build_resolver(
|
|
28
|
+
deps_config,
|
|
29
|
+
all_packages_ref: all_packages_ref,
|
|
30
|
+
package_name: package_name,
|
|
31
|
+
)
|
|
32
|
+
box.const_set(:BOXWERK_DEPENDENCY_RESOLVER, resolver)
|
|
33
|
+
|
|
34
|
+
# Define const_missing on Object within the box so that top-level
|
|
35
|
+
# constant lookups (e.g. Invoice) trigger the dependency search.
|
|
36
|
+
box.eval(<<~RUBY)
|
|
37
|
+
class Object
|
|
38
|
+
def self.const_missing(const_name)
|
|
39
|
+
BOXWERK_DEPENDENCY_RESOLVER.call(const_name)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
RUBY
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Builds a resolver proc that searches dependencies for a constant.
|
|
46
|
+
def self.build_resolver(
|
|
47
|
+
deps_config,
|
|
48
|
+
all_packages_ref: nil,
|
|
49
|
+
package_name: nil
|
|
50
|
+
)
|
|
51
|
+
proc do |const_name|
|
|
52
|
+
name_str = const_name.to_s
|
|
53
|
+
found = false
|
|
54
|
+
value = nil
|
|
55
|
+
|
|
56
|
+
deps_config.each do |dep|
|
|
57
|
+
dep_box = dep[:box]
|
|
58
|
+
file_index = dep[:file_index]
|
|
59
|
+
public_constants = dep[:public_constants]
|
|
60
|
+
private_constants = dep[:private_constants]
|
|
61
|
+
pkg_name = dep[:package_name]
|
|
62
|
+
|
|
63
|
+
# Check if this dependency has the constant or a namespace
|
|
64
|
+
# matching it (e.g. "Menu" when file_index has "Menu::Item")
|
|
65
|
+
has_constant =
|
|
66
|
+
file_index.key?(name_str) ||
|
|
67
|
+
file_index.any? { |k, _| k.start_with?("#{name_str}::") } ||
|
|
68
|
+
(
|
|
69
|
+
begin
|
|
70
|
+
dep_box.const_get(const_name)
|
|
71
|
+
true
|
|
72
|
+
rescue NameError
|
|
73
|
+
false
|
|
74
|
+
end
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
next unless has_constant
|
|
78
|
+
|
|
79
|
+
# Check explicitly private constants
|
|
80
|
+
if private_constants && !private_constants.empty?
|
|
81
|
+
if private_constants.include?(name_str) ||
|
|
82
|
+
private_constants.any? { |pc| name_str.start_with?("#{pc}::") }
|
|
83
|
+
from = package_name ? " referenced from '#{package_name}'" : ''
|
|
84
|
+
raise NameError.new(
|
|
85
|
+
"private constant #{name_str}#{from} — #{name_str} is private to '#{pkg_name}'",
|
|
86
|
+
const_name,
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Check public constants whitelist (privacy enforcement).
|
|
92
|
+
# A namespace module (e.g. Menu) is allowed if any public
|
|
93
|
+
# constant lives under it (e.g. Menu::Item).
|
|
94
|
+
if public_constants
|
|
95
|
+
direct_match = public_constants.include?(name_str)
|
|
96
|
+
namespace_match =
|
|
97
|
+
public_constants.any? { |pc| pc.start_with?("#{name_str}::") }
|
|
98
|
+
unless direct_match || namespace_match
|
|
99
|
+
from = package_name ? " referenced from '#{package_name}'" : ''
|
|
100
|
+
raise NameError.new(
|
|
101
|
+
"private constant #{name_str}#{from} — #{name_str} is private to '#{pkg_name}'",
|
|
102
|
+
const_name,
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Resolve the constant from the dependency box
|
|
108
|
+
value =
|
|
109
|
+
begin
|
|
110
|
+
dep_box.const_get(const_name)
|
|
111
|
+
rescue NameError
|
|
112
|
+
file = file_index[name_str]
|
|
113
|
+
if file
|
|
114
|
+
dep_box.require(file)
|
|
115
|
+
dep_box.const_get(const_name)
|
|
116
|
+
else
|
|
117
|
+
# Namespace module — trigger autoload of a child constant
|
|
118
|
+
# so the module gets defined in the dependency box.
|
|
119
|
+
child_key =
|
|
120
|
+
file_index.keys.find { |k| k.start_with?("#{name_str}::") }
|
|
121
|
+
if child_key
|
|
122
|
+
child_file = file_index[child_key]
|
|
123
|
+
dep_box.require(child_file)
|
|
124
|
+
dep_box.const_get(const_name)
|
|
125
|
+
else
|
|
126
|
+
raise NameError.new(
|
|
127
|
+
"uninitialized constant #{name_str}",
|
|
128
|
+
const_name,
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
found = true
|
|
135
|
+
break
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
unless found
|
|
139
|
+
hint = find_hint(name_str, all_packages_ref)
|
|
140
|
+
msg =
|
|
141
|
+
if hint
|
|
142
|
+
visibility = hint[:private] ? 'private in' : 'defined in'
|
|
143
|
+
from = package_name ? ", not a dependency of '#{package_name}'" : ''
|
|
144
|
+
"uninitialized constant #{name_str} (#{visibility} '#{hint[:package_name]}'#{from})"
|
|
145
|
+
else
|
|
146
|
+
"uninitialized constant #{name_str}"
|
|
147
|
+
end
|
|
148
|
+
raise NameError.new(msg, const_name)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
value
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Searches all packages (via lazy ref) for one whose file_index
|
|
156
|
+
# contains the given constant name. Returns a hash with :package_name
|
|
157
|
+
# and :private (boolean), or nil if not found.
|
|
158
|
+
#
|
|
159
|
+
# For packages that weren't booted (not in file_indexes), does a
|
|
160
|
+
# lightweight file-system scan so hints work with selective booting.
|
|
161
|
+
def self.find_hint(name_str, all_packages_ref)
|
|
162
|
+
return nil unless all_packages_ref
|
|
163
|
+
|
|
164
|
+
file_indexes = all_packages_ref[:file_indexes]
|
|
165
|
+
packages = all_packages_ref[:packages]
|
|
166
|
+
root_path = all_packages_ref[:root_path]
|
|
167
|
+
dep_names = all_packages_ref[:dep_names]
|
|
168
|
+
self_name = all_packages_ref[:self_name]
|
|
169
|
+
|
|
170
|
+
packages.each_value do |pkg|
|
|
171
|
+
next if pkg.name == self_name
|
|
172
|
+
next if dep_names.include?(pkg.name)
|
|
173
|
+
|
|
174
|
+
pkg_file_index = file_indexes[pkg.name]
|
|
175
|
+
|
|
176
|
+
if pkg_file_index
|
|
177
|
+
# Package was booted — use its file index directly
|
|
178
|
+
next unless pkg_file_index.key?(name_str) ||
|
|
179
|
+
pkg_file_index.any? { |k, _| k.start_with?("#{name_str}::") }
|
|
180
|
+
else
|
|
181
|
+
# Package was not booted — do a lightweight file-system scan
|
|
182
|
+
pkg_file_index = scan_package_for_hint(pkg, root_path)
|
|
183
|
+
next unless pkg_file_index.key?(name_str) ||
|
|
184
|
+
pkg_file_index.any? { |k, _| k.start_with?("#{name_str}::") }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
pub_consts = PrivacyChecker.public_constants(pkg, root_path)
|
|
188
|
+
is_private =
|
|
189
|
+
if pub_consts
|
|
190
|
+
!pub_consts.include?(name_str) &&
|
|
191
|
+
!pub_consts.any? { |pc| pc.start_with?("#{name_str}::") }
|
|
192
|
+
else
|
|
193
|
+
false
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
return { package_name: pkg.name, private: is_private }
|
|
197
|
+
end
|
|
198
|
+
nil
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Lightweight file-system scan to build a constant → file map for a
|
|
202
|
+
# package without booting its box. Used for NameError hints.
|
|
203
|
+
#
|
|
204
|
+
# Does NOT use Zeitwerk::Loader (which registers dirs globally and
|
|
205
|
+
# would conflict if called twice for the same dir). Instead performs
|
|
206
|
+
# a plain Dir.glob walk with Zeitwerk::Inflector for naming.
|
|
207
|
+
def self.scan_package_for_hint(pkg, root_path)
|
|
208
|
+
pkg_dir =
|
|
209
|
+
if pkg.root?
|
|
210
|
+
root_path
|
|
211
|
+
else
|
|
212
|
+
File.join(root_path, pkg.name)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
lib_dir = pkg.root? ? nil : File.join(pkg_dir, 'lib')
|
|
216
|
+
pub_dir = PrivacyChecker.public_path_for(pkg, root_path)
|
|
217
|
+
|
|
218
|
+
inflector = Zeitwerk::Inflector.new
|
|
219
|
+
result = {}
|
|
220
|
+
|
|
221
|
+
[lib_dir, pub_dir].compact.each do |dir|
|
|
222
|
+
next unless File.directory?(dir)
|
|
223
|
+
|
|
224
|
+
Dir.glob(File.join(dir, '**', '*.rb')).sort.each do |abspath|
|
|
225
|
+
relative = abspath.delete_prefix("#{dir}/").delete_suffix('.rb')
|
|
226
|
+
parts = relative.split('/')
|
|
227
|
+
cnames = parts.map { |part| inflector.camelize(part, abspath) }
|
|
228
|
+
full_path = cnames.join('::')
|
|
229
|
+
result[full_path] ||= abspath
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
result
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bundler'
|
|
4
|
+
|
|
5
|
+
module Boxwerk
|
|
6
|
+
# Resolves per-package gem dependencies from lockfiles.
|
|
7
|
+
#
|
|
8
|
+
# Parses lockfiles with Bundler::LockfileParser and resolves gem load paths
|
|
9
|
+
# for $LOAD_PATH isolation per box. Unlike Bundler itself, this resolver can
|
|
10
|
+
# find gems outside the current bundle by searching all gem directories.
|
|
11
|
+
#
|
|
12
|
+
# Gems are fully isolated per box — they do not leak across package
|
|
13
|
+
# boundaries. Cross-package version conflicts are harmless because each box
|
|
14
|
+
# has its own $LOAD_PATH and $LOADED_FEATURES snapshot. The only situation
|
|
15
|
+
# worth flagging is when a package defines a gem that is also in the root
|
|
16
|
+
# Gemfile at a different version: both versions end up in memory (the global
|
|
17
|
+
# version inherited at box creation, the package version loaded on demand).
|
|
18
|
+
class GemResolver
|
|
19
|
+
# Represents a resolved gem for a package: name, version, load paths,
|
|
20
|
+
# and autorequire directives from the Gemfile.
|
|
21
|
+
# autorequire: nil → transitive dependency (not in Gemfile)
|
|
22
|
+
# autorequire: :default → require the gem name (Gemfile entry, no require option)
|
|
23
|
+
# autorequire: [] → skip (require: false)
|
|
24
|
+
# autorequire: ["x/y"] → require each listed path
|
|
25
|
+
GemInfo = Struct.new(:name, :version, :load_paths, :autorequire, keyword_init: true)
|
|
26
|
+
|
|
27
|
+
attr_reader :root_path
|
|
28
|
+
|
|
29
|
+
def initialize(root_path)
|
|
30
|
+
@root_path = root_path
|
|
31
|
+
@all_specs = nil
|
|
32
|
+
@package_gems = {} # package name -> [GemInfo]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Returns an array of load paths for a package's gem dependencies.
|
|
36
|
+
# Returns nil if the package has no gems.rb/Gemfile.
|
|
37
|
+
def resolve_for(package)
|
|
38
|
+
gems = gems_for(package)
|
|
39
|
+
return nil unless gems&.any?
|
|
40
|
+
|
|
41
|
+
gems.flat_map(&:load_paths).uniq
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Returns [GemInfo] for a package, cached after first resolution.
|
|
45
|
+
# Merges lockfile resolution with Gemfile autorequire directives.
|
|
46
|
+
def gems_for(package)
|
|
47
|
+
return @package_gems[package.name] if @package_gems.key?(package.name)
|
|
48
|
+
|
|
49
|
+
gemfile_path = find_gemfile(package)
|
|
50
|
+
unless gemfile_path
|
|
51
|
+
@package_gems[package.name] = nil
|
|
52
|
+
return nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
lockfile_path = find_lockfile(gemfile_path)
|
|
56
|
+
unless lockfile_path && File.exist?(lockfile_path)
|
|
57
|
+
pkg_label = package.root? ? '.' : package.name
|
|
58
|
+
warn "Boxwerk: No lockfile found for #{pkg_label}. Run 'boxwerk install' to install gems."
|
|
59
|
+
@package_gems[package.name] = nil
|
|
60
|
+
return nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
requires = parse_gemfile_requires(gemfile_path)
|
|
64
|
+
gems = resolve_gems_from_lockfile(lockfile_path)
|
|
65
|
+
gems.each do |g|
|
|
66
|
+
next unless requires.key?(g.name)
|
|
67
|
+
# :default means "require the gem name" (Gemfile entry with no require option).
|
|
68
|
+
# nil means transitive dependency (not in Gemfile) — skip auto-require.
|
|
69
|
+
g.autorequire = requires[g.name] || :default
|
|
70
|
+
end
|
|
71
|
+
@package_gems[package.name] = gems
|
|
72
|
+
gems
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Parses the Gemfile to extract autorequire directives.
|
|
76
|
+
# Returns { gem_name => autorequire_value } where value is:
|
|
77
|
+
# nil → require the gem name (default)
|
|
78
|
+
# [] → skip (require: false)
|
|
79
|
+
# ["path"] → require the listed paths
|
|
80
|
+
#
|
|
81
|
+
# Uses a lightweight DSL evaluator to avoid Bundler::Dsl side-effects.
|
|
82
|
+
def parse_gemfile_requires(gemfile_path)
|
|
83
|
+
parser = GemfileRequireParser.new
|
|
84
|
+
parser.eval_gemfile(gemfile_path)
|
|
85
|
+
parser.requires
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Checks for gem version conflicts between global and per-package gems.
|
|
89
|
+
# Returns an array of conflict descriptions (empty if none).
|
|
90
|
+
#
|
|
91
|
+
# Cross-package conflicts are NOT checked because gems are fully isolated
|
|
92
|
+
# per box — each box has its own $LOAD_PATH and $LOADED_FEATURES snapshot.
|
|
93
|
+
# Package A can safely use gem Z v2 while package B uses gem Z v1, even if
|
|
94
|
+
# A depends on B.
|
|
95
|
+
#
|
|
96
|
+
# The only conflict worth flagging is a **global override**: a package
|
|
97
|
+
# defines a gem that is also in the root Gemfile at a different version.
|
|
98
|
+
# Both versions load into memory (global inherited at box creation,
|
|
99
|
+
# package version loaded on demand), which wastes memory but is
|
|
100
|
+
# functionally correct.
|
|
101
|
+
def check_conflicts(package_resolver)
|
|
102
|
+
conflicts = []
|
|
103
|
+
|
|
104
|
+
root_gems = gems_for(package_resolver.root)
|
|
105
|
+
return conflicts unless root_gems&.any?
|
|
106
|
+
|
|
107
|
+
root_gem_map = root_gems.each_with_object({}) { |g, h| h[g.name] = g }
|
|
108
|
+
|
|
109
|
+
package_resolver.packages.each_value do |pkg|
|
|
110
|
+
next if pkg.root?
|
|
111
|
+
|
|
112
|
+
pkg_gems = gems_for(pkg)
|
|
113
|
+
next unless pkg_gems
|
|
114
|
+
|
|
115
|
+
pkg_gems.each do |gem_info|
|
|
116
|
+
root_gem = root_gem_map[gem_info.name]
|
|
117
|
+
next unless root_gem
|
|
118
|
+
next if root_gem.version == gem_info.version
|
|
119
|
+
|
|
120
|
+
conflicts << {
|
|
121
|
+
type: :global_override,
|
|
122
|
+
gem_name: gem_info.name,
|
|
123
|
+
package: pkg.name,
|
|
124
|
+
package_version: gem_info.version,
|
|
125
|
+
global_version: root_gem.version,
|
|
126
|
+
}
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
conflicts
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def package_dir(package)
|
|
136
|
+
if package.root?
|
|
137
|
+
@root_path
|
|
138
|
+
else
|
|
139
|
+
File.join(@root_path, package.name)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def find_gemfile(package)
|
|
144
|
+
dir = package_dir(package)
|
|
145
|
+
|
|
146
|
+
gemfile = File.join(dir, 'gems.rb')
|
|
147
|
+
return gemfile if File.exist?(gemfile)
|
|
148
|
+
|
|
149
|
+
gemfile = File.join(dir, 'Gemfile')
|
|
150
|
+
return gemfile if File.exist?(gemfile)
|
|
151
|
+
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def find_lockfile(gemfile_path)
|
|
156
|
+
if gemfile_path.end_with?('gems.rb')
|
|
157
|
+
gemfile_path.sub('gems.rb', 'gems.locked')
|
|
158
|
+
else
|
|
159
|
+
"#{gemfile_path}.lock"
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Parses a lockfile and returns [GemInfo] with resolved load paths.
|
|
164
|
+
# Path gems (like `boxwerk`) that can't be found in gem dirs are included
|
|
165
|
+
# with empty load_paths so they appear in display output.
|
|
166
|
+
def resolve_gems_from_lockfile(lockfile_path)
|
|
167
|
+
lockfile_content = File.read(lockfile_path)
|
|
168
|
+
parser = Bundler::LockfileParser.new(lockfile_content)
|
|
169
|
+
|
|
170
|
+
seen = Set.new
|
|
171
|
+
gems = []
|
|
172
|
+
parser.specs.each do |spec|
|
|
173
|
+
# Deduplicate by name — lockfiles can have multiple platform-specific
|
|
174
|
+
# variants of the same gem (e.g. sqlite3 for x86_64-linux and arm64-darwin).
|
|
175
|
+
next unless seen.add?(spec.name)
|
|
176
|
+
|
|
177
|
+
paths = resolve_gem_paths(spec.name, spec.version.to_s)
|
|
178
|
+
gems << GemInfo.new(
|
|
179
|
+
name: spec.name,
|
|
180
|
+
version: spec.version.to_s,
|
|
181
|
+
load_paths: paths || [],
|
|
182
|
+
)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
gems
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Resolves load paths for a specific gem version.
|
|
189
|
+
def resolve_gem_paths(name, version)
|
|
190
|
+
spec = find_gem_spec(name, version)
|
|
191
|
+
unless spec
|
|
192
|
+
unless name == 'boxwerk'
|
|
193
|
+
warn "Boxwerk: gem '#{name}' (#{version}) not installed, skipping"
|
|
194
|
+
end
|
|
195
|
+
return nil
|
|
196
|
+
end
|
|
197
|
+
collect_paths(spec)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Finds a gem specification by searching all gem directories.
|
|
201
|
+
def find_gem_spec(name, version)
|
|
202
|
+
all_gem_specs.find { |s| s.name == name && s.version.to_s == version } ||
|
|
203
|
+
all_gem_specs.find { |s| s.name == name }
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Loads all gem specifications from all gem directories.
|
|
207
|
+
# Cached for the lifetime of this resolver.
|
|
208
|
+
def all_gem_specs
|
|
209
|
+
@all_specs ||=
|
|
210
|
+
begin
|
|
211
|
+
dirs =
|
|
212
|
+
Gem.path.flat_map do |p|
|
|
213
|
+
Dir.glob(File.join(p, 'specifications', '*.gemspec'))
|
|
214
|
+
end
|
|
215
|
+
dirs.map { |path| Gem::Specification.load(path) }.compact
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Recursively collects load paths for a gem and its runtime dependencies.
|
|
220
|
+
def collect_paths(spec, resolved = Set.new)
|
|
221
|
+
return [] if resolved.include?(spec.name)
|
|
222
|
+
|
|
223
|
+
resolved.add(spec.name)
|
|
224
|
+
paths = spec.full_require_paths.dup
|
|
225
|
+
|
|
226
|
+
spec.runtime_dependencies.each do |dep|
|
|
227
|
+
dep_spec = find_gem_spec(dep.name, dep.requirement.to_s.delete('= '))
|
|
228
|
+
dep_spec ||= all_gem_specs.find { |s| s.name == dep.name }
|
|
229
|
+
paths.concat(collect_paths(dep_spec, resolved)) if dep_spec
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
paths
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Boxwerk
|
|
4
|
+
# Lightweight Gemfile parser that extracts autorequire directives.
|
|
5
|
+
#
|
|
6
|
+
# Evaluates a Gemfile in a sandbox that only captures `gem` calls,
|
|
7
|
+
# avoiding Bundler::Dsl side-effects. Handles:
|
|
8
|
+
# gem 'name' → nil (require the gem name)
|
|
9
|
+
# gem 'name', require: false → [] (skip)
|
|
10
|
+
# gem 'name', require: 'x' → ["x"] (require specific paths)
|
|
11
|
+
class GemfileRequireParser
|
|
12
|
+
attr_reader :requires
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@requires = {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def eval_gemfile(path)
|
|
19
|
+
instance_eval(File.read(path), path)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def source(*); end
|
|
25
|
+
def ruby(*); end
|
|
26
|
+
def git_source(*); end
|
|
27
|
+
def platform(*); end
|
|
28
|
+
def platforms(*); end
|
|
29
|
+
def group(*); end
|
|
30
|
+
def install_if(*); end
|
|
31
|
+
def plugin(*); end
|
|
32
|
+
|
|
33
|
+
def gem(name, *args)
|
|
34
|
+
opts = args.last.is_a?(Hash) ? args.last : {}
|
|
35
|
+
|
|
36
|
+
if opts.key?(:require)
|
|
37
|
+
val = opts[:require]
|
|
38
|
+
@requires[name] =
|
|
39
|
+
case val
|
|
40
|
+
when false then []
|
|
41
|
+
when String then [val]
|
|
42
|
+
when Array then val
|
|
43
|
+
else nil
|
|
44
|
+
end
|
|
45
|
+
else
|
|
46
|
+
@requires[name] = nil
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Boxwerk
|
|
4
|
+
# Runtime context for the root box, accessible via +Boxwerk.global+ from
|
|
5
|
+
# any box context (global boot, package boot, or application code).
|
|
6
|
+
class GlobalContext
|
|
7
|
+
# @return [GlobalContext::Autoloader] Autoloader configuration for the root box.
|
|
8
|
+
attr_reader :autoloader
|
|
9
|
+
|
|
10
|
+
def initialize(root_path)
|
|
11
|
+
@root_path = root_path
|
|
12
|
+
@autoloader = Autoloader.new(root_path)
|
|
13
|
+
@default_dirs = []
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Autoload configuration for the root box. Provides {AutoloaderMixin}'s
|
|
17
|
+
# +push_dir+, +collapse+, +ignore+, and +setup+ in +global/boot.rb+.
|
|
18
|
+
#
|
|
19
|
+
# Registrations are lazy (autoload entries only) until the framework
|
|
20
|
+
# eager-loads them after +global/boot.rb+ completes.
|
|
21
|
+
class Autoloader
|
|
22
|
+
include AutoloaderMixin
|
|
23
|
+
|
|
24
|
+
def initialize(root_path)
|
|
25
|
+
init_dirs
|
|
26
|
+
@root_path = root_path
|
|
27
|
+
@accumulated_entries = []
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def do_setup(new_push, new_collapse)
|
|
33
|
+
all_entries = []
|
|
34
|
+
|
|
35
|
+
new_push.each do |dir|
|
|
36
|
+
abs_dir = File.expand_path(dir, @root_path)
|
|
37
|
+
next unless File.directory?(abs_dir)
|
|
38
|
+
all_entries.concat(ZeitwerkScanner.scan(abs_dir))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
new_collapse.each do |dir|
|
|
42
|
+
abs_dir = File.expand_path(dir, @root_path)
|
|
43
|
+
next unless File.directory?(abs_dir)
|
|
44
|
+
all_entries.concat(ZeitwerkScanner.scan_files_only(abs_dir))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
return if all_entries.empty?
|
|
48
|
+
|
|
49
|
+
ZeitwerkScanner.register_autoloads(Ruby::Box.root, all_entries)
|
|
50
|
+
@accumulated_entries.concat(all_entries)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Eagerly requires all registered files so child boxes inherit constants.
|
|
54
|
+
# Called by Setup after global/boot.rb when eager_load_global is true.
|
|
55
|
+
def eager_load!
|
|
56
|
+
return if @accumulated_entries.empty?
|
|
57
|
+
|
|
58
|
+
root_box = Ruby::Box.root
|
|
59
|
+
@accumulated_entries.each { |e| root_box.require(e.file) if e.file }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Dir info for the info command (accessed via GlobalContext#dir_info).
|
|
63
|
+
def dir_info
|
|
64
|
+
{ autoload: @push_dirs.dup, collapse: @collapse_dirs.dup, ignore: @ignore_dirs.dup }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
# Records a directory scanned by Setup (e.g. global/) for the info command.
|
|
71
|
+
def record_scanned_dir(rel_path)
|
|
72
|
+
@default_dirs << rel_path
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Returns combined dir info for the info command.
|
|
76
|
+
def dir_info
|
|
77
|
+
al_info = @autoloader.__send__(:dir_info)
|
|
78
|
+
{
|
|
79
|
+
autoload: @default_dirs + al_info[:autoload],
|
|
80
|
+
collapse: al_info[:collapse],
|
|
81
|
+
ignore: al_info[:ignore],
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|