torikago 0.0.1 → 0.1.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,4 +1,5 @@
1
1
  require "pathname"
2
+ require "ripper"
2
3
  require "yaml"
3
4
 
4
5
  module Torikago
@@ -6,37 +7,145 @@ module Torikago
6
7
  # manifests. This is intentionally conservative; runtime enforcement still
7
8
  # lives in Gateway.
8
9
  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
10
+ class Result
11
+ attr_accessor :dynamic_gateway_call_count, :errors, :scanned_file_count, :gateway_call_count, :manifest_count
12
+
13
+ def initialize(errors: Array.new, scanned_file_count: 0, gateway_call_count: 0, dynamic_gateway_call_count: 0, manifest_count: 0)
14
+ @errors = errors
15
+ @scanned_file_count = scanned_file_count
16
+ @gateway_call_count = gateway_call_count
17
+ @dynamic_gateway_call_count = dynamic_gateway_call_count
18
+ @manifest_count = manifest_count
19
+ end
20
+
16
21
  def ok?
17
22
  errors.empty?
18
23
  end
19
24
  end
20
25
 
21
- CALL_PATTERN = /
22
- Torikago::Gateway\.call\(
23
- \s*
24
- ["'](?<class_name>[A-Z][A-Za-z0-9:]+)["']
25
- /x.freeze
26
+ GatewayCall = Struct.new(:class_name, :method_name, keyword_init: true)
27
+
28
+ class GatewayCallExtractor
29
+ attr_reader :calls, :dynamic_call_count
30
+
31
+ def initialize(source)
32
+ @sexp = Ripper.sexp(source)
33
+ @calls = Array.new
34
+ @dynamic_call_count = 0
35
+ end
36
+
37
+ def call
38
+ walk(sexp)
39
+ self
40
+ end
41
+
42
+ private
43
+
44
+ attr_reader :sexp
45
+
46
+ def walk(node)
47
+ return unless node.is_a?(Array)
48
+
49
+ extract(node) if node.first == :method_add_arg
50
+ node.each { |child| walk(child) if child.is_a?(Array) }
51
+ end
52
+
53
+ def extract(node)
54
+ call_node = node[1]
55
+ return unless call_node&.first == :call
56
+ return unless token_value(call_node[3]) == "invoke"
57
+
58
+ receiver = call_node[1]
59
+ arguments = arguments_from(node[2])
60
+
61
+ if gateway_constant?(receiver)
62
+ record(string_literal(arguments[0]), symbol_literal(arguments[1]))
63
+ elsif build_call?(receiver)
64
+ build_arguments = arguments_from(receiver[2])
65
+ record(string_literal(build_arguments[0]), symbol_literal(arguments[0]))
66
+ end
67
+ end
68
+
69
+ def record(class_name, method_name)
70
+ if class_name && method_name
71
+ calls << GatewayCall.new(class_name: class_name, method_name: method_name)
72
+ else
73
+ @dynamic_call_count += 1
74
+ end
75
+ end
76
+
77
+ def build_call?(node)
78
+ return false unless node&.first == :method_add_arg
79
+
80
+ call_node = node[1]
81
+ call_node&.first == :call &&
82
+ token_value(call_node[3]) == "build" &&
83
+ gateway_constant?(call_node[1])
84
+ end
85
+
86
+ def gateway_constant?(node)
87
+ constant_name(node) == "Torikago::Gateway"
88
+ end
89
+
90
+ def constant_name(node)
91
+ return unless node.is_a?(Array)
92
+
93
+ case node.first
94
+ when :var_ref, :const_ref, :top_const_ref
95
+ token_value(node[1])
96
+ when :const_path_ref
97
+ [constant_name(node[1]), token_value(node[2])].compact.join("::")
98
+ end
99
+ end
100
+
101
+ def arguments_from(node)
102
+ return Array.new unless node&.first == :arg_paren
103
+
104
+ args_add_block = node[1]
105
+ return Array.new unless args_add_block&.first == :args_add_block
106
+
107
+ args_add_block[1] || Array.new
108
+ end
109
+
110
+ def string_literal(node)
111
+ return unless node&.first == :string_literal
112
+
113
+ content = node[1]
114
+ parts = content&.first == :string_content ? content.drop(1) : Array.new
115
+ return unless parts.length == 1 && parts.first&.first == :@tstring_content
116
+
117
+ token_value(parts.first)
118
+ end
119
+
120
+ def symbol_literal(node)
121
+ return unless node&.first == :symbol_literal
122
+
123
+ symbol = node[1]
124
+ token = symbol&.first == :symbol ? symbol[1] : nil
125
+ token_value(token)&.to_sym
126
+ end
127
+
128
+ def token_value(token)
129
+ token[1] if token.is_a?(Array) && token.first.to_s.start_with?("@")
130
+ end
131
+ end
26
132
 
27
133
  def initialize(configuration:, source_roots:)
28
134
  @configuration = configuration
29
135
  @source_roots = Array(source_roots).map { |root| Pathname(root) }
30
- @manifests = {}
136
+ @manifests = Hash.new
31
137
  end
32
138
 
33
139
  def call
34
- errors = []
140
+ errors = Array.new
35
141
  gateway_call_count = 0
142
+ dynamic_gateway_call_count = 0
36
143
  scanned_files = source_files
37
144
 
38
145
  scanned_files.each do |path|
39
- gateway_call_count += scan_gateway_calls(path, errors)
146
+ static_count, dynamic_count = scan_gateway_calls(path, errors)
147
+ gateway_call_count += static_count
148
+ dynamic_gateway_call_count += dynamic_count
40
149
  end
41
150
 
42
151
  manifest_count = 0
@@ -49,6 +158,7 @@ module Torikago
49
158
  errors: errors,
50
159
  scanned_file_count: scanned_files.size,
51
160
  gateway_call_count: gateway_call_count,
161
+ dynamic_gateway_call_count: dynamic_gateway_call_count,
52
162
  manifest_count: manifest_count
53
163
  )
54
164
  end
@@ -62,11 +172,11 @@ module Torikago
62
172
  end
63
173
 
64
174
  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]
175
+ extraction = GatewayCallExtractor.new(File.read(path)).call
176
+ extraction.calls.each do |gateway_call|
177
+ class_name = gateway_call.class_name
178
+ method_name = gateway_call.method_name
179
+
70
180
  # Public API names are expected to start with their owning module
71
181
  # namespace, e.g. Foo::ListProductsQuery targets the :foo box.
72
182
  target_box = infer_box_name(class_name)
@@ -74,30 +184,54 @@ module Torikago
74
184
  caller_box = infer_caller_box_from_path(path)
75
185
 
76
186
  if manifest_entry.nil?
77
- errors << "#{path}: #{class_name} is not declared in #{target_box}/package_api.yml exports"
187
+ errors << "#{path}: #{class_name}##{method_name} is not declared in #{target_box}/package_api.yml exports"
188
+ next
189
+ end
190
+
191
+ methods = exported_methods(manifest_entry)
192
+ if !methods.empty? && !methods.include?(method_name.to_s)
193
+ errors << "#{path}: #{class_name}##{method_name} is not exported by #{target_box}/package_api.yml"
78
194
  next
79
195
  end
80
196
 
81
197
  next if caller_box.nil?
82
198
  next if caller_box == target_box
83
199
 
84
- allowed_callers = Array(manifest_entry["allowed_callers"]).map(&:to_s)
200
+ allowed_callers = allowed_callers(manifest_entry).map { |caller| caller.to_s }
85
201
  next if allowed_callers.include?(caller_box.to_s)
86
202
 
87
- errors << "#{path}: #{caller_box} is not allowed to call #{class_name}"
203
+ errors << "#{path}: #{caller_box} is not allowed to call #{class_name}##{method_name}"
88
204
  end
89
205
 
90
- call_count
206
+ [extraction.calls.size, extraction.dynamic_call_count]
91
207
  end
92
208
 
93
209
  def validate_manifest_entries(definition, errors)
94
- exported_package_apis(load_manifest(definition)).each_key do |class_name|
210
+ manifest_path = definition.root.join("package_api.yml")
211
+ exported_package_apis(load_manifest(definition)).each do |class_name, manifest_entry|
212
+ methods = exported_methods(manifest_entry)
213
+ if methods.empty?
214
+ errors << "#{manifest_path}: #{class_name} must declare a non-empty methods array"
215
+ end
216
+
95
217
  # The manifest is the contract, but the checker also catches stale
96
218
  # entries whose implementation file has been deleted or moved.
97
219
  expected_path = expected_public_api_path(definition, class_name)
98
- next if expected_path.exist?
220
+ unless expected_path.exist?
221
+ errors << "#{manifest_path}: #{class_name} does not have a matching file at #{expected_path}"
222
+ next
223
+ end
224
+
225
+ next if methods.empty?
226
+
227
+ implemented_methods, dynamic_definition = public_instance_methods(expected_path, class_name)
228
+ next if dynamic_definition
229
+
230
+ methods.each do |method_name|
231
+ next if implemented_methods.include?(method_name)
99
232
 
100
- errors << "#{definition.root.join('package_api.yml')}: #{class_name} does not have a matching file at #{expected_path}"
233
+ errors << "#{expected_path}: #{class_name}##{method_name} is exported but no public instance method definition was found"
234
+ end
101
235
  end
102
236
  end
103
237
 
@@ -112,9 +246,9 @@ module Torikago
112
246
  manifest_path = definition.root.join("package_api.yml")
113
247
  manifests[definition.name] =
114
248
  if manifest_path.exist?
115
- YAML.safe_load(manifest_path.read, permitted_classes: [], aliases: false) || {}
249
+ YAML.safe_load(manifest_path.read, permitted_classes: Array.new, aliases: false) || Hash.new
116
250
  else
117
- {}
251
+ Hash.new
118
252
  end
119
253
  end
120
254
  end
@@ -135,7 +269,122 @@ module Torikago
135
269
  end
136
270
 
137
271
  def exported_package_apis(manifest)
138
- manifest.fetch("exports") { manifest.fetch("public_api", {}) }
272
+ manifest.fetch("exports") { manifest.fetch("public_api", Hash.new) }
273
+ end
274
+
275
+ def allowed_callers(manifest_entry)
276
+ return Array.new unless manifest_entry.is_a?(Hash)
277
+
278
+ allowed = manifest_entry["allowed_callers"]
279
+ return allowed if allowed.is_a?(Array)
280
+
281
+ Array.new
282
+ end
283
+
284
+ def exported_methods(manifest_entry)
285
+ return Array.new unless manifest_entry.is_a?(Hash)
286
+
287
+ methods = manifest_entry["methods"]
288
+ return methods.map(&:to_s) if methods.is_a?(Array)
289
+
290
+ Array.new
291
+ end
292
+
293
+ def public_instance_methods(path, class_name)
294
+ sexp = Ripper.sexp(File.read(path))
295
+ methods = Array.new
296
+ dynamic_definition = false
297
+
298
+ visit_definitions = lambda do |node, namespace|
299
+ next unless node.is_a?(Array)
300
+
301
+ case node.first
302
+ when :program
303
+ Array(node[1]).each { |child| visit_definitions.call(child, namespace) }
304
+ when :module
305
+ name = qualified_constant_name(node[1], namespace)
306
+ visit_definitions.call(node[2], name)
307
+ when :class
308
+ name = qualified_constant_name(node[1], namespace)
309
+ if name == class_name
310
+ class_body_methods(node[3]).tap do |result|
311
+ methods.concat(result.fetch(:methods))
312
+ dynamic_definition ||= result.fetch(:dynamic)
313
+ end
314
+ else
315
+ visit_definitions.call(node[3], name)
316
+ end
317
+ when :bodystmt
318
+ Array(node[1]).each { |child| visit_definitions.call(child, namespace) }
319
+ else
320
+ node.each { |child| visit_definitions.call(child, namespace) if child.is_a?(Array) }
321
+ end
322
+ end
323
+
324
+ visit_definitions.call(sexp, nil)
325
+ [methods.uniq, dynamic_definition]
326
+ end
327
+
328
+ def class_body_methods(body)
329
+ visibility = :public
330
+ methods = Array.new
331
+ dynamic = false
332
+ statements = body&.first == :bodystmt ? Array(body[1]) : Array.new
333
+
334
+ statements.each do |statement|
335
+ visibility_name = bare_call_name(statement)
336
+ case visibility_name
337
+ when "public"
338
+ visibility = :public
339
+ next
340
+ when "private"
341
+ visibility = :private
342
+ next
343
+ when "protected"
344
+ visibility = :protected
345
+ next
346
+ end
347
+
348
+ if statement&.first == :def
349
+ methods << statement[1][1].to_s if visibility == :public
350
+ elsif contains_call?(statement, "define_method")
351
+ dynamic = true
352
+ end
353
+ end
354
+
355
+ { methods: methods, dynamic: dynamic }
356
+ end
357
+
358
+ def bare_call_name(node)
359
+ return unless node&.first == :vcall
360
+
361
+ token = node[1]
362
+ token[1] if token&.first == :@ident
363
+ end
364
+
365
+ def contains_call?(node, method_name)
366
+ return false unless node.is_a?(Array)
367
+ return true if %i[vcall fcall].include?(node.first) && node.dig(1, 1) == method_name
368
+
369
+ node.any? { |child| child.is_a?(Array) && contains_call?(child, method_name) }
370
+ end
371
+
372
+ def qualified_constant_name(node, namespace)
373
+ name = constant_name(node)
374
+ return name if name&.include?("::") || namespace.nil?
375
+
376
+ "#{namespace}::#{name}"
377
+ end
378
+
379
+ def constant_name(node)
380
+ return unless node.is_a?(Array)
381
+
382
+ case node.first
383
+ when :var_ref, :const_ref, :top_const_ref
384
+ node.dig(1, 1)
385
+ when :const_path_ref
386
+ [constant_name(node[1]), node.dig(2, 1)].compact.join("::")
387
+ end
139
388
  end
140
389
 
141
390
  def underscore(name)
data/lib/torikago/cli.rb CHANGED
@@ -13,7 +13,7 @@ module Torikago
13
13
  init
14
14
  interactively generate package_api.yml files and config/initializers/torikago.rb
15
15
  check
16
- validate Gateway.call usage against package_api.yml
16
+ validate Gateway invocations against package_api.yml
17
17
  update-package-api [BOX]
18
18
  regenerate package_api.yml entries from the configured public API entrypoint
19
19
  help, --help, -h
@@ -74,7 +74,7 @@ module Torikago
74
74
  )
75
75
 
76
76
  manifest_path = module_root.join("package_api.yml")
77
- manifest = manifest_path.exist? ? load_yaml_file(manifest_path) : { "exports" => {} }
77
+ manifest = manifest_path.exist? ? load_yaml_file(manifest_path) : { "exports" => Hash.new }
78
78
  manifest_path.write(render_package_api_manifest(manifest))
79
79
  stdout.puts("generated #{manifest_path}")
80
80
  end
@@ -102,7 +102,7 @@ module Torikago
102
102
  if result.ok?
103
103
  stdout.puts(
104
104
  "ok: scanned #{result.scanned_file_count} Ruby files, " \
105
- "found #{result.gateway_call_count} Gateway.call usages, " \
105
+ "found #{result.gateway_call_count} static Gateway invocations, " \
106
106
  "validated #{result.manifest_count} package_api manifests"
107
107
  )
108
108
  0
@@ -110,7 +110,7 @@ module Torikago
110
110
  result.errors.each { |error| stderr.puts(error) }
111
111
  stderr.puts(
112
112
  "failed: scanned #{result.scanned_file_count} Ruby files, " \
113
- "found #{result.gateway_call_count} Gateway.call usages, " \
113
+ "found #{result.gateway_call_count} static Gateway invocations, " \
114
114
  "validated #{result.manifest_count} package_api manifests, " \
115
115
  "#{result.errors.size} errors"
116
116
  )
@@ -158,7 +158,7 @@ module Torikago
158
158
  end
159
159
 
160
160
  def load_yaml_file(path)
161
- YAML.safe_load(path.read, permitted_classes: [], aliases: false) || {}
161
+ YAML.safe_load(path.read, permitted_classes: Array.new, aliases: false) || Hash.new
162
162
  end
163
163
 
164
164
  def render_initializer(configuration)
@@ -166,7 +166,7 @@ module Torikago
166
166
  "# This file registers torikago runtime boundaries for your Rails app.",
167
167
  "#",
168
168
  "# Each config.register call defines one module root. Calls between modules",
169
- "# should go through Torikago::Gateway.call(\"Module::ExportedApi\") instead",
169
+ "# should go through Torikago::Gateway.invoke(\"Module::ExportedApi\", :call) instead",
170
170
  "# of reaching across module constants directly.",
171
171
  "#",
172
172
  "# Options:",
@@ -178,6 +178,11 @@ module Torikago
178
178
  "# Directory containing exported Package API classes. The conventional",
179
179
  "# default is app/package_api.",
180
180
  "#",
181
+ "# rails_engine:",
182
+ "# Set true when the module owns a Rails::Engine entrypoint at",
183
+ "# lib/<module_name>.rb. That entrypoint is then left for Rails and",
184
+ "# skipped by the module Box runtime.",
185
+ "#",
181
186
  "# gemfile:",
182
187
  "# Optional module-local Gemfile. When Ruby::Box isolation is enabled,",
183
188
  "# torikago prepends that module's resolved gem require paths inside the",
@@ -192,7 +197,7 @@ module Torikago
192
197
  "#",
193
198
  "# bin/torikago update-package-api",
194
199
  "#",
195
- "# To validate Gateway.call usage against package_api.yml, run:",
200
+ "# To validate Gateway invocations against package_api.yml, run:",
196
201
  "#",
197
202
  "# bin/torikago check",
198
203
  "",
@@ -203,8 +208,16 @@ module Torikago
203
208
  lines << " config.register("
204
209
  lines << " :#{definition.name},"
205
210
  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
211
+ option_lines = Array.new
212
+ option_lines << " entrypoint: #{definition.entrypoint.inspect}" if definition.entrypoint
213
+ option_lines << " rails_engine: #{definition.rails_engine.inspect}" if definition.rails_engine
214
+ option_lines << " setup: #{definition.setup.inspect}" if definition.setup
215
+ option_lines << " gemfile: #{definition.gemfile.inspect}" if definition.gemfile
216
+
217
+ option_lines.each_with_index do |option_line, index|
218
+ comma = index == option_lines.length - 1 ? "" : ","
219
+ lines << "#{option_line}#{comma}"
220
+ end
208
221
  lines << " )"
209
222
  lines << ""
210
223
  end
@@ -219,8 +232,9 @@ module Torikago
219
232
  #
220
233
  # Each key under exports is a class that may be called through:
221
234
  #
222
- # Torikago::Gateway.call("ModuleName::SomeQuery")
235
+ # Torikago::Gateway.invoke("ModuleName::SomeQuery", :call)
223
236
  #
237
+ # methods lists the public instance methods exposed through Gateway.
224
238
  # allowed_callers lists other modules that may call that export. The
225
239
  # host app and the module itself are allowed implicitly.
226
240
  #
@@ -4,13 +4,13 @@ module Torikago
4
4
  # Stores module definitions declared by the host application. Other runtime
5
5
  # objects treat this as the source of truth for module roots and boot options.
6
6
  class Configuration
7
- Definition = Struct.new(:name, :root, :entrypoint, :setup, :gemfile, keyword_init: true)
7
+ Definition = Struct.new(:name, :root, :entrypoint, :rails_engine, :setup, :gemfile, keyword_init: true)
8
8
 
9
9
  def initialize
10
- @definitions = {}
10
+ @definitions = Hash.new
11
11
  end
12
12
 
13
- def register(name, root:, entrypoint: nil, setup: nil, gemfile: nil)
13
+ def register(name, root:, entrypoint: nil, rails_engine: false, setup: nil, gemfile: nil)
14
14
  if @definitions.key?(name.to_sym)
15
15
  raise ArgumentError, "module already registered: #{name}"
16
16
  end
@@ -19,6 +19,7 @@ module Torikago
19
19
  name: name.to_sym,
20
20
  root: Pathname(root),
21
21
  entrypoint: entrypoint,
22
+ rails_engine: rails_engine,
22
23
  setup: setup,
23
24
  gemfile: gemfile
24
25
  )
@@ -0,0 +1,30 @@
1
+ module Torikago
2
+ # A host-owned Rack-compatible route endpoint that dispatches directly to a
3
+ # controller class loaded inside a registered module runtime. It is not Rack
4
+ # middleware. The host router never resolves the controller constant, so
5
+ # Rails::Engine is not required for isolation.
6
+ class ControllerEndpoint
7
+ def initialize(module_name, controller_name, action_name, registry: nil)
8
+ @module_name = module_name.to_sym
9
+ @controller_name = controller_name.to_s
10
+ @action_name = action_name.to_sym
11
+ @registry = registry
12
+ end
13
+
14
+ def call(env)
15
+ registry.resolve(module_name).dispatch_controller(
16
+ env,
17
+ controller_name: controller_name,
18
+ action_name: action_name
19
+ )
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :action_name, :controller_name, :module_name
25
+
26
+ def registry
27
+ @registry || Torikago.registry
28
+ end
29
+ end
30
+ end