rigortype 0.1.19 → 0.2.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.
Files changed (166) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -23
  3. data/lib/rigor/analysis/check_rules/rule_walk.rb +3 -21
  4. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +24 -15
  5. data/lib/rigor/analysis/check_rules.rb +492 -71
  6. data/lib/rigor/analysis/dependency_source_inference/index.rb +4 -7
  7. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -18
  8. data/lib/rigor/analysis/dependency_source_inference.rb +3 -12
  9. data/lib/rigor/analysis/fact_store.rb +5 -4
  10. data/lib/rigor/analysis/rule_catalog.rb +153 -6
  11. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +17 -17
  12. data/lib/rigor/analysis/runner/project_pre_passes.rb +9 -8
  13. data/lib/rigor/analysis/runner.rb +17 -6
  14. data/lib/rigor/analysis/self_call_resolution_recorder.rb +3 -4
  15. data/lib/rigor/analysis/worker_session.rb +10 -14
  16. data/lib/rigor/builtins/predefined_constant_refinements.rb +151 -0
  17. data/lib/rigor/cache/store.rb +5 -3
  18. data/lib/rigor/cli/annotate_command.rb +28 -7
  19. data/lib/rigor/cli/baseline_command.rb +4 -3
  20. data/lib/rigor/cli/check_command.rb +115 -16
  21. data/lib/rigor/cli/coverage_command.rb +148 -16
  22. data/lib/rigor/cli/coverage_scan.rb +57 -0
  23. data/lib/rigor/cli/explain_command.rb +2 -0
  24. data/lib/rigor/cli/lsp_command.rb +3 -7
  25. data/lib/rigor/cli/mutation_protection_renderer.rb +63 -0
  26. data/lib/rigor/cli/mutation_protection_report.rb +73 -0
  27. data/lib/rigor/cli/options.rb +9 -0
  28. data/lib/rigor/cli/plugins_command.rb +2 -1
  29. data/lib/rigor/cli/protection_renderer.rb +63 -0
  30. data/lib/rigor/cli/protection_report.rb +68 -0
  31. data/lib/rigor/cli/sig_gen_command.rb +2 -1
  32. data/lib/rigor/cli/trace_command.rb +2 -1
  33. data/lib/rigor/cli/triage_command.rb +2 -1
  34. data/lib/rigor/cli/type_of_command.rb +1 -1
  35. data/lib/rigor/cli/type_scan_command.rb +2 -1
  36. data/lib/rigor/cli.rb +3 -2
  37. data/lib/rigor/configuration/dependencies.rb +2 -4
  38. data/lib/rigor/configuration.rb +45 -7
  39. data/lib/rigor/environment/bundle_sig_discovery.rb +61 -13
  40. data/lib/rigor/environment/class_registry.rb +4 -3
  41. data/lib/rigor/environment/constant_type_cache_holder.rb +43 -0
  42. data/lib/rigor/environment/lockfile_resolver.rb +1 -1
  43. data/lib/rigor/environment/rbs_collection_discovery.rb +1 -2
  44. data/lib/rigor/environment/rbs_coverage_report.rb +2 -1
  45. data/lib/rigor/environment/rbs_loader.rb +49 -5
  46. data/lib/rigor/environment.rb +17 -7
  47. data/lib/rigor/flow_contribution/fact.rb +1 -1
  48. data/lib/rigor/flow_contribution.rb +3 -5
  49. data/lib/rigor/inference/acceptance.rb +17 -9
  50. data/lib/rigor/inference/block_parameter_binder.rb +2 -3
  51. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -2
  52. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -2
  53. data/lib/rigor/inference/builtins/method_catalog.rb +19 -0
  54. data/lib/rigor/inference/builtins/string_catalog.rb +9 -1
  55. data/lib/rigor/inference/expression_typer.rb +20 -28
  56. data/lib/rigor/inference/hkt_body.rb +8 -11
  57. data/lib/rigor/inference/hkt_body_parser.rb +10 -12
  58. data/lib/rigor/inference/hkt_registry.rb +10 -11
  59. data/lib/rigor/inference/method_dispatcher/call_context.rb +1 -4
  60. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +156 -21
  61. data/lib/rigor/inference/method_dispatcher/data_folding.rb +9 -73
  62. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -7
  63. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +10 -16
  64. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +25 -13
  65. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +93 -0
  66. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -3
  67. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +24 -22
  68. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +90 -15
  69. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +303 -0
  70. data/lib/rigor/inference/method_dispatcher.rb +40 -48
  71. data/lib/rigor/inference/mutation_widening.rb +5 -11
  72. data/lib/rigor/inference/narrowing.rb +14 -16
  73. data/lib/rigor/inference/parameter_inference_collector.rb +367 -0
  74. data/lib/rigor/inference/project_patched_methods.rb +4 -7
  75. data/lib/rigor/inference/project_patched_scanner.rb +2 -13
  76. data/lib/rigor/inference/protection_scanner.rb +86 -0
  77. data/lib/rigor/inference/scope_indexer.rb +129 -55
  78. data/lib/rigor/inference/statement_evaluator.rb +244 -114
  79. data/lib/rigor/inference/struct_fold_safety.rb +181 -0
  80. data/lib/rigor/inference/synthetic_method.rb +7 -7
  81. data/lib/rigor/language_server/completion_provider.rb +6 -12
  82. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -4
  83. data/lib/rigor/language_server/document_symbol_provider.rb +3 -3
  84. data/lib/rigor/language_server/hover_provider.rb +2 -3
  85. data/lib/rigor/language_server/hover_renderer.rb +2 -11
  86. data/lib/rigor/language_server/server.rb +9 -17
  87. data/lib/rigor/language_server.rb +4 -5
  88. data/lib/rigor/plugin/base.rb +10 -8
  89. data/lib/rigor/plugin/macro/block_as_method.rb +3 -4
  90. data/lib/rigor/plugin/macro/heredoc_template.rb +4 -7
  91. data/lib/rigor/plugin/macro/trait_registry.rb +3 -6
  92. data/lib/rigor/plugin/macro.rb +4 -5
  93. data/lib/rigor/plugin/manifest.rb +45 -66
  94. data/lib/rigor/plugin/registry.rb +6 -7
  95. data/lib/rigor/plugin/type_node_resolver.rb +6 -8
  96. data/lib/rigor/protection/mutation_scanner.rb +120 -0
  97. data/lib/rigor/protection/mutator.rb +246 -0
  98. data/lib/rigor/rbs_extended.rb +24 -36
  99. data/lib/rigor/reflection.rb +4 -7
  100. data/lib/rigor/scope/discovery_index.rb +14 -2
  101. data/lib/rigor/scope.rb +54 -11
  102. data/lib/rigor/sig_gen/observed_call.rb +3 -3
  103. data/lib/rigor/sig_gen/writer.rb +40 -2
  104. data/lib/rigor/source/constant_path.rb +62 -0
  105. data/lib/rigor/source.rb +1 -0
  106. data/lib/rigor/type/bound_method.rb +2 -11
  107. data/lib/rigor/type/combinator.rb +16 -3
  108. data/lib/rigor/type/constant.rb +2 -11
  109. data/lib/rigor/type/data_class.rb +2 -11
  110. data/lib/rigor/type/data_instance.rb +2 -11
  111. data/lib/rigor/type/hash_shape.rb +2 -11
  112. data/lib/rigor/type/integer_range.rb +2 -11
  113. data/lib/rigor/type/intersection.rb +2 -11
  114. data/lib/rigor/type/nominal.rb +2 -11
  115. data/lib/rigor/type/plain_lattice.rb +37 -0
  116. data/lib/rigor/type/refined.rb +72 -13
  117. data/lib/rigor/type/singleton.rb +2 -11
  118. data/lib/rigor/type/struct_class.rb +75 -0
  119. data/lib/rigor/type/struct_instance.rb +93 -0
  120. data/lib/rigor/type/tuple.rb +5 -15
  121. data/lib/rigor/type.rb +2 -0
  122. data/lib/rigor/version.rb +1 -1
  123. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  124. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +3 -3
  125. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +3 -3
  126. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +5 -13
  127. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +11 -17
  128. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +7 -10
  129. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +3 -2
  130. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  131. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +6 -8
  132. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +5 -7
  133. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +1 -2
  134. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +9 -11
  135. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +8 -9
  136. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +13 -12
  137. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +3 -4
  138. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +8 -8
  139. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +9 -11
  140. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +7 -8
  141. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +7 -9
  142. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +12 -13
  143. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +15 -23
  144. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +3 -3
  145. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +3 -3
  146. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +2 -4
  147. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +27 -11
  148. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +1 -1
  149. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -6
  150. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +12 -18
  151. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +5 -5
  152. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -4
  153. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +1 -1
  154. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  155. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +19 -14
  156. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +0 -1
  157. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +5 -4
  158. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +2 -3
  159. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +7 -11
  160. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +4 -5
  161. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +6 -9
  162. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +5 -15
  163. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +28 -41
  164. data/sig/rigor/scope.rbs +9 -1
  165. data/sig/rigor/type.rbs +36 -1
  166. metadata +19 -1
@@ -32,9 +32,9 @@ module Rigor
32
32
  # its `#prepare(services)` hook. `consumes:` lists the
33
33
  # `(plugin_id, name)` pairs this plugin reads from
34
34
  # `services.fact_store`. The loader uses both for
35
- # topological sort + missing-producer detection (slice 5);
36
- # slice 4 carries the declarations on the manifest but the
37
- # loader does not yet enforce them.
35
+ # topological sort + missing-producer detection; slice 4
36
+ # added the declarations; slice 5 (`Loader#topo_sort_plugins`)
37
+ # enforces ordering and missing-producer validation.
38
38
  class Consumption < Data.define(:plugin_id, :name, :optional)
39
39
  def initialize(plugin_id:, name:, optional: false)
40
40
  super(plugin_id: plugin_id.to_s, name: name.to_sym, optional: optional ? true : false)
@@ -277,10 +277,18 @@ module Rigor
277
277
  end
278
278
  end
279
279
 
280
- def validate_produces!(produces)
281
- return if produces.is_a?(Array) && produces.all? { |p| p.is_a?(Symbol) || p.is_a?(String) }
280
+ # Shared shape check for the Array-of-X manifest fields. Every entry
281
+ # must satisfy the block; otherwise raise the uniform "must be an
282
+ # Array of <label>" message. Centralises the message format the
283
+ # field validators below share so it cannot drift between them.
284
+ def validate_array_of!(field, value, label, &)
285
+ return if value.is_a?(Array) && value.all?(&)
282
286
 
283
- raise ArgumentError, "plugin manifest produces must be an Array of Symbol/String, got #{produces.inspect}"
287
+ raise ArgumentError, "plugin manifest #{field} must be an Array of #{label}, got #{value.inspect}"
288
+ end
289
+
290
+ def validate_produces!(produces)
291
+ validate_array_of!("produces", produces, "Symbol/String") { |p| p.is_a?(Symbol) || p.is_a?(String) }
284
292
  end
285
293
 
286
294
  # ADR-10 5a — `owns_receivers:` declares the class names
@@ -291,11 +299,7 @@ module Rigor
291
299
  # so plugin contributions stay authoritative for those
292
300
  # types.
293
301
  def validate_owns_receivers!(owns_receivers)
294
- return if owns_receivers.is_a?(Array) && owns_receivers.all? { |c| c.is_a?(String) && !c.empty? }
295
-
296
- raise ArgumentError,
297
- "plugin manifest owns_receivers must be an Array of non-empty String, " \
298
- "got #{owns_receivers.inspect}"
302
+ validate_array_of!("owns_receivers", owns_receivers, "non-empty String") { |c| c.is_a?(String) && !c.empty? }
299
303
  end
300
304
 
301
305
  # ADR-26 — `open_receivers:` declares the class names this
@@ -309,11 +313,7 @@ module Rigor
309
313
  # `owns_receivers:` (which routes dispatch); this one only
310
314
  # suppresses the diagnostic.
311
315
  def validate_open_receivers!(open_receivers)
312
- return if open_receivers.is_a?(Array) && open_receivers.all? { |c| c.is_a?(String) && !c.empty? }
313
-
314
- raise ArgumentError,
315
- "plugin manifest open_receivers must be an Array of non-empty String, " \
316
- "got #{open_receivers.inspect}"
316
+ validate_array_of!("open_receivers", open_receivers, "non-empty String") { |c| c.is_a?(String) && !c.empty? }
317
317
  end
318
318
 
319
319
  # ADR-13 slice 2 — `type_node_resolvers:` declares the
@@ -325,11 +325,9 @@ module Rigor
325
325
  # integration that actually drives the chain lands in
326
326
  # slice 3.
327
327
  def validate_type_node_resolvers!(resolvers)
328
- return if resolvers.is_a?(Array) && resolvers.all?(TypeNodeResolver)
329
-
330
- raise ArgumentError,
331
- "plugin manifest type_node_resolvers must be an Array of " \
332
- "Rigor::Plugin::TypeNodeResolver instances, got #{resolvers.inspect}"
328
+ validate_array_of!("type_node_resolvers", resolvers, "Rigor::Plugin::TypeNodeResolver instances") do |r|
329
+ r.is_a?(TypeNodeResolver)
330
+ end
333
331
  end
334
332
 
335
333
  # ADR-16 slice 1a — `block_as_methods:` declares the Tier A
@@ -338,11 +336,9 @@ module Rigor
338
336
  # actually narrows `Scope#self_type` for matching blocks
339
337
  # arrives in a subsequent slice.
340
338
  def validate_block_as_methods!(entries)
341
- return if entries.is_a?(Array) && entries.all?(Macro::BlockAsMethod)
342
-
343
- raise ArgumentError,
344
- "plugin manifest block_as_methods must be an Array of " \
345
- "Rigor::Plugin::Macro::BlockAsMethod instances, got #{entries.inspect}"
339
+ validate_array_of!("block_as_methods", entries, "Rigor::Plugin::Macro::BlockAsMethod instances") do |e|
340
+ e.is_a?(Macro::BlockAsMethod)
341
+ end
346
342
  end
347
343
 
348
344
  # ADR-16 slice 2a — `heredoc_templates:` declares the Tier C
@@ -351,11 +347,9 @@ module Rigor
351
347
  # manifest; the pre-pass + `SyntheticMethodIndex` that actually
352
348
  # emit synthetic methods arrive in slice 2b.
353
349
  def validate_heredoc_templates!(entries)
354
- return if entries.is_a?(Array) && entries.all?(Macro::HeredocTemplate)
355
-
356
- raise ArgumentError,
357
- "plugin manifest heredoc_templates must be an Array of " \
358
- "Rigor::Plugin::Macro::HeredocTemplate instances, got #{entries.inspect}"
350
+ validate_array_of!("heredoc_templates", entries, "Rigor::Plugin::Macro::HeredocTemplate instances") do |e|
351
+ e.is_a?(Macro::HeredocTemplate)
352
+ end
359
353
  end
360
354
 
361
355
  # ADR-36 — `nested_class_templates:` declares the
@@ -365,11 +359,10 @@ module Rigor
365
359
  # subclasses + their `#inner` reader through the existing
366
360
  # `SyntheticMethodIndex` primitive.
367
361
  def validate_nested_class_templates!(entries)
368
- return if entries.is_a?(Array) && entries.all?(Macro::NestedClassTemplate)
369
-
370
- raise ArgumentError,
371
- "plugin manifest nested_class_templates must be an Array of " \
372
- "Rigor::Plugin::Macro::NestedClassTemplate instances, got #{entries.inspect}"
362
+ validate_array_of!("nested_class_templates", entries,
363
+ "Rigor::Plugin::Macro::NestedClassTemplate instances") do |e|
364
+ e.is_a?(Macro::NestedClassTemplate)
365
+ end
373
366
  end
374
367
 
375
368
  # ADR-16 slice 3a — `trait_registries:` declares the Tier B
@@ -379,11 +372,9 @@ module Rigor
379
372
  # `SyntheticMethodIndex` (slice 2b primitive) arrives in
380
373
  # slice 3b.
381
374
  def validate_trait_registries!(entries)
382
- return if entries.is_a?(Array) && entries.all?(Macro::TraitRegistry)
383
-
384
- raise ArgumentError,
385
- "plugin manifest trait_registries must be an Array of " \
386
- "Rigor::Plugin::Macro::TraitRegistry instances, got #{entries.inspect}"
375
+ validate_array_of!("trait_registries", entries, "Rigor::Plugin::Macro::TraitRegistry instances") do |e|
376
+ e.is_a?(Macro::TraitRegistry)
377
+ end
387
378
  end
388
379
 
389
380
  # ADR-20 slice 6 — `hkt_registrations:` declares the
@@ -398,11 +389,9 @@ module Rigor
398
389
  # user `.rbs` overlays merge on top of plugin entries
399
390
  # last-write-wins.
400
391
  def validate_hkt_registrations!(entries)
401
- return if entries.is_a?(Array) && entries.all?(Inference::HktRegistry::Registration)
402
-
403
- raise ArgumentError,
404
- "plugin manifest hkt_registrations must be an Array of " \
405
- "Rigor::Inference::HktRegistry::Registration instances, got #{entries.inspect}"
392
+ validate_array_of!("hkt_registrations", entries, "Rigor::Inference::HktRegistry::Registration instances") do |e|
393
+ e.is_a?(Inference::HktRegistry::Registration)
394
+ end
406
395
  end
407
396
 
408
397
  # ADR-20 slice 6 — `hkt_definitions:` declares the
@@ -415,11 +404,9 @@ module Rigor
415
404
  # via {Rigor::Inference::HktBody}'s node-constructor API
416
405
  # without parsing a string.
417
406
  def validate_hkt_definitions!(entries)
418
- return if entries.is_a?(Array) && entries.all?(Inference::HktRegistry::Definition)
419
-
420
- raise ArgumentError,
421
- "plugin manifest hkt_definitions must be an Array of " \
422
- "Rigor::Inference::HktRegistry::Definition instances, got #{entries.inspect}"
407
+ validate_array_of!("hkt_definitions", entries, "Rigor::Inference::HktRegistry::Definition instances") do |e|
408
+ e.is_a?(Inference::HktRegistry::Definition)
409
+ end
423
410
  end
424
411
 
425
412
  # ADR-25 — `signature_paths:` declares the RBS signature
@@ -429,11 +416,7 @@ module Rigor
429
416
  # loader validates each exists and `Environment.for_project`
430
417
  # merges the resolved set into the RBS environment.
431
418
  def validate_signature_paths!(paths)
432
- return if paths.is_a?(Array) && paths.all? { |p| p.is_a?(String) && !p.empty? }
433
-
434
- raise ArgumentError,
435
- "plugin manifest signature_paths must be an Array of non-empty String, " \
436
- "got #{paths.inspect}"
419
+ validate_array_of!("signature_paths", paths, "non-empty String") { |p| p.is_a?(String) && !p.empty? }
437
420
  end
438
421
 
439
422
  # ADR-28 — `protocol_contracts:` declares the path-scoped
@@ -449,11 +432,9 @@ module Rigor
449
432
  # MAY override `Plugin::Base#protocol_contracts` to fold in
450
433
  # per-project config (e.g. a custom convention path).
451
434
  def validate_protocol_contracts!(entries)
452
- return if entries.is_a?(Array) && entries.all?(ProtocolContract)
453
-
454
- raise ArgumentError,
455
- "plugin manifest protocol_contracts must be an Array of " \
456
- "Rigor::Plugin::ProtocolContract instances, got #{entries.inspect}"
435
+ validate_array_of!("protocol_contracts", entries, "Rigor::Plugin::ProtocolContract instances") do |e|
436
+ e.is_a?(ProtocolContract)
437
+ end
457
438
  end
458
439
 
459
440
  # ADR-38 — `additional_initializers:` declares the
@@ -465,11 +446,9 @@ module Rigor
465
446
  # loaded plugins; `Inference::ScopeIndexer` consults the set at
466
447
  # its single gate.
467
448
  def validate_additional_initializers!(entries)
468
- return if entries.is_a?(Array) && entries.all?(AdditionalInitializer)
469
-
470
- raise ArgumentError,
471
- "plugin manifest additional_initializers must be an Array of " \
472
- "Rigor::Plugin::AdditionalInitializer instances, got #{entries.inspect}"
449
+ validate_array_of!("additional_initializers", entries, "Rigor::Plugin::AdditionalInitializer instances") do |e|
450
+ e.is_a?(AdditionalInitializer)
451
+ end
473
452
  end
474
453
 
475
454
  # ADR-32 WD4 — `source_rbs_synthesizer:` declares a callable
@@ -317,13 +317,12 @@ module Rigor
317
317
  !load_errors.empty?
318
318
  end
319
319
 
320
- # ADR-13 slice 2 — flat ordered list of every loaded
321
- # plugin's manifest-declared {TypeNodeResolver} instances,
322
- # in plugin registration order. Slice 3 wires this into
323
- # the parser's resolver chain; until then the method is a
324
- # read-side aggregator only. The first non-nil
325
- # `#resolve(node, scope)` return wins per ADR-13 WD3 / WD5
326
- # — registration order is the user's lever.
320
+ # ADR-13 — flat ordered list of every loaded plugin's
321
+ # manifest-declared {TypeNodeResolver} instances, in plugin
322
+ # registration order. `Environment#build_name_scope` builds a
323
+ # `TypeNode::ResolverChain` from this list (environment.rb).
324
+ # The first non-nil `#resolve(node, scope)` return wins per
325
+ # ADR-13 WD3 / WD5 — registration order is the user's lever.
327
326
  def type_node_resolvers
328
327
  plugins.flat_map { |plugin| plugin.manifest.type_node_resolvers }
329
328
  end
@@ -24,14 +24,12 @@ module Rigor
24
24
  # )
25
25
  # end
26
26
  #
27
- # Slice 2 of the ADR-13 envelope (this file) ships the base
28
- # class + manifest hook + registry aggregation. The parser-
29
- # side wiring that actually consults the resolver chain
30
- # arrives in slice 3, when {Rigor::TypeNode::NameScope} and
31
- # the dispatcher between {Rigor::Builtins::ImportedRefinements::Parser}
32
- # and the chain land. Until then resolvers can be unit-tested
33
- # in isolation but never run for a real `%a{rigor:v1:...}`
34
- # payload.
27
+ # ADR-13 base class, manifest hook, and registry aggregation
28
+ # for plugin-contributed type-node resolvers. Resolvers declared
29
+ # via `manifest(type_node_resolvers:)` run for every real
30
+ # `%a{rigor:v1:...}` payload through `TypeNode::ResolverChain`
31
+ # (built by `Environment#build_name_scope` from
32
+ # `Plugin::Registry#type_node_resolvers`).
35
33
  #
36
34
  # Resolvers SHOULD be stateless and re-entrant; the registry
37
35
  # builds the chain once per `Analysis::Runner.run` and may
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../analysis/runner"
6
+ require_relative "mutator"
7
+
8
+ module Rigor
9
+ module Protection
10
+ # ADR-63 Tier 2 — the mutation *effectiveness* tier (the truth tier behind
11
+ # Tier 1's static {Inference::ProtectionScanner} proxy). For one file it
12
+ # answers the question Tier 1 only bounds: when a type-visible bug is
13
+ # introduced at a dispatch site, does Rigor actually catch it?
14
+ #
15
+ # Mechanism (the ADR-62 warm loop, narrowed to per-file measurement):
16
+ # generate the type-visible mutations ({Mutator}), keep only those whose
17
+ # receiver Rigor holds a concrete type for (the type-aware filter — the
18
+ # FP-safe meaning-maker; an unresolved receiver is kept), then for each:
19
+ # re-analyse the mutated SOURCE against a clean baseline and read whether a
20
+ # NEW diagnostic appears. A *killed* mutation is a caught breakage; a
21
+ # *survived* one is a breakage Rigor missed — an "add a type here" site.
22
+ #
23
+ # The expensive builds (RBS environment + the whole-project pre-pass scan)
24
+ # are paid ONCE by the caller and threaded in via `environment:` /
25
+ # `project_scan:`; each mutant reuses them through
26
+ # `Runner.new(prebuilt:)#run_source` (in-memory overlay, no disk write).
27
+ # Passing `prebuilt:` disables the run-result cache (whose key digests the
28
+ # *disk* file), so a mutant is never served a stale clean hit.
29
+ class MutationScanner
30
+ # A surviving mutation site — a breakage Rigor did not catch.
31
+ SurvivingSite = Data.define(:line, :receiver, :method_name, :operator)
32
+
33
+ FileResult = Data.define(:path, :killed, :survived, :sites) do
34
+ # Mutations actually analysed (parse-invalid mutants are not counted).
35
+ def total = killed + survived
36
+
37
+ # Effectiveness ratio; a file with no type-relevant mutation is
38
+ # vacuously fully effective (no breakage was available to miss).
39
+ def ratio = total.zero? ? 1.0 : killed.to_f / total
40
+ end
41
+
42
+ # @param configuration [Rigor::Configuration]
43
+ # @param environment [Rigor::Environment] pre-built once by the caller
44
+ # @param project_scan [Rigor::Analysis::ProjectScan] pre-built once
45
+ # @param limit [Integer, nil] optional per-file mutation cap (sampled with
46
+ # `seed`); nil analyses every type-relevant mutation (deterministic).
47
+ # @param seed [Integer] RNG seed for the optional sample.
48
+ def initialize(configuration:, environment:, project_scan:, limit: nil, seed: 1)
49
+ @configuration = configuration
50
+ @environment = environment
51
+ @project_scan = project_scan
52
+ @limit = limit
53
+ @seed = seed
54
+ end
55
+
56
+ # @param path [String] the file to measure (used as the in-memory bind path)
57
+ # @param source [String, nil] the file's source; read from disk when nil
58
+ # @return [FileResult]
59
+ def scan_file(path, source: nil)
60
+ source ||= File.read(path, encoding: Encoding::UTF_8)
61
+ mutator = Mutator.new(source)
62
+ kept, = mutator.filter_by_type(mutator.mutations, environment: @environment, path: path)
63
+ kept = sample(kept)
64
+ return FileResult.new(path: path, killed: 0, survived: 0, sites: []) if kept.empty?
65
+
66
+ baseline = signatures(analyse(source, path))
67
+ measure(source, path, kept, baseline)
68
+ end
69
+
70
+ private
71
+
72
+ def measure(source, path, mutations, baseline)
73
+ killed = 0
74
+ sites = []
75
+ mutations.each do |mut|
76
+ case classify(source, path, mut, baseline)
77
+ when :killed then killed += 1
78
+ when :survived then sites << surviving_site(mut)
79
+ # :invalid — a parse-broken mutant; not a measurement, skip it.
80
+ end
81
+ end
82
+ FileResult.new(path: path, killed: killed, survived: sites.size, sites: sites)
83
+ end
84
+
85
+ def classify(source, path, mut, baseline)
86
+ mutant_source = mut.apply(source)
87
+ return :invalid unless Prism.parse(mutant_source).success?
88
+
89
+ new_diagnostics = analyse(mutant_source, path).reject { |d| baseline.include?(sig(d)) }
90
+ new_diagnostics.empty? ? :survived : :killed
91
+ rescue StandardError
92
+ # A harness-level failure on one mutant must not abort the file.
93
+ :invalid
94
+ end
95
+
96
+ # cache_store: nil + prebuilt: scan ⇒ the run cache is bypassed and the
97
+ # mutant is always re-analysed against the in-memory bytes.
98
+ def analyse(source, path)
99
+ Rigor::Analysis::Runner.new(
100
+ configuration: @configuration, environment: @environment, prebuilt: @project_scan,
101
+ cache_store: nil, collect_stats: false
102
+ ).run_source(source: source, path: path).diagnostics
103
+ end
104
+
105
+ def sample(mutations)
106
+ return mutations unless @limit
107
+
108
+ mutations.sample(@limit, random: Random.new(@seed))
109
+ end
110
+
111
+ def signatures(diagnostics) = diagnostics.to_set { |d| sig(d) }
112
+ def sig(diagnostic) = [diagnostic.rule, diagnostic.path, diagnostic.line, diagnostic.column, diagnostic.message]
113
+
114
+ def surviving_site(mut)
115
+ SurvivingSite.new(line: mut.line, receiver: mut.anchor_type,
116
+ method_name: mut.method_name, operator: mut.operator.to_s)
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../scope"
6
+ require_relative "../inference/scope_indexer"
7
+
8
+ module Rigor
9
+ # ADR-63 Tier 2 — the productized subset of the dev-only mutation-testing
10
+ # harness (`tool/mutation/`, ADR-62). Only the *per-file effectiveness
11
+ # measurement* lives here — the type-visible {Mutator} and the warm-loop
12
+ # {MutationScanner} kill-rate measurement. The dev sweep / fuzz / survivor
13
+ # clustering stay off the frozen surface in `tool/mutation/mutate.rb`
14
+ # (which now reuses this {Mutator} so there is one source of truth).
15
+ module Protection
16
+ # One concrete edit: replace source bytes [start, stop) with `replacement`.
17
+ # `anchor` is the Prism node whose inferred type decides type-relevance —
18
+ # the call receiver whose contract the mutation could violate, or nil when
19
+ # there is no concrete receiver (implicit-self call, literal outside a call).
20
+ # `anchor_type` (the rendered receiver type) and `method_name` are filled in
21
+ # for reporting a surviving site; both may stay nil.
22
+ Mutation = Struct.new(
23
+ :operator, :expected_rule, :start, :stop, :replacement, :line, :label, :anchor,
24
+ :anchor_type, :method_name,
25
+ keyword_init: true
26
+ ) do
27
+ def apply(source)
28
+ prefix = source.byteslice(0, start)
29
+ suffix = source.byteslice(stop, source.bytesize - stop) || ""
30
+ "#{prefix}#{replacement}#{suffix}"
31
+ end
32
+ end
33
+
34
+ # Generates type-visible mutations of a Ruby source string by walking the
35
+ # Prism AST and recording byte-range splices (no unparser needed — Prism
36
+ # hands us exact offsets, and the analyzer re-parses the spliced source).
37
+ #
38
+ # A mutation is "type-visible" when it should trip a diagnostic rule *if*
39
+ # Rigor holds a type at the site: a call-argument literal dropped to `nil`
40
+ # or type-swapped (→ `call.argument-type-mismatch`), or a call site renamed
41
+ # to a missing method (→ `call.undefined-method`). Only call sites and
42
+ # bodies are mutated, never `def` signatures, so a reused project scan stays
43
+ # valid.
44
+ class Mutator
45
+ IDENT = /\A[a-z_][A-Za-z0-9_]*\z/
46
+ QUOTES = ['"', "'"].freeze
47
+ # Mutating an argument to a universal-equality method is always an
48
+ # equivalent mutant: Ruby's `==` / `<=>` family returns false / nil on a
49
+ # type mismatch rather than raising, so the engine exempts them
50
+ # (`UNIVERSAL_EQUALITY_METHODS`). Skip them to keep survivors meaningful.
51
+ UNIVERSAL_EQUALITY = %w[== != eql? equal? <=>].freeze
52
+
53
+ # Every operator the mutator knows. Each maps to the diagnostic rule
54
+ # family it is *engineered* to trip when the mutated value/call sits in a
55
+ # context where Rigor has type knowledge.
56
+ ALL_OPERATORS = %i[nil_inject type_swap undefined_method arity_extra].freeze
57
+
58
+ # The default set. `arity_extra` is excluded: most Ruby methods accept an
59
+ # extra argument (splat / optional), so appending one is usually an
60
+ # equivalent mutant — it contributes almost only noise. Re-enable it
61
+ # explicitly via `operators:` to measure arity teeth. (A signature-arity
62
+ # guard would make it default-worthy — a follow-up.)
63
+ OPERATORS = %i[nil_inject type_swap undefined_method].freeze
64
+
65
+ def initialize(source, operators: OPERATORS)
66
+ @source = source
67
+ @operators = operators
68
+ @parse = Prism.parse(source)
69
+ @anchor_for = {} # literal node -> its enclosing call's receiver node (or nil)
70
+ end
71
+
72
+ def mutations
73
+ return [] unless @parse.success?
74
+
75
+ index_literal_anchors(@parse.value)
76
+ out = []
77
+ walk(@parse.value) { |node| collect(node, out) }
78
+ out
79
+ end
80
+
81
+ # Phase 1.5 — keep only mutations whose anchor types to a concrete,
82
+ # non-Dynamic type, i.e. a site where Rigor actually holds a contract the
83
+ # mutation could violate. Drops implicit-self calls and literals outside a
84
+ # typed call (no contract → guaranteed survival → noise). FP-safe
85
+ # direction: an unresolved/probe-failed type KEEPS the mutation, so the
86
+ # filter never hides a kill it is unsure about — it only removes
87
+ # provably-Dynamic sites. Returns [kept, dropped_count]. Builds the scope
88
+ # index from THIS mutator's parse so anchor node identity matches the keys.
89
+ def filter_by_type(mutations, environment:, path:)
90
+ base = Rigor::Scope.empty(environment: environment, source_path: path)
91
+ index = Rigor::Inference::ScopeIndexer.index(@parse.value, default_scope: base)
92
+ cache = {}
93
+ kept = mutations.select do |mut|
94
+ keep, type = anchor_decision(mut.anchor, index, cache)
95
+ mut.anchor_type = type if keep
96
+ keep
97
+ end
98
+ [kept, mutations.size - kept.size]
99
+ end
100
+
101
+ private
102
+
103
+ def walk(node, &blk)
104
+ return if node.nil?
105
+
106
+ blk.call(node)
107
+ node.compact_child_nodes.each { |child| walk(child, &blk) }
108
+ end
109
+
110
+ def collect(node, out)
111
+ case node
112
+ when Prism::IntegerNode, Prism::FloatNode
113
+ literal_mutations(node, out, numeric: true)
114
+ when Prism::StringNode
115
+ literal_mutations(node, out, numeric: false)
116
+ when Prism::CallNode
117
+ call_mutations(node, out)
118
+ end
119
+ end
120
+
121
+ # Record, for each literal that is a direct call argument, the receiver of
122
+ # the enclosing call — the anchor whose param contract a literal mutation
123
+ # could violate. Literals elsewhere get a nil anchor (filtered out under
124
+ # the type filter).
125
+ def index_literal_anchors(node)
126
+ return if node.nil?
127
+
128
+ if node.is_a?(Prism::CallNode) && node.arguments
129
+ node.arguments.arguments.each do |arg|
130
+ @anchor_for[arg] = [node.receiver, node.name.to_s] if literal?(arg)
131
+ end
132
+ end
133
+ node.compact_child_nodes.each { |child| index_literal_anchors(child) }
134
+ end
135
+
136
+ def literal?(node)
137
+ node.is_a?(Prism::IntegerNode) || node.is_a?(Prism::FloatNode) || node.is_a?(Prism::StringNode)
138
+ end
139
+
140
+ # Returns [keep?, rendered_type]. Keep when `anchor` is a site where Rigor
141
+ # holds a concrete (non-Dynamic/Top) type. FP-safe: an unresolved or
142
+ # probe-failed type keeps the mutation (with a nil rendered type).
143
+ def anchor_decision(anchor, index, cache)
144
+ return [false, nil] if anchor.nil?
145
+ return cache[anchor] if cache.key?(anchor)
146
+
147
+ cache[anchor] = compute_anchor_decision(anchor, index)
148
+ end
149
+
150
+ def compute_anchor_decision(anchor, index)
151
+ scope = index[anchor]
152
+ return [true, nil] if scope.nil? # unresolved scope → keep (FP-safe)
153
+
154
+ type = scope.type_of(anchor)
155
+ return [true, nil] if type.nil?
156
+
157
+ concrete = !non_concrete_type?(type)
158
+ [concrete, concrete ? render_type(type) : nil]
159
+ rescue StandardError
160
+ [true, nil] # never let a probe failure hide a candidate
161
+ end
162
+
163
+ # A receiver type Rigor cannot bite on, so a mutation anchored to it would
164
+ # survive as noise: `Dynamic` / `Top` / `bot`, or a union with any such arm
165
+ # (gradually valid — `Array | Dynamic[top]`.whatever never fires). A union
166
+ # of fully-concrete arms (`String | Symbol`) stays concrete — it now has
167
+ # undefined-method teeth.
168
+ def non_concrete_type?(type)
169
+ return true if type.is_a?(Rigor::Type::Dynamic) || type.is_a?(Rigor::Type::Top) ||
170
+ type.is_a?(Rigor::Type::Bot)
171
+ return type.members.any? { |member| non_concrete_type?(member) } if type.is_a?(Rigor::Type::Union)
172
+
173
+ false
174
+ end
175
+
176
+ def render_type(type)
177
+ type.respond_to?(:describe) ? type.describe(:short) : type.to_s
178
+ rescue StandardError
179
+ type.class.name
180
+ end
181
+
182
+ # Mutate a literal: drop it to nil (possible-nil channel) and swap its
183
+ # type (type-mismatch channel). String literals are only touched when the
184
+ # node is a real quoted string, so we never corrupt `%w[...]` words.
185
+ def literal_mutations(node, out, numeric:)
186
+ return if !numeric && !QUOTES.include?(node.opening_loc&.slice)
187
+
188
+ anchor, method = @anchor_for[node]
189
+ return if UNIVERSAL_EQUALITY.include?(method)
190
+
191
+ loc = node.location
192
+ add(out, :nil_inject, "call.argument-type-mismatch", loc.start_offset, loc.end_offset,
193
+ "nil", loc.start_line, "literal → nil (#{snippet(loc)})", anchor, method)
194
+ swap = numeric ? '"rigor_mutant"' : "0"
195
+ add(out, :type_swap, "call.argument-type-mismatch", loc.start_offset, loc.end_offset,
196
+ swap, loc.start_line, "literal type swap (#{snippet(loc)} → #{swap})", anchor, method)
197
+ end
198
+
199
+ def call_mutations(node, out)
200
+ rename_call(node, out)
201
+ extend_arity(node, out)
202
+ end
203
+
204
+ # Rename the *call site* (not the def) to a method that cannot exist, so a
205
+ # typed receiver trips call.undefined-method. We leave `def` signatures
206
+ # untouched on purpose: the prebuilt ProjectScan still carries the file's
207
+ # original declarations, so mutating only bodies/call-sites keeps it valid.
208
+ # Anchor is the explicit receiver (nil ⇒ implicit self ⇒ filtered out, as
209
+ # call.self-undefined-method ships `:off`).
210
+ def rename_call(node, out)
211
+ name = node.name.to_s
212
+ mloc = node.message_loc
213
+ return unless mloc && IDENT.match?(name)
214
+
215
+ add(out, :undefined_method, "call.undefined-method", mloc.start_offset, mloc.end_offset,
216
+ "#{name}__rigor_absent", mloc.start_line, "call ##{name} → missing method", node.receiver, name)
217
+ end
218
+
219
+ # Append a trailing argument inside explicit `(...)` parens to trip an
220
+ # arity diagnostic against a known fixed-arity signature.
221
+ def extend_arity(node, out)
222
+ open = node.opening_loc
223
+ close = node.closing_loc
224
+ return unless close && open&.slice == "("
225
+
226
+ args = node.arguments&.arguments
227
+ insertion = args && !args.empty? ? ", nil" : "nil"
228
+ add(out, :arity_extra, "call.wrong-arity", close.start_offset, close.start_offset,
229
+ insertion, node.location.start_line, "call ##{node.name} +1 arg", node.receiver, node.name.to_s)
230
+ end
231
+
232
+ def add(out, operator, rule, start, stop, replacement, line, label, anchor, method_name) # rubocop:disable Metrics/ParameterLists
233
+ return unless @operators.include?(operator)
234
+
235
+ out << Mutation.new(operator: operator, expected_rule: rule, start: start, stop: stop,
236
+ replacement: replacement, line: line, label: label, anchor: anchor,
237
+ method_name: method_name)
238
+ end
239
+
240
+ def snippet(loc)
241
+ text = loc.slice.gsub(/\s+/, " ")
242
+ text.length > 30 ? "#{text[0, 27]}..." : text
243
+ end
244
+ end
245
+ end
246
+ end