Class: Torikago::Checker
- Inherits:
-
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
-
#allowed_callers(manifest_entry) ⇒ Array[untyped]
-
#bare_call_name(node) ⇒ String?
-
#call ⇒ Result
-
#class_body_methods(body) ⇒ { methods: Array[String], dynamic: bool }
-
#constant_name(node) ⇒ String?
-
#contains_call?(node, method_name) ⇒ Boolean
-
#errors ⇒ Array[String]
-
#expected_public_api_path(definition, class_name) ⇒ Pathname
-
#exported_methods(manifest_entry) ⇒ Array[String]
-
#exported_package_apis(manifest) ⇒ Hash[String, untyped]
-
#infer_box_name(class_name) ⇒ Symbol
-
#infer_caller_box_from_path(path) ⇒ Symbol?
-
#initialize(configuration:, source_roots:) ⇒ Checker
constructor
A new instance of Checker.
-
#load_manifest(definition) ⇒ Hash[String, untyped]
-
#public_api_entry_for(box_name, class_name) ⇒ Object
-
#public_api_root(definition) ⇒ Pathname
-
#public_instance_methods(path, class_name) ⇒ [Array[String], bool]
-
#qualified_constant_name(node, namespace) ⇒ String?
-
#scan_gateway_calls(path, errors) ⇒ [Integer, Integer]
-
#source_files ⇒ Array[String]
-
#underscore(name) ⇒ String
-
#validate_manifest_entries(definition, errors) ⇒ Object
Constructor Details
#initialize(configuration:, source_roots:) ⇒ Checker
Returns a new instance of Checker.
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
Returns the value of attribute configuration.
168
169
170
|
# File 'lib/torikago/checker.rb', line 168
def configuration
@configuration
end
|
#manifests ⇒ Hash[Symbol, Hash[String, untyped]]
Returns the value of attribute manifests.
168
169
170
|
# File 'lib/torikago/checker.rb', line 168
def manifests
@manifests
end
|
#source_roots ⇒ Array[Pathname]
Returns the value of attribute source_roots.
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]
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?
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
|
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 }
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?
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
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
|
#errors ⇒ Array[String]
302
|
# File 'sig/torikago.rbs', line 302
def errors: () -> Array[String]
|
#expected_public_api_path(definition, class_name) ⇒ 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]
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]
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
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?
265
266
267
268
269
|
# File 'lib/torikago/checker.rb', line 265
def infer_caller_box_from_path(path)
path.match(%r{/modules/(?<box>[a-z0-9_]+)/})&.named_captures&.fetch("box", nil)&.to_sym
end
|
#load_manifest(definition) ⇒ 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
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
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?
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]
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?
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]
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)
= GatewayCallExtractor.new(File.read(path)).call
.calls.each do |gateway_call|
class_name = gateway_call.class_name
method_name = gateway_call.method_name
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
[.calls.size, .dynamic_call_count]
end
|
#source_files ⇒ 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
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
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
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
|