Class: Torikago::Checker

Inherits:
Object
  • Object
show all
Defined in:
lib/torikago/checker.rb,
sig/torikago.rbs

Overview

Lightweight static checks for explicit Gateway calls and package API manifests. This is intentionally conservative; runtime enforcement still lives in Gateway.

Defined Under Namespace

Classes: GatewayCall, GatewayCallExtractor, Result

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(configuration:, source_roots:) ⇒ Checker

Returns a new instance of Checker.

Parameters:



133
134
135
136
137
# File 'lib/torikago/checker.rb', line 133

def initialize(configuration:, source_roots:)
  @configuration = configuration
  @source_roots = Array(source_roots).map { |root| Pathname(root) }
  @manifests = Hash.new
end

Instance Attribute Details

#configurationConfiguration (readonly)

Returns the value of attribute configuration.

Returns:



168
169
170
# File 'lib/torikago/checker.rb', line 168

def configuration
  @configuration
end

#manifestsHash[Symbol, Hash[String, untyped]] (readonly)

Returns the value of attribute manifests.

Returns:

  • (Hash[Symbol, Hash[String, untyped]])


168
169
170
# File 'lib/torikago/checker.rb', line 168

def manifests
  @manifests
end

#source_rootsArray[Pathname] (readonly)

Returns the value of attribute source_roots.

Returns:

  • (Array[Pathname])


168
169
170
# File 'lib/torikago/checker.rb', line 168

def source_roots
  @source_roots
end

Instance Method Details

#allowed_callers(manifest_entry) ⇒ Array[untyped]

Parameters:

  • manifest_entry (Object)

Returns:

  • (Array[untyped])


275
276
277
278
279
280
281
282
# File 'lib/torikago/checker.rb', line 275

def allowed_callers(manifest_entry)
  return Array.new unless manifest_entry.is_a?(Hash)

  allowed = manifest_entry["allowed_callers"]
  return allowed if allowed.is_a?(Array)

  Array.new
end

#bare_call_name(node) ⇒ String?

Parameters:

  • node (Object)

Returns:

  • (String, nil)


358
359
360
361
362
363
# File 'lib/torikago/checker.rb', line 358

def bare_call_name(node)
  return unless node&.first == :vcall

  token = node[1]
  token[1] if token&.first == :@ident
end

#callResult

Returns:



139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
# File 'lib/torikago/checker.rb', line 139

def call
  errors = Array.new
  gateway_call_count = 0
  dynamic_gateway_call_count = 0
  scanned_files = source_files

  scanned_files.each do |path|
    static_count, dynamic_count = scan_gateway_calls(path, errors)
    gateway_call_count += static_count
    dynamic_gateway_call_count += dynamic_count
  end

  manifest_count = 0
  configuration.each_definition do |definition|
    manifest_count += 1
    validate_manifest_entries(definition, errors)
  end

  Result.new(
    errors: errors,
    scanned_file_count: scanned_files.size,
    gateway_call_count: gateway_call_count,
    dynamic_gateway_call_count: dynamic_gateway_call_count,
    manifest_count: manifest_count
  )
end

#class_body_methods(body) ⇒ { methods: Array[String], dynamic: bool }

Parameters:

  • body (Object)

Returns:

  • ({ methods: Array[String], dynamic: bool })


328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
# File 'lib/torikago/checker.rb', line 328

def class_body_methods(body)
  visibility = :public
  methods = Array.new
  dynamic = false
  statements = body&.first == :bodystmt ? Array(body[1]) : Array.new

  statements.each do |statement|
    visibility_name = bare_call_name(statement)
    case visibility_name
    when "public"
      visibility = :public
      next
    when "private"
      visibility = :private
      next
    when "protected"
      visibility = :protected
      next
    end

    if statement&.first == :def
      methods << statement[1][1].to_s if visibility == :public
    elsif contains_call?(statement, "define_method")
      dynamic = true
    end
  end

  { methods: methods, dynamic: dynamic }
end

#constant_name(node) ⇒ String?

Parameters:

  • node (Object)

Returns:

  • (String, nil)


379
380
381
382
383
384
385
386
387
388
# File 'lib/torikago/checker.rb', line 379

def constant_name(node)
  return unless node.is_a?(Array)

  case node.first
  when :var_ref, :const_ref, :top_const_ref
    node.dig(1, 1)
  when :const_path_ref
    [constant_name(node[1]), node.dig(2, 1)].compact.join("::")
  end
end

#contains_call?(node, method_name) ⇒ Boolean

Parameters:

  • node (Object)
  • method_name (String)

Returns:

  • (Boolean)


365
366
367
368
369
370
# File 'lib/torikago/checker.rb', line 365

def contains_call?(node, method_name)
  return false unless node.is_a?(Array)
  return true if %i[vcall fcall].include?(node.first) && node.dig(1, 1) == method_name

  node.any? { |child| child.is_a?(Array) && contains_call?(child, method_name) }
end

#errorsArray[String]

Returns:

  • (Array[String])


302
# File 'sig/torikago.rbs', line 302

def errors: () -> Array[String]

#expected_public_api_path(definition, class_name) ⇒ Pathname

Parameters:

Returns:

  • (Pathname)


256
257
258
259
# File 'lib/torikago/checker.rb', line 256

def expected_public_api_path(definition, class_name)
  relative_path = class_name.split("::").map { |segment| underscore(segment) }.join("/")
  public_api_root(definition).join("#{relative_path}.rb")
end

#exported_methods(manifest_entry) ⇒ Array[String]

Parameters:

  • manifest_entry (Object)

Returns:

  • (Array[String])


284
285
286
287
288
289
290
291
# File 'lib/torikago/checker.rb', line 284

def exported_methods(manifest_entry)
  return Array.new unless manifest_entry.is_a?(Hash)

  methods = manifest_entry["methods"]
  return methods.map(&:to_s) if methods.is_a?(Array)

  Array.new
end

#exported_package_apis(manifest) ⇒ Hash[String, untyped]

Parameters:

  • manifest (Hash[String, untyped])

Returns:

  • (Hash[String, untyped])


271
272
273
# File 'lib/torikago/checker.rb', line 271

def exported_package_apis(manifest)
  manifest.fetch("exports") { manifest.fetch("public_api", Hash.new) }
end

#infer_box_name(class_name) ⇒ Symbol

Parameters:

  • class_name (String)

Returns:

  • (Symbol)


261
262
263
# File 'lib/torikago/checker.rb', line 261

def infer_box_name(class_name)
  class_name.split("::").first.downcase.to_sym
end

#infer_caller_box_from_path(path) ⇒ Symbol?

Parameters:

  • path (String)

Returns:

  • (Symbol, nil)


265
266
267
268
269
# File 'lib/torikago/checker.rb', line 265

def infer_caller_box_from_path(path)
  # This path heuristic matches the Rails modular-monolith layout that
  # torikago is designed around: modules/<box-name>/...
  path.match(%r{/modules/(?<box>[a-z0-9_]+)/})&.named_captures&.fetch("box", nil)&.to_sym
end

#load_manifest(definition) ⇒ Hash[String, untyped]

Parameters:

Returns:

  • (Hash[String, untyped])


244
245
246
247
248
249
250
251
252
253
254
# File 'lib/torikago/checker.rb', line 244

def load_manifest(definition)
  manifests.fetch(definition.name) do
    manifest_path = definition.root.join("package_api.yml")
    manifests[definition.name] =
      if manifest_path.exist?
        YAML.safe_load(manifest_path.read, permitted_classes: Array.new, aliases: false) || Hash.new
      else
        Hash.new
      end
  end
end

#public_api_entry_for(box_name, class_name) ⇒ Object

Parameters:

  • box_name (Symbol)
  • class_name (String)

Returns:

  • (Object)


238
239
240
241
242
# File 'lib/torikago/checker.rb', line 238

def public_api_entry_for(box_name, class_name)
  exported_package_apis(load_manifest(configuration.fetch(box_name)))[class_name]
rescue KeyError
  nil
end

#public_api_root(definition) ⇒ Pathname

Parameters:

Returns:

  • (Pathname)


396
397
398
399
400
401
402
403
404
405
406
# File 'lib/torikago/checker.rb', line 396

def public_api_root(definition)
  return definition.root.join("app/package_api") if definition.entrypoint.nil?

  # entrypoint may point at either a directory or a single boot file. For a
  # file entrypoint, package API implementations live next to that file.
  candidate = definition.root.join(definition.entrypoint)
  return candidate if candidate.directory?
  return candidate unless candidate.extname == ".rb"

  candidate.dirname
end

#public_instance_methods(path, class_name) ⇒ [Array[String], bool]

Parameters:

  • path (Pathname)
  • class_name (String)

Returns:

  • ([Array[String], bool])


293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
# File 'lib/torikago/checker.rb', line 293

def public_instance_methods(path, class_name)
  sexp = Ripper.sexp(File.read(path))
  methods = Array.new
  dynamic_definition = false

  visit_definitions = lambda do |node, namespace|
    next unless node.is_a?(Array)

    case node.first
    when :program
      Array(node[1]).each { |child| visit_definitions.call(child, namespace) }
    when :module
      name = qualified_constant_name(node[1], namespace)
      visit_definitions.call(node[2], name)
    when :class
      name = qualified_constant_name(node[1], namespace)
      if name == class_name
        class_body_methods(node[3]).tap do |result|
          methods.concat(result.fetch(:methods))
          dynamic_definition ||= result.fetch(:dynamic)
        end
      else
        visit_definitions.call(node[3], name)
      end
    when :bodystmt
      Array(node[1]).each { |child| visit_definitions.call(child, namespace) }
    else
      node.each { |child| visit_definitions.call(child, namespace) if child.is_a?(Array) }
    end
  end

  visit_definitions.call(sexp, nil)
  [methods.uniq, dynamic_definition]
end

#qualified_constant_name(node, namespace) ⇒ String?

Parameters:

  • node (Object)
  • namespace (String, nil)

Returns:

  • (String, nil)


372
373
374
375
376
377
# File 'lib/torikago/checker.rb', line 372

def qualified_constant_name(node, namespace)
  name = constant_name(node)
  return name if name&.include?("::") || namespace.nil?

  "#{namespace}::#{name}"
end

#scan_gateway_calls(path, errors) ⇒ [Integer, Integer]

Parameters:

  • path (String)
  • errors (Array[String])

Returns:

  • ([Integer, Integer])


174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'lib/torikago/checker.rb', line 174

def scan_gateway_calls(path, errors)
  extraction = GatewayCallExtractor.new(File.read(path)).call
  extraction.calls.each do |gateway_call|
    class_name = gateway_call.class_name
    method_name = gateway_call.method_name

    # Public API names are expected to start with their owning module
    # namespace, e.g. Foo::ListProductsQuery targets the :foo box.
    target_box = infer_box_name(class_name)
    manifest_entry = public_api_entry_for(target_box, class_name)
    caller_box = infer_caller_box_from_path(path)

    if manifest_entry.nil?
      errors << "#{path}: #{class_name}##{method_name} is not declared in #{target_box}/package_api.yml exports"
      next
    end

    methods = exported_methods(manifest_entry)
    if !methods.empty? && !methods.include?(method_name.to_s)
      errors << "#{path}: #{class_name}##{method_name} is not exported by #{target_box}/package_api.yml"
      next
    end

    next if caller_box.nil?
    next if caller_box == target_box

    allowed_callers = allowed_callers(manifest_entry).map { |caller| caller.to_s }
    next if allowed_callers.include?(caller_box.to_s)

    errors << "#{path}: #{caller_box} is not allowed to call #{class_name}##{method_name}"
  end

  [extraction.calls.size, extraction.dynamic_call_count]
end

#source_filesArray[String]

Returns:

  • (Array[String])


170
171
172
# File 'lib/torikago/checker.rb', line 170

def source_files
  source_roots.flat_map { |root| Dir[root.join("**/*.rb").to_s] }.sort.uniq
end

#underscore(name) ⇒ String

Parameters:

  • name (String)

Returns:

  • (String)


390
391
392
393
394
# File 'lib/torikago/checker.rb', line 390

def underscore(name)
  word = name.gsub(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
  word.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
  word.downcase
end

#validate_manifest_entries(definition, errors) ⇒ Object

Parameters:

Returns:

  • (Object)


209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
# File 'lib/torikago/checker.rb', line 209

def validate_manifest_entries(definition, errors)
  manifest_path = definition.root.join("package_api.yml")
  exported_package_apis(load_manifest(definition)).each do |class_name, manifest_entry|
    methods = exported_methods(manifest_entry)
    if methods.empty?
      errors << "#{manifest_path}: #{class_name} must declare a non-empty methods array"
    end

    # The manifest is the contract, but the checker also catches stale
    # entries whose implementation file has been deleted or moved.
    expected_path = expected_public_api_path(definition, class_name)
    unless expected_path.exist?
      errors << "#{manifest_path}: #{class_name} does not have a matching file at #{expected_path}"
      next
    end

    next if methods.empty?

    implemented_methods, dynamic_definition = public_instance_methods(expected_path, class_name)
    next if dynamic_definition

    methods.each do |method_name|
      next if implemented_methods.include?(method_name)

      errors << "#{expected_path}: #{class_name}##{method_name} is exported but no public instance method definition was found"
    end
  end
end