rigortype 0.1.11 → 0.1.13

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/check_rules.rb +96 -3
  3. data/lib/rigor/analysis/erb_template_detector.rb +38 -0
  4. data/lib/rigor/analysis/runner.rb +6 -1
  5. data/lib/rigor/analysis/worker_session.rb +6 -1
  6. data/lib/rigor/cli/plugins_command.rb +308 -0
  7. data/lib/rigor/cli/plugins_renderer.rb +173 -0
  8. data/lib/rigor/cli/skill_command.rb +170 -0
  9. data/lib/rigor/cli.rb +37 -1
  10. data/lib/rigor/configuration/severity_profile.rb +3 -0
  11. data/lib/rigor/inference/block_parameter_binder.rb +35 -0
  12. data/lib/rigor/inference/expression_typer.rb +69 -30
  13. data/lib/rigor/inference/indexed_narrowing.rb +187 -0
  14. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +24 -0
  15. data/lib/rigor/inference/method_dispatcher.rb +23 -0
  16. data/lib/rigor/inference/mutation_widening.rb +285 -0
  17. data/lib/rigor/inference/narrowing.rb +72 -4
  18. data/lib/rigor/inference/scope_indexer.rb +409 -12
  19. data/lib/rigor/inference/statement_evaluator.rb +256 -4
  20. data/lib/rigor/scope.rb +195 -4
  21. data/lib/rigor/version.rb +1 -1
  22. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +22 -1
  23. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +94 -6
  24. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +11 -1
  25. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +7 -1
  26. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +135 -11
  27. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +94 -43
  28. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +138 -35
  29. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +17 -3
  30. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +10 -0
  31. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +13 -3
  32. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +6 -2
  33. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +83 -7
  34. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +4 -1
  35. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +16 -1
  36. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +81 -5
  37. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +11 -3
  38. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +194 -5
  39. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +264 -0
  40. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +100 -0
  41. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +175 -0
  42. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +64 -3
  43. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1107 -59
  44. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +81 -4
  45. data/sig/rigor/scope.rbs +23 -0
  46. data/skills/rigor-baseline-reduce/SKILL.md +100 -0
  47. data/skills/rigor-baseline-reduce/references/01-classify.md +107 -0
  48. data/skills/rigor-baseline-reduce/references/02-fix-or-suppress.md +133 -0
  49. data/skills/rigor-plugin-author/SKILL.md +95 -0
  50. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +195 -0
  51. data/skills/rigor-plugin-author/references/02-walker-and-types.md +155 -0
  52. data/skills/rigor-plugin-author/references/03-test-and-ship.md +163 -0
  53. data/skills/rigor-project-init/SKILL.md +129 -0
  54. data/skills/rigor-project-init/references/01-detect.md +101 -0
  55. data/skills/rigor-project-init/references/02-configure.md +185 -0
  56. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +168 -0
  57. data/skills/rigor-project-init/references/04-sig-uplift.md +171 -0
  58. metadata +22 -1
@@ -4,6 +4,7 @@ require "prism"
4
4
 
5
5
  require_relative "../scope"
6
6
  require_relative "../type"
7
+ require_relative "mutation_widening"
7
8
  require_relative "narrowing"
8
9
  require_relative "statement_evaluator"
9
10
 
@@ -126,7 +127,16 @@ module Rigor
126
127
  table = {}.compare_by_identity
127
128
  table.default = seeded_scope
128
129
 
129
- on_enter = ->(node, scope) { table[node] = scope unless table.key?(node) }
130
+ # Last-visit-wins, not first: when `StatementEvaluator`
131
+ # internally re-evaluates a subtree (notably `eval_begin`'s
132
+ # retry-edge widening pass), the LATER visit carries the
133
+ # corrected entry scope (e.g. a `tries` widened to
134
+ # `Nominal[Integer]` after the rescue body's `tries += 1;
135
+ # retry` is observed). The diagnostic layer reads
136
+ # `table[node]` to type predicates; the second pass's
137
+ # entry is the one that reflects all flow-derived
138
+ # rebinds, so it MUST overwrite the first.
139
+ on_enter = ->(node, scope) { table[node] = scope }
130
140
  StatementEvaluator.new(scope: seeded_scope, on_enter: on_enter).evaluate(root)
131
141
 
132
142
  propagate(root, table, seeded_scope)
@@ -172,11 +182,135 @@ module Rigor
172
182
  # via `Type::Combinator.union`.
173
183
  def build_class_ivar_index(root, default_scope)
174
184
  accumulator = {}
175
- walk_class_ivars(root, [], default_scope, accumulator)
185
+ mutated_ivars = {}
186
+ read_before_write = {}
187
+ init_writes = {}
188
+ walk_class_ivars(root, [], default_scope, accumulator, mutated_ivars,
189
+ read_before_write, init_writes)
190
+ widen_mutated_ivar_entries!(accumulator, mutated_ivars)
191
+ contribute_read_before_write_nil!(accumulator, read_before_write, init_writes)
176
192
  accumulator.transform_values(&:freeze).freeze
177
193
  end
178
194
 
179
- def walk_class_ivars(node, qualified_prefix, default_scope, accumulator)
195
+ # B2.3 finalize the read-before-write nil contribution.
196
+ # For each class, for each ivar where SOME method body
197
+ # observed a read-before-write AND no `initialize` write
198
+ # exists for that ivar, contribute `Constant[nil]` to the
199
+ # class-wide accumulator.
200
+ #
201
+ # The `initialize` filter is the soundness gate: Ruby
202
+ # semantics guarantee `initialize` runs first (via
203
+ # `Class.new`), so a write there reaches every other
204
+ # method body's read. Read-before-write in a non-init
205
+ # method is then NOT a nil-at-runtime case — it's just
206
+ # AST-order coincidence. Without this filter a normal
207
+ # `def initialize; @x = ... end` / `def use; @x.foo end`
208
+ # class would have `@x` widened with nil, producing FPs
209
+ # at every `@x.foo` call.
210
+ def contribute_read_before_write_nil!(accumulator, read_before_write, init_writes)
211
+ nil_t = Type::Combinator.constant_of(nil)
212
+ read_before_write.each do |class_name, ivar_set|
213
+ init_set = init_writes[class_name] || EMPTY_GUARDED_IVARS
214
+ per_class = accumulator[class_name]
215
+ next if per_class.nil?
216
+
217
+ ivar_set.each do |ivar_name|
218
+ # Soundness gates (in order):
219
+ # (1) `initialize` writes the ivar → it's set
220
+ # before any other method runs, so the
221
+ # read-before-write in a sibling method is
222
+ # NOT a runtime nil case.
223
+ # (2) The accumulator has NO entry for the ivar
224
+ # → some write was deliberately skipped (the
225
+ # falsey-default `@x = nil unless @x` slice's
226
+ # no-seed behaviour). Adding nil here would
227
+ # defeat that skip and re-introduce the
228
+ # `Constant[nil]` FP the skip silenced.
229
+ next if init_set.include?(ivar_name)
230
+ next unless per_class.key?(ivar_name)
231
+
232
+ existing = per_class[ivar_name]
233
+ per_class[ivar_name] = Type::Combinator.union(existing, nil_t)
234
+ end
235
+ end
236
+ end
237
+
238
+ # Walks the post-collected accumulator and widens any Tuple
239
+ # / HashShape entry for an ivar that observed a mutator call
240
+ # anywhere in the same class body. The mutation evidence
241
+ # comes from `gather_ivar_writes` recording every
242
+ # `@ivar.<method>(...)` call whose method is in
243
+ # `MutationWidening::ARRAY_MUTATORS` or `HASH_MUTATORS`.
244
+ #
245
+ # The widening uses `MutationWidening.widen_for_mutator` —
246
+ # the same primitive `Inference::StatementEvaluator#eval_call`
247
+ # applies for per-method-body widening on a local / ivar
248
+ # receiver. The class-level pass extends that primitive's
249
+ # reach so a `Tuple`-seeded ivar in `initialize` is observed
250
+ # as `Nominal[Array]` at the entry of every OTHER method
251
+ # body in the class — closing the cross-method gap noted in
252
+ # ROADMAP § Future cycles / Type-language / engine
253
+ # ("Tuple / HashShape widening for ivar-seeded literals
254
+ # after mutation"; Redmine 6.1.2
255
+ # `Redmine::Views::Builders::Structure` is the canonical
256
+ # worked site).
257
+ #
258
+ # Always-safe: the widening can only LOSE precision; the
259
+ # underlying nominal (`Array` / `Hash`) and the element
260
+ # union are preserved.
261
+ def widen_mutated_ivar_entries!(accumulator, mutated_ivars)
262
+ accumulator.each do |class_name, ivars|
263
+ observed = mutated_ivars[class_name]
264
+ next if observed.nil? || observed.empty?
265
+
266
+ ivars.each do |ivar_name, type|
267
+ methods = observed[ivar_name]
268
+ next if methods.nil? || methods.empty?
269
+
270
+ ivars[ivar_name] = widen_type_for_observed_mutators(type, methods)
271
+ end
272
+ end
273
+ end
274
+
275
+ # Walks a class-ivar accumulator entry (which may be a
276
+ # `Union` of multiple write rvalues) and widens any
277
+ # `Tuple` or `HashShape` member whose corresponding
278
+ # mutator family was observed against the ivar somewhere
279
+ # in the class. Class-level widening is more aggressive
280
+ # than the per-method-body `MutationWidening` primitive:
281
+ # it widens both the SHAPE carrier (Tuple → Array,
282
+ # HashShape → Hash) AND the element types to
283
+ # `Dynamic[Top]`. The justification — once any method
284
+ # mutates the ivar, its post-mutation contents are
285
+ # statically unknown across method boundaries, so
286
+ # preserving the seed-write's element precision would be
287
+ # an unsound over-claim (e.g. `@struct = [{}]; somewhere:
288
+ # @struct << []` makes the next read's element no longer
289
+ # `Constant[{}]`).
290
+ def widen_type_for_observed_mutators(type, observed_methods)
291
+ members = type.is_a?(Type::Union) ? type.members : [type]
292
+ widened = members.map { |m| widen_member_for_observed_mutators(m, observed_methods) }
293
+ Type::Combinator.union(*widened)
294
+ end
295
+
296
+ def widen_member_for_observed_mutators(member, observed_methods)
297
+ case member
298
+ when Type::Tuple
299
+ return member unless observed_methods.any? { |m| MutationWidening::ARRAY_MUTATORS.include?(m) }
300
+
301
+ Type::Combinator.nominal_of("Array", type_args: [Type::Combinator.untyped])
302
+ when Type::HashShape
303
+ return member unless observed_methods.any? { |m| MutationWidening::HASH_MUTATORS.include?(m) }
304
+
305
+ Type::Combinator.nominal_of("Hash",
306
+ type_args: [Type::Combinator.untyped, Type::Combinator.untyped])
307
+ else
308
+ member
309
+ end
310
+ end
311
+
312
+ def walk_class_ivars(node, qualified_prefix, default_scope, accumulator, mutated_ivars,
313
+ read_before_write = nil, init_writes = nil)
180
314
  return unless node.is_a?(Prism::Node)
181
315
 
182
316
  case node
@@ -184,20 +318,42 @@ module Rigor
184
318
  name = qualified_name_for(node.constant_path)
185
319
  if name
186
320
  child_prefix = qualified_prefix + [name]
187
- walk_class_ivars(node.body, child_prefix, default_scope, accumulator) if node.body
321
+ if node.body
322
+ # Class-body level `@x = nil` writes don't
323
+ # initialise instance ivars at runtime (the
324
+ # class's own singleton ivars and the instance's
325
+ # ivars are separate stores), but they signal
326
+ # "the author KNOWS @x could be nil" and extend
327
+ # the B2.3 soundness gate: an ivar with a
328
+ # class-body write is exempted from the
329
+ # read-before-write nil contribution because the
330
+ # seed already reflects the author's acknowledged
331
+ # nullability via the def-body writes' union.
332
+ # Without this exemption, code that explicitly
333
+ # `@x = nil`s at class-body level then writes
334
+ # `@x = SomeClass.new` inside an instance method
335
+ # gains an unjustified nil widening at every
336
+ # read.
337
+ collect_class_body_ivar_writes(node.body, child_prefix.join("::"), init_writes) if init_writes
338
+ walk_class_ivars(node.body, child_prefix, default_scope, accumulator,
339
+ mutated_ivars, read_before_write, init_writes)
340
+ end
188
341
  return
189
342
  end
190
343
  when Prism::DefNode
191
- collect_def_ivar_writes(node, qualified_prefix, default_scope, accumulator)
344
+ collect_def_ivar_writes(node, qualified_prefix, default_scope, accumulator,
345
+ mutated_ivars, read_before_write, init_writes)
192
346
  return
193
347
  end
194
348
 
195
349
  node.compact_child_nodes.each do |child|
196
- walk_class_ivars(child, qualified_prefix, default_scope, accumulator)
350
+ walk_class_ivars(child, qualified_prefix, default_scope, accumulator,
351
+ mutated_ivars, read_before_write, init_writes)
197
352
  end
198
353
  end
199
354
 
200
- def collect_def_ivar_writes(def_node, qualified_prefix, default_scope, accumulator)
355
+ def collect_def_ivar_writes(def_node, qualified_prefix, default_scope, accumulator, mutated_ivars,
356
+ read_before_write = nil, init_writes = nil)
201
357
  return if def_node.body.nil? || qualified_prefix.empty?
202
358
 
203
359
  class_name = qualified_prefix.join("::")
@@ -211,32 +367,273 @@ module Rigor
211
367
  end
212
368
  body_scope = default_scope.with_self_type(self_type)
213
369
 
214
- gather_ivar_writes(def_node.body, body_scope, class_name, accumulator)
370
+ gather_ivar_writes(def_node.body, body_scope, class_name, accumulator, EMPTY_GUARDED_IVARS, mutated_ivars)
371
+
372
+ # B2.3 — collect per-method evidence for the read-before-
373
+ # write nil contribution. The accumulator-level decision
374
+ # ("is this ivar truly read-before-write across the
375
+ # class lifetime?") is finalised at
376
+ # `contribute_read_before_write_nil!` after the whole
377
+ # class body has been walked, using `init_writes` as
378
+ # the soundness gate (an ivar written in `initialize`
379
+ # is initialised before any other method body runs).
380
+ collect_read_before_write_evidence(def_node, class_name, read_before_write, init_writes)
381
+ end
382
+
383
+ # Walks the method body in AST (== execution) order
384
+ # tracking ivar names whose first reference is a read.
385
+ # The set is unioned into the class-wide
386
+ # `read_before_write` accumulator. For `initialize` def
387
+ # bodies, every write target is unioned into
388
+ # `init_writes` instead — used by the finalisation step
389
+ # to suppress nil contribution for ivars the constructor
390
+ # guarantees are initialised.
391
+ def collect_read_before_write_evidence(def_node, class_name, read_before_write, init_writes)
392
+ return if read_before_write.nil? || init_writes.nil?
393
+
394
+ seen_writes = Set.new
395
+ read_first = Set.new
396
+ detect_read_before_write(def_node.body, seen_writes, read_first)
397
+
398
+ if def_node.name == :initialize
399
+ init_set = (init_writes[class_name] ||= Set.new)
400
+ seen_writes.each { |name| init_set << name }
401
+ return
402
+ end
403
+
404
+ return if read_first.empty?
405
+
406
+ rbw_set = (read_before_write[class_name] ||= Set.new)
407
+ read_first.each { |name| rbw_set << name }
408
+ end
409
+
410
+ IVAR_WRITE_NODES = [
411
+ Prism::InstanceVariableWriteNode,
412
+ Prism::InstanceVariableOrWriteNode,
413
+ Prism::InstanceVariableAndWriteNode,
414
+ Prism::InstanceVariableOperatorWriteNode
415
+ ].freeze
416
+ private_constant :IVAR_WRITE_NODES
417
+
418
+ # Walks class-body level statements (i.e. NOT inside any
419
+ # nested DefNode / ClassNode / ModuleNode) and records
420
+ # every `@x = …` write target as a class-body init.
421
+ # Consumed by `contribute_read_before_write_nil!` to
422
+ # exempt ivars the author already knows might be nil
423
+ # (the `@x = nil` at class-body level is the canonical
424
+ # nullability acknowledgement; the instance @x is
425
+ # technically a separate store, but the pragmatic intent
426
+ # is unambiguous).
427
+ def collect_class_body_ivar_writes(node, class_name, init_writes)
428
+ return unless node.is_a?(Prism::Node)
429
+ return if IVAR_BARRIER_NODES.any? { |klass| node.is_a?(klass) }
430
+
431
+ if node.is_a?(Prism::InstanceVariableWriteNode) ||
432
+ node.is_a?(Prism::InstanceVariableOrWriteNode) ||
433
+ node.is_a?(Prism::InstanceVariableAndWriteNode) ||
434
+ node.is_a?(Prism::InstanceVariableOperatorWriteNode)
435
+ (init_writes[class_name] ||= Set.new) << node.name
436
+ end
437
+
438
+ node.compact_child_nodes.each do |child|
439
+ collect_class_body_ivar_writes(child, class_name, init_writes)
440
+ end
441
+ end
442
+
443
+ def detect_read_before_write(node, seen_writes, read_first)
444
+ return unless node.is_a?(Prism::Node)
445
+ return if IVAR_BARRIER_NODES.any? { |klass| node.is_a?(klass) }
446
+
447
+ read_first << node.name if node.is_a?(Prism::InstanceVariableReadNode) && !seen_writes.include?(node.name)
448
+
449
+ # Descend BEFORE recording a write — `@x = @x + 1`'s
450
+ # RHS is an `InstanceVariableReadNode` that runs before
451
+ # the write is committed; the read is therefore
452
+ # read-before-write semantically. Prism's
453
+ # `compact_child_nodes` returns the value child before
454
+ # the lvalue target, matching this order.
455
+ node.compact_child_nodes.each do |c|
456
+ detect_read_before_write(c, seen_writes, read_first)
457
+ end
458
+
459
+ seen_writes << node.name if IVAR_WRITE_NODES.any? { |klass| node.is_a?(klass) }
215
460
  end
216
461
 
217
462
  IVAR_BARRIER_NODES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode].freeze
218
463
  private_constant :IVAR_BARRIER_NODES
219
464
 
220
- def gather_ivar_writes(node, scope, class_name, accumulator)
465
+ EMPTY_GUARDED_IVARS = Set.new.freeze
466
+ private_constant :EMPTY_GUARDED_IVARS
467
+
468
+ def gather_ivar_writes(node, scope, class_name, accumulator, guarded_ivars = EMPTY_GUARDED_IVARS,
469
+ mutated_ivars = nil)
221
470
  return unless node.is_a?(Prism::Node)
222
471
 
223
- record_ivar_write(node, scope, class_name, accumulator) if node.is_a?(Prism::InstanceVariableWriteNode)
472
+ if node.is_a?(Prism::InstanceVariableWriteNode)
473
+ record_ivar_write(node, scope, class_name, accumulator,
474
+ guarded: guarded_ivars.include?(node.name))
475
+ end
476
+ record_ivar_mutator_call(node, class_name, mutated_ivars) if mutated_ivars && node.is_a?(Prism::CallNode)
224
477
 
225
478
  # Don't recurse into nested defs, classes, or modules; their
226
479
  # ivars belong to their own enclosing class.
227
480
  return if IVAR_BARRIER_NODES.any? { |klass| node.is_a?(klass) }
228
481
 
229
- node.compact_child_nodes.each { |c| gather_ivar_writes(c, scope, class_name, accumulator) }
482
+ if node.is_a?(Prism::IfNode) || node.is_a?(Prism::UnlessNode)
483
+ walk_conditional_ivar_writes(node, scope, class_name, accumulator, guarded_ivars, mutated_ivars)
484
+ return
485
+ end
486
+
487
+ node.compact_child_nodes.each do |c|
488
+ gather_ivar_writes(c, scope, class_name, accumulator, guarded_ivars, mutated_ivars)
489
+ end
490
+ end
491
+
492
+ # Records `@ivar.<method>(...)` calls whose method is in
493
+ # `MutationWidening::ARRAY_MUTATORS` or `HASH_MUTATORS`.
494
+ # The class-ivar pre-pass uses the resulting set to widen
495
+ # the post-collected accumulator entries (see
496
+ # {.widen_mutated_ivar_entries!}). Always-safe to over-
497
+ # collect: any name that the widening primitive declines
498
+ # is ignored at finalization.
499
+ def record_ivar_mutator_call(node, class_name, mutated_ivars)
500
+ receiver = node.receiver
501
+ return unless receiver.is_a?(Prism::InstanceVariableReadNode)
502
+ return unless MutationWidening::ARRAY_MUTATORS.include?(node.name) ||
503
+ MutationWidening::HASH_MUTATORS.include?(node.name)
504
+
505
+ per_class = (mutated_ivars[class_name] ||= {})
506
+ per_ivar = (per_class[receiver.name] ||= Set.new)
507
+ per_ivar << node.name
508
+ end
509
+
510
+ # Walk an `IfNode` / `UnlessNode` so writes inside the THEN body
511
+ # that look like defensive ivar initialisation gain a `nil` union
512
+ # in the seeded type. Without this, `@x = v unless @x` records
513
+ # `Constant[v]` for `@x`, then the predicate folds to that same
514
+ # constant and `flow.always-truthy-condition` fires against a
515
+ # working program. Mirrors the existing skip for `@x ||= v`
516
+ # (`Prism::InstanceVariableOrWriteNode`, which the pre-pass does
517
+ # not seed at all).
518
+ #
519
+ # Polarity-aware on purpose: only the THEN body picks up the
520
+ # guard. The ELSE branch of `if @x; ...; else; @x = init; end`
521
+ # would otherwise be marked too — but that pattern (write @x in
522
+ # the else of `if @x`) is a separate idiom whose surrounding
523
+ # reads of `@x` would then surface a nil-receiver FP. The
524
+ # ELSE branch is left ungarded so those reads continue to type
525
+ # as they did before this fix.
526
+ def walk_conditional_ivar_writes(node, scope, class_name, accumulator, guarded_ivars, mutated_ivars = nil)
527
+ then_guards = then_body_guarded_ivars(node)
528
+ then_guarded = then_guards.empty? ? guarded_ivars : (guarded_ivars | then_guards)
529
+
530
+ gather_ivar_writes(node.predicate, scope, class_name, accumulator, guarded_ivars, mutated_ivars)
531
+ if node.statements
532
+ gather_ivar_writes(node.statements, scope, class_name, accumulator, then_guarded, mutated_ivars)
533
+ end
534
+ branch = node.is_a?(Prism::IfNode) ? node.subsequent : node.else_clause
535
+ gather_ivar_writes(branch, scope, class_name, accumulator, guarded_ivars, mutated_ivars) if branch
536
+ end
537
+
538
+ # Returns the set of ivar names that, in the THEN body of this
539
+ # conditional, are statically known to be in a nil / unset state
540
+ # — i.e. the body really IS the defensive-init half of the
541
+ # idiom. Conservative on purpose: only the shapes that
542
+ # idiomatically express "the ivar is missing" qualify.
543
+ #
544
+ # For `unless P; body; end`, body runs when `P` is falsey:
545
+ # - `P = @x` (or `@x && other` / `@x || other`) → @x is falsey
546
+ # - `P = defined?(@x)` → @x is undefined
547
+ #
548
+ # For `if P; body; ...`, body runs when `P` is truthy:
549
+ # - `P = @x.nil?` → @x is nil
550
+ # - `P = !@x` / `not @x` → @x is falsey
551
+ def then_body_guarded_ivars(node)
552
+ names = Set.new
553
+ if node.is_a?(Prism::UnlessNode)
554
+ collect_truthy_test_ivars(node.predicate, names)
555
+ collect_defined_test_ivars(node.predicate, names)
556
+ else
557
+ collect_nil_test_ivars(node.predicate, names)
558
+ end
559
+ names
560
+ end
561
+
562
+ def collect_truthy_test_ivars(node, names)
563
+ return unless node.is_a?(Prism::Node)
564
+
565
+ case node
566
+ when Prism::InstanceVariableReadNode
567
+ names << node.name
568
+ when Prism::AndNode, Prism::OrNode
569
+ collect_truthy_test_ivars(node.left, names)
570
+ collect_truthy_test_ivars(node.right, names)
571
+ end
572
+ end
573
+
574
+ def collect_defined_test_ivars(node, names)
575
+ return unless node.is_a?(Prism::Node)
576
+
577
+ case node
578
+ when Prism::DefinedNode
579
+ target = node.value
580
+ names << target.name if target.is_a?(Prism::InstanceVariableReadNode)
581
+ when Prism::AndNode, Prism::OrNode
582
+ collect_defined_test_ivars(node.left, names)
583
+ collect_defined_test_ivars(node.right, names)
584
+ end
585
+ end
586
+
587
+ def collect_nil_test_ivars(node, names)
588
+ return unless node.is_a?(Prism::Node)
589
+
590
+ case node
591
+ when Prism::CallNode
592
+ receiver = node.receiver
593
+ if receiver.is_a?(Prism::InstanceVariableReadNode) &&
594
+ %i[nil? !].include?(node.name)
595
+ names << receiver.name
596
+ end
597
+ when Prism::AndNode, Prism::OrNode
598
+ collect_nil_test_ivars(node.left, names)
599
+ collect_nil_test_ivars(node.right, names)
600
+ end
230
601
  end
231
602
 
232
- def record_ivar_write(node, scope, class_name, accumulator)
603
+ def record_ivar_write(node, scope, class_name, accumulator, guarded: false)
233
604
  rvalue_type = scope.type_of(node.value)
605
+
606
+ # `@x = nil unless @x` / `@y = false unless @y` —
607
+ # follow-up to the polarity-aware defensive-init guard
608
+ # fix (ROADMAP § Future cycles — "Defensive ivar-init
609
+ # with nil / false rvalue"). When the rvalue is itself a
610
+ # falsey Constant, `union(rvalue, Constant[nil])`
611
+ # collapses (for `nil`) or doesn't widen the type's
612
+ # truthiness profile (for `false`) — the predicate
613
+ # `unless @x` then folds to a single `Constant[nil]` /
614
+ # `Constant[false]` and the
615
+ # `flow.always-truthy-condition` / `-always-falsey-`
616
+ # rule false-fires on the no-op-but-documented-default
617
+ # idiom. Skip the seed contribution for this write
618
+ # (matches the existing skip for `@x ||= v`, which the
619
+ # pre-pass also does not seed). Other writes to the
620
+ # same ivar still contribute; the falsey-default write
621
+ # carries no useful precision the predicate hasn't
622
+ # already given us. See tdiary-core HEAD `ee40c2b`
623
+ # `lib/tdiary/configuration.rb:157` for the worked site.
624
+ return if guarded && falsey_constant?(rvalue_type)
625
+
626
+ rvalue_type = Type::Combinator.union(rvalue_type, Type::Combinator.constant_of(nil)) if guarded
234
627
  accumulator[class_name] ||= {}
235
628
  existing = accumulator[class_name][node.name]
236
629
  accumulator[class_name][node.name] =
237
630
  existing ? Type::Combinator.union(existing, rvalue_type) : rvalue_type
238
631
  end
239
632
 
633
+ def falsey_constant?(type)
634
+ type.is_a?(Type::Constant) && (type.value.nil? || type.value == false)
635
+ end
636
+
240
637
  # Slice 7 phase 6 — class-cvar pre-pass. Same shape as the
241
638
  # ivar pre-pass but collects `Prism::ClassVariableWriteNode`
242
639
  # writes inside ANY def body (instance or singleton) of the