rigortype 0.1.18 → 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 (210) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +159 -224
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -3
  4. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
  5. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +32 -23
  6. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
  7. data/lib/rigor/analysis/check_rules/rule_walk.rb +151 -23
  8. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +24 -15
  9. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +9 -3
  10. data/lib/rigor/analysis/check_rules.rb +756 -132
  11. data/lib/rigor/analysis/dependency_source_inference/index.rb +4 -7
  12. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -18
  13. data/lib/rigor/analysis/dependency_source_inference.rb +3 -12
  14. data/lib/rigor/analysis/diagnostic.rb +8 -0
  15. data/lib/rigor/analysis/fact_store.rb +5 -4
  16. data/lib/rigor/analysis/rule_catalog.rb +153 -6
  17. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +19 -18
  18. data/lib/rigor/analysis/runner/project_pre_passes.rb +13 -9
  19. data/lib/rigor/analysis/runner.rb +75 -27
  20. data/lib/rigor/analysis/self_call_resolution_recorder.rb +3 -4
  21. data/lib/rigor/analysis/worker_session.rb +31 -25
  22. data/lib/rigor/bleeding_edge.rb +123 -0
  23. data/lib/rigor/builtins/predefined_constant_refinements.rb +151 -0
  24. data/lib/rigor/cache/descriptor.rb +86 -8
  25. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  26. data/lib/rigor/cache/store.rb +5 -3
  27. data/lib/rigor/cli/annotate_command.rb +122 -16
  28. data/lib/rigor/cli/baseline_command.rb +4 -3
  29. data/lib/rigor/cli/check_command.rb +118 -16
  30. data/lib/rigor/cli/coverage_command.rb +148 -16
  31. data/lib/rigor/cli/coverage_scan.rb +57 -0
  32. data/lib/rigor/cli/explain_command.rb +2 -0
  33. data/lib/rigor/cli/lsp_command.rb +3 -7
  34. data/lib/rigor/cli/mutation_protection_renderer.rb +63 -0
  35. data/lib/rigor/cli/mutation_protection_report.rb +73 -0
  36. data/lib/rigor/cli/options.rb +9 -0
  37. data/lib/rigor/cli/plugins_command.rb +4 -5
  38. data/lib/rigor/cli/plugins_renderer.rb +0 -2
  39. data/lib/rigor/cli/protection_renderer.rb +63 -0
  40. data/lib/rigor/cli/protection_report.rb +68 -0
  41. data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
  42. data/lib/rigor/cli/sig_gen_command.rb +2 -1
  43. data/lib/rigor/cli/trace_command.rb +2 -1
  44. data/lib/rigor/cli/triage_command.rb +8 -4
  45. data/lib/rigor/cli/triage_renderer.rb +15 -1
  46. data/lib/rigor/cli/type_of_command.rb +1 -1
  47. data/lib/rigor/cli/type_scan_command.rb +2 -1
  48. data/lib/rigor/cli.rb +12 -3
  49. data/lib/rigor/configuration/dependencies.rb +2 -4
  50. data/lib/rigor/configuration/severity_profile.rb +13 -1
  51. data/lib/rigor/configuration.rb +100 -6
  52. data/lib/rigor/environment/bundle_sig_discovery.rb +61 -13
  53. data/lib/rigor/environment/class_registry.rb +4 -3
  54. data/lib/rigor/environment/constant_type_cache_holder.rb +43 -0
  55. data/lib/rigor/environment/lockfile_resolver.rb +1 -1
  56. data/lib/rigor/environment/rbs_collection_discovery.rb +1 -2
  57. data/lib/rigor/environment/rbs_coverage_report.rb +2 -1
  58. data/lib/rigor/environment/rbs_loader.rb +74 -5
  59. data/lib/rigor/environment.rb +17 -7
  60. data/lib/rigor/flow_contribution/fact.rb +1 -1
  61. data/lib/rigor/flow_contribution.rb +3 -5
  62. data/lib/rigor/inference/acceptance.rb +17 -9
  63. data/lib/rigor/inference/block_parameter_binder.rb +2 -3
  64. data/lib/rigor/inference/body_fixpoint.rb +89 -0
  65. data/lib/rigor/inference/budget_trace.rb +29 -2
  66. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -2
  67. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -2
  68. data/lib/rigor/inference/builtins/method_catalog.rb +19 -0
  69. data/lib/rigor/inference/builtins/string_catalog.rb +9 -1
  70. data/lib/rigor/inference/expression_typer.rb +1072 -71
  71. data/lib/rigor/inference/hkt_body.rb +8 -11
  72. data/lib/rigor/inference/hkt_body_parser.rb +10 -12
  73. data/lib/rigor/inference/hkt_registry.rb +10 -11
  74. data/lib/rigor/inference/macro_block_self_type.rb +2 -2
  75. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
  76. data/lib/rigor/inference/method_dispatcher/call_context.rb +1 -4
  77. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +210 -35
  78. data/lib/rigor/inference/method_dispatcher/data_folding.rb +9 -73
  79. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -7
  80. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +10 -16
  81. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +25 -13
  82. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +93 -0
  83. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -3
  84. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +24 -22
  85. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
  86. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
  87. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +237 -24
  88. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +303 -0
  89. data/lib/rigor/inference/method_dispatcher.rb +112 -49
  90. data/lib/rigor/inference/method_parameter_binder.rb +56 -2
  91. data/lib/rigor/inference/multi_target_binder.rb +46 -3
  92. data/lib/rigor/inference/mutation_widening.rb +147 -11
  93. data/lib/rigor/inference/narrowing.rb +284 -53
  94. data/lib/rigor/inference/parameter_inference_collector.rb +367 -0
  95. data/lib/rigor/inference/project_patched_methods.rb +4 -7
  96. data/lib/rigor/inference/project_patched_scanner.rb +2 -13
  97. data/lib/rigor/inference/protection_scanner.rb +86 -0
  98. data/lib/rigor/inference/scope_indexer.rb +821 -76
  99. data/lib/rigor/inference/statement_evaluator.rb +1179 -102
  100. data/lib/rigor/inference/struct_fold_safety.rb +181 -0
  101. data/lib/rigor/inference/synthetic_method.rb +7 -7
  102. data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
  103. data/lib/rigor/language_server/completion_provider.rb +6 -12
  104. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -4
  105. data/lib/rigor/language_server/document_symbol_provider.rb +3 -3
  106. data/lib/rigor/language_server/hover_provider.rb +2 -3
  107. data/lib/rigor/language_server/hover_renderer.rb +2 -11
  108. data/lib/rigor/language_server/server.rb +9 -17
  109. data/lib/rigor/language_server.rb +4 -5
  110. data/lib/rigor/plugin/base.rb +245 -87
  111. data/lib/rigor/plugin/macro/block_as_method.rb +25 -25
  112. data/lib/rigor/plugin/macro/heredoc_template.rb +4 -7
  113. data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
  114. data/lib/rigor/plugin/macro/trait_registry.rb +3 -6
  115. data/lib/rigor/plugin/macro.rb +6 -8
  116. data/lib/rigor/plugin/manifest.rb +49 -90
  117. data/lib/rigor/plugin/node_rule_walk.rb +59 -14
  118. data/lib/rigor/plugin/registry.rb +18 -18
  119. data/lib/rigor/plugin/type_node_resolver.rb +6 -8
  120. data/lib/rigor/protection/mutation_scanner.rb +120 -0
  121. data/lib/rigor/protection/mutator.rb +246 -0
  122. data/lib/rigor/rbs_extended.rb +24 -36
  123. data/lib/rigor/reflection.rb +4 -7
  124. data/lib/rigor/scope/discovery_index.rb +16 -2
  125. data/lib/rigor/scope.rb +185 -16
  126. data/lib/rigor/sig_gen/generator.rb +8 -0
  127. data/lib/rigor/sig_gen/observed_call.rb +3 -3
  128. data/lib/rigor/sig_gen/writer.rb +40 -2
  129. data/lib/rigor/source/constant_path.rb +62 -0
  130. data/lib/rigor/source.rb +1 -0
  131. data/lib/rigor/triage/catalogue.rb +4 -19
  132. data/lib/rigor/triage.rb +69 -1
  133. data/lib/rigor/type/bound_method.rb +2 -11
  134. data/lib/rigor/type/combinator.rb +45 -3
  135. data/lib/rigor/type/constant.rb +2 -11
  136. data/lib/rigor/type/data_class.rb +2 -11
  137. data/lib/rigor/type/data_instance.rb +2 -11
  138. data/lib/rigor/type/hash_shape.rb +2 -11
  139. data/lib/rigor/type/integer_range.rb +2 -11
  140. data/lib/rigor/type/intersection.rb +2 -11
  141. data/lib/rigor/type/nominal.rb +2 -11
  142. data/lib/rigor/type/plain_lattice.rb +37 -0
  143. data/lib/rigor/type/refined.rb +72 -13
  144. data/lib/rigor/type/singleton.rb +2 -11
  145. data/lib/rigor/type/struct_class.rb +75 -0
  146. data/lib/rigor/type/struct_instance.rb +93 -0
  147. data/lib/rigor/type/tuple.rb +5 -15
  148. data/lib/rigor/type.rb +2 -0
  149. data/lib/rigor/version.rb +1 -1
  150. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  151. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +3 -3
  152. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +16 -32
  153. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +5 -13
  154. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
  155. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +11 -17
  156. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +34 -100
  157. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +3 -2
  158. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
  159. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  160. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +26 -27
  161. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +5 -7
  162. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +9 -8
  163. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +9 -11
  164. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +8 -9
  165. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +13 -12
  166. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +3 -4
  167. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +8 -8
  168. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +9 -11
  169. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +7 -8
  170. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +18 -49
  171. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +12 -13
  172. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +15 -23
  173. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +4 -4
  174. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +3 -3
  175. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
  176. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +2 -4
  177. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +27 -11
  178. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +22 -35
  179. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -6
  180. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +12 -18
  181. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +16 -23
  182. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  183. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -4
  184. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +1 -1
  185. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  186. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +21 -27
  187. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +0 -1
  188. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
  189. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +5 -4
  190. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
  191. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
  192. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +2 -3
  193. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +7 -11
  194. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +4 -5
  195. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +6 -9
  196. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +5 -15
  197. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +52 -40
  198. data/sig/rigor/analysis/fact_store.rbs +3 -0
  199. data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
  200. data/sig/rigor/plugin/base.rbs +5 -2
  201. data/sig/rigor/plugin/manifest.rbs +1 -2
  202. data/sig/rigor/scope.rbs +18 -1
  203. data/sig/rigor/type.rbs +37 -1
  204. data/sig/rigor.rbs +1 -1
  205. data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
  206. data/skills/rigor-plugin-author/SKILL.md +6 -4
  207. data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
  208. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
  209. metadata +25 -2
  210. data/lib/rigor/plugin/macro/external_file.rb +0 -143
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "macro/block_as_method"
4
- require_relative "macro/external_file"
5
4
  require_relative "macro/heredoc_template"
6
5
  require_relative "macro/nested_class_template"
7
6
  require_relative "macro/trait_registry"
@@ -11,15 +10,14 @@ module Rigor
11
10
  # Substrate declarations for the macro / DSL expansion tiers
12
11
  # introduced by ADR-16. Plugin authors declare entries under
13
12
  # `Plugin::Manifest` slots (`block_as_methods:`,
14
- # `trait_registries:`, `heredoc_macros:`,
15
- # `external_file_inclusions:`) and the substrate consumes them
13
+ # `trait_registries:`, `heredoc_templates:`,
14
+ # `nested_class_templates:`) and the substrate consumes them
16
15
  # to recognise the call shapes a library exposes to its users.
17
16
  #
18
- # Slice 1a (this file's first delivery) ships the Tier A value
19
- # class only. The other tiers' value classes + their manifest
20
- # slots arrive in subsequent slices per ADR-16 § Implementation
21
- # slicing. The namespace is reserved here so subsequent slices
22
- # add files alongside `block_as_method.rb` without churn.
17
+ # Tier A (`BlockAsMethod`), Tier B (`TraitRegistry`), Tier C
18
+ # (`HeredocTemplate`), and ADR-36 nested-class emission
19
+ # (`NestedClassTemplate`) value classes are all shipped here.
20
+ # Engine wiring lives in `lib/rigor/inference/`.
23
21
  #
24
22
  # Per ADR-16 § WD13, substrate-produced output ships at a
25
23
  # **floor** in v0.1.x ("substrate-affected code parses cleanly
@@ -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)
@@ -43,7 +43,7 @@ module Rigor
43
43
 
44
44
  attr_reader :id, :version, :description, :config_schema, :config_defaults, :produces, :consumes,
45
45
  :owns_receivers, :open_receivers, :type_node_resolvers, :block_as_methods,
46
- :heredoc_templates, :nested_class_templates, :trait_registries, :external_files,
46
+ :heredoc_templates, :nested_class_templates, :trait_registries,
47
47
  :hkt_registrations, :hkt_definitions, :signature_paths, :protocol_contracts,
48
48
  :source_rbs_synthesizer, :additional_initializers
49
49
 
@@ -52,7 +52,7 @@ module Rigor
52
52
  description: nil, config_schema: {},
53
53
  produces: [], consumes: [], owns_receivers: [], open_receivers: [], type_node_resolvers: [],
54
54
  block_as_methods: [], heredoc_templates: [], nested_class_templates: [],
55
- trait_registries: [], external_files: [],
55
+ trait_registries: [],
56
56
  hkt_registrations: [], hkt_definitions: [], signature_paths: [], protocol_contracts: [],
57
57
  source_rbs_synthesizer: nil, additional_initializers: []
58
58
  )
@@ -67,7 +67,6 @@ module Rigor
67
67
  validate_heredoc_templates!(heredoc_templates)
68
68
  validate_nested_class_templates!(nested_class_templates)
69
69
  validate_trait_registries!(trait_registries)
70
- validate_external_files!(external_files)
71
70
  validate_hkt_registrations!(hkt_registrations)
72
71
  validate_hkt_definitions!(hkt_definitions)
73
72
  validate_signature_paths!(signature_paths)
@@ -77,7 +76,7 @@ module Rigor
77
76
 
78
77
  assign_fields(id, version, description, config_schema, produces, consumes, owns_receivers,
79
78
  open_receivers, type_node_resolvers, block_as_methods, heredoc_templates, trait_registries,
80
- external_files, hkt_registrations, hkt_definitions, signature_paths, protocol_contracts,
79
+ hkt_registrations, hkt_definitions, signature_paths, protocol_contracts,
81
80
  source_rbs_synthesizer)
82
81
  assign_nested_class_templates(nested_class_templates)
83
82
  assign_additional_initializers(additional_initializers)
@@ -89,7 +88,7 @@ module Rigor
89
88
  # rubocop:disable Metrics/ParameterLists, Metrics/AbcSize
90
89
  def assign_fields(id, version, description, config_schema, produces, consumes, owns_receivers,
91
90
  open_receivers, type_node_resolvers, block_as_methods, heredoc_templates, trait_registries,
92
- external_files, hkt_registrations, hkt_definitions, signature_paths, protocol_contracts,
91
+ hkt_registrations, hkt_definitions, signature_paths, protocol_contracts,
93
92
  source_rbs_synthesizer)
94
93
  @id = id.dup.freeze
95
94
  @version = version.dup.freeze
@@ -104,7 +103,6 @@ module Rigor
104
103
  @block_as_methods = block_as_methods.dup.freeze
105
104
  @heredoc_templates = heredoc_templates.dup.freeze
106
105
  @trait_registries = trait_registries.dup.freeze
107
- @external_files = external_files.dup.freeze
108
106
  @hkt_registrations = hkt_registrations.dup.freeze
109
107
  @hkt_definitions = hkt_definitions.dup.freeze
110
108
  @signature_paths = signature_paths.map { |p| p.to_s.dup.freeze }.freeze
@@ -170,7 +168,6 @@ module Rigor
170
168
  "heredoc_templates" => heredoc_templates.map(&:to_h),
171
169
  "nested_class_templates" => nested_class_templates.map(&:to_h),
172
170
  "trait_registries" => trait_registries.map(&:to_h),
173
- "external_files" => external_files.map(&:to_h),
174
171
  "hkt_registrations" => hkt_registrations.map(&:to_h),
175
172
  "hkt_definitions" => hkt_definitions.map { |d| { "uri" => d.uri, "params" => d.params } },
176
173
  "signature_paths" => signature_paths,
@@ -280,10 +277,18 @@ module Rigor
280
277
  end
281
278
  end
282
279
 
283
- def validate_produces!(produces)
284
- 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?(&)
285
286
 
286
- 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) }
287
292
  end
288
293
 
289
294
  # ADR-10 5a — `owns_receivers:` declares the class names
@@ -294,11 +299,7 @@ module Rigor
294
299
  # so plugin contributions stay authoritative for those
295
300
  # types.
296
301
  def validate_owns_receivers!(owns_receivers)
297
- return if owns_receivers.is_a?(Array) && owns_receivers.all? { |c| c.is_a?(String) && !c.empty? }
298
-
299
- raise ArgumentError,
300
- "plugin manifest owns_receivers must be an Array of non-empty String, " \
301
- "got #{owns_receivers.inspect}"
302
+ validate_array_of!("owns_receivers", owns_receivers, "non-empty String") { |c| c.is_a?(String) && !c.empty? }
302
303
  end
303
304
 
304
305
  # ADR-26 — `open_receivers:` declares the class names this
@@ -312,11 +313,7 @@ module Rigor
312
313
  # `owns_receivers:` (which routes dispatch); this one only
313
314
  # suppresses the diagnostic.
314
315
  def validate_open_receivers!(open_receivers)
315
- return if open_receivers.is_a?(Array) && open_receivers.all? { |c| c.is_a?(String) && !c.empty? }
316
-
317
- raise ArgumentError,
318
- "plugin manifest open_receivers must be an Array of non-empty String, " \
319
- "got #{open_receivers.inspect}"
316
+ validate_array_of!("open_receivers", open_receivers, "non-empty String") { |c| c.is_a?(String) && !c.empty? }
320
317
  end
321
318
 
322
319
  # ADR-13 slice 2 — `type_node_resolvers:` declares the
@@ -328,11 +325,9 @@ module Rigor
328
325
  # integration that actually drives the chain lands in
329
326
  # slice 3.
330
327
  def validate_type_node_resolvers!(resolvers)
331
- return if resolvers.is_a?(Array) && resolvers.all?(TypeNodeResolver)
332
-
333
- raise ArgumentError,
334
- "plugin manifest type_node_resolvers must be an Array of " \
335
- "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
336
331
  end
337
332
 
338
333
  # ADR-16 slice 1a — `block_as_methods:` declares the Tier A
@@ -341,11 +336,9 @@ module Rigor
341
336
  # actually narrows `Scope#self_type` for matching blocks
342
337
  # arrives in a subsequent slice.
343
338
  def validate_block_as_methods!(entries)
344
- return if entries.is_a?(Array) && entries.all?(Macro::BlockAsMethod)
345
-
346
- raise ArgumentError,
347
- "plugin manifest block_as_methods must be an Array of " \
348
- "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
349
342
  end
350
343
 
351
344
  # ADR-16 slice 2a — `heredoc_templates:` declares the Tier C
@@ -354,11 +347,9 @@ module Rigor
354
347
  # manifest; the pre-pass + `SyntheticMethodIndex` that actually
355
348
  # emit synthetic methods arrive in slice 2b.
356
349
  def validate_heredoc_templates!(entries)
357
- return if entries.is_a?(Array) && entries.all?(Macro::HeredocTemplate)
358
-
359
- raise ArgumentError,
360
- "plugin manifest heredoc_templates must be an Array of " \
361
- "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
362
353
  end
363
354
 
364
355
  # ADR-36 — `nested_class_templates:` declares the
@@ -368,11 +359,10 @@ module Rigor
368
359
  # subclasses + their `#inner` reader through the existing
369
360
  # `SyntheticMethodIndex` primitive.
370
361
  def validate_nested_class_templates!(entries)
371
- return if entries.is_a?(Array) && entries.all?(Macro::NestedClassTemplate)
372
-
373
- raise ArgumentError,
374
- "plugin manifest nested_class_templates must be an Array of " \
375
- "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
376
366
  end
377
367
 
378
368
  # ADR-16 slice 3a — `trait_registries:` declares the Tier B
@@ -382,28 +372,9 @@ module Rigor
382
372
  # `SyntheticMethodIndex` (slice 2b primitive) arrives in
383
373
  # slice 3b.
384
374
  def validate_trait_registries!(entries)
385
- return if entries.is_a?(Array) && entries.all?(Macro::TraitRegistry)
386
-
387
- raise ArgumentError,
388
- "plugin manifest trait_registries must be an Array of " \
389
- "Rigor::Plugin::Macro::TraitRegistry instances, got #{entries.inspect}"
390
- end
391
-
392
- # ADR-16 slice 5a — `external_files:` declares the Tier D
393
- # substrate entries (external-Ruby-file inclusion under a
394
- # declared `self`). Slice 5a carries the declarations on
395
- # the manifest; the engine integration that walks the
396
- # matched files + narrows their entry scope is **queued for
397
- # slice 5b**, gated on demonstrated demand from concrete
398
- # plugin targets (Redmine webhook payloads, tDiary plugin
399
- # loader, etc.). Plugin authors MAY declare entries today;
400
- # the substrate does not yet act on them.
401
- def validate_external_files!(entries)
402
- return if entries.is_a?(Array) && entries.all?(Macro::ExternalFile)
403
-
404
- raise ArgumentError,
405
- "plugin manifest external_files must be an Array of " \
406
- "Rigor::Plugin::Macro::ExternalFile 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
407
378
  end
408
379
 
409
380
  # ADR-20 slice 6 — `hkt_registrations:` declares the
@@ -418,11 +389,9 @@ module Rigor
418
389
  # user `.rbs` overlays merge on top of plugin entries
419
390
  # last-write-wins.
420
391
  def validate_hkt_registrations!(entries)
421
- return if entries.is_a?(Array) && entries.all?(Inference::HktRegistry::Registration)
422
-
423
- raise ArgumentError,
424
- "plugin manifest hkt_registrations must be an Array of " \
425
- "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
426
395
  end
427
396
 
428
397
  # ADR-20 slice 6 — `hkt_definitions:` declares the
@@ -435,11 +404,9 @@ module Rigor
435
404
  # via {Rigor::Inference::HktBody}'s node-constructor API
436
405
  # without parsing a string.
437
406
  def validate_hkt_definitions!(entries)
438
- return if entries.is_a?(Array) && entries.all?(Inference::HktRegistry::Definition)
439
-
440
- raise ArgumentError,
441
- "plugin manifest hkt_definitions must be an Array of " \
442
- "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
443
410
  end
444
411
 
445
412
  # ADR-25 — `signature_paths:` declares the RBS signature
@@ -449,11 +416,7 @@ module Rigor
449
416
  # loader validates each exists and `Environment.for_project`
450
417
  # merges the resolved set into the RBS environment.
451
418
  def validate_signature_paths!(paths)
452
- return if paths.is_a?(Array) && paths.all? { |p| p.is_a?(String) && !p.empty? }
453
-
454
- raise ArgumentError,
455
- "plugin manifest signature_paths must be an Array of non-empty String, " \
456
- "got #{paths.inspect}"
419
+ validate_array_of!("signature_paths", paths, "non-empty String") { |p| p.is_a?(String) && !p.empty? }
457
420
  end
458
421
 
459
422
  # ADR-28 — `protocol_contracts:` declares the path-scoped
@@ -469,11 +432,9 @@ module Rigor
469
432
  # MAY override `Plugin::Base#protocol_contracts` to fold in
470
433
  # per-project config (e.g. a custom convention path).
471
434
  def validate_protocol_contracts!(entries)
472
- return if entries.is_a?(Array) && entries.all?(ProtocolContract)
473
-
474
- raise ArgumentError,
475
- "plugin manifest protocol_contracts must be an Array of " \
476
- "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
477
438
  end
478
439
 
479
440
  # ADR-38 — `additional_initializers:` declares the
@@ -485,11 +446,9 @@ module Rigor
485
446
  # loaded plugins; `Inference::ScopeIndexer` consults the set at
486
447
  # its single gate.
487
448
  def validate_additional_initializers!(entries)
488
- return if entries.is_a?(Array) && entries.all?(AdditionalInitializer)
489
-
490
- raise ArgumentError,
491
- "plugin manifest additional_initializers must be an Array of " \
492
- "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
493
452
  end
494
453
 
495
454
  # ADR-32 WD4 — `source_rbs_synthesizer:` declares a callable
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "node_context"
4
4
  require_relative "../source/node_walker"
5
+ require_relative "../analysis/check_rules/rule_walk"
5
6
 
6
7
  module Rigor
7
8
  module Plugin
@@ -64,30 +65,74 @@ module Rigor
64
65
  # Walk `root` once, dispatching every node to each matching
65
66
  # `(plugin, rule)`. Returns an Array of {Result} in plugin
66
67
  # (registry) order. `root` nil yields one empty Result per plugin.
67
- def diagnostics_for_file(path:, scope:, root:)
68
+ #
69
+ # ADR-53 B4 — when `collector_driver` is given (an
70
+ # {Analysis::CheckRules::RuleWalk::CollectorDriver}), the SAME
71
+ # single traversal also drives the built-in {CheckRules} node
72
+ # collectors: each visited node is dispatched both to the plugin
73
+ # rules (this walk's original job) and to the built-in collectors
74
+ # (the `CollectorDriver`), so a file is walked once for both
75
+ # instead of once each. The two dispatch models coexist: plugin
76
+ # rules keep `is_a?` matching via the per-class memo and receive a
77
+ # lazily-built {NodeContext} (ancestors); built-in collectors keep
78
+ # exact-node-class dispatch and receive the immutable
79
+ # {RuleWalk::Context} threaded through the descent. Order is
80
+ # preserved because each side accumulates into its own bucket
81
+ # (per-plugin {Result}s / per-collector `results`) and the two are
82
+ # assembled separately by their respective diagnostic builders.
83
+ # A raising plugin rule isolates only that plugin (per-{State}
84
+ # rescue) and never aborts built-in collection, nor vice versa
85
+ # (the collectors' `visit` is the verbatim legacy gather logic,
86
+ # which does not raise on the corpora).
87
+ def diagnostics_for_file(path:, scope:, root:, collector_driver: nil)
68
88
  return @entries.map { |plugin, _| Result.new(plugin, [], nil) } if root.nil?
69
89
 
70
90
  states = @entries.map { |plugin, rules| State.new(plugin, rules, scope, root) }
71
- walk(path, scope, root, states)
91
+ walk(path, scope, root, states, collector_driver)
72
92
  states.map(&:result)
73
93
  end
74
94
 
75
95
  private
76
96
 
77
- def walk(path, scope, root, states)
78
- Source::NodeWalker.each_with_ancestors(root) do |node, ancestors|
79
- context = nil
80
- states.each do |state|
81
- next if state.failed?
97
+ def walk(path, scope, root, states, collector_driver)
98
+ context = collector_driver ? Analysis::CheckRules::RuleWalk::Context.root : nil
99
+ walk_node(root, [], context, path, scope, states, collector_driver)
100
+ end
101
+
102
+ # The single converged DFS pre-order traversal. Threads both the
103
+ # live `ancestors` stack (for plugin {NodeContext}) and the
104
+ # immutable built-in {RuleWalk::Context} (for the collectors),
105
+ # derived together as the walk descends — the cheap-ancestors
106
+ # option from the ADR-53 B4 design note. Identical pre-order over
107
+ # `compact_child_nodes` to both the legacy
108
+ # `Source::NodeWalker.each_with_ancestors` and `RuleWalk.walk`, so
109
+ # every node is visited in the same order each side saw before.
110
+ def walk_node(node, ancestors, context, path, scope, states, collector_driver)
111
+ return unless node.is_a?(Prism::Node)
112
+
113
+ dispatch_plugins(node, ancestors, path, scope, states)
114
+ collector_driver&.visit(node, context)
115
+
116
+ child_context = collector_driver&.descend(node, context)
117
+ ancestors.push(node)
118
+ node.compact_child_nodes.each do |child|
119
+ walk_node(child, ancestors, child_context, path, scope, states, collector_driver)
120
+ end
121
+ ancestors.pop
122
+ end
82
123
 
83
- matched = state.rules_for(node)
84
- next if matched.empty?
124
+ def dispatch_plugins(node, ancestors, path, scope, states)
125
+ node_context = nil
126
+ states.each do |state|
127
+ next if state.failed?
85
128
 
86
- # One frozen NodeContext per node, built lazily and shared
87
- # across every plugin that matches this node.
88
- context ||= NodeContext.new(ancestors)
89
- state.run_rules(matched, node, scope, path, context)
90
- end
129
+ matched = state.rules_for(node)
130
+ next if matched.empty?
131
+
132
+ # One frozen NodeContext per node, built lazily and shared
133
+ # across every plugin that matches this node.
134
+ node_context ||= NodeContext.new(ancestors)
135
+ state.run_rules(matched, node, scope, path, node_context)
91
136
  end
92
137
  end
93
138
 
@@ -7,7 +7,7 @@ module Rigor
7
7
  # ADR-52 WD1 — the compiled contribution table. Categorises a loaded
8
8
  # plugin set by which per-call contribution paths each plugin
9
9
  # actually implements, AND compiles the declarative gates (method
10
- # names, `block_as_methods` verbs, `owns_receivers`) into frozen
10
+ # names, `block_as_methods` method names, `owns_receivers`) into frozen
11
11
  # lookup structures, so the engine's hot sites discover "no plugin
12
12
  # cares about this call" in O(1) instead of O(plugins × rules) — a
13
13
  # top hotspot on plugin-heavy projects (GitLab's 11 plugins, of
@@ -16,7 +16,7 @@ module Rigor
16
16
  #
17
17
  # Ordering contract: the gates only PRUNE consultations that could
18
18
  # not fire (every pruned rule would have failed its own `methods:` /
19
- # `verbs:` check); the engine still iterates the plugin subsets in
19
+ # `method_names:` check); the engine still iterates the plugin subsets in
20
20
  # registry order and each plugin's rules in declaration order, so
21
21
  # the surviving contributions arrive in exactly the order the
22
22
  # ungated walk produced — diagnostics stay byte-identical. The
@@ -36,7 +36,7 @@ module Rigor
36
36
  def initialize(plugins)
37
37
  compile_memberships(plugins)
38
38
  compile_gates
39
- @block_entries_by_verb = build_block_entries(plugins)
39
+ @block_entries_by_method_name = build_block_entries(plugins)
40
40
  @owns_receivers = plugins.flat_map { |p| manifest_for(p)&.owns_receivers || [] }.uniq.freeze
41
41
  # Per-run ancestry verdict memo, keyed by environment identity
42
42
  # then class name. Mutable inside the frozen index — sound
@@ -88,11 +88,12 @@ module Rigor
88
88
  gate.nil? || gate.include?(method_name)
89
89
  end
90
90
 
91
- # The `Macro::BlockAsMethod` entries whose `verbs` include `verb`,
92
- # in (plugin registration, manifest declaration) order — the same
93
- # first-match order the previous plugins × entries walk visited.
94
- def block_entries_for(verb)
95
- @block_entries_by_verb.fetch(verb, EMPTY_BLOCK_ENTRIES)
91
+ # The `Macro::BlockAsMethod` entries whose `method_names` include
92
+ # `method_name`, in (plugin registration, manifest declaration)
93
+ # order — the same first-match order the previous plugins ×
94
+ # entries walk visited.
95
+ def block_entries_for(method_name)
96
+ @block_entries_by_method_name.fetch(method_name, EMPTY_BLOCK_ENTRIES)
96
97
  end
97
98
 
98
99
  # True when `class_name` equals or inherits from any plugin's
@@ -188,15 +189,15 @@ module Rigor
188
189
  gates.values.reduce(Set.new) { |acc, names| acc.merge(names) }.freeze
189
190
  end
190
191
 
191
- # `verb Symbol → [BlockAsMethod entries]`, insertion-ordered by
192
- # (plugin, declaration). Verbs are Symbol-normalised by
192
+ # `method-name Symbol → [BlockAsMethod entries]`, insertion-ordered
193
+ # by (plugin, declaration). Method names are Symbol-normalised by
193
194
  # `Macro::BlockAsMethod#initialize`.
194
195
  def build_block_entries(plugins)
195
196
  table = {}
196
197
  plugins.each do |plugin|
197
198
  entries = manifest_for(plugin)&.block_as_methods || []
198
199
  entries.each do |entry|
199
- entry.verbs.each { |verb| (table[verb] ||= []) << entry }
200
+ entry.method_names.each { |name| (table[name] ||= []) << entry }
200
201
  end
201
202
  end
202
203
  table.each_value(&:freeze)
@@ -316,13 +317,12 @@ module Rigor
316
317
  !load_errors.empty?
317
318
  end
318
319
 
319
- # ADR-13 slice 2 — flat ordered list of every loaded
320
- # plugin's manifest-declared {TypeNodeResolver} instances,
321
- # in plugin registration order. Slice 3 wires this into
322
- # the parser's resolver chain; until then the method is a
323
- # read-side aggregator only. The first non-nil
324
- # `#resolve(node, scope)` return wins per ADR-13 WD3 / WD5
325
- # — 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.
326
326
  def type_node_resolvers
327
327
  plugins.flat_map { |plugin| plugin.manifest.type_node_resolvers }
328
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