boxwerk 0.1.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.
@@ -1,51 +1,94 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'yaml'
4
-
5
3
  module Boxwerk
6
- # Represents a package loaded from package.yml
4
+ # Data class representing a package. Reads the standard package.yml format.
7
5
  class Package
8
- attr_reader :name, :path, :exports, :imports, :box, :loaded_exports
6
+ attr_reader :name, :config
9
7
 
10
- def initialize(name, path)
8
+ def initialize(name:, config: {})
11
9
  @name = name
12
- @path = path
13
- @box = nil
14
- @exports = []
15
- @imports = []
16
- @loaded_exports = {}
10
+ @config = config
11
+ end
17
12
 
18
- load_config
13
+ def root?
14
+ @name == '.'
19
15
  end
20
16
 
21
- def booted?
22
- !@box.nil?
17
+ def dependencies
18
+ @config['dependencies'] || []
23
19
  end
24
20
 
25
- def box=(box_instance)
26
- @box = box_instance
21
+ def enforce_dependencies?
22
+ @config['enforce_dependencies'] == true
27
23
  end
28
24
 
29
- def dependencies
30
- @imports.map do |item|
31
- if item.is_a?(String)
32
- item
25
+ def ==(other)
26
+ other.is_a?(Package) && name == other.name
27
+ end
28
+
29
+ alias eql? ==
30
+
31
+ def hash
32
+ name.hash
33
+ end
34
+
35
+ def to_s
36
+ name
37
+ end
38
+
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
+ '.'
33
51
  else
34
- item.keys.first
52
+ relative_path(pkg_dir, root_path)
35
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) }
36
59
  end
37
- end
38
60
 
39
- private
61
+ new(name: name, config: config)
62
+ end
40
63
 
41
- def load_config
42
- config_path = File.join(@path, 'package.yml')
43
- return unless File.exist?(config_path)
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
44
76
 
45
- config = YAML.load_file(config_path)
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
46
86
 
47
- @exports = config['exports'] || []
48
- @imports = config['imports'] || []
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
49
92
  end
50
93
  end
51
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