rigortype 0.1.19 → 0.2.1

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 (197) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +41 -6
  3. data/data/core_overlay/numeric.rbs +33 -0
  4. data/data/core_overlay/pathname.rbs +25 -0
  5. data/data/core_overlay/string_scanner.rbs +28 -0
  6. data/data/gem_overlay/activesupport/core_ext.rbs +473 -0
  7. data/data/vendored_gem_sigs/ast/ast.rbs +130 -0
  8. data/data/vendored_gem_sigs/bcrypt/bcrypt.rbs +47 -0
  9. data/data/vendored_gem_sigs/bundler/bundler.rbs +238 -0
  10. data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +34 -0
  11. data/data/vendored_gem_sigs/did_you_mean/did_you_mean_extras.rbs +34 -0
  12. data/data/vendored_gem_sigs/idn-ruby/idn.rbs +54 -0
  13. data/data/vendored_gem_sigs/mysql2/client.rbs +55 -0
  14. data/data/vendored_gem_sigs/mysql2/error.rbs +5 -0
  15. data/data/vendored_gem_sigs/mysql2/result.rbs +31 -0
  16. data/data/vendored_gem_sigs/mysql2/statement.rbs +5 -0
  17. data/data/vendored_gem_sigs/nokogiri/nokogiri.rbs +2332 -0
  18. data/data/vendored_gem_sigs/nokogiri/nokogiri_html5.rbs +47 -0
  19. data/data/vendored_gem_sigs/pg/pg.rbs +212 -0
  20. data/data/vendored_gem_sigs/prism/prism_supplement.rbs +44 -0
  21. data/data/vendored_gem_sigs/redis/errors.rbs +50 -0
  22. data/data/vendored_gem_sigs/redis/future.rbs +5 -0
  23. data/data/vendored_gem_sigs/redis/redis.rbs +348 -0
  24. data/data/vendored_gem_sigs/redis/redis_extras.rbs +130 -0
  25. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +226 -0
  26. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -23
  27. data/lib/rigor/analysis/check_rules/rule_walk.rb +3 -21
  28. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +24 -15
  29. data/lib/rigor/analysis/check_rules.rb +492 -71
  30. data/lib/rigor/analysis/dependency_source_inference/index.rb +4 -7
  31. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -18
  32. data/lib/rigor/analysis/dependency_source_inference.rb +3 -12
  33. data/lib/rigor/analysis/fact_store.rb +5 -4
  34. data/lib/rigor/analysis/rule_catalog.rb +153 -6
  35. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +17 -17
  36. data/lib/rigor/analysis/runner/project_pre_passes.rb +9 -8
  37. data/lib/rigor/analysis/runner.rb +17 -6
  38. data/lib/rigor/analysis/self_call_resolution_recorder.rb +3 -4
  39. data/lib/rigor/analysis/worker_session.rb +10 -14
  40. data/lib/rigor/builtins/predefined_constant_refinements.rb +151 -0
  41. data/lib/rigor/cache/store.rb +5 -3
  42. data/lib/rigor/cli/annotate_command.rb +28 -7
  43. data/lib/rigor/cli/baseline_command.rb +4 -3
  44. data/lib/rigor/cli/check_command.rb +138 -16
  45. data/lib/rigor/cli/coverage_command.rb +138 -31
  46. data/lib/rigor/cli/coverage_mutation.rb +149 -0
  47. data/lib/rigor/cli/coverage_scan.rb +57 -0
  48. data/lib/rigor/cli/explain_command.rb +2 -0
  49. data/lib/rigor/cli/fused_protection_renderer.rb +67 -0
  50. data/lib/rigor/cli/fused_protection_report.rb +76 -0
  51. data/lib/rigor/cli/lsp_command.rb +3 -7
  52. data/lib/rigor/cli/mutation_protection_renderer.rb +63 -0
  53. data/lib/rigor/cli/mutation_protection_report.rb +73 -0
  54. data/lib/rigor/cli/options.rb +9 -0
  55. data/lib/rigor/cli/plugins_command.rb +2 -1
  56. data/lib/rigor/cli/protection_renderer.rb +63 -0
  57. data/lib/rigor/cli/protection_report.rb +68 -0
  58. data/lib/rigor/cli/sig_gen_command.rb +2 -1
  59. data/lib/rigor/cli/trace_command.rb +2 -1
  60. data/lib/rigor/cli/triage_command.rb +2 -1
  61. data/lib/rigor/cli/type_of_command.rb +1 -1
  62. data/lib/rigor/cli/type_scan_command.rb +2 -1
  63. data/lib/rigor/cli.rb +3 -2
  64. data/lib/rigor/config_audit.rb +152 -0
  65. data/lib/rigor/configuration/dependencies.rb +2 -4
  66. data/lib/rigor/configuration.rb +57 -7
  67. data/lib/rigor/environment/bundle_sig_discovery.rb +61 -13
  68. data/lib/rigor/environment/class_registry.rb +4 -3
  69. data/lib/rigor/environment/constant_type_cache_holder.rb +43 -0
  70. data/lib/rigor/environment/lockfile_resolver.rb +1 -1
  71. data/lib/rigor/environment/rbs_collection_discovery.rb +1 -2
  72. data/lib/rigor/environment/rbs_coverage_report.rb +2 -1
  73. data/lib/rigor/environment/rbs_loader.rb +76 -5
  74. data/lib/rigor/environment.rb +66 -8
  75. data/lib/rigor/flow_contribution/fact.rb +1 -1
  76. data/lib/rigor/flow_contribution.rb +3 -5
  77. data/lib/rigor/inference/acceptance.rb +17 -9
  78. data/lib/rigor/inference/block_parameter_binder.rb +2 -3
  79. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -2
  80. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -2
  81. data/lib/rigor/inference/builtins/method_catalog.rb +19 -0
  82. data/lib/rigor/inference/builtins/string_catalog.rb +9 -1
  83. data/lib/rigor/inference/expression_typer.rb +20 -28
  84. data/lib/rigor/inference/hkt_body.rb +8 -11
  85. data/lib/rigor/inference/hkt_body_parser.rb +10 -12
  86. data/lib/rigor/inference/hkt_registry.rb +10 -11
  87. data/lib/rigor/inference/method_dispatcher/call_context.rb +1 -4
  88. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +169 -24
  89. data/lib/rigor/inference/method_dispatcher/data_folding.rb +9 -73
  90. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -7
  91. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +10 -16
  92. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +25 -13
  93. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +93 -0
  94. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -3
  95. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +24 -22
  96. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +90 -15
  97. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +303 -0
  98. data/lib/rigor/inference/method_dispatcher.rb +40 -48
  99. data/lib/rigor/inference/mutation_widening.rb +5 -11
  100. data/lib/rigor/inference/narrowing.rb +14 -16
  101. data/lib/rigor/inference/parameter_inference_collector.rb +367 -0
  102. data/lib/rigor/inference/project_patched_methods.rb +4 -7
  103. data/lib/rigor/inference/project_patched_scanner.rb +2 -13
  104. data/lib/rigor/inference/protection_scanner.rb +86 -0
  105. data/lib/rigor/inference/scope_indexer.rb +129 -55
  106. data/lib/rigor/inference/statement_evaluator.rb +271 -114
  107. data/lib/rigor/inference/struct_fold_safety.rb +181 -0
  108. data/lib/rigor/inference/synthetic_method.rb +7 -7
  109. data/lib/rigor/language_server/completion_provider.rb +6 -12
  110. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -4
  111. data/lib/rigor/language_server/document_symbol_provider.rb +3 -3
  112. data/lib/rigor/language_server/hover_provider.rb +2 -3
  113. data/lib/rigor/language_server/hover_renderer.rb +2 -11
  114. data/lib/rigor/language_server/server.rb +9 -17
  115. data/lib/rigor/language_server.rb +4 -5
  116. data/lib/rigor/plugin/base.rb +10 -8
  117. data/lib/rigor/plugin/macro/block_as_method.rb +3 -4
  118. data/lib/rigor/plugin/macro/heredoc_template.rb +4 -7
  119. data/lib/rigor/plugin/macro/trait_registry.rb +3 -6
  120. data/lib/rigor/plugin/macro.rb +4 -5
  121. data/lib/rigor/plugin/manifest.rb +45 -66
  122. data/lib/rigor/plugin/registry.rb +6 -7
  123. data/lib/rigor/plugin/type_node_resolver.rb +6 -8
  124. data/lib/rigor/protection/diagnostic_oracle.rb +51 -0
  125. data/lib/rigor/protection/mutation_scanner.rb +180 -0
  126. data/lib/rigor/protection/mutator.rb +267 -0
  127. data/lib/rigor/protection/test_suite_oracle.rb +68 -0
  128. data/lib/rigor/rbs_extended.rb +24 -36
  129. data/lib/rigor/reflection.rb +4 -7
  130. data/lib/rigor/scope/discovery_index.rb +14 -2
  131. data/lib/rigor/scope.rb +54 -11
  132. data/lib/rigor/sig_gen/observed_call.rb +3 -3
  133. data/lib/rigor/sig_gen/writer.rb +40 -2
  134. data/lib/rigor/signature_path_audit.rb +92 -0
  135. data/lib/rigor/source/constant_path.rb +62 -0
  136. data/lib/rigor/source.rb +1 -0
  137. data/lib/rigor/type/bound_method.rb +2 -11
  138. data/lib/rigor/type/combinator.rb +16 -3
  139. data/lib/rigor/type/constant.rb +2 -11
  140. data/lib/rigor/type/data_class.rb +2 -11
  141. data/lib/rigor/type/data_instance.rb +2 -11
  142. data/lib/rigor/type/hash_shape.rb +2 -11
  143. data/lib/rigor/type/integer_range.rb +2 -11
  144. data/lib/rigor/type/intersection.rb +2 -11
  145. data/lib/rigor/type/nominal.rb +2 -11
  146. data/lib/rigor/type/plain_lattice.rb +37 -0
  147. data/lib/rigor/type/refined.rb +72 -13
  148. data/lib/rigor/type/singleton.rb +2 -11
  149. data/lib/rigor/type/struct_class.rb +75 -0
  150. data/lib/rigor/type/struct_instance.rb +93 -0
  151. data/lib/rigor/type/tuple.rb +5 -15
  152. data/lib/rigor/type.rb +2 -0
  153. data/lib/rigor/version.rb +1 -1
  154. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  155. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +3 -3
  156. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +3 -3
  157. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +5 -13
  158. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +11 -17
  159. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +7 -10
  160. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +3 -2
  161. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  162. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +6 -8
  163. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +5 -7
  164. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +1 -2
  165. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +9 -11
  166. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +8 -9
  167. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +13 -12
  168. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +3 -4
  169. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +8 -8
  170. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +9 -11
  171. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +7 -8
  172. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +7 -9
  173. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +12 -13
  174. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +15 -23
  175. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +3 -3
  176. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +3 -3
  177. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +2 -4
  178. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +27 -11
  179. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +1 -1
  180. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -6
  181. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +12 -18
  182. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +5 -5
  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 +19 -14
  187. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +0 -1
  188. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +5 -4
  189. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +2 -3
  190. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +7 -11
  191. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +4 -5
  192. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +6 -9
  193. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +5 -15
  194. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +28 -41
  195. data/sig/rigor/scope.rbs +9 -1
  196. data/sig/rigor/type.rbs +36 -1
  197. metadata +49 -1
@@ -0,0 +1,267 @@
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
+ # ADR-69 Seam 2 (AllSites) — keep every *dispatch-site* mutation (a method
102
+ # call or a call-argument literal), Dynamic receiver included, annotating
103
+ # the anchor type where Rigor holds one. Drops only non-dispatch literals
104
+ # (a literal outside any call — no receiver contract to violate). The
105
+ # biteable {#filter_by_type} hides exactly the Dynamic sites a test-suite
106
+ # consumer most wants to probe: where Rigor cannot bite, a test is the only
107
+ # protection. Use only with a {TestSuiteOracle} — at a Dynamic site the
108
+ # type pass can never kill, so without the test axis these are all noise.
109
+ def dispatch_site_mutations(mutations, environment:, path:)
110
+ base = Rigor::Scope.empty(environment: environment, source_path: path)
111
+ index = Rigor::Inference::ScopeIndexer.index(@parse.value, default_scope: base)
112
+ cache = {}
113
+ mutations.select do |mut|
114
+ next false if mut.method_name.nil?
115
+
116
+ _keep, type = anchor_decision(mut.anchor, index, cache)
117
+ mut.anchor_type = type
118
+ true
119
+ end
120
+ end
121
+
122
+ private
123
+
124
+ def walk(node, &blk)
125
+ return if node.nil?
126
+
127
+ blk.call(node)
128
+ node.compact_child_nodes.each { |child| walk(child, &blk) }
129
+ end
130
+
131
+ def collect(node, out)
132
+ case node
133
+ when Prism::IntegerNode, Prism::FloatNode
134
+ literal_mutations(node, out, numeric: true)
135
+ when Prism::StringNode
136
+ literal_mutations(node, out, numeric: false)
137
+ when Prism::CallNode
138
+ call_mutations(node, out)
139
+ end
140
+ end
141
+
142
+ # Record, for each literal that is a direct call argument, the receiver of
143
+ # the enclosing call — the anchor whose param contract a literal mutation
144
+ # could violate. Literals elsewhere get a nil anchor (filtered out under
145
+ # the type filter).
146
+ def index_literal_anchors(node)
147
+ return if node.nil?
148
+
149
+ if node.is_a?(Prism::CallNode) && node.arguments
150
+ node.arguments.arguments.each do |arg|
151
+ @anchor_for[arg] = [node.receiver, node.name.to_s] if literal?(arg)
152
+ end
153
+ end
154
+ node.compact_child_nodes.each { |child| index_literal_anchors(child) }
155
+ end
156
+
157
+ def literal?(node)
158
+ node.is_a?(Prism::IntegerNode) || node.is_a?(Prism::FloatNode) || node.is_a?(Prism::StringNode)
159
+ end
160
+
161
+ # Returns [keep?, rendered_type]. Keep when `anchor` is a site where Rigor
162
+ # holds a concrete (non-Dynamic/Top) type. FP-safe: an unresolved or
163
+ # probe-failed type keeps the mutation (with a nil rendered type).
164
+ def anchor_decision(anchor, index, cache)
165
+ return [false, nil] if anchor.nil?
166
+ return cache[anchor] if cache.key?(anchor)
167
+
168
+ cache[anchor] = compute_anchor_decision(anchor, index)
169
+ end
170
+
171
+ def compute_anchor_decision(anchor, index)
172
+ scope = index[anchor]
173
+ return [true, nil] if scope.nil? # unresolved scope → keep (FP-safe)
174
+
175
+ type = scope.type_of(anchor)
176
+ return [true, nil] if type.nil?
177
+
178
+ concrete = !non_concrete_type?(type)
179
+ [concrete, concrete ? render_type(type) : nil]
180
+ rescue StandardError
181
+ [true, nil] # never let a probe failure hide a candidate
182
+ end
183
+
184
+ # A receiver type Rigor cannot bite on, so a mutation anchored to it would
185
+ # survive as noise: `Dynamic` / `Top` / `bot`, or a union with any such arm
186
+ # (gradually valid — `Array | Dynamic[top]`.whatever never fires). A union
187
+ # of fully-concrete arms (`String | Symbol`) stays concrete — it now has
188
+ # undefined-method teeth.
189
+ def non_concrete_type?(type)
190
+ return true if type.is_a?(Rigor::Type::Dynamic) || type.is_a?(Rigor::Type::Top) ||
191
+ type.is_a?(Rigor::Type::Bot)
192
+ return type.members.any? { |member| non_concrete_type?(member) } if type.is_a?(Rigor::Type::Union)
193
+
194
+ false
195
+ end
196
+
197
+ def render_type(type)
198
+ type.respond_to?(:describe) ? type.describe(:short) : type.to_s
199
+ rescue StandardError
200
+ type.class.name
201
+ end
202
+
203
+ # Mutate a literal: drop it to nil (possible-nil channel) and swap its
204
+ # type (type-mismatch channel). String literals are only touched when the
205
+ # node is a real quoted string, so we never corrupt `%w[...]` words.
206
+ def literal_mutations(node, out, numeric:)
207
+ return if !numeric && !QUOTES.include?(node.opening_loc&.slice)
208
+
209
+ anchor, method = @anchor_for[node]
210
+ return if UNIVERSAL_EQUALITY.include?(method)
211
+
212
+ loc = node.location
213
+ add(out, :nil_inject, "call.argument-type-mismatch", loc.start_offset, loc.end_offset,
214
+ "nil", loc.start_line, "literal → nil (#{snippet(loc)})", anchor, method)
215
+ swap = numeric ? '"rigor_mutant"' : "0"
216
+ add(out, :type_swap, "call.argument-type-mismatch", loc.start_offset, loc.end_offset,
217
+ swap, loc.start_line, "literal type swap (#{snippet(loc)} → #{swap})", anchor, method)
218
+ end
219
+
220
+ def call_mutations(node, out)
221
+ rename_call(node, out)
222
+ extend_arity(node, out)
223
+ end
224
+
225
+ # Rename the *call site* (not the def) to a method that cannot exist, so a
226
+ # typed receiver trips call.undefined-method. We leave `def` signatures
227
+ # untouched on purpose: the prebuilt ProjectScan still carries the file's
228
+ # original declarations, so mutating only bodies/call-sites keeps it valid.
229
+ # Anchor is the explicit receiver (nil ⇒ implicit self ⇒ filtered out, as
230
+ # call.self-undefined-method ships `:off`).
231
+ def rename_call(node, out)
232
+ name = node.name.to_s
233
+ mloc = node.message_loc
234
+ return unless mloc && IDENT.match?(name)
235
+
236
+ add(out, :undefined_method, "call.undefined-method", mloc.start_offset, mloc.end_offset,
237
+ "#{name}__rigor_absent", mloc.start_line, "call ##{name} → missing method", node.receiver, name)
238
+ end
239
+
240
+ # Append a trailing argument inside explicit `(...)` parens to trip an
241
+ # arity diagnostic against a known fixed-arity signature.
242
+ def extend_arity(node, out)
243
+ open = node.opening_loc
244
+ close = node.closing_loc
245
+ return unless close && open&.slice == "("
246
+
247
+ args = node.arguments&.arguments
248
+ insertion = args && !args.empty? ? ", nil" : "nil"
249
+ add(out, :arity_extra, "call.wrong-arity", close.start_offset, close.start_offset,
250
+ insertion, node.location.start_line, "call ##{node.name} +1 arg", node.receiver, node.name.to_s)
251
+ end
252
+
253
+ def add(out, operator, rule, start, stop, replacement, line, label, anchor, method_name) # rubocop:disable Metrics/ParameterLists
254
+ return unless @operators.include?(operator)
255
+
256
+ out << Mutation.new(operator: operator, expected_rule: rule, start: start, stop: stop,
257
+ replacement: replacement, line: line, label: label, anchor: anchor,
258
+ method_name: method_name)
259
+ end
260
+
261
+ def snippet(loc)
262
+ text = loc.slice.gsub(/\s+/, " ")
263
+ text.length > 30 ? "#{text[0, 27]}..." : text
264
+ end
265
+ end
266
+ end
267
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Protection
5
+ # ADR-70 — the **test-suite** kill oracle, the dynamic sibling of
6
+ # {DiagnosticOracle} on the ADR-69 seam. A mutant is killed iff applying its
7
+ # bytes to the file under test turns the project's test suite **red**. This is
8
+ # the dynamic half of the fused static∪dynamic protection map: a `Dynamic`
9
+ # site Rigor cannot bite (a type survivor) may still be fully guarded by a
10
+ # test.
11
+ #
12
+ # The suite command is the **runner hook** (`--test-command`, e.g.
13
+ # `bundle exec rake`). The runner is injectable so the decision logic is
14
+ # unit-testable without shelling out; the default shells out and reads the
15
+ # process exit status (0 = green / passed).
16
+ #
17
+ # I/O policy: {#killed?} writes the mutant to disk, runs the suite, and
18
+ # **always restores** the original bytes in an `ensure` — a normal exception
19
+ # never leaves a mutant on disk. (A hard interrupt mid-suite is the standard
20
+ # mutation-testing hazard the `ensure` cannot cover; callers running this in
21
+ # CI accept that, as `mutant` / Stryker do.)
22
+ class TestSuiteOracle
23
+ # @param command [Array<String>] the test command (the runner hook)
24
+ # @param runner [#call, nil] `runner.call(command) -> true iff the suite
25
+ # passed`. Defaults to shelling out via `system`.
26
+ def initialize(command:, runner: nil)
27
+ @command = command
28
+ @runner = runner || method(:shell_run)
29
+ end
30
+
31
+ # The baseline: the suite must pass on clean code, else "a mutant survived"
32
+ # is meaningless (every mutant would look killed, or none would). Run once
33
+ # before measuring.
34
+ def green?
35
+ @runner.call(@command)
36
+ end
37
+
38
+ # Killed iff the mutant turns the suite red. Restores `original` afterward.
39
+ # @param path [String] the file to (temporarily) overwrite with the mutant
40
+ # @param original [String] the clean bytes to restore
41
+ # @param mutant_source [String] the mutated bytes to test against
42
+ def killed?(path:, original:, mutant_source:)
43
+ File.write(path, mutant_source)
44
+ !@runner.call(@command)
45
+ ensure
46
+ File.write(path, original)
47
+ end
48
+
49
+ private
50
+
51
+ # Run the suite with Bundler's environment stripped, so a `bundle exec`
52
+ # test command resolves the **target** project's Gemfile — not whatever
53
+ # bundle Rigor itself was launched under. Running Rigor via `bundle exec`
54
+ # leaks `RUBYOPT=-rbundler/setup` + `GEM_HOME` / `BUNDLE_*` into a plain
55
+ # `system` subprocess, which then resolves the target's Gemfile against
56
+ # Rigor's gems and fails — so a green suite looks red and the run aborts.
57
+ # `with_unbundled_env` restores the pre-bundler env (a bare `env -u
58
+ # BUNDLE_GEMFILE` is not enough — the `BUNDLER_ORIG_*` preservers defeat
59
+ # it). Found validating ADR-70 on real projects (2026-06-17).
60
+ def shell_run(command)
61
+ run = -> { system(*command, out: File::NULL, err: File::NULL) }
62
+ return run.call unless defined?(Bundler) && Bundler.respond_to?(:with_unbundled_env)
63
+
64
+ Bundler.with_unbundled_env(&run)
65
+ end
66
+ end
67
+ end
68
+ end
@@ -7,43 +7,30 @@ require_relative "rbs_extended/reporter"
7
7
  require_relative "rbs_extended/hkt_directives"
8
8
 
9
9
  module Rigor
10
- # Slice 7 phase 15 first-preview reader for the
11
- # `RBS::Extended` annotation surface described in
10
+ # Reader for the `RBS::Extended` annotation surface described in
12
11
  # `docs/type-specification/rbs-extended.md`.
13
12
  #
14
- # This module reads `%a{rigor:v1:<directive> <payload>}`
15
- # annotations off RBS method definitions and returns
16
- # well-typed effect objects the inference engine can
17
- # consume. v0.0.2 recognises:
13
+ # Reads `%a{rigor:v1:<directive> <payload>}` annotations off RBS
14
+ # method definitions and returns well-typed effect objects the
15
+ # inference engine can consume. Implemented directives:
18
16
  #
19
- # - `rigor:v1:predicate-if-true <target> is <ClassName>`
20
- # - `rigor:v1:predicate-if-false <target> is <ClassName>`
21
- # - `rigor:v1:assert <target> is <ClassName>`
22
- # - `rigor:v1:assert-if-true <target> is <ClassName>`
23
- # - `rigor:v1:assert-if-false <target> is <ClassName>`
17
+ # - `rigor:v1:predicate-if-true <target> is <ClassName|refinement>`
18
+ # - `rigor:v1:predicate-if-false <target> is <ClassName|refinement>`
19
+ # - `rigor:v1:assert <target> is <ClassName|refinement>`
20
+ # - `rigor:v1:assert-if-true <target> is <ClassName|refinement>`
21
+ # - `rigor:v1:assert-if-false <target> is <ClassName|refinement>`
22
+ # - `rigor:v1:param <name> <type-expr>` — per-call param narrowing
23
+ # - `rigor:v1:return <type-expr>` — per-call return override
24
+ # - `rigor:v1:conforms-to <InterfaceName>` — structural conformance
24
25
  #
25
- # `predicate-if-*` fires when the call is used as an
26
- # `if` / `unless` condition; `assert` fires unconditionally
27
- # at the call's post-scope; `assert-if-true` /
28
- # `assert-if-false` fire at the post-scope only when the
29
- # call's return value can be observed as truthy / falsey
30
- # (currently: when the call is the predicate of a
31
- # subsequent `if` / `unless`). Other directives in the spec
32
- # (`param`, `return`, `conforms-to`, negation `~T`,
33
- # `target: self` narrowing, ...) remain on the v0.0.x
34
- # roadmap. Annotations whose key is in the `rigor:v1:`
35
- # namespace but whose directive is unrecognised are
36
- # silently ignored at first-preview quality (a future slice
37
- # MAY surface them as diagnostics-on-Rigor-itself per the
38
- # spec's "unsupported metadata" guidance).
39
- #
40
- # The parser is minimal: it accepts a strict shape
41
- # `<target> is <ClassName>` where `<target>` is a Ruby
42
- # identifier (parameter name) or `self`, and `<ClassName>`
43
- # is a single non-namespaced class identifier or a
44
- # `::Foo::Bar` style constant path. Negative refinements
45
- # (`~T`), intersections, and unions are deferred to the
46
- # next iteration.
26
+ # `predicate-if-*` fires when the call is used as an `if` / `unless`
27
+ # condition; `assert` fires unconditionally at the call's post-scope;
28
+ # `assert-if-true` / `assert-if-false` fire at the post-scope only
29
+ # when the call's return value can be observed as truthy / falsey.
30
+ # Negation (`~T`) is supported for both class-name and refinement
31
+ # right-hand sides. Parameterised refinements (`non-empty-array[T]`)
32
+ # are also recognised. Annotations whose directive is unrecognised
33
+ # are silently ignored per the spec's "unsupported metadata" guidance.
47
34
  module RbsExtended # rubocop:disable Metrics/ModuleLength
48
35
  DIRECTIVE_PREFIX = "rigor:v1:"
49
36
 
@@ -58,9 +45,10 @@ module Rigor
58
45
  # a kebab-case refinement name (`non-empty-string`,
59
46
  # `lowercase-string`, …) instead of a Capitalised class
60
47
  # name. The narrowing tier substitutes the carrier for the
61
- # current local type; `class_name` is then nil and
62
- # `negative` is false (refinement-form directives do not
63
- # support `~T` negation in v0.0.4).
48
+ # current local type; `class_name` is then nil. `negative`
49
+ # may be true for refinement-form directives `~T` negation
50
+ # is supported; the narrowing tier computes the complement
51
+ # decomposition (see `AssertEffect` docs below).
64
52
  class PredicateEffect < Data.define(:edge, :target_kind, :target_name, :class_name, :negative, :refinement_type)
65
53
  def truthy_only? = edge == :truthy_only
66
54
  def falsey_only? = edge == :falsey_only
@@ -17,11 +17,8 @@ module Rigor
17
17
  # classes / modules, in-source constants, discovered method
18
18
  # nodes, class ivar / cvar declarations).
19
19
  #
20
- # This module is the **stable read shape** that v0.1.0's plugin
21
- # API will be designed against. ADR-2 (`docs/adr/2-extension-api.md`)
22
- # calls out a unified reflection layer as a prerequisite for the
23
- # extension protocols, and `docs/design/20260505-v0.1.0-readiness.md`
24
- # nominates this module as the highest-leverage cold-start slice.
20
+ # This module is the **stable read shape** the plugin API is
21
+ # designed against (ADR-2, `docs/adr/2-extension-api.md`).
25
22
  #
26
23
  # The facade is **read-only and additive**. Existing call sites
27
24
  # that read directly from `Rigor::Scope` or
@@ -52,8 +49,8 @@ module Rigor
52
49
  # defined in the analyzed sources?
53
50
  #
54
51
  # The provenance side of the API (which source family contributed
55
- # each fact) is explicitly out of scope for the v0.0.7 first
56
- # pass. v0.1.0's plugin API adds it as a separate concern.
52
+ # each fact) is explicitly out of scope for the v0.0.7 first pass;
53
+ # v0.1.0's plugin API added it as a separate concern.
57
54
  module Reflection
58
55
  module_function
59
56
 
@@ -28,7 +28,9 @@ module Rigor
28
28
  :discovered_superclasses,
29
29
  :discovered_includes,
30
30
  :discovered_class_sources,
31
- :data_member_layouts
31
+ :data_member_layouts,
32
+ :struct_member_layouts,
33
+ :param_inferred_types
32
34
  )
33
35
 
34
36
  class DiscoveryIndex
@@ -53,7 +55,17 @@ module Rigor
53
55
  discovered_superclasses: EMPTY_TABLE,
54
56
  discovered_includes: EMPTY_TABLE,
55
57
  discovered_class_sources: EMPTY_TABLE,
56
- data_member_layouts: EMPTY_TABLE
58
+ data_member_layouts: EMPTY_TABLE,
59
+ struct_member_layouts: EMPTY_TABLE,
60
+ # ADR-67 WD3 — the call-site parameter-inference table, keyed by
61
+ # `[class_name, method_name, kind]` (the same `(class, method, kind)`
62
+ # triple {Inference::ParameterInferenceCollector} records and that
63
+ # `build_method_entry_scope` reconstructs from the lexical class path).
64
+ # The value is a `{param_name(Symbol) => Rigor::Type}` map of the union
65
+ # of resolved call-site argument types. Empty on every normal run; only
66
+ # the `coverage --protection` collection pass populates it today, so a
67
+ # `check` run leaves it empty and seeds nothing (byte-identical).
68
+ param_inferred_types: EMPTY_TABLE
57
69
  )
58
70
  end
59
71
  end
data/lib/rigor/scope.rb CHANGED
@@ -23,7 +23,7 @@ module Rigor
23
23
  :ivars, :cvars, :globals,
24
24
  :indexed_narrowings, :method_chain_narrowings,
25
25
  :declaration_sourced,
26
- :source_path, :discovery
26
+ :source_path, :discovery, :struct_fold_safe_locals
27
27
 
28
28
  # ADR-53 Track A — the seed-time discovery tables live on the
29
29
  # {DiscoveryIndex} the scope carries by a single reference; the
@@ -51,6 +51,13 @@ module Rigor
51
51
  def discovered_includes = @discovery.discovered_includes
52
52
  def discovered_class_sources = @discovery.discovered_class_sources
53
53
  def data_member_layouts = @discovery.data_member_layouts
54
+ def struct_member_layouts = @discovery.struct_member_layouts
55
+ # ADR-67 WD3 — call-site-inferred parameter types, keyed by
56
+ # `[class_name, method_name, kind]`. `build_method_entry_scope` consults
57
+ # this to seed an undeclared `def` parameter with the union of its
58
+ # resolved call-site argument types (precision-additive; an RBS-declared
59
+ # parameter always wins). Empty unless a collection pass seeded it.
60
+ def param_inferred_types = @discovery.param_inferred_types
54
61
 
55
62
  # Narrowing key for an indexed read `receiver[key]` where both
56
63
  # the receiver and the key are stable enough to address. The
@@ -99,8 +106,14 @@ module Rigor
99
106
  # principle. Any flow-live touch (write / narrowing) drops the mark, so
100
107
  # the diagnostic keeps firing exactly as before on flow-observed nil.
101
108
  EMPTY_DECLARATION_SOURCED = Set.new.freeze
109
+ # ADR-48 Struct slice 3 — the per-body set of local names whose struct
110
+ # member reads are fold-safe (provably never mutated / aliased / escaped).
111
+ # A static per-scope context like {#source_path}: inherited unchanged
112
+ # through flow transitions and ignored by `==` / `hash`.
113
+ EMPTY_FOLD_SAFE = Set.new.freeze
102
114
  private_constant :EMPTY_VAR_BINDINGS, :EMPTY_INDEXED_NARROWINGS,
103
- :EMPTY_CHAIN_NARROWINGS, :EMPTY_DECLARATION_SOURCED
115
+ :EMPTY_CHAIN_NARROWINGS, :EMPTY_DECLARATION_SOURCED,
116
+ :EMPTY_FOLD_SAFE
104
117
 
105
118
  class << self
106
119
  def empty(environment: Environment.default, source_path: nil)
@@ -120,7 +133,8 @@ module Rigor
120
133
  indexed_narrowings: EMPTY_INDEXED_NARROWINGS,
121
134
  method_chain_narrowings: EMPTY_CHAIN_NARROWINGS,
122
135
  declaration_sourced: EMPTY_DECLARATION_SOURCED,
123
- source_path: nil
136
+ source_path: nil,
137
+ struct_fold_safe_locals: EMPTY_FOLD_SAFE
124
138
  )
125
139
  @environment = environment
126
140
  @locals = locals
@@ -134,6 +148,7 @@ module Rigor
134
148
  @method_chain_narrowings = method_chain_narrowings
135
149
  @declaration_sourced = declaration_sourced
136
150
  @source_path = source_path
151
+ @struct_fold_safe_locals = struct_fold_safe_locals
137
152
  freeze
138
153
  end
139
154
 
@@ -180,17 +195,29 @@ module Rigor
180
195
  rebuild(self_type: type)
181
196
  end
182
197
 
183
- # ADR-11 per-call-site assertion gating prerequisite. The
184
- # analyzer's per-file boundary stamps the current source
185
- # file's path onto the seed scope; nested rebuilds carry
186
- # the value through so plugin rules (`dynamic_return`'s
187
- # `file_methods:` gate, sigil checks) can resolve "which
188
- # file does this call site belong to?" without
198
+ # ADR-28 / ADR-52 slice 5a — per-file source path carried on
199
+ # the scope. The analyzer stamps the current file's path onto
200
+ # the seed scope; nested rebuilds propagate it so plugin rules
201
+ # (`dynamic_return`'s `file_methods:` gate, sigil checks) can
202
+ # resolve "which file does this call site belong to?" without
189
203
  # thread-locals.
190
204
  def with_source_path(path)
191
205
  rebuild(source_path: path)
192
206
  end
193
207
 
208
+ # ADR-48 Struct slice 3 — installs the per-body fold-safe-local set
209
+ # ({Inference::StructFoldSafety}). Set once at body entry; inherited
210
+ # unchanged through subsequent flow transitions.
211
+ def with_struct_fold_safe(locals)
212
+ rebuild(struct_fold_safe_locals: locals)
213
+ end
214
+
215
+ # True when `name`'s `Struct` member reads are fold-safe in this body
216
+ # (the local is provably never mutated / aliased / escaped).
217
+ def struct_fold_safe?(name)
218
+ @struct_fold_safe_locals.include?(name.to_sym)
219
+ end
220
+
194
221
  # ADR-53 Track A — swaps the whole discovery index in one transition.
195
222
  # The sole seeding path; the per-table writers it replaced are derived
196
223
  # off-`Scope` through `scope.discovery.with(table_name: table)`.
@@ -484,6 +511,20 @@ module Rigor
484
511
  layout
485
512
  end
486
513
 
514
+ # ADR-48 Struct follow-up — the `{ members:, keyword_init: }` layout
515
+ # recorded for a `Struct.new(...)`-defined class, in the constant form
516
+ # (`Point = Struct.new(:x, :y)`) and the named-subclass form
517
+ # (`class Point < Struct.new(:x, :y)`). Consumed by
518
+ # {Inference::MethodDispatcher::StructFolding} so `Point.new(...)` on a
519
+ # `Singleton[Point]` receiver materialises a member instance. Returns nil
520
+ # when the class has no recorded struct layout. Mirrors
521
+ # {#data_member_layout}'s dependency-recording contract.
522
+ def struct_member_layout(class_name)
523
+ layout = @discovery.struct_member_layouts[class_name.to_s]
524
+ record_class_dependency(class_name) if layout && Analysis::DependencyRecorder.active?
525
+ layout
526
+ end
527
+
487
528
  # ADR-24 slice 2 — per-class/module table mapping a fully
488
529
  # qualified user class or module to the list of module
489
530
  # names it `include`s / `prepend`s, AS WRITTEN at the
@@ -674,7 +715,8 @@ module Rigor
674
715
  indexed_narrowings: @indexed_narrowings,
675
716
  method_chain_narrowings: @method_chain_narrowings,
676
717
  declaration_sourced: @declaration_sourced,
677
- source_path: @source_path
718
+ source_path: @source_path,
719
+ struct_fold_safe_locals: @struct_fold_safe_locals
678
720
  )
679
721
  self.class.new(
680
722
  environment: environment, locals: locals,
@@ -684,7 +726,8 @@ module Rigor
684
726
  indexed_narrowings: indexed_narrowings,
685
727
  method_chain_narrowings: method_chain_narrowings,
686
728
  declaration_sourced: declaration_sourced,
687
- source_path: source_path
729
+ source_path: source_path,
730
+ struct_fold_safe_locals: struct_fold_safe_locals
688
731
  )
689
732
  end
690
733
 
@@ -5,9 +5,9 @@ module Rigor
5
5
  # Per-call-site argument observation produced by
6
6
  # {ObservationCollector}. ADR-14 follow-up: the earlier
7
7
  # MVP shape (`Array[Type]` of positional types only)
8
- # could not represent keyword arguments — every call like
9
- # `MethodCatalog.new(path: ..., mutating_selectors: ...)`
10
- # discarded the whole observation via `non_positional?`.
8
+ # could not represent keyword arguments — keyword calls
9
+ # like `MethodCatalog.new(path: ..., mutating_selectors: ...)`
10
+ # were silently skipped in that shape.
11
11
  # The new shape carries positional and keyword arg types
12
12
  # in parallel so the per-position / per-keyword unions
13
13
  # can each be reconstructed independently.