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.
@@ -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
@@ -0,0 +1,16 @@
1
+ module Torikago
2
+ class Error < StandardError
3
+ end
4
+
5
+ class DependencyError < Error
6
+ end
7
+
8
+ class BoxUnavailableError < Error
9
+ end
10
+
11
+ class PublicApiError < Error
12
+ end
13
+
14
+ class GemfileOverrideError < Error
15
+ end
16
+ end