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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4067561b7267c62468f0ba974be7a68e536fa620cc25c7356ba643f774bc14d9
4
+ data.tar.gz: 5ceb5e3693ba1a767a2e93f439384df0d568306beabbbb62efb9538dc807dc79
5
+ SHA512:
6
+ metadata.gz: 9bbe65e72b673873e80dfc51c64de86a1c3240976ebad98f964d47abdef046b99058dc8f33365d9aba9714e6e2a92685db98737084e7b81bb34cc17321fe37fa
7
+ data.tar.gz: 6968dad15fcdc9e710819468925d4e5da7778b90d53615d31308fcba9f58a9f99d7a6d6f9082d6091618c480cef96083a7476ea013c07c28d3418e55f0f4d9ef
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 se4weed
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.ja.md ADDED
@@ -0,0 +1,144 @@
1
+ # torikago
2
+
3
+ `torikago`は、Railsのmodular monolithでmoduleごとの実行境界を扱うためのgemです。`packwerk`や`Rails::Engine`で構造上の境界を作るだけでなく、`Ruby::Box`を使って実行時の境界も強くすることを目指しています。
4
+
5
+ `torikago`では、module間の呼び出しを`Torikago::Gateway.call(...)`に集約し、各moduleのPackage APIと呼び出し可能なmoduleを事前に定義します。これによって、意図していないmodule間参照を実行時に防ぎやすくします。
6
+
7
+ ![torikago architecture](docs/image.png)
8
+
9
+ ## 設定例
10
+
11
+ Rails app側でmoduleを登録します。
12
+
13
+ ```ruby
14
+ Torikago.configure do |config|
15
+ config.register(
16
+ :foo,
17
+ root: Rails.root.join("modules/foo"),
18
+ entrypoint: "app/package_api", # optional
19
+ setup: "config/box_setup.rb", # optional
20
+ gemfile: "Gemfile" # optional
21
+ )
22
+ end
23
+ ```
24
+
25
+ `config.register`で指定できる主な項目は次のとおりです。
26
+
27
+ - `root`
28
+ - moduleのルートディレクトリ
29
+ - `entrypoint`
30
+ - public APIを探索するディレクトリ、またはその配下のファイル
31
+ - 未指定時は`app/package_api`
32
+ - `setup`
33
+ - Box boot前に読み込むsetup hook
34
+ - monkey patchやbox固有の初期化処理に使う
35
+ - `gemfile`
36
+ - そのBoxで優先したいgem require pathを解決するためのGemfile
37
+ - Box cold boot時に、解決したrequire pathをそのBoxの`load_path`先頭側へ追加する
38
+ - main box側のgem activationに頼らず、module codeからmodule-localなgem versionを`require`できるようにするための設定
39
+
40
+ module側では、公開するPackage APIと、どのmoduleから呼べるかを定義します。
41
+
42
+ ```yaml
43
+ exports:
44
+ Foo::ListProductsQuery:
45
+ allowed_callers:
46
+ - baz
47
+ ```
48
+
49
+ module自身からの呼び出しとmain boxからの呼び出しは許可されます。`allowed_callers`は、他moduleからの参照だけを制限します。
50
+
51
+ 呼び出し側では、対象のclass名を使って呼び出します。
52
+
53
+ ```ruby
54
+ Torikago::Gateway.call("Foo::ListProductsQuery")
55
+
56
+ # 引数を渡す場合
57
+ Torikago::Gateway.call("Bar::SubmitOrderCommand", title: "Book")
58
+ ```
59
+
60
+ `Torikago::Gateway.call(...)`は、class名から対象moduleを解決し、そのmoduleのexportされたPackage API定義を確認したうえで、対象Boxの中で`new.call(...)`を実行します。
61
+
62
+ ## Example app
63
+
64
+ `example/rails-modular-monolith/`に、最小のRails example appが入っています。
65
+
66
+ ## 使い方
67
+
68
+ ### gem本体のテスト
69
+
70
+ ```sh
71
+ bundle exec rake test
72
+ ```
73
+
74
+ ### example appのテスト
75
+
76
+ ```sh
77
+ cd example/rails-modular-monolith
78
+ RUBY_BOX=1 bundle exec rails test
79
+ ```
80
+
81
+ ### example appの起動
82
+
83
+ ```sh
84
+ cd example/rails-modular-monolith
85
+ RUBY_BOX=1 bundle exec rails s
86
+ ```
87
+
88
+ `Ruby::Box`を実際に有効にするには`RUBY_BOX=1`が必要です。
89
+
90
+ ## CLI
91
+
92
+ `exe/torikago`からCLIを利用できます。
93
+
94
+ ```sh
95
+ bundle exec ruby exe/torikago --help
96
+ ```
97
+
98
+ 主なコマンド:
99
+
100
+ - `torikago init`
101
+ - 対話式で`package_api.yml`と`config/initializers/torikago.rb`を生成する
102
+ - `torikago check`
103
+ - `Gateway.call`とmanifestの整合性を検証する
104
+ - `torikago update-package-api [BOX]`
105
+ - 設定済みentrypointから`package_api.yml`を更新する
106
+
107
+ `torikago check`は、`Torikago::Gateway.call("...")`の呼び出しを走査し、
108
+
109
+ - manifestにそのclassが定義されているか
110
+ - 呼び出し元moduleが`allowed_callers`に含まれているか
111
+ - manifest上のclassに対応するファイルが存在するか
112
+
113
+ を確認します。
114
+
115
+ ## `RUBY_BOX=1`とbootについて
116
+
117
+ 現状のexample appでは、`RUBY_BOX=1`下でRails bootを安定させるために、いくつか回避策を入れています。
118
+
119
+ - Bundler pluginを無効化する
120
+ - `tmpdir`を早めに読み込む
121
+ - `RUBY_BOX=1`時は`Bundler.require(*Rails.groups)`を避け、必要なgemを明示的に`require`する
122
+
123
+ これらは`torikago`の最終形というより、現時点でexampleを安定して動かすための実務的なworkaroundです。
124
+
125
+ ## 現時点の制約
126
+
127
+ - 初回のBox bootは重い
128
+ - cold boot時は数秒オーダーのコストが出ることがある
129
+ - `Ruby::Box`自体がexperimental
130
+ - segfaultや不安定さに遭遇することがある
131
+ - Railsや一部gemとの相性問題がある
132
+ - とくにVM全体へ影響するglobal-effect gemは、きれいに分離しきれない
133
+ - full `Rails::Engine` confinementを素直にやるのはまだ難しい
134
+
135
+ 代表的な例外:
136
+
137
+ - `Torikago::DependencyError`
138
+ - 許可されていないmodule間参照
139
+ - `Torikago::PublicApiError`
140
+ - manifestに宣言されていないPackage APIの呼び出し
141
+ - `Torikago::GemfileOverrideError`
142
+ - Box用Gemfileの解決やactivateに失敗したとき
143
+
144
+ そのため、現時点の`torikago`は「すぐ本番導入できる完成品」というより、modular monolithの実行境界をどこまで強くできるかを探る実装です。
data/README.md ADDED
@@ -0,0 +1,142 @@
1
+ # torikago
2
+
3
+ `torikago` is a gem for introducing per-module runtime boundaries to Rails modular monoliths. It aims to strengthen runtime isolation with `Ruby::Box`, in addition to the structural boundaries you can already get from tools like `packwerk` and `Rails::Engine`.
4
+
5
+ With `torikago`, module-to-module calls are funneled through `Torikago::Gateway.call(...)`, and each module declares which Package APIs it exposes and which modules may call them. This makes it easier to prevent unintended cross-module references at runtime.
6
+
7
+ ![torikago architecture](docs/image.png)
8
+
9
+ ## Configuration example
10
+
11
+ Register modules from your Rails app:
12
+
13
+ ```ruby
14
+ Torikago.configure do |config|
15
+ config.register(
16
+ :foo,
17
+ root: Rails.root.join("modules/foo"),
18
+ entrypoint: "app/package_api", # optional
19
+ setup: "config/box_setup.rb", # optional
20
+ gemfile: "Gemfile" # optional
21
+ )
22
+ end
23
+ ```
24
+
25
+ The main `config.register` options are:
26
+
27
+ - `root`
28
+ - the module root directory
29
+ - `entrypoint`
30
+ - the directory, or file under that directory, used to discover public APIs
31
+ - defaults to `app/package_api`
32
+ - `setup`
33
+ - a setup hook loaded before Box boot completes
34
+ - useful for monkey patches or Box-specific initialization
35
+ - `gemfile`
36
+ - a Gemfile used to resolve Box-specific gem require paths
37
+ - during Box cold boot, the resolved require paths are prepended to that Box's `load_path`
38
+ - this is intended to let module code `require` module-local gem versions without relying on main-box gem activation
39
+
40
+ On the module side, declare the Package APIs you expose and which modules may call them:
41
+
42
+ ```yaml
43
+ exports:
44
+ Foo::ListProductsQuery:
45
+ allowed_callers:
46
+ - baz
47
+ ```
48
+
49
+ Calls from the module itself and from the main box are allowed implicitly. `allowed_callers` only restricts calls coming from other modules.
50
+
51
+ Call a Package API by class name:
52
+
53
+ ```ruby
54
+ Torikago::Gateway.call("Foo::ListProductsQuery")
55
+
56
+ # with arguments
57
+ Torikago::Gateway.call("Bar::SubmitOrderCommand", title: "Book")
58
+ ```
59
+
60
+ `Torikago::Gateway.call(...)` resolves the target module from the class name, checks the exported Package API declaration for that module, and then runs `new.call(...)` inside the target Box.
61
+
62
+ ## Example app
63
+
64
+ A minimal Rails example app lives in `example/rails-modular-monolith/`.
65
+
66
+ ## Usage
67
+
68
+ ### Run gem tests
69
+
70
+ ```sh
71
+ bundle exec rake test
72
+ ```
73
+
74
+ ### Run example app tests
75
+
76
+ ```sh
77
+ cd example/rails-modular-monolith
78
+ RUBY_BOX=1 bundle exec rails test
79
+ ```
80
+
81
+ ### Start the example app
82
+
83
+ ```sh
84
+ cd example/rails-modular-monolith
85
+ RUBY_BOX=1 bundle exec rails s
86
+ ```
87
+
88
+ `RUBY_BOX=1` is required to actually enable `Ruby::Box`.
89
+
90
+ ## CLI
91
+
92
+ Use the CLI via `exe/torikago`:
93
+
94
+ ```sh
95
+ bundle exec ruby exe/torikago --help
96
+ ```
97
+
98
+ Main commands:
99
+
100
+ - `torikago init`
101
+ - interactively generate `package_api.yml` files and `config/initializers/torikago.rb`
102
+ - `torikago check`
103
+ - validate `Gateway.call` usage against manifests
104
+ - `torikago update-package-api [BOX]`
105
+ - regenerate `package_api.yml` from the configured entrypoint
106
+
107
+ `torikago check` scans `Torikago::Gateway.call("...")` usage and verifies:
108
+
109
+ - the class is declared in a manifest
110
+ - the caller module is included in `allowed_callers`
111
+ - the manifest entry has a matching implementation file
112
+
113
+ ## About `RUBY_BOX=1` and boot
114
+
115
+ The current example app includes a few practical boot workarounds to keep Rails startup stable under `RUBY_BOX=1`:
116
+
117
+ - disable Bundler plugins
118
+ - load `tmpdir` early
119
+ - avoid `Bundler.require(*Rails.groups)` under `RUBY_BOX=1` and require the needed gems explicitly
120
+
121
+ These are pragmatic workarounds for the current example app, not a finalized long-term contract for `torikago`.
122
+
123
+ ## Current limitations
124
+
125
+ - Initial Box boot is slow
126
+ - cold boot can take several seconds
127
+ - `Ruby::Box` itself is still experimental
128
+ - segfaults and instability can happen
129
+ - Some gems do not cooperate well with this model
130
+ - especially global-effect gems that influence the whole VM
131
+ - Full `Rails::Engine` confinement is still difficult to do cleanly
132
+
133
+ Common errors:
134
+
135
+ - `Torikago::DependencyError`
136
+ - an unauthorized cross-module reference
137
+ - `Torikago::PublicApiError`
138
+ - calling a Package API that is not declared in the manifest
139
+ - `Torikago::GemfileOverrideError`
140
+ - failure while resolving or activating a Box-specific Gemfile override
141
+
142
+ So, at the moment, `torikago` is better understood as an implementation exploring how far runtime boundaries in a modular monolith can be pushed, rather than a fully production-ready finished product.
data/exe/torikago ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ lib_dir = File.expand_path("../lib", __dir__)
4
+ $LOAD_PATH.unshift(lib_dir) unless $LOAD_PATH.include?(lib_dir)
5
+
6
+ require "torikago"
7
+
8
+ exit Torikago::CLI.new.run(ARGV)
@@ -0,0 +1,159 @@
1
+ require "pathname"
2
+ require "yaml"
3
+
4
+ module Torikago
5
+ # Lightweight static checks for explicit Gateway calls and package API
6
+ # manifests. This is intentionally conservative; runtime enforcement still
7
+ # lives in Gateway.
8
+ class Checker
9
+ Result = Struct.new(
10
+ :errors,
11
+ :scanned_file_count,
12
+ :gateway_call_count,
13
+ :manifest_count,
14
+ keyword_init: true
15
+ ) do
16
+ def ok?
17
+ errors.empty?
18
+ end
19
+ end
20
+
21
+ CALL_PATTERN = /
22
+ Torikago::Gateway\.call\(
23
+ \s*
24
+ ["'](?<class_name>[A-Z][A-Za-z0-9:]+)["']
25
+ /x.freeze
26
+
27
+ def initialize(configuration:, source_roots:)
28
+ @configuration = configuration
29
+ @source_roots = Array(source_roots).map { |root| Pathname(root) }
30
+ @manifests = {}
31
+ end
32
+
33
+ def call
34
+ errors = []
35
+ gateway_call_count = 0
36
+ scanned_files = source_files
37
+
38
+ scanned_files.each do |path|
39
+ gateway_call_count += scan_gateway_calls(path, errors)
40
+ end
41
+
42
+ manifest_count = 0
43
+ configuration.each_definition do |definition|
44
+ manifest_count += 1
45
+ validate_manifest_entries(definition, errors)
46
+ end
47
+
48
+ Result.new(
49
+ errors: errors,
50
+ scanned_file_count: scanned_files.size,
51
+ gateway_call_count: gateway_call_count,
52
+ manifest_count: manifest_count
53
+ )
54
+ end
55
+
56
+ private
57
+
58
+ attr_reader :configuration, :manifests, :source_roots
59
+
60
+ def source_files
61
+ source_roots.flat_map { |root| Dir[root.join("**/*.rb").to_s] }.sort.uniq
62
+ end
63
+
64
+ def scan_gateway_calls(path, errors)
65
+ content = File.read(path)
66
+ call_count = 0
67
+ content.to_enum(:scan, CALL_PATTERN).each do
68
+ call_count += 1
69
+ class_name = Regexp.last_match[:class_name]
70
+ # Public API names are expected to start with their owning module
71
+ # namespace, e.g. Foo::ListProductsQuery targets the :foo box.
72
+ target_box = infer_box_name(class_name)
73
+ manifest_entry = public_api_entry_for(target_box, class_name)
74
+ caller_box = infer_caller_box_from_path(path)
75
+
76
+ if manifest_entry.nil?
77
+ errors << "#{path}: #{class_name} is not declared in #{target_box}/package_api.yml exports"
78
+ next
79
+ end
80
+
81
+ next if caller_box.nil?
82
+ next if caller_box == target_box
83
+
84
+ allowed_callers = Array(manifest_entry["allowed_callers"]).map(&:to_s)
85
+ next if allowed_callers.include?(caller_box.to_s)
86
+
87
+ errors << "#{path}: #{caller_box} is not allowed to call #{class_name}"
88
+ end
89
+
90
+ call_count
91
+ end
92
+
93
+ def validate_manifest_entries(definition, errors)
94
+ exported_package_apis(load_manifest(definition)).each_key do |class_name|
95
+ # The manifest is the contract, but the checker also catches stale
96
+ # entries whose implementation file has been deleted or moved.
97
+ expected_path = expected_public_api_path(definition, class_name)
98
+ next if expected_path.exist?
99
+
100
+ errors << "#{definition.root.join('package_api.yml')}: #{class_name} does not have a matching file at #{expected_path}"
101
+ end
102
+ end
103
+
104
+ def public_api_entry_for(box_name, class_name)
105
+ exported_package_apis(load_manifest(configuration.fetch(box_name)))[class_name]
106
+ rescue KeyError
107
+ nil
108
+ end
109
+
110
+ def load_manifest(definition)
111
+ manifests.fetch(definition.name) do
112
+ manifest_path = definition.root.join("package_api.yml")
113
+ manifests[definition.name] =
114
+ if manifest_path.exist?
115
+ YAML.safe_load(manifest_path.read, permitted_classes: [], aliases: false) || {}
116
+ else
117
+ {}
118
+ end
119
+ end
120
+ end
121
+
122
+ def expected_public_api_path(definition, class_name)
123
+ relative_path = class_name.split("::").map { |segment| underscore(segment) }.join("/")
124
+ public_api_root(definition).join("#{relative_path}.rb")
125
+ end
126
+
127
+ def infer_box_name(class_name)
128
+ class_name.split("::").first.downcase.to_sym
129
+ end
130
+
131
+ def infer_caller_box_from_path(path)
132
+ # This path heuristic matches the Rails modular-monolith layout that
133
+ # torikago is designed around: modules/<box-name>/...
134
+ path.match(%r{/modules/(?<box>[a-z0-9_]+)/})&.named_captures&.fetch("box", nil)&.to_sym
135
+ end
136
+
137
+ def exported_package_apis(manifest)
138
+ manifest.fetch("exports") { manifest.fetch("public_api", {}) }
139
+ end
140
+
141
+ def underscore(name)
142
+ word = name.gsub(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
143
+ word.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
144
+ word.downcase
145
+ end
146
+
147
+ def public_api_root(definition)
148
+ return definition.root.join("app/package_api") if definition.entrypoint.nil?
149
+
150
+ # entrypoint may point at either a directory or a single boot file. For a
151
+ # file entrypoint, package API implementations live next to that file.
152
+ candidate = definition.root.join(definition.entrypoint)
153
+ return candidate if candidate.directory?
154
+ return candidate unless candidate.extname == ".rb"
155
+
156
+ candidate.dirname
157
+ end
158
+ end
159
+ end