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
data/lib/boxwerk/package.rb
CHANGED
|
@@ -1,42 +1,94 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'yaml'
|
|
4
|
-
|
|
5
3
|
module Boxwerk
|
|
6
|
-
#
|
|
7
|
-
# Tracks exports, imports, dependencies, and the isolated Ruby::Box instance.
|
|
4
|
+
# Data class representing a package. Reads the standard package.yml format.
|
|
8
5
|
class Package
|
|
9
|
-
attr_reader :name, :
|
|
10
|
-
attr_accessor :box
|
|
6
|
+
attr_reader :name, :config
|
|
11
7
|
|
|
12
|
-
def initialize(name
|
|
8
|
+
def initialize(name:, config: {})
|
|
13
9
|
@name = name
|
|
14
|
-
@
|
|
15
|
-
@box = nil
|
|
16
|
-
@exports = []
|
|
17
|
-
@imports = []
|
|
18
|
-
@loaded_exports = {}
|
|
19
|
-
|
|
20
|
-
load_config
|
|
10
|
+
@config = config
|
|
21
11
|
end
|
|
22
12
|
|
|
23
|
-
def
|
|
24
|
-
|
|
13
|
+
def root?
|
|
14
|
+
@name == '.'
|
|
25
15
|
end
|
|
26
16
|
|
|
27
17
|
def dependencies
|
|
28
|
-
@
|
|
18
|
+
@config['dependencies'] || []
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def enforce_dependencies?
|
|
22
|
+
@config['enforce_dependencies'] == true
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def ==(other)
|
|
26
|
+
other.is_a?(Package) && name == other.name
|
|
29
27
|
end
|
|
30
28
|
|
|
31
|
-
|
|
29
|
+
alias eql? ==
|
|
30
|
+
|
|
31
|
+
def hash
|
|
32
|
+
name.hash
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def to_s
|
|
36
|
+
name
|
|
37
|
+
end
|
|
32
38
|
|
|
33
|
-
def
|
|
34
|
-
|
|
35
|
-
|
|
39
|
+
def inspect
|
|
40
|
+
"#<Boxwerk::Package #{name}>"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Loads a Package from a package.yml file.
|
|
44
|
+
# The name is the relative path from root_path to the package directory.
|
|
45
|
+
def self.from_yml(yml_path, root_path:)
|
|
46
|
+
config = YAML.safe_load_file(yml_path) || {}
|
|
47
|
+
pkg_dir = File.dirname(yml_path)
|
|
48
|
+
name =
|
|
49
|
+
if File.expand_path(pkg_dir) == File.expand_path(root_path)
|
|
50
|
+
'.'
|
|
51
|
+
else
|
|
52
|
+
relative_path(pkg_dir, root_path)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Normalize dependency names declared in the package.yml
|
|
56
|
+
if config['dependencies']
|
|
57
|
+
config = config.dup
|
|
58
|
+
config['dependencies'] = config['dependencies'].map { |d| normalize(d) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
new(name: name, config: config)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Creates an implicit root package that depends on all other packages.
|
|
65
|
+
# No enforcement is enabled.
|
|
66
|
+
def self.implicit_root(all_package_names)
|
|
67
|
+
new(
|
|
68
|
+
name: '.',
|
|
69
|
+
config: {
|
|
70
|
+
'enforce_dependencies' => false,
|
|
71
|
+
'enforce_privacy' => false,
|
|
72
|
+
'dependencies' => all_package_names.reject { |n| n == '.' },
|
|
73
|
+
},
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Normalizes a package name: strips leading ./ and trailing /.
|
|
78
|
+
# Allows users to write packs/loyalty/, ./packs/loyalty, or packs/loyalty
|
|
79
|
+
# interchangeably in package.yml dependencies and CLI --package flag.
|
|
80
|
+
def self.normalize(name)
|
|
81
|
+
name = name.to_s.strip
|
|
82
|
+
name = name.sub(%r{\A\./}, '')
|
|
83
|
+
name = name.sub(%r{/\z}, '')
|
|
84
|
+
name
|
|
85
|
+
end
|
|
36
86
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
87
|
+
def self.relative_path(path, base)
|
|
88
|
+
Pathname
|
|
89
|
+
.new(File.expand_path(path))
|
|
90
|
+
.relative_path_from(Pathname.new(File.expand_path(base)))
|
|
91
|
+
.to_s
|
|
40
92
|
end
|
|
41
93
|
end
|
|
42
94
|
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Boxwerk
|
|
4
|
+
# Runtime context for the current package, accessible via +Boxwerk.package+.
|
|
5
|
+
# Available during +boot.rb+ execution and throughout the application.
|
|
6
|
+
class PackageContext
|
|
7
|
+
# @return [String] The package name (e.g. +"packs/orders"+ or +"."+ for root).
|
|
8
|
+
attr_reader :name
|
|
9
|
+
|
|
10
|
+
# @return [String] Absolute path to the package directory.
|
|
11
|
+
attr_reader :root_path
|
|
12
|
+
|
|
13
|
+
# @return [PackageContext::Autoloader] Autoloader configuration for this package.
|
|
14
|
+
attr_reader :autoloader
|
|
15
|
+
|
|
16
|
+
def initialize(name:, root_path:, config:, autoloader:)
|
|
17
|
+
@name = name
|
|
18
|
+
@root_path = root_path
|
|
19
|
+
@config = config.freeze
|
|
20
|
+
@autoloader = autoloader
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [Boolean] Whether this is the root package.
|
|
24
|
+
def root?
|
|
25
|
+
@name == '.'
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [Hash] Frozen package configuration from +package.yml+.
|
|
29
|
+
def config
|
|
30
|
+
@config
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Autoload configuration for a package box. Provides {AutoloaderMixin}'s
|
|
34
|
+
# +push_dir+, +collapse+, +ignore+, and +setup+ in +boot.rb+.
|
|
35
|
+
#
|
|
36
|
+
# Registration happens immediately when +push_dir+ or +collapse+ is called,
|
|
37
|
+
# making constants available for the rest of the boot script without an
|
|
38
|
+
# explicit +setup+ call.
|
|
39
|
+
class Autoloader
|
|
40
|
+
include AutoloaderMixin
|
|
41
|
+
|
|
42
|
+
def initialize(root_path, box:, default_autoload_dirs: [])
|
|
43
|
+
init_dirs
|
|
44
|
+
@root_path = root_path
|
|
45
|
+
@box = box
|
|
46
|
+
@default_autoload_dirs = default_autoload_dirs
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def do_setup(new_push, new_collapse)
|
|
52
|
+
return unless @box
|
|
53
|
+
|
|
54
|
+
all_entries = []
|
|
55
|
+
|
|
56
|
+
new_push.each do |dir|
|
|
57
|
+
abs_dir = File.expand_path(dir, @root_path)
|
|
58
|
+
next unless File.directory?(abs_dir)
|
|
59
|
+
all_entries.concat(ZeitwerkScanner.scan(abs_dir))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
new_collapse.each do |dir|
|
|
63
|
+
abs_dir = File.expand_path(dir, @root_path)
|
|
64
|
+
next unless File.directory?(abs_dir)
|
|
65
|
+
root_dir = find_root_for(abs_dir)
|
|
66
|
+
all_entries.concat(ZeitwerkScanner.scan_files_only(abs_dir, root_dir: root_dir))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
return if all_entries.empty?
|
|
70
|
+
|
|
71
|
+
ZeitwerkScanner.register_autoloads(@box, all_entries)
|
|
72
|
+
file_index = ZeitwerkScanner.build_file_index(all_entries)
|
|
73
|
+
@accumulated_file_index ||= {}
|
|
74
|
+
@accumulated_file_index.merge!(file_index)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Returns the absolute root autoload dir that contains abs_dir, or nil.
|
|
78
|
+
def find_root_for(abs_dir)
|
|
79
|
+
(@default_autoload_dirs + @push_dirs)
|
|
80
|
+
.map { |d| File.expand_path(d, @root_path) }
|
|
81
|
+
.find { |root| abs_dir.start_with?("#{root}/") }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# All user-configured collapse dirs (for BoxManager namespace cleanup).
|
|
85
|
+
def all_collapse_dirs = @collapse_dirs.dup
|
|
86
|
+
|
|
87
|
+
# All user-configured ignore dirs (for BoxManager namespace cleanup).
|
|
88
|
+
def ignore_dirs = @ignore_dirs.dup
|
|
89
|
+
|
|
90
|
+
# File index accumulated from all auto-setup calls (for BoxManager).
|
|
91
|
+
def accumulated_file_index = @accumulated_file_index || {}
|
|
92
|
+
|
|
93
|
+
# Dir info for BoxManager#record_package_dirs (used by info command).
|
|
94
|
+
def dir_info
|
|
95
|
+
{
|
|
96
|
+
autoload: @default_autoload_dirs + @push_dirs,
|
|
97
|
+
collapse: @collapse_dirs.dup,
|
|
98
|
+
ignore: @ignore_dirs.dup,
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
require 'pathname'
|
|
5
|
+
|
|
6
|
+
module Boxwerk
|
|
7
|
+
# Discovers packages by scanning for package.yml files and provides
|
|
8
|
+
# ordering for boot. Supports boxwerk.yml configuration for package_paths.
|
|
9
|
+
class PackageResolver
|
|
10
|
+
attr_reader :packages, :root
|
|
11
|
+
|
|
12
|
+
def initialize(root_path, config_overrides: {})
|
|
13
|
+
@root_path = File.expand_path(root_path)
|
|
14
|
+
@packages = {}
|
|
15
|
+
@config = load_boxwerk_config.merge(config_overrides)
|
|
16
|
+
|
|
17
|
+
discover_packages
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Returns packages in boot order. Dependencies come before dependents
|
|
21
|
+
# when possible. Circular dependencies are allowed — strongly connected
|
|
22
|
+
# components are grouped together. Packages with enforce_dependencies
|
|
23
|
+
# disabled are treated as depending on all other packages for ordering.
|
|
24
|
+
def topological_order
|
|
25
|
+
visited = {}
|
|
26
|
+
order = []
|
|
27
|
+
@packages.each_value { |pkg| visit(pkg, visited, order, Set.new) }
|
|
28
|
+
order
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Returns the direct dependency Package objects for a given package.
|
|
32
|
+
def direct_dependencies(package)
|
|
33
|
+
package.dependencies.filter_map { |dep_name| @packages[dep_name] }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Returns all packages except the given one.
|
|
37
|
+
def all_except(package)
|
|
38
|
+
@packages.values.reject { |p| p.name == package.name }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Boxwerk configuration from boxwerk.yml.
|
|
42
|
+
def boxwerk_config
|
|
43
|
+
@config
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def load_boxwerk_config
|
|
49
|
+
yml_path = File.join(@root_path, 'boxwerk.yml')
|
|
50
|
+
if File.exist?(yml_path)
|
|
51
|
+
YAML.safe_load_file(yml_path) || {}
|
|
52
|
+
else
|
|
53
|
+
{}
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def discover_packages
|
|
58
|
+
yml_paths = find_package_ymls
|
|
59
|
+
|
|
60
|
+
yml_paths.each do |yml_path|
|
|
61
|
+
package = Package.from_yml(yml_path, root_path: @root_path)
|
|
62
|
+
@packages[package.name] = package
|
|
63
|
+
@root = package if package.root?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Implicit root package if no package.yml at root
|
|
67
|
+
unless @root
|
|
68
|
+
@root = Package.implicit_root(@packages.keys)
|
|
69
|
+
@packages['.'] = @root
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def find_package_ymls
|
|
74
|
+
ymls = []
|
|
75
|
+
|
|
76
|
+
# Check for root package.yml
|
|
77
|
+
root_yml = File.join(@root_path, 'package.yml')
|
|
78
|
+
ymls << root_yml if File.exist?(root_yml)
|
|
79
|
+
|
|
80
|
+
# Use package_paths from boxwerk.yml (default: ["**/"])
|
|
81
|
+
package_paths = @config['package_paths'] || ['**/']
|
|
82
|
+
|
|
83
|
+
package_paths.each do |pattern|
|
|
84
|
+
glob = File.join(@root_path, pattern, 'package.yml')
|
|
85
|
+
Dir
|
|
86
|
+
.glob(glob)
|
|
87
|
+
.each do |path|
|
|
88
|
+
next if path == root_yml
|
|
89
|
+
ymls << path
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
ymls.uniq
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def visit(package, visited, order, in_stack)
|
|
97
|
+
return if visited[package.name]
|
|
98
|
+
|
|
99
|
+
visited[package.name] = true
|
|
100
|
+
in_stack.add(package.name)
|
|
101
|
+
|
|
102
|
+
# When enforce_dependencies is false, treat as depending on all
|
|
103
|
+
# other packages for boot ordering purposes.
|
|
104
|
+
deps =
|
|
105
|
+
if package.enforce_dependencies?
|
|
106
|
+
package.dependencies
|
|
107
|
+
else
|
|
108
|
+
@packages.keys.reject { |n| n == package.name }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
deps.each do |dep_name|
|
|
112
|
+
dep = @packages[dep_name]
|
|
113
|
+
next unless dep
|
|
114
|
+
next if in_stack.include?(dep_name)
|
|
115
|
+
visit(dep, visited, order, in_stack)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
in_stack.delete(package.name)
|
|
119
|
+
order << package
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'zeitwerk'
|
|
4
|
+
|
|
5
|
+
module Boxwerk
|
|
6
|
+
# Enforces privacy rules at runtime.
|
|
7
|
+
# Reads enforce_privacy, public_path, and private_constants from package.yml.
|
|
8
|
+
# Files with `# pack_public: true` sigil are treated as public.
|
|
9
|
+
module PrivacyChecker
|
|
10
|
+
DEFAULT_PUBLIC_PATH = 'public/'
|
|
11
|
+
PUBLICIZED_SIGIL_REGEX = /#.*pack_public:\s*true/
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
# Returns true if the package enforces privacy.
|
|
15
|
+
def enforces_privacy?(package)
|
|
16
|
+
setting = package.config['enforce_privacy']
|
|
17
|
+
[true, 'strict'].include?(setting)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Returns the public path for a package (absolute).
|
|
21
|
+
def public_path_for(package, root_path)
|
|
22
|
+
user_path = package.config['public_path']
|
|
23
|
+
relative =
|
|
24
|
+
if user_path
|
|
25
|
+
user_path.end_with?('/') ? user_path : "#{user_path}/"
|
|
26
|
+
else
|
|
27
|
+
DEFAULT_PUBLIC_PATH
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
if package.root?
|
|
31
|
+
File.join(root_path, relative)
|
|
32
|
+
else
|
|
33
|
+
File.join(root_path, package.name, relative)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Returns the set of public constant names for a package.
|
|
38
|
+
# These are derived from files in the public_path using Ruby naming conventions.
|
|
39
|
+
def public_constants(package, root_path)
|
|
40
|
+
return nil unless enforces_privacy?(package)
|
|
41
|
+
|
|
42
|
+
pub_path = public_path_for(package, root_path)
|
|
43
|
+
constants = Set.new
|
|
44
|
+
|
|
45
|
+
if File.directory?(pub_path)
|
|
46
|
+
Dir
|
|
47
|
+
.glob(File.join(pub_path, '**', '*.rb'))
|
|
48
|
+
.each do |file|
|
|
49
|
+
const_name = constant_name_from_path(file, pub_path)
|
|
50
|
+
constants.add(const_name) if const_name
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Also scan all package files for pack_public: true sigil
|
|
55
|
+
package_lib = package_lib_path(package, root_path)
|
|
56
|
+
if package_lib && File.directory?(package_lib)
|
|
57
|
+
Dir
|
|
58
|
+
.glob(File.join(package_lib, '**', '*.rb'))
|
|
59
|
+
.each do |file|
|
|
60
|
+
if publicized_file?(file)
|
|
61
|
+
const_name = constant_name_from_path(file, package_lib)
|
|
62
|
+
constants.add(const_name) if const_name
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
constants
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Returns the set of constants with pack_public: true sigil in package lib/.
|
|
71
|
+
# These are individual files opted-in to public access without a public_path dir.
|
|
72
|
+
def pack_public_constants(package, root_path)
|
|
73
|
+
lib = package_lib_path(package, root_path)
|
|
74
|
+
return nil unless lib && File.directory?(lib)
|
|
75
|
+
|
|
76
|
+
constants = Set.new
|
|
77
|
+
Dir
|
|
78
|
+
.glob(File.join(lib, '**', '*.rb'))
|
|
79
|
+
.each do |file|
|
|
80
|
+
if publicized_file?(file)
|
|
81
|
+
const_name = constant_name_from_path(file, lib)
|
|
82
|
+
constants.add(const_name) if const_name
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
constants.empty? ? nil : constants
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Returns the set of explicitly private constant names.
|
|
89
|
+
# Strips leading :: prefix.
|
|
90
|
+
def private_constants_list(package)
|
|
91
|
+
(package.config['private_constants'] || [])
|
|
92
|
+
.map { |name| name.start_with?('::') ? name[2..] : name }
|
|
93
|
+
.to_set
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Checks if a constant is accessible from outside the package.
|
|
97
|
+
# Returns true if accessible, false if blocked by privacy.
|
|
98
|
+
def accessible?(
|
|
99
|
+
const_name,
|
|
100
|
+
package,
|
|
101
|
+
root_path,
|
|
102
|
+
public_constants_cache: nil
|
|
103
|
+
)
|
|
104
|
+
return true unless enforces_privacy?(package)
|
|
105
|
+
|
|
106
|
+
# Check explicitly private constants
|
|
107
|
+
privates = private_constants_list(package)
|
|
108
|
+
return false if privates.include?(const_name.to_s)
|
|
109
|
+
if privates.any? { |pc| const_name.to_s.start_with?("#{pc}::") }
|
|
110
|
+
return false
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Check if constant is in the public set
|
|
114
|
+
pub_consts =
|
|
115
|
+
public_constants_cache || public_constants(package, root_path)
|
|
116
|
+
return true if pub_consts.nil? # privacy not enforced
|
|
117
|
+
|
|
118
|
+
# If public_path has files, check against them
|
|
119
|
+
# If public_path is empty/doesn't exist but enforce_privacy is on,
|
|
120
|
+
# all constants are private (no public API defined)
|
|
121
|
+
pub_consts.include?(const_name.to_s)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
# Derives a constant name from a file path relative to a base directory.
|
|
127
|
+
# Uses Zeitwerk's inflector for Ruby naming conventions.
|
|
128
|
+
def constant_name_from_path(file_path, base_path)
|
|
129
|
+
normalized_base = base_path.end_with?('/') ? base_path : "#{base_path}/"
|
|
130
|
+
relative = file_path.delete_prefix(normalized_base).delete_suffix('.rb')
|
|
131
|
+
return nil if relative.empty?
|
|
132
|
+
|
|
133
|
+
inflector = Zeitwerk::Inflector.new
|
|
134
|
+
relative
|
|
135
|
+
.split('/')
|
|
136
|
+
.map { |part| inflector.camelize(part, base_path) }
|
|
137
|
+
.join('::')
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Checks if a file contains the pack_public: true sigil in first 5 lines.
|
|
141
|
+
def publicized_file?(file_path)
|
|
142
|
+
File
|
|
143
|
+
.foreach(file_path)
|
|
144
|
+
.first(5)
|
|
145
|
+
.any? { |line| line.match?(PUBLICIZED_SIGIL_REGEX) }
|
|
146
|
+
rescue Errno::ENOENT
|
|
147
|
+
false
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def package_lib_path(package, root_path)
|
|
151
|
+
if package.root?
|
|
152
|
+
nil
|
|
153
|
+
else
|
|
154
|
+
File.join(root_path, package.name, 'lib')
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
data/lib/boxwerk/setup.rb
CHANGED
|
@@ -1,41 +1,90 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Boxwerk
|
|
4
|
-
# Setup finds the root package, builds the dependency graph, and boots all packages.
|
|
5
|
-
# Orchestrates the entire Boxwerk initialization process.
|
|
6
4
|
module Setup
|
|
7
5
|
class << self
|
|
8
|
-
def run
|
|
9
|
-
root_path =
|
|
10
|
-
raise 'Cannot find package.yml in current directory or ancestors' unless root_path
|
|
6
|
+
def run(start_dir: Dir.pwd, packages: nil, config: {})
|
|
7
|
+
root_path = find_root(start_dir)
|
|
11
8
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
9
|
+
resolver = Boxwerk::PackageResolver.new(root_path, config_overrides: config)
|
|
10
|
+
config = resolver.boxwerk_config
|
|
11
|
+
eager_load_global = config.fetch('eager_load_global', true)
|
|
12
|
+
eager_load_packages = config.fetch('eager_load_packages', false)
|
|
15
13
|
|
|
16
|
-
|
|
14
|
+
# Create GlobalContext and expose it as Boxwerk.global before boot.rb runs
|
|
15
|
+
global_context = GlobalContext.new(root_path)
|
|
16
|
+
root_box = Ruby::Box.root
|
|
17
|
+
root_box.__send__(:remove_const, :BOXWERK_GLOBAL) if root_box.const_defined?(:BOXWERK_GLOBAL, false)
|
|
18
|
+
root_box.const_set(:BOXWERK_GLOBAL, global_context)
|
|
19
|
+
|
|
20
|
+
# Run global boot in root box (after gems, before package boxes).
|
|
21
|
+
run_global_boot(root_path, eager_load: eager_load_global, global_context: global_context)
|
|
22
|
+
|
|
23
|
+
# Eager-load all Zeitwerk-managed constants in root box so child
|
|
24
|
+
# boxes inherit fully resolved constants (not pending autoloads).
|
|
25
|
+
eager_load_zeitwerk if eager_load_global
|
|
26
|
+
|
|
27
|
+
@box_manager = Boxwerk::BoxManager.new(root_path)
|
|
28
|
+
if packages
|
|
29
|
+
packages.each do |pkg|
|
|
30
|
+
@box_manager.boot_package(
|
|
31
|
+
pkg,
|
|
32
|
+
resolver,
|
|
33
|
+
eager_load_packages: eager_load_packages,
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
else
|
|
37
|
+
@box_manager.boot_all(
|
|
38
|
+
resolver,
|
|
39
|
+
eager_load_packages: eager_load_packages,
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
check_gem_conflicts(@box_manager.gem_resolver, resolver)
|
|
44
|
+
|
|
45
|
+
@resolver = resolver
|
|
17
46
|
@booted = true
|
|
18
|
-
|
|
47
|
+
|
|
48
|
+
{ resolver: resolver, box_manager: @box_manager, root_path: root_path }
|
|
19
49
|
end
|
|
20
50
|
|
|
21
|
-
def
|
|
22
|
-
@
|
|
51
|
+
def resolver
|
|
52
|
+
@resolver
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def box_manager
|
|
56
|
+
@box_manager
|
|
23
57
|
end
|
|
24
58
|
|
|
25
59
|
def booted?
|
|
26
60
|
@booted || false
|
|
27
61
|
end
|
|
28
62
|
|
|
29
|
-
def
|
|
30
|
-
@
|
|
63
|
+
def root_box
|
|
64
|
+
return nil unless @box_manager && @resolver&.root
|
|
65
|
+
|
|
66
|
+
@box_manager.boxes[@resolver.root.name]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def reset
|
|
70
|
+
@resolver = nil
|
|
71
|
+
@box_manager = nil
|
|
31
72
|
@booted = false
|
|
73
|
+
if defined?(Ruby::Box)
|
|
74
|
+
root_box = Ruby::Box.root
|
|
75
|
+
root_box.__send__(:remove_const, :BOXWERK_GLOBAL) if root_box.const_defined?(:BOXWERK_GLOBAL, false)
|
|
76
|
+
end
|
|
32
77
|
end
|
|
33
78
|
|
|
34
79
|
private
|
|
35
80
|
|
|
36
|
-
|
|
81
|
+
# Finds the project root directory. Walks up the directory tree
|
|
82
|
+
# looking for boxwerk.yml or package.yml. Falls back to start_dir
|
|
83
|
+
# if neither is found (implicit root).
|
|
84
|
+
def find_root(start_dir)
|
|
37
85
|
current = File.expand_path(start_dir)
|
|
38
86
|
loop do
|
|
87
|
+
return current if File.exist?(File.join(current, 'boxwerk.yml'))
|
|
39
88
|
return current if File.exist?(File.join(current, 'package.yml'))
|
|
40
89
|
|
|
41
90
|
parent = File.dirname(current)
|
|
@@ -43,7 +92,66 @@ module Boxwerk
|
|
|
43
92
|
|
|
44
93
|
current = parent
|
|
45
94
|
end
|
|
46
|
-
|
|
95
|
+
|
|
96
|
+
# Fall back to CWD as implicit root
|
|
97
|
+
File.expand_path(start_dir)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Runs the optional global boot in the root box. The global/
|
|
101
|
+
# directory is autoloaded and global/boot.rb is required in the root
|
|
102
|
+
# box. These run after global gems are loaded but before package
|
|
103
|
+
# boxes are created, so definitions here are inherited by all boxes.
|
|
104
|
+
#
|
|
105
|
+
# Root-level boot.rb is NOT handled here — it runs in the root
|
|
106
|
+
# package box via BoxManager (like any other package boot.rb).
|
|
107
|
+
def run_global_boot(root_path, eager_load: true, global_context: nil)
|
|
108
|
+
root_box = Ruby::Box.root
|
|
109
|
+
global_dir = File.join(root_path, 'global')
|
|
110
|
+
global_boot = File.join(global_dir, 'boot.rb')
|
|
111
|
+
global_entries = nil
|
|
112
|
+
|
|
113
|
+
# Always register lazy autoloads for global/ dir so constants are
|
|
114
|
+
# accessible in boot.rb (autoload fires on access in root box context).
|
|
115
|
+
# This works regardless of eager_load_global so global constants can
|
|
116
|
+
# be used in boot.rb even when eager loading is disabled.
|
|
117
|
+
if File.directory?(global_dir)
|
|
118
|
+
global_context&.__send__(:record_scanned_dir, 'global/')
|
|
119
|
+
entries = ZeitwerkScanner.scan(global_dir)
|
|
120
|
+
global_entries = entries.reject { |e| e.file == global_boot }
|
|
121
|
+
ZeitwerkScanner.register_autoloads(root_box, global_entries) if global_entries.any?
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Run global/boot.rb in root box (always, regardless of eager_load)
|
|
125
|
+
root_box.require(global_boot) if File.exist?(global_boot)
|
|
126
|
+
|
|
127
|
+
# After boot.rb, register lazy autoloads for any dirs added via
|
|
128
|
+
# Boxwerk.global.autoloader.push_dir during boot.
|
|
129
|
+
global_context&.autoloader&.setup
|
|
130
|
+
|
|
131
|
+
# Eager-load AFTER boot.rb so boot scripts run first.
|
|
132
|
+
if eager_load
|
|
133
|
+
global_entries&.each { |e| root_box.require(e.file) if e.file }
|
|
134
|
+
global_context&.autoloader&.__send__(:eager_load!)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def check_gem_conflicts(gem_resolver, package_resolver)
|
|
139
|
+
conflicts = gem_resolver.check_conflicts(package_resolver)
|
|
140
|
+
conflicts.each do |c|
|
|
141
|
+
warn "Boxwerk: gem '#{c[:gem_name]}' is #{c[:package_version]} in #{c[:package]} " \
|
|
142
|
+
"but #{c[:global_version]} in global gems — both versions will be loaded into memory"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Eager-load all Zeitwerk-managed constants (gem autoloads) in the root
|
|
147
|
+
# box. Child boxes created via Ruby::Box.new inherit a snapshot of the
|
|
148
|
+
# root box's constants. Zeitwerk autoloads are lazy — without eager
|
|
149
|
+
# loading, child boxes inherit pending autoload entries that may not
|
|
150
|
+
# resolve correctly across box boundaries.
|
|
151
|
+
def eager_load_zeitwerk
|
|
152
|
+
Ruby::Box.root.eval(<<~RUBY)
|
|
153
|
+
Zeitwerk::Loader.eager_load_all if defined?(Zeitwerk)
|
|
154
|
+
RUBY
|
|
47
155
|
end
|
|
48
156
|
end
|
|
49
157
|
end
|
data/lib/boxwerk/version.rb
CHANGED