rigortype 0.3.6 → 0.3.8

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 (223) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/date.yml +6 -3
  4. data/data/builtins/ruby_core/enumerable.yml +1 -0
  5. data/data/builtins/ruby_core/exception.yml +2 -1
  6. data/data/builtins/ruby_core/file.yml +28 -14
  7. data/data/builtins/ruby_core/hash.yml +5 -2
  8. data/data/builtins/ruby_core/io.yml +33 -16
  9. data/data/builtins/ruby_core/random.yml +2 -1
  10. data/data/builtins/ruby_core/re.yml +6 -3
  11. data/data/builtins/ruby_core/struct.yml +2 -0
  12. data/data/builtins/ruby_core/time.yml +2 -1
  13. data/data/gem_overlay/activesupport/core_ext.rbs +409 -11
  14. data/docs/handbook/11-sig-gen.md +24 -14
  15. data/docs/manual/02-cli-reference.md +23 -7
  16. data/docs/manual/04-diagnostics.md +1 -1
  17. data/docs/manual/05-inspecting-types.md +20 -8
  18. data/docs/manual/07-plugins.md +4 -0
  19. data/docs/manual/plugins/rigor-actionpack.md +61 -0
  20. data/docs/manual/plugins/rigor-activerecord.md +74 -1
  21. data/docs/manual/plugins/rigor-activesupport-core-ext.md +114 -12
  22. data/docs/manual/plugins/rigor-sidekiq.md +22 -0
  23. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +12 -0
  24. data/lib/rigor/analysis/check_rules/dead_version_guard_arms.rb +98 -0
  25. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -3
  26. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +199 -0
  27. data/lib/rigor/analysis/check_rules/rule_walk.rb +1 -2
  28. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -2
  29. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +6 -12
  30. data/lib/rigor/analysis/check_rules.rb +383 -48
  31. data/lib/rigor/analysis/crash_signature.rb +188 -0
  32. data/lib/rigor/analysis/dependency_recorder.rb +18 -3
  33. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  34. data/lib/rigor/analysis/incremental.rb +28 -0
  35. data/lib/rigor/analysis/incremental_session.rb +63 -16
  36. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +1 -2
  37. data/lib/rigor/analysis/reachability/graph.rb +14 -5
  38. data/lib/rigor/analysis/reachability/scan.rb +7 -3
  39. data/lib/rigor/analysis/result.rb +32 -0
  40. data/lib/rigor/analysis/run_cache_key.rb +41 -1
  41. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +233 -16
  42. data/lib/rigor/analysis/runner/pool_coordinator.rb +266 -47
  43. data/lib/rigor/analysis/runner/project_pre_passes.rb +15 -11
  44. data/lib/rigor/analysis/runner/run_snapshots.rb +12 -2
  45. data/lib/rigor/analysis/runner.rb +212 -16
  46. data/lib/rigor/analysis/worker_session.rb +37 -3
  47. data/lib/rigor/bleeding_edge.rb +7 -6
  48. data/lib/rigor/builtins/hkt_builtins.rb +45 -6
  49. data/lib/rigor/builtins/imported_refinements.rb +11 -9
  50. data/lib/rigor/builtins/predefined_constant_refinements.rb +108 -59
  51. data/lib/rigor/builtins/static_return_refinements.rb +20 -1
  52. data/lib/rigor/cache/annotation_location.rb +72 -0
  53. data/lib/rigor/cache/descriptor.rb +42 -2
  54. data/lib/rigor/cache/incremental_snapshot.rb +40 -3
  55. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +70 -8
  56. data/lib/rigor/cache/store.rb +17 -1
  57. data/lib/rigor/cli/coverage_command.rb +23 -18
  58. data/lib/rigor/cli/coverage_mutation.rb +13 -17
  59. data/lib/rigor/cli/coverage_scan.rb +47 -6
  60. data/lib/rigor/cli/fused_protection_renderer.rb +19 -2
  61. data/lib/rigor/cli/fused_protection_report.rb +23 -2
  62. data/lib/rigor/cli/measurement_integrity_warning.rb +57 -0
  63. data/lib/rigor/cli/mutation_protection_renderer.rb +22 -2
  64. data/lib/rigor/cli/mutation_protection_report.rb +31 -2
  65. data/lib/rigor/cli/sig_gen_command.rb +47 -0
  66. data/lib/rigor/cli/type_of_command.rb +188 -41
  67. data/lib/rigor/cli/type_of_renderer.rb +72 -3
  68. data/lib/rigor/cli/type_scan_command.rb +10 -1
  69. data/lib/rigor/cli.rb +1 -1
  70. data/lib/rigor/configuration.rb +7 -4
  71. data/lib/rigor/effects/plugin_facts.rb +1 -1
  72. data/lib/rigor/effects/scanner.rb +2 -3
  73. data/lib/rigor/environment/bundle_sig_discovery.rb +64 -13
  74. data/lib/rigor/environment/failure_slot.rb +28 -0
  75. data/lib/rigor/environment/lockfile_resolver.rb +15 -5
  76. data/lib/rigor/environment/missing_gem_constant_index.rb +45 -4
  77. data/lib/rigor/environment/rbs_hierarchy.rb +16 -22
  78. data/lib/rigor/environment/rbs_loader.rb +743 -93
  79. data/lib/rigor/environment.rb +125 -25
  80. data/lib/rigor/inference/acceptance.rb +172 -20
  81. data/lib/rigor/inference/captured_locals.rb +62 -0
  82. data/lib/rigor/inference/content_join.rb +347 -0
  83. data/lib/rigor/inference/def_handle.rb +15 -3
  84. data/lib/rigor/inference/def_node_resolver.rb +62 -3
  85. data/lib/rigor/inference/expression_typer.rb +1020 -141
  86. data/lib/rigor/inference/fork_map.rb +6 -1
  87. data/lib/rigor/inference/hkt_reducer.rb +12 -1
  88. data/lib/rigor/inference/hkt_registry.rb +61 -3
  89. data/lib/rigor/inference/hkt_sugar_translator.rb +87 -0
  90. data/lib/rigor/inference/index_write_widening.rb +48 -0
  91. data/lib/rigor/inference/indexed_narrowing.rb +14 -0
  92. data/lib/rigor/inference/method_dispatcher/block_folding.rb +7 -5
  93. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +10 -0
  94. data/lib/rigor/inference/method_dispatcher/json_folding.rb +58 -0
  95. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +81 -38
  96. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +54 -17
  97. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +1 -2
  98. data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +63 -0
  99. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +63 -10
  100. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +104 -0
  101. data/lib/rigor/inference/method_dispatcher/universal_object_dispatch.rb +81 -0
  102. data/lib/rigor/inference/method_dispatcher.rb +155 -14
  103. data/lib/rigor/inference/method_parameter_binder.rb +3 -3
  104. data/lib/rigor/inference/mutation_widening.rb +244 -174
  105. data/lib/rigor/inference/narrowing.rb +331 -27
  106. data/lib/rigor/inference/parameter_inference_collector.rb +1 -0
  107. data/lib/rigor/inference/pre_eval_constants.rb +15 -0
  108. data/lib/rigor/inference/precision_scanner.rb +5 -2
  109. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  110. data/lib/rigor/inference/rbs_type_translator.rb +89 -39
  111. data/lib/rigor/inference/scope_indexer.rb +1493 -140
  112. data/lib/rigor/inference/statement_evaluator.rb +276 -91
  113. data/lib/rigor/inference/struct_fold_safety.rb +186 -27
  114. data/lib/rigor/inference/version_guard.rb +229 -0
  115. data/lib/rigor/language_server/selection_range_provider.rb +1 -1
  116. data/lib/rigor/plugin/base.rb +3 -2
  117. data/lib/rigor/plugin/inflector.rb +14 -5
  118. data/lib/rigor/plugin/io_boundary.rb +107 -4
  119. data/lib/rigor/plugin/loader.rb +14 -0
  120. data/lib/rigor/plugin/registry.rb +25 -2
  121. data/lib/rigor/protection/analysis_guard.rb +60 -0
  122. data/lib/rigor/protection/closure_kill_oracle.rb +43 -38
  123. data/lib/rigor/protection/diagnostic_oracle.rb +12 -4
  124. data/lib/rigor/protection/discovery_seed.rb +1 -1
  125. data/lib/rigor/protection/measurement_integrity.rb +36 -0
  126. data/lib/rigor/protection/mutation_scanner.rb +61 -6
  127. data/lib/rigor/protection/mutator.rb +121 -18
  128. data/lib/rigor/rbs_extended/envelope_scanner.rb +8 -7
  129. data/lib/rigor/rbs_extended/hkt_directives.rb +16 -1
  130. data/lib/rigor/rbs_extended/reporter.rb +93 -13
  131. data/lib/rigor/rbs_extended.rb +6 -1
  132. data/lib/rigor/reflection.rb +182 -30
  133. data/lib/rigor/scope/discovery_index.rb +39 -1
  134. data/lib/rigor/scope.rb +385 -4
  135. data/lib/rigor/sig_gen/classification.rb +12 -1
  136. data/lib/rigor/sig_gen/generator.rb +320 -85
  137. data/lib/rigor/sig_gen/renderer.rb +14 -12
  138. data/lib/rigor/sig_gen/writer.rb +3 -0
  139. data/lib/rigor/source/constant_path.rb +79 -2
  140. data/lib/rigor/triage/catalogue.rb +1 -1
  141. data/lib/rigor/type/combinator.rb +10 -0
  142. data/lib/rigor/type/maybe.rb +47 -0
  143. data/lib/rigor/type/refined.rb +1 -2
  144. data/lib/rigor/type/result.rb +53 -0
  145. data/lib/rigor/type.rb +2 -0
  146. data/lib/rigor/version.rb +1 -1
  147. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +3 -1
  148. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +58 -8
  149. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +15 -2
  150. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +4 -1
  151. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +3 -1
  152. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +39 -14
  153. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +36 -3
  154. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +6 -2
  155. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +21 -5
  156. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +47 -8
  157. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +13 -3
  158. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +242 -4
  159. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +3 -1
  160. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +7 -3
  161. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +62 -11
  162. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +15 -2
  163. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +3 -1
  164. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +5 -1
  165. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -2
  166. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +798 -22
  167. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +156 -13
  168. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +140 -40
  169. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -2
  170. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +20 -6
  171. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +14 -4
  172. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -3
  173. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +23 -6
  174. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +264 -9
  175. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +876 -23
  176. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +99 -0
  177. data/plugins/rigor-dry-monads/lib/rigor-dry-monads.rb +5 -0
  178. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +34 -0
  179. data/plugins/rigor-ethon/lib/rigor-ethon.rb +3 -0
  180. data/plugins/rigor-ethon/sig/ethon.rbs +27 -0
  181. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -2
  182. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +198 -0
  183. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/binding_recognizer.rb +75 -0
  184. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/catalog.rb +64 -0
  185. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +173 -0
  186. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +46 -0
  187. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +161 -0
  188. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +121 -0
  189. data/plugins/rigor-ffi/lib/rigor-ffi.rb +3 -0
  190. data/plugins/rigor-ffi/sig/ffi.rbs +67 -0
  191. data/plugins/rigor-ffi-rzmq/lib/rigor/plugin/ffi_rzmq.rb +19 -0
  192. data/plugins/rigor-ffi-rzmq/lib/rigor-ffi-rzmq.rb +3 -0
  193. data/plugins/rigor-ffi-rzmq/sig/ffi_rzmq.rbs +29 -0
  194. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -4
  195. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +2 -1
  196. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
  197. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +2 -1
  198. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +2 -1
  199. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +2 -1
  200. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +156 -11
  201. data/plugins/rigor-rbnacl/lib/rigor/plugin/rbnacl.rb +30 -0
  202. data/plugins/rigor-rbnacl/lib/rigor-rbnacl.rb +3 -0
  203. data/plugins/rigor-rbnacl/sig/rbnacl.rbs +22 -0
  204. data/plugins/rigor-sassc/lib/rigor/plugin/sassc.rb +55 -0
  205. data/plugins/rigor-sassc/lib/rigor-sassc.rb +3 -0
  206. data/plugins/rigor-sassc/sig/sassc.rbs +24 -0
  207. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +32 -11
  208. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +3 -1
  209. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +4 -1
  210. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +63 -10
  211. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +15 -2
  212. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +61 -2
  213. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +2 -2
  214. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +4 -2
  215. data/sig/rigor/analysis/baseline.rbs +1 -1
  216. data/sig/rigor/environment.rbs +9 -0
  217. data/sig/rigor/inference.rbs +4 -1
  218. data/sig/rigor/plugin/io_boundary.rbs +2 -0
  219. data/sig/rigor/reflection.rbs +3 -1
  220. data/sig/rigor/scope.rbs +25 -1
  221. data/sig/rigor/type.rbs +95 -1
  222. data/sig/rigor.rbs +16 -0
  223. metadata +71 -3
@@ -18,7 +18,7 @@ module Rigor
18
18
  # rendered receiver type) and `method_name` are filled in for reporting a surviving site; both may stay nil.
19
19
  Mutation = Struct.new(
20
20
  :operator, :expected_rule, :start, :stop, :replacement, :line, :label, :anchor,
21
- :anchor_type, :method_name,
21
+ :anchor_type, :method_name, :call_node,
22
22
  keyword_init: true
23
23
  ) do
24
24
  def apply(source)
@@ -35,6 +35,103 @@ module Rigor
35
35
  # call-argument literal dropped to `nil` or type-swapped (→ `call.argument-type-mismatch`), or a call site
36
36
  # renamed to a missing method (→ `call.undefined-method`). Only call sites and bodies are mutated, never
37
37
  # `def` signatures, so a reused project scan stays valid.
38
+
39
+ # Signature-arity guard for `arity_extra` (ADR-62): verifies through `ScopeIndexer` and
40
+ # `Reflection` that a call site targets a known, fixed/bounded positional signature and that
41
+ # adding 1 argument exceeds `max_arity`. Drops variadic or untyped calls to eliminate noise.
42
+ module ArityGuard
43
+ CONSTANT_CLASSES = {
44
+ Integer => "Integer", Float => "Float", String => "String",
45
+ Symbol => "Symbol", Range => "Range",
46
+ TrueClass => "TrueClass", FalseClass => "FalseClass",
47
+ NilClass => "NilClass"
48
+ }.freeze
49
+
50
+ module_function
51
+
52
+ def fixed_arity_call?(mut, index)
53
+ scope = call_scope(mut, index)
54
+ return false if scope.nil? || !plain_positional_call?(mut.call_node)
55
+
56
+ receiver_type = scope.type_of(mut.call_node.receiver)
57
+ class_name = concrete_class_name(receiver_type)
58
+ return false if class_name.nil? || !Rigor::Reflection.rbs_class_known?(class_name, scope: scope)
59
+
60
+ max_arity = method_max_arity(receiver_type, class_name, mut.call_node.name, scope)
61
+ return false if max_arity.nil? || max_arity == Float::INFINITY
62
+
63
+ actual_args_count(mut.call_node) + 1 > max_arity
64
+ rescue StandardError
65
+ false
66
+ end
67
+
68
+ def call_scope(mut, index)
69
+ call_node = mut.call_node
70
+ return nil if call_node.nil? || call_node.receiver.nil?
71
+
72
+ index[call_node] || index[mut.anchor]
73
+ end
74
+
75
+ def actual_args_count(call_node)
76
+ (call_node.arguments&.arguments || []).size
77
+ end
78
+
79
+ def method_max_arity(receiver_type, class_name, method_name, scope)
80
+ method_def =
81
+ if receiver_type.is_a?(Type::Singleton)
82
+ Rigor::Reflection.singleton_method_definition(class_name, method_name, scope: scope)
83
+ else
84
+ Rigor::Reflection.instance_method_definition(class_name, method_name, scope: scope)
85
+ end
86
+ return nil if method_def.nil? || method_def == true
87
+
88
+ max_positional_arity(method_def)
89
+ end
90
+
91
+ def plain_positional_call?(call_node)
92
+ arguments = call_node.arguments
93
+ return true if arguments.nil?
94
+
95
+ arguments.arguments.none? do |arg|
96
+ arg.is_a?(Prism::SplatNode) ||
97
+ arg.is_a?(Prism::KeywordHashNode) ||
98
+ arg.is_a?(Prism::BlockArgumentNode) ||
99
+ arg.is_a?(Prism::ForwardingArgumentsNode)
100
+ end
101
+ end
102
+
103
+ def max_positional_arity(method_def)
104
+ maxes = []
105
+ method_def.method_types.each do |mt|
106
+ function = mt.type
107
+ return nil unless function.respond_to?(:required_keywords)
108
+ return nil unless function.required_keywords.empty? && function.trailing_positionals.empty?
109
+
110
+ return Float::INFINITY if function.rest_positionals
111
+
112
+ min_arity = function.required_positionals.size
113
+ maxes << (min_arity + function.optional_positionals.size)
114
+ end
115
+ return nil if maxes.empty?
116
+
117
+ maxes.max
118
+ end
119
+
120
+ def concrete_class_name(type)
121
+ case type
122
+ when Type::Nominal, Type::Singleton, Type::DataClass, Type::DataInstance,
123
+ Type::StructClass, Type::StructInstance
124
+ type.class_name
125
+ when Type::Tuple then "Array"
126
+ when Type::HashShape then "Hash"
127
+ when Type::Constant then CONSTANT_CLASSES[type.value.class] || type.value.class.name
128
+ when Type::Refined, Type::Difference then concrete_class_name(type.base)
129
+ when Type::IntegerRange then "Integer"
130
+ when Type::App then concrete_class_name(type.bound)
131
+ end
132
+ end
133
+ end
134
+
38
135
  class Mutator
39
136
  IDENT = /\A[a-z_][A-Za-z0-9_]*\z/
40
137
  QUOTES = ['"', "'"].freeze
@@ -47,11 +144,9 @@ module Rigor
47
144
  # the mutated value/call sits in a context where Rigor has type knowledge.
48
145
  ALL_OPERATORS = %i[nil_inject type_swap undefined_method arity_extra].freeze
49
146
 
50
- # The default set. `arity_extra` is excluded: most Ruby methods accept an extra argument (splat / optional),
51
- # so appending one is usually an equivalent mutant it contributes almost only noise. Re-enable it
52
- # explicitly via `operators:` to measure arity teeth. (A signature-arity guard would make it
53
- # default-worthy — a follow-up.)
54
- OPERATORS = %i[nil_inject type_swap undefined_method].freeze
147
+ # The default set. With the signature-arity guard in place (`filter_by_type`), `arity_extra` is
148
+ # default-worthy: it only mutates call sites whose callee has a known, fixed/bounded positional arity.
149
+ OPERATORS = ALL_OPERATORS
55
150
 
56
151
  def initialize(source, operators: OPERATORS)
57
152
  @source = source
@@ -85,7 +180,9 @@ module Rigor
85
180
  kept = mutations.select do |mut|
86
181
  keep, type = anchor_decision(mut.anchor, index, cache)
87
182
  mut.anchor_type = type if keep
88
- keep
183
+ next false unless keep
184
+
185
+ mut.operator == :arity_extra ? ArityGuard.fixed_arity_call?(mut, index) : true
89
186
  end
90
187
  [kept, mutations.size - kept.size]
91
188
  end
@@ -105,7 +202,7 @@ module Rigor
105
202
 
106
203
  _keep, type = anchor_decision(mut.anchor, index, cache)
107
204
  mut.anchor_type = type
108
- true
205
+ mut.operator == :arity_extra ? ArityGuard.fixed_arity_call?(mut, index) : true
109
206
  end
110
207
  end
111
208
 
@@ -243,22 +340,28 @@ module Rigor
243
340
  # Append a trailing argument inside explicit `(...)` parens to trip an arity diagnostic against a known
244
341
  # fixed-arity signature.
245
342
  def extend_arity(node, out)
246
- open = node.opening_loc
247
- close = node.closing_loc
248
- return unless close && open&.slice == "("
249
-
250
- args = node.arguments&.arguments
251
- insertion = args && !args.empty? ? ", nil" : "nil"
252
- add(out, :arity_extra, "call.wrong-arity", close.start_offset, close.start_offset,
253
- insertion, node.location.start_line, "call ##{node.name} +1 arg", node.receiver, node.name.to_s)
343
+ if node.closing_loc && node.opening_loc&.slice == "("
344
+ offset = node.closing_loc.start_offset
345
+ args = node.arguments&.arguments
346
+ insertion = args && !args.empty? ? ", nil" : "nil"
347
+ elsif node.arguments && !node.arguments.arguments.empty?
348
+ offset = node.arguments.location.end_offset
349
+ insertion = ", nil"
350
+ else
351
+ return
352
+ end
353
+
354
+ add(out, :arity_extra, "call.wrong-arity", offset, offset,
355
+ insertion, node.location.start_line, "call #{node.name} +1 arg", node.receiver,
356
+ node.name.to_s, call_node: node)
254
357
  end
255
358
 
256
- def add(out, operator, rule, start, stop, replacement, line, label, anchor, method_name) # rubocop:disable Metrics/ParameterLists
359
+ def add(out, operator, rule, start, stop, replacement, line, label, anchor, method_name, call_node: nil) # rubocop:disable Metrics/ParameterLists
257
360
  return unless @operators.include?(operator)
258
361
 
259
362
  out << Mutation.new(operator: operator, expected_rule: rule, start: start, stop: stop,
260
363
  replacement: replacement, line: line, label: label, anchor: anchor,
261
- method_name: method_name)
364
+ method_name: method_name, call_node: call_node)
262
365
  end
263
366
 
264
367
  def snippet(loc)
@@ -22,14 +22,15 @@ module Rigor
22
22
  #
23
23
  # ## Why it parses rather than reading the built environment
24
24
  #
25
- # {ConformanceChecker}, the obvious model, walks `RbsLoader`'s built env. An envelope cannot: the
25
+ # {ConformanceChecker}, the obvious model, walks `RbsLoader`'s built env. An envelope did not: the
26
26
  # diagnostic has to name **where the bound was written** (`sig/foo.rbs:12`), and the ADR-54 env
27
- # cache dumps every `RBS::Location` to a zero-range `<cached>` sentinel, so a warm run would lose
28
- # both the position and the only evidence of which file a declaration came from. Parsing the
29
- # project's own signature sources is a few milliseconds over a tree Rigor already globs, it is
30
- # identical warm and cold, and it enforces ADR-103 WD6's trust rule structurally: a `%a{pure}` in
31
- # rbs core or in a gem's shipped RBS is never read, so it cannot bound a project method that
32
- # happens to share its key.
27
+ # cache reduced every `RBS::Location` to a zero-range `<cached>` sentinel, so a warm run lost both
28
+ # the position and the only evidence of which file a declaration came from. That particular loss is
29
+ # closed now #696 restored the buffer name, #799 an annotation's position so what still decides
30
+ # it is the rest: parsing the project's own signature sources is a few milliseconds over a tree
31
+ # Rigor already globs, it is identical warm and cold, and it enforces ADR-103 WD6's trust rule
32
+ # structurally: a `%a{pure}` in rbs core or in a gem's shipped RBS is never read, so it cannot bound
33
+ # a project method that happens to share its key.
33
34
  #
34
35
  # Malformed payloads and `pure`-versus-`effect` contradictions are recorded on a {Reporter} the
35
36
  # scanner owns and ride out on {Result#unresolved}. They surface no diagnostic in this slice:
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../inference/hkt_registry"
4
4
  require_relative "../inference/hkt_body_parser"
5
+ require_relative "reporter"
5
6
 
6
7
  module Rigor
7
8
  module RbsExtended
@@ -286,10 +287,24 @@ module Rigor
286
287
  source_location.respond_to?(:start_line) ? source_location.start_line : nil
287
288
  end
288
289
 
290
+ # Issue #785 — routes a declined directive to the per-run reporter. The FIRST arm is the one that
291
+ # matters: the production reporter is {RbsExtended::Reporter}, whose surface is `record_unresolved` /
292
+ # `record_lossy_projection` / `record_hkt_error` — it has neither `#record` nor `#<<`, so before this
293
+ # arm existed every call here fell off the end of the `if` and every malformed HKT directive was
294
+ # dropped in every real run, while the doc comments claimed an `:info` entry was recorded. The
295
+ # position is flattened at record time through {RbsExtended::Reporter.position_of}, the one place
296
+ # every stream flattens through since #805, because the entry has to reach the pool drain channel as
297
+ # primitives (see {RbsExtended::Reporter}).
298
+ #
299
+ # The `#record` / `#<<` arms stay for the collecting doubles the directive specs and plugin authors
300
+ # use, which is the contract `scan_rbs_loader`'s `@param reporter [#record, nil]` documents.
289
301
  def record_hkt_error(reporter, message, source_location)
290
302
  return if reporter.nil?
291
303
 
292
- if reporter.respond_to?(:record)
304
+ if reporter.respond_to?(:record_hkt_error)
305
+ path, line, column = Reporter.position_of(source_location)
306
+ reporter.record_hkt_error(message: message, path: path, line: line, column: column)
307
+ elsif reporter.respond_to?(:record)
293
308
  reporter.record(directive: "hkt", message: message, source_location: source_location)
294
309
  elsif reporter.respond_to?(:<<)
295
310
  reporter << { directive: "hkt", message: message, source_location: source_location }
@@ -6,7 +6,7 @@ module Rigor
6
6
  # cannot surface at the point of failure (the parsers are fail-soft, returning `nil` so call sites fall back
7
7
  # to the RBS-declared type).
8
8
  #
9
- # Owns two event streams:
9
+ # Owns three event streams:
10
10
  #
11
11
  # - `#unresolved_payloads` — `rigor:v1:*` directive payloads the resolver could not turn into a
12
12
  # {Rigor::Type}. Surface as `dynamic.rbs-extended.unresolved` `:info` diagnostics.
@@ -14,21 +14,60 @@ module Rigor
14
14
  # `required_of` / `readonly_of`) applied to a carrier that does not preserve shape information (anything
15
15
  # other than `Type::HashShape` / `Type::Tuple`). Surface as `dynamic.shape.lossy-projection` `:info`
16
16
  # diagnostics.
17
+ # - `#hkt_directive_errors` — malformed `rigor:v1:hkt_register` / `rigor:v1:hkt_define` directives the
18
+ # ADR-20 parser declined. Surface as `dynamic.rbs-extended.hkt-directive-invalid` `:info` diagnostics.
17
19
  #
18
20
  # Mutable through the run; consumed once by {Rigor::Analysis::Runner} at end-of-run. Each event is
19
- # deduplicated by `(payload, source_location)` for unresolved and `(head, source_location)` for
20
- # lossy-projection so a single annotation read from many call sites yields one diagnostic.
21
+ # deduplicated by its whole entry — `(payload, path, line, column)` for unresolved, `(head, path, line,
22
+ # column)` for lossy-projection, `(message, path, line, column)` for an hkt directive so a single
23
+ # annotation read from many call sites yields one diagnostic.
21
24
  #
22
25
  # The reporter is intentionally thread-safe via a coarse `Mutex` because the inference engine may read the
23
26
  # same method definition from multiple files in parallel; the critical sections are short (Array#include? +
24
27
  # Array#<<) so the lock contention is negligible.
25
28
  class Reporter
26
- UnresolvedEntry = Data.define(:payload, :source_location)
27
- LossyProjectionEntry = Data.define(:head, :source_location)
29
+ # Every entry carries its position as `(path, line, column)` primitives, flattened from the parser's
30
+ # `RBS::Location` by {.position_of} at record time. Issues #785 (hkt) and #805 (the two older streams).
31
+ # Two reasons, and either alone decides it. All three streams are drained out of every fork-pool worker
32
+ # ({Rigor::Analysis::WorkerSession#drain_reporters}), and an `RBS::Location` is a C-extension object with
33
+ # no `_dump`, so a `Marshal.dump` of the drain payload raises `TypeError` — which kills the worker at
34
+ # drain time and degrades the run to in-process re-analysis. And an `RBS::Location` compares equal only
35
+ # against a location over the SAME `RBS::Buffer` object, so two workers that each read the same `.rbs`
36
+ # would hand the coordinator two entries the dedup cannot collapse — `--workers=N` would then print N
37
+ # copies of a row `--workers=0` prints once.
38
+ #
39
+ # The record methods take the triple rather than the location for the same reason: a door that accepts
40
+ # an `RBS::Location` is a door a future caller reintroduces the bug through.
41
+ UnresolvedEntry = Data.define(:payload, :path, :line, :column)
42
+ LossyProjectionEntry = Data.define(:head, :path, :line, :column)
43
+ HktDirectiveEntry = Data.define(:message, :path, :line, :column)
44
+
45
+ # Flattens an `RBS::Location` (or anything answering the same readers) to the `(path, line, column)`
46
+ # triple every entry carries. `column` is 1-based, since `RBS::Location#start_column` is 0-based and
47
+ # diagnostics are not. Each component is `nil` when the location cannot supply it, and the diagnostic
48
+ # then falls back to `.rigor.yml:1:1`.
49
+ #
50
+ # Fail-soft by construction, like every parser that calls it: a location that raises while being read
51
+ # costs the entry its position, never the run.
52
+ #
53
+ # @param source_location [RBS::Location, nil]
54
+ # @return [Array(String, nil, Integer, nil, Integer, nil)]
55
+ def self.position_of(source_location)
56
+ return [nil, nil, nil] if source_location.nil?
57
+
58
+ buffer = source_location.respond_to?(:buffer) ? source_location.buffer : nil
59
+ name = buffer.respond_to?(:name) ? buffer.name.to_s : ""
60
+ line = source_location.respond_to?(:start_line) ? source_location.start_line : nil
61
+ column = source_location.respond_to?(:start_column) ? source_location.start_column + 1 : nil
62
+ [name.empty? ? nil : name, line, column]
63
+ rescue StandardError
64
+ [nil, nil, nil]
65
+ end
28
66
 
29
67
  def initialize
30
68
  @unresolved_payloads = []
31
69
  @lossy_projections = []
70
+ @hkt_directive_errors = []
32
71
  @mutex = Mutex.new
33
72
  end
34
73
 
@@ -42,11 +81,13 @@ module Rigor
42
81
  @mutex.synchronize { @lossy_projections.dup.freeze }
43
82
  end
44
83
 
45
- # Records a `dynamic.rbs-extended.unresolved` event. The `source_location` argument is the {RBS::Location}
46
- # attached to the source annotation (or `nil` when the caller doesn't have one the diagnostic falls back
47
- # to a generic location in that case).
48
- def record_unresolved(payload:, source_location: nil)
49
- entry = UnresolvedEntry.new(payload: payload.to_s, source_location: source_location)
84
+ # Records a `dynamic.rbs-extended.unresolved` event. The position triple is the source annotation's
85
+ # `.rbs` file / line / 1-based column {.position_of} flattens the caller's `RBS::Location` into it
86
+ # each `nil` when the caller had no location (the diagnostic then falls back to `.rigor.yml:1:1`).
87
+ def record_unresolved(payload:, path: nil, line: nil, column: nil)
88
+ entry = UnresolvedEntry.new(
89
+ payload: frozen_text(payload), path: frozen_text(path), line: line, column: column
90
+ )
50
91
  @mutex.synchronize do
51
92
  return if @unresolved_payloads.include?(entry)
52
93
 
@@ -56,8 +97,11 @@ module Rigor
56
97
 
57
98
  # Records a `dynamic.shape.lossy-projection` event for one of the five shape-projection heads. `head` MUST
58
99
  # be a String (`"pick_of"`, `"omit_of"`, …); the diagnostic message identifies which projection degraded.
59
- def record_lossy_projection(head:, source_location: nil)
60
- entry = LossyProjectionEntry.new(head: head.to_s, source_location: source_location)
100
+ # The position triple is read exactly as {#record_unresolved}'s is.
101
+ def record_lossy_projection(head:, path: nil, line: nil, column: nil)
102
+ entry = LossyProjectionEntry.new(
103
+ head: frozen_text(head), path: frozen_text(path), line: line, column: column
104
+ )
61
105
  @mutex.synchronize do
62
106
  return if @lossy_projections.include?(entry)
63
107
 
@@ -65,10 +109,46 @@ module Rigor
65
109
  end
66
110
  end
67
111
 
112
+ # @return [Array<HktDirectiveEntry>] frozen snapshot of the accumulated hkt-directive failures.
113
+ def hkt_directive_errors
114
+ @mutex.synchronize { @hkt_directive_errors.dup.freeze }
115
+ end
116
+
117
+ # Records a `dynamic.rbs-extended.hkt-directive-invalid` event: one malformed ADR-20 HKT directive the
118
+ # {Rigor::RbsExtended::HktDirectives} parser declined. `message` names what the parser objected to; the
119
+ # position triple is the annotation's `.rbs` file / line / 1-based column, each `nil` when the caller had
120
+ # no location (the diagnostic then falls back to `.rigor.yml:1:1`).
121
+ #
122
+ # Every String is frozen individually, not just the enclosing `Data`: the entry crosses the pool drain
123
+ # channel, whose stated invariant is that its payload is `Ractor.shareable?` as well as Marshal-clean.
124
+ def record_hkt_error(message:, path: nil, line: nil, column: nil)
125
+ entry = HktDirectiveEntry.new(
126
+ message: frozen_text(message), path: frozen_text(path), line: line, column: column
127
+ )
128
+ @mutex.synchronize do
129
+ return if @hkt_directive_errors.include?(entry)
130
+
131
+ @hkt_directive_errors << entry
132
+ end
133
+ end
134
+
68
135
  # True when no events have accumulated. Used by callers that want to skip the diagnostic-emission pass
69
136
  # entirely on the common no-event path.
70
137
  def empty?
71
- @mutex.synchronize { @unresolved_payloads.empty? && @lossy_projections.empty? }
138
+ @mutex.synchronize do
139
+ @unresolved_payloads.empty? && @lossy_projections.empty? && @hkt_directive_errors.empty?
140
+ end
141
+ end
142
+
143
+ private
144
+
145
+ # `nil` stays `nil` — the diagnostic falls back to `.rigor.yml:1:1` on a missing path. Anything else
146
+ # becomes an individually frozen String: the `Data` wrapper is frozen on its own, but the pool drain's
147
+ # stated invariant is that its payload is `Ractor.shareable?`, which is a DEEP freeze. Coercing here also
148
+ # means a caller that hands the door an `RBS::Location` by mistake stores its `#to_s`, not the object —
149
+ # the entry cannot become un-Marshalable from the outside.
150
+ def frozen_text(value)
151
+ value.nil? ? nil : value.to_s.dup.freeze
72
152
  end
73
153
  end
74
154
  end
@@ -812,10 +812,15 @@ module Rigor
812
812
  # ADR-13 slice 3b — guards every reporter call so the in-RbsExtended-module call sites can record events
813
813
  # uniformly without nil-checking each time. When the reporter is nil (the v0.1.0 → v0.1.3 default for call
814
814
  # sites that do not yet thread `environment:`), the call is a no-op and the parser stays fail-soft.
815
+ #
816
+ # Issue #805 — the location is flattened to `(path, line, column)` here, at record time, because the
817
+ # entry is drained out of every fork-pool worker and an `RBS::Location` neither survives `Marshal.dump`
818
+ # nor compares equal across two workers' buffers (see {Rigor::RbsExtended::Reporter}).
815
819
  def record_unresolved(reporter, payload, source_location)
816
820
  return if reporter.nil?
817
821
 
818
- reporter.record_unresolved(payload: payload, source_location: source_location)
822
+ path, line, column = Reporter.position_of(source_location)
823
+ reporter.record_unresolved(payload: payload, path: path, line: line, column: column)
819
824
  end
820
825
  end
821
826
  end