torikago 0.0.1
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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.ja.md +144 -0
- data/README.md +142 -0
- data/exe/torikago +8 -0
- data/lib/torikago/checker.rb +159 -0
- data/lib/torikago/cli.rb +235 -0
- data/lib/torikago/configuration.rb +45 -0
- data/lib/torikago/current_execution.rb +23 -0
- data/lib/torikago/engine_container.rb +340 -0
- data/lib/torikago/errors.rb +16 -0
- data/lib/torikago/gateway.rb +86 -0
- data/lib/torikago/package_api_updater.rb +101 -0
- data/lib/torikago/registry.rb +34 -0
- data/lib/torikago/version.rb +3 -0
- data/lib/torikago.rb +71 -0
- data/test/test_helper.rb +9 -0
- data/test/torikago/checker.rb +164 -0
- data/test/torikago/cli.rb +143 -0
- data/test/torikago/configuration.rb +74 -0
- data/test/torikago/engine_container.rb +549 -0
- data/test/torikago/gateway.rb +155 -0
- data/test/torikago/package_api_updater.rb +94 -0
- data/test/torikago/registry.rb +83 -0
- data/test/torikago/version.rb +11 -0
- data/test/torikago.rb +110 -0
- metadata +73 -0
data/lib/torikago/cli.rb
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "pathname"
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Torikago
|
|
6
|
+
# Small command dispatcher used by the gem executable. Commands intentionally
|
|
7
|
+
# delegate to service objects so the CLI stays thin and easy to test.
|
|
8
|
+
class CLI
|
|
9
|
+
HELP_TEXT = <<~TEXT.freeze
|
|
10
|
+
usage: torikago COMMAND [ARGS]
|
|
11
|
+
|
|
12
|
+
commands:
|
|
13
|
+
init
|
|
14
|
+
interactively generate package_api.yml files and config/initializers/torikago.rb
|
|
15
|
+
check
|
|
16
|
+
validate Gateway.call usage against package_api.yml
|
|
17
|
+
update-package-api [BOX]
|
|
18
|
+
regenerate package_api.yml entries from the configured public API entrypoint
|
|
19
|
+
help, --help, -h
|
|
20
|
+
show this help
|
|
21
|
+
TEXT
|
|
22
|
+
|
|
23
|
+
def initialize(stdin: $stdin, stdout: $stdout, stderr: $stderr)
|
|
24
|
+
@stdin = stdin
|
|
25
|
+
@stdout = stdout
|
|
26
|
+
@stderr = stderr
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def run(argv)
|
|
30
|
+
command = argv.shift
|
|
31
|
+
|
|
32
|
+
case command
|
|
33
|
+
when nil, "help", "--help", "-h"
|
|
34
|
+
stdout.print(HELP_TEXT)
|
|
35
|
+
0
|
|
36
|
+
when "init"
|
|
37
|
+
run_init
|
|
38
|
+
when "check"
|
|
39
|
+
run_check
|
|
40
|
+
when "update-package-api"
|
|
41
|
+
run_update_package_api(argv.shift)
|
|
42
|
+
else
|
|
43
|
+
stderr.puts("unknown command: #{command}")
|
|
44
|
+
stderr.print(HELP_TEXT)
|
|
45
|
+
1
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
attr_reader :stderr, :stdin, :stdout
|
|
52
|
+
|
|
53
|
+
def run_init
|
|
54
|
+
modules_root_input = ask("Modules directory", default: "modules")
|
|
55
|
+
modules_root = Pathname(modules_root_input)
|
|
56
|
+
module_directories = discover_module_directories(modules_root)
|
|
57
|
+
|
|
58
|
+
if module_directories.empty?
|
|
59
|
+
stderr.puts("no modules found under #{modules_root}")
|
|
60
|
+
return 1
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
configuration = Configuration.new
|
|
64
|
+
|
|
65
|
+
module_directories.each do |module_root|
|
|
66
|
+
module_name = module_root.basename.to_s
|
|
67
|
+
entrypoint = ask("Public API directory for #{module_name}", default: "app/package_api")
|
|
68
|
+
|
|
69
|
+
configuration.register(
|
|
70
|
+
module_name.to_sym,
|
|
71
|
+
root: module_root.to_s,
|
|
72
|
+
entrypoint: entrypoint,
|
|
73
|
+
gemfile: module_root.join("Gemfile").exist? ? "Gemfile" : nil
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
manifest_path = module_root.join("package_api.yml")
|
|
77
|
+
manifest = manifest_path.exist? ? load_yaml_file(manifest_path) : { "exports" => {} }
|
|
78
|
+
manifest_path.write(render_package_api_manifest(manifest))
|
|
79
|
+
stdout.puts("generated #{manifest_path}")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
initializer_path = Pathname("config/initializers/torikago.rb")
|
|
83
|
+
FileUtils.mkdir_p(initializer_path.dirname)
|
|
84
|
+
initializer_path.write(render_initializer(configuration))
|
|
85
|
+
stdout.puts("generated #{initializer_path}")
|
|
86
|
+
|
|
87
|
+
if yes?(ask("Run `torikago update-package-api` now?", default: "Y"))
|
|
88
|
+
updates = PackageApiUpdater.new(configuration: configuration).call
|
|
89
|
+
updates.each_value { |path| stdout.puts("updated #{path}") }
|
|
90
|
+
stdout.puts("updated #{updates.size} package_api manifest#{'s' unless updates.size == 1}")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
0
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def run_check
|
|
97
|
+
result = Checker.new(
|
|
98
|
+
configuration: discover_configuration,
|
|
99
|
+
source_roots: [Pathname("app"), Pathname("modules")]
|
|
100
|
+
).call
|
|
101
|
+
|
|
102
|
+
if result.ok?
|
|
103
|
+
stdout.puts(
|
|
104
|
+
"ok: scanned #{result.scanned_file_count} Ruby files, " \
|
|
105
|
+
"found #{result.gateway_call_count} Gateway.call usages, " \
|
|
106
|
+
"validated #{result.manifest_count} package_api manifests"
|
|
107
|
+
)
|
|
108
|
+
0
|
|
109
|
+
else
|
|
110
|
+
result.errors.each { |error| stderr.puts(error) }
|
|
111
|
+
stderr.puts(
|
|
112
|
+
"failed: scanned #{result.scanned_file_count} Ruby files, " \
|
|
113
|
+
"found #{result.gateway_call_count} Gateway.call usages, " \
|
|
114
|
+
"validated #{result.manifest_count} package_api manifests, " \
|
|
115
|
+
"#{result.errors.size} errors"
|
|
116
|
+
)
|
|
117
|
+
1
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def run_update_package_api(module_name)
|
|
122
|
+
updates = PackageApiUpdater.new(configuration: discover_configuration).call(module_name)
|
|
123
|
+
updates.each_value { |path| stdout.puts("updated #{path}") }
|
|
124
|
+
stdout.puts("updated #{updates.size} package_api manifest#{'s' unless updates.size == 1}")
|
|
125
|
+
0
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def discover_configuration
|
|
129
|
+
if File.exist?("config/environment.rb")
|
|
130
|
+
# In Rails apps, prefer the application's configured module registry
|
|
131
|
+
# over filesystem guessing.
|
|
132
|
+
require File.expand_path("config/environment")
|
|
133
|
+
return Torikago.configuration
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Outside Rails, use the conventional modules/* layout as a lightweight
|
|
137
|
+
# fallback so check/update-package-api can run in early prototypes.
|
|
138
|
+
Configuration.new.tap do |configuration|
|
|
139
|
+
Dir["modules/*"].sort.each do |module_root|
|
|
140
|
+
configuration.register(File.basename(module_root).to_sym, root: module_root)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def ask(prompt, default: nil)
|
|
146
|
+
stdout.print("#{prompt}")
|
|
147
|
+
stdout.print(" [#{default}]") if default
|
|
148
|
+
stdout.print(": ")
|
|
149
|
+
|
|
150
|
+
input = stdin.gets&.strip
|
|
151
|
+
return default if input.nil? || input.empty?
|
|
152
|
+
|
|
153
|
+
input
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def discover_module_directories(modules_root)
|
|
157
|
+
Dir[modules_root.join("*").to_s].map { |path| Pathname(path) }.select(&:directory?).sort
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def load_yaml_file(path)
|
|
161
|
+
YAML.safe_load(path.read, permitted_classes: [], aliases: false) || {}
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def render_initializer(configuration)
|
|
165
|
+
lines = [
|
|
166
|
+
"# This file registers torikago runtime boundaries for your Rails app.",
|
|
167
|
+
"#",
|
|
168
|
+
"# Each config.register call defines one module root. Calls between modules",
|
|
169
|
+
"# should go through Torikago::Gateway.call(\"Module::ExportedApi\") instead",
|
|
170
|
+
"# of reaching across module constants directly.",
|
|
171
|
+
"#",
|
|
172
|
+
"# Options:",
|
|
173
|
+
"#",
|
|
174
|
+
"# root:",
|
|
175
|
+
"# Filesystem root for the module.",
|
|
176
|
+
"#",
|
|
177
|
+
"# entrypoint:",
|
|
178
|
+
"# Directory containing exported Package API classes. The conventional",
|
|
179
|
+
"# default is app/package_api.",
|
|
180
|
+
"#",
|
|
181
|
+
"# gemfile:",
|
|
182
|
+
"# Optional module-local Gemfile. When Ruby::Box isolation is enabled,",
|
|
183
|
+
"# torikago prepends that module's resolved gem require paths inside the",
|
|
184
|
+
"# module Box.",
|
|
185
|
+
"#",
|
|
186
|
+
"# setup:",
|
|
187
|
+
"# Optional setup file loaded before Package API files. Use this for",
|
|
188
|
+
"# explicit module boot behavior such as carefully scoped monkey patches.",
|
|
189
|
+
"#",
|
|
190
|
+
"# Package API permissions live in each module's package_api.yml under exports.",
|
|
191
|
+
"# After changing Package API files, run:",
|
|
192
|
+
"#",
|
|
193
|
+
"# bin/torikago update-package-api",
|
|
194
|
+
"#",
|
|
195
|
+
"# To validate Gateway.call usage against package_api.yml, run:",
|
|
196
|
+
"#",
|
|
197
|
+
"# bin/torikago check",
|
|
198
|
+
"",
|
|
199
|
+
"Torikago.configure do |config|"
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
configuration.each_definition do |definition|
|
|
203
|
+
lines << " config.register("
|
|
204
|
+
lines << " :#{definition.name},"
|
|
205
|
+
lines << " root: Rails.root.join(\"#{definition.root.to_s}\"),"
|
|
206
|
+
lines << " entrypoint: #{definition.entrypoint.inspect}#{definition.gemfile ? "," : ""}"
|
|
207
|
+
lines << " gemfile: #{definition.gemfile.inspect}" if definition.gemfile
|
|
208
|
+
lines << " )"
|
|
209
|
+
lines << ""
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
lines << "end"
|
|
213
|
+
lines.join("\n")
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def render_package_api_manifest(manifest)
|
|
217
|
+
<<~YAML
|
|
218
|
+
# This file declares the Package APIs exported by this module.
|
|
219
|
+
#
|
|
220
|
+
# Each key under exports is a class that may be called through:
|
|
221
|
+
#
|
|
222
|
+
# Torikago::Gateway.call("ModuleName::SomeQuery")
|
|
223
|
+
#
|
|
224
|
+
# allowed_callers lists other modules that may call that export. The
|
|
225
|
+
# host app and the module itself are allowed implicitly.
|
|
226
|
+
#
|
|
227
|
+
YAML
|
|
228
|
+
.then { |header| header + YAML.dump(manifest) }
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def yes?(input)
|
|
232
|
+
input.to_s.strip.empty? || %w[Y y yes YES].include?(input)
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
require "pathname"
|
|
2
|
+
|
|
3
|
+
module Torikago
|
|
4
|
+
# Stores module definitions declared by the host application. Other runtime
|
|
5
|
+
# objects treat this as the source of truth for module roots and boot options.
|
|
6
|
+
class Configuration
|
|
7
|
+
Definition = Struct.new(:name, :root, :entrypoint, :setup, :gemfile, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@definitions = {}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def register(name, root:, entrypoint: nil, setup: nil, gemfile: nil)
|
|
14
|
+
if @definitions.key?(name.to_sym)
|
|
15
|
+
raise ArgumentError, "module already registered: #{name}"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
@definitions[name.to_sym] = Definition.new(
|
|
19
|
+
name: name.to_sym,
|
|
20
|
+
root: Pathname(root),
|
|
21
|
+
entrypoint: entrypoint,
|
|
22
|
+
setup: setup,
|
|
23
|
+
gemfile: gemfile
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def registered?(name)
|
|
28
|
+
@definitions.key?(name.to_sym)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def each_definition(&block)
|
|
32
|
+
return @definitions.each_value unless block
|
|
33
|
+
|
|
34
|
+
@definitions.each_value(&block)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def fetch(name)
|
|
38
|
+
normalized_name = name.to_sym
|
|
39
|
+
|
|
40
|
+
@definitions.fetch(normalized_name) do
|
|
41
|
+
raise KeyError, "module not registered: #{name}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Torikago
|
|
2
|
+
# Tracks which module is currently executing so Gateway can enforce
|
|
3
|
+
# caller-specific package API permissions.
|
|
4
|
+
module CurrentExecution
|
|
5
|
+
STORAGE_KEY = :__torikago_current_box
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def current_box
|
|
10
|
+
Thread.current[STORAGE_KEY]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def with_box(box_name)
|
|
14
|
+
previous_box = current_box
|
|
15
|
+
Thread.current[STORAGE_KEY] = box_name.to_sym
|
|
16
|
+
yield
|
|
17
|
+
ensure
|
|
18
|
+
# Gateway calls can be nested, so restore the previous caller even when a
|
|
19
|
+
# package API raises.
|
|
20
|
+
Thread.current[STORAGE_KEY] = previous_box
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
require "pathname"
|
|
2
|
+
|
|
3
|
+
module Torikago
|
|
4
|
+
# Owns the runtime for one registered module. When Ruby::Box is available it
|
|
5
|
+
# loads the module into an isolated Box; otherwise it falls back to the host
|
|
6
|
+
# process for development and tests.
|
|
7
|
+
class EngineContainer
|
|
8
|
+
def initialize(name:, module_root:, entrypoint: nil, setup: nil, gemfile: nil, box_factory: nil, gemfile_dependency_loader: nil, gem_activator: nil)
|
|
9
|
+
@name = name
|
|
10
|
+
@module_root = Pathname(module_root)
|
|
11
|
+
@entrypoint = entrypoint
|
|
12
|
+
@setup = setup
|
|
13
|
+
@gemfile = gemfile
|
|
14
|
+
@box_factory = box_factory
|
|
15
|
+
@gemfile_dependency_loader = gemfile_dependency_loader || method(:load_gemfile_dependencies)
|
|
16
|
+
@gem_activator = gem_activator || method(:activate_gem_dependency)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(public_api_class_name, *args, **kwargs)
|
|
20
|
+
CurrentExecution.with_box(name) do
|
|
21
|
+
public_api_class = resolve_public_api_class(public_api_class_name)
|
|
22
|
+
public_api_class.new.call(*args, **kwargs)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
attr_reader :box_factory, :entrypoint, :gem_activator, :gemfile, :gemfile_dependency_loader, :module_root, :name, :setup
|
|
29
|
+
|
|
30
|
+
def boot_runtime!
|
|
31
|
+
return if @booted
|
|
32
|
+
|
|
33
|
+
files = runtime_files
|
|
34
|
+
gemfile_dependencies
|
|
35
|
+
if isolated_box_enabled?
|
|
36
|
+
# The Box starts with an independent load path and constant table, so
|
|
37
|
+
# boot has to copy enough host context before loading module code.
|
|
38
|
+
prepare_box!
|
|
39
|
+
prepend_gemfile_require_paths_to_box!
|
|
40
|
+
load_setup_hook_into_box!
|
|
41
|
+
ensure_root_namespace_in_box!
|
|
42
|
+
|
|
43
|
+
files.each do |path|
|
|
44
|
+
box.load(path)
|
|
45
|
+
end
|
|
46
|
+
else
|
|
47
|
+
# Non-Box mode preserves the same public behavior while giving up
|
|
48
|
+
# runtime isolation. This keeps local development usable on normal Ruby.
|
|
49
|
+
apply_gemfile_overrides!
|
|
50
|
+
load_setup_hook!
|
|
51
|
+
ensure_root_namespace!
|
|
52
|
+
|
|
53
|
+
files.each do |path|
|
|
54
|
+
load path
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
@booted = true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def load_setup_hook!
|
|
62
|
+
path = setup_path
|
|
63
|
+
return unless path
|
|
64
|
+
|
|
65
|
+
unless path.exist?
|
|
66
|
+
raise LoadError, "setup not found for #{name}: #{path}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
load path.to_s
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def apply_gemfile_overrides!
|
|
73
|
+
path = gemfile_path
|
|
74
|
+
return unless path
|
|
75
|
+
|
|
76
|
+
gemfile_dependencies.each do |dependency|
|
|
77
|
+
gem_activator.call(dependency)
|
|
78
|
+
rescue Gem::LoadError => e
|
|
79
|
+
raise GemfileOverrideError,
|
|
80
|
+
"failed to activate #{dependency.fetch(:name)} (#{dependency.fetch(:requirement)}) for #{name}: #{e.message}"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def prepend_gemfile_require_paths_to_box!
|
|
85
|
+
paths = gemfile_dependencies.flat_map { |dependency| Array(dependency[:require_paths] || dependency["require_paths"]) }
|
|
86
|
+
return if paths.empty?
|
|
87
|
+
return unless box.respond_to?(:load_path)
|
|
88
|
+
|
|
89
|
+
# Put module-specific gems ahead of the host load path so the Box resolves
|
|
90
|
+
# dependency versions from the module Gemfile first.
|
|
91
|
+
box.load_path.replace(paths.map(&:to_s) + box.load_path)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def gemfile_dependencies
|
|
95
|
+
path = gemfile_path
|
|
96
|
+
return [] unless path
|
|
97
|
+
|
|
98
|
+
@gemfile_dependencies ||= gemfile_dependency_loader.call(path)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def resolve_public_api_class(class_name)
|
|
102
|
+
boot_runtime!
|
|
103
|
+
root = isolated_box_enabled? ? box : Object
|
|
104
|
+
# const_get is evaluated inside the Box object when isolation is enabled,
|
|
105
|
+
# which keeps public API constants out of the host Object namespace.
|
|
106
|
+
class_name.split("::").reduce(root) { |context, segment| context.const_get(segment) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def runtime_files
|
|
110
|
+
@runtime_files ||= [
|
|
111
|
+
*library_files,
|
|
112
|
+
*gateway_model_files,
|
|
113
|
+
*Dir[public_api_root.join("**/*.rb").to_s].sort
|
|
114
|
+
]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def public_api_root
|
|
118
|
+
configured_entrypoint = entrypoint
|
|
119
|
+
return module_root.join("app/package_api") if configured_entrypoint.nil?
|
|
120
|
+
|
|
121
|
+
candidate = module_root.join(configured_entrypoint)
|
|
122
|
+
return candidate if candidate.directory?
|
|
123
|
+
return candidate unless candidate.extname == ".rb"
|
|
124
|
+
|
|
125
|
+
candidate.dirname
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def gemfile_path
|
|
129
|
+
return unless gemfile
|
|
130
|
+
|
|
131
|
+
module_root.join(gemfile)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def setup_path
|
|
135
|
+
return unless setup
|
|
136
|
+
|
|
137
|
+
module_root.join(setup)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def ensure_root_namespace!
|
|
141
|
+
namespace = camelize(name.to_s)
|
|
142
|
+
return if Object.const_defined?(namespace, false)
|
|
143
|
+
|
|
144
|
+
Object.const_set(namespace, Module.new)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def ensure_root_namespace_in_box!
|
|
148
|
+
namespace = camelize(name.to_s)
|
|
149
|
+
return if box.const_defined?(namespace, false)
|
|
150
|
+
|
|
151
|
+
box.const_set(namespace, Module.new)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def isolated_box_enabled?
|
|
155
|
+
return true if box_factory
|
|
156
|
+
|
|
157
|
+
ruby_box_runtime_available?
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def box
|
|
161
|
+
@box ||= if box_factory
|
|
162
|
+
box_factory.call
|
|
163
|
+
else
|
|
164
|
+
Ruby::Box.new
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def prepare_box!
|
|
169
|
+
return if @box_prepared
|
|
170
|
+
|
|
171
|
+
box.load_path.replace($LOAD_PATH.dup) if box.respond_to?(:load_path)
|
|
172
|
+
if box.respond_to?(:require)
|
|
173
|
+
box.require("torikago/current_execution")
|
|
174
|
+
end
|
|
175
|
+
@box_prepared = true
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def gateway_model_files
|
|
179
|
+
# Package APIs may depend on small PORO-style models. Active Record models
|
|
180
|
+
# are skipped because Rails owns their loading and database connection.
|
|
181
|
+
Dir[module_root.join("app/models/**/*.rb").to_s].sort.reject do |path|
|
|
182
|
+
rails_model_file?(path)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def rails_model_file?(path)
|
|
187
|
+
source = File.read(path)
|
|
188
|
+
source.match?(/ActiveRecord::Base|<\s+\w+Record\b|^\s*validates\s/m)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def load_setup_hook_into_box!
|
|
192
|
+
path = setup_path
|
|
193
|
+
return unless path
|
|
194
|
+
|
|
195
|
+
unless path.exist?
|
|
196
|
+
raise LoadError, "setup not found for #{name}: #{path}"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
box.load(path.to_s)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def library_files
|
|
203
|
+
all_files = Dir[module_root.join("lib/**/*.rb").to_s].sort
|
|
204
|
+
monkey_patch_files = Dir[module_root.join("lib/monkey_patches/**/*.rb").to_s]
|
|
205
|
+
|
|
206
|
+
# Monkey patches are only loaded through the explicit setup hook so a
|
|
207
|
+
# module has to opt in to global-ish runtime changes.
|
|
208
|
+
all_files - monkey_patch_files
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def ruby_box_runtime_available?
|
|
212
|
+
return @ruby_box_runtime_available unless @ruby_box_runtime_available.nil?
|
|
213
|
+
|
|
214
|
+
@ruby_box_runtime_available = if ENV["RUBY_BOX"] == "1"
|
|
215
|
+
begin
|
|
216
|
+
Ruby::Box.new
|
|
217
|
+
true
|
|
218
|
+
rescue RuntimeError
|
|
219
|
+
false
|
|
220
|
+
end
|
|
221
|
+
else
|
|
222
|
+
false
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def camelize(segment)
|
|
227
|
+
segment.split("_").map(&:capitalize).join
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def load_gemfile_dependencies(path)
|
|
231
|
+
unless path.exist?
|
|
232
|
+
raise GemfileOverrideError, "gemfile not found for #{name}: #{path}"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Prefer cheap local parsing for path and exact-version dependencies, then
|
|
236
|
+
# fall back to Bundler for more complex Gemfiles.
|
|
237
|
+
path_gem_dependencies = load_path_gem_dependencies(path)
|
|
238
|
+
return path_gem_dependencies unless path_gem_dependencies.empty?
|
|
239
|
+
|
|
240
|
+
installed_gem_dependencies = load_installed_gem_dependencies(path)
|
|
241
|
+
return installed_gem_dependencies unless installed_gem_dependencies.empty?
|
|
242
|
+
|
|
243
|
+
require "bundler"
|
|
244
|
+
|
|
245
|
+
lockfile = Pathname("#{path}.lock")
|
|
246
|
+
definition = Bundler::Definition.build(path.to_s, lockfile.exist? ? lockfile.to_s : nil, nil)
|
|
247
|
+
|
|
248
|
+
specs_by_name = definition.specs.each_with_object({}) do |spec, specs|
|
|
249
|
+
specs[spec.name] ||= spec
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
definition.dependencies.filter_map do |dependency|
|
|
253
|
+
spec = specs_by_name.fetch(dependency.name, nil)
|
|
254
|
+
next unless spec
|
|
255
|
+
|
|
256
|
+
requirement = dependency.requirement.to_s
|
|
257
|
+
{
|
|
258
|
+
name: dependency.name,
|
|
259
|
+
requirement: requirement,
|
|
260
|
+
require_paths: spec.full_require_paths
|
|
261
|
+
}
|
|
262
|
+
end
|
|
263
|
+
rescue Bundler::BundlerError => e
|
|
264
|
+
raise GemfileOverrideError, "failed to load gemfile for #{name}: #{e.message}"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def load_path_gem_dependencies(path)
|
|
268
|
+
path.dirname.then do |gemfile_root|
|
|
269
|
+
path.read.scan(/^\s*gem\s+["']([^"']+)["']\s*,\s*path:\s*["']([^"']+)["']/).filter_map do |gem_name, relative_path|
|
|
270
|
+
gem_root = gemfile_root.join(relative_path)
|
|
271
|
+
gemspec_path = gem_root.join("#{gem_name}.gemspec")
|
|
272
|
+
gemspec_path = Dir[gem_root.join("*.gemspec").to_s].sort.first unless gemspec_path.exist?
|
|
273
|
+
next unless gemspec_path
|
|
274
|
+
|
|
275
|
+
spec = Gem::Specification.load(gemspec_path.to_s)
|
|
276
|
+
next unless spec
|
|
277
|
+
|
|
278
|
+
{
|
|
279
|
+
name: spec.name,
|
|
280
|
+
requirement: "= #{spec.version}",
|
|
281
|
+
require_paths: spec.require_paths.map { |require_path| gem_root.join(require_path).to_s }
|
|
282
|
+
}
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def load_installed_gem_dependencies(path)
|
|
288
|
+
dependencies = exact_version_gemfile_dependencies(path)
|
|
289
|
+
return [] if dependencies.empty?
|
|
290
|
+
|
|
291
|
+
dependencies.filter_map do |dependency|
|
|
292
|
+
specs = installed_specs_for(dependency.fetch(:name), dependency.fetch(:requirement))
|
|
293
|
+
spec = specs.max_by(&:version)
|
|
294
|
+
unless spec
|
|
295
|
+
raise GemfileOverrideError,
|
|
296
|
+
"failed to load gemfile for #{name}: Could not find gem '#{dependency.fetch(:name)} (#{dependency.fetch(:requirement)})' in locally installed gems."
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
{
|
|
300
|
+
name: spec.name,
|
|
301
|
+
requirement: dependency.fetch(:requirement),
|
|
302
|
+
require_paths: spec.full_require_paths
|
|
303
|
+
}
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def installed_specs_for(gem_name, requirement)
|
|
308
|
+
specs = Gem::Specification.find_all_by_name(gem_name, requirement)
|
|
309
|
+
return specs unless specs.empty?
|
|
310
|
+
|
|
311
|
+
gem_requirement = Gem::Requirement.new(requirement)
|
|
312
|
+
Gem::Specification.dirs.flat_map do |specification_dir|
|
|
313
|
+
Dir[File.join(specification_dir, "#{gem_name}-*.gemspec")].filter_map do |gemspec_path|
|
|
314
|
+
spec = Gem::Specification.load(gemspec_path)
|
|
315
|
+
next unless spec
|
|
316
|
+
next unless spec.name == gem_name
|
|
317
|
+
next unless gem_requirement.satisfied_by?(spec.version)
|
|
318
|
+
|
|
319
|
+
spec
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def exact_version_gemfile_dependencies(path)
|
|
325
|
+
path.read.each_line.filter_map do |line|
|
|
326
|
+
match = line.match(/^\s*gem\s+["']([^"']+)["']\s*,\s*["']=\s*([^"']+)["']/)
|
|
327
|
+
next unless match
|
|
328
|
+
|
|
329
|
+
{
|
|
330
|
+
name: match[1],
|
|
331
|
+
requirement: "= #{match[2]}"
|
|
332
|
+
}
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def activate_gem_dependency(dependency)
|
|
337
|
+
Kernel.send(:gem, dependency.fetch(:name), dependency.fetch(:requirement))
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|