rigortype 0.4.3 → 0.4.4

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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/core_overlay/ostruct.rbs +40 -0
  4. data/docs/handbook/11-sig-gen.md +16 -0
  5. data/docs/manual/02-cli-reference.md +4 -3
  6. data/docs/manual/03-configuration.md +16 -5
  7. data/docs/manual/04-diagnostics.md +44 -2
  8. data/docs/manual/07-plugins.md +1 -1
  9. data/docs/manual/10-mcp-server.md +32 -3
  10. data/docs/manual/18-removing-dead-code.md +11 -1
  11. data/docs/manual/plugins/rigor-rails-routes.md +2 -2
  12. data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
  13. data/exe/rigor +12 -17
  14. data/lib/rigor/analysis/check_rules/rule_ids.rb +57 -0
  15. data/lib/rigor/analysis/check_rules.rb +46 -36
  16. data/lib/rigor/analysis/incremental.rb +12 -0
  17. data/lib/rigor/analysis/incremental_session.rb +121 -21
  18. data/lib/rigor/analysis/reachability/graph.rb +23 -3
  19. data/lib/rigor/analysis/reachability/scan.rb +51 -9
  20. data/lib/rigor/analysis/rule_catalog.rb +6 -1
  21. data/lib/rigor/analysis/run_cache_key.rb +31 -7
  22. data/lib/rigor/analysis/run_cache_probe.rb +5 -3
  23. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +48 -12
  24. data/lib/rigor/analysis/runner/pool_coordinator.rb +110 -15
  25. data/lib/rigor/analysis/runner/project_pre_passes.rb +29 -4
  26. data/lib/rigor/analysis/runner.rb +35 -9
  27. data/lib/rigor/analysis/severity_stamp.rb +34 -1
  28. data/lib/rigor/analysis/worker_session.rb +57 -1
  29. data/lib/rigor/cache/descriptor.rb +40 -6
  30. data/lib/rigor/cache/incremental_snapshot.rb +20 -4
  31. data/lib/rigor/cli/mcp_command.rb +1 -1
  32. data/lib/rigor/config_audit.rb +111 -15
  33. data/lib/rigor/configuration/dependencies.rb +19 -11
  34. data/lib/rigor/environment/rbs_loader.rb +46 -10
  35. data/lib/rigor/inference/block_call_timing.rb +5 -0
  36. data/lib/rigor/inference/captured_locals.rb +26 -3
  37. data/lib/rigor/inference/definer_resolution.rb +16 -7
  38. data/lib/rigor/inference/expression_typer.rb +59 -4
  39. data/lib/rigor/inference/guard_rebinding.rb +7 -2
  40. data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
  41. data/lib/rigor/inference/module_function_state.rb +80 -13
  42. data/lib/rigor/inference/narrowing.rb +184 -68
  43. data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
  44. data/lib/rigor/inference/repeated_or_writes.rb +414 -1
  45. data/lib/rigor/inference/scope_indexer.rb +439 -70
  46. data/lib/rigor/inference/self_call_ivar_writes.rb +341 -0
  47. data/lib/rigor/inference/statement_evaluator.rb +84 -29
  48. data/lib/rigor/lens/anchor.rb +3 -3
  49. data/lib/rigor/lens/contract_reader.rb +15 -0
  50. data/lib/rigor/lens/document.rb +2 -2
  51. data/lib/rigor/lens/file_reader.rb +442 -73
  52. data/lib/rigor/lens/members.rb +65 -15
  53. data/lib/rigor/lens/run.rb +44 -2
  54. data/lib/rigor/lens.rb +2 -1
  55. data/lib/rigor/mcp/server.rb +61 -20
  56. data/lib/rigor/plugin/box_probe.rb +154 -7
  57. data/lib/rigor/project_environment.rb +6 -3
  58. data/lib/rigor/scope.rb +11 -6
  59. data/lib/rigor/sig_gen/layout_index.rb +36 -0
  60. data/lib/rigor/sig_gen/method_candidate.rb +8 -0
  61. data/lib/rigor/sig_gen/path_mapper.rb +18 -2
  62. data/lib/rigor/sig_gen/writer.rb +47 -34
  63. data/lib/rigor/version.rb +1 -1
  64. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
  65. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  66. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +28 -6
  67. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
  68. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
  69. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
  70. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
  71. metadata +4 -1
@@ -321,6 +321,37 @@ module Rigor
321
321
  try_project_singleton_inference(receiver, node, arg_types, method_name: :[])
322
322
  end
323
323
 
324
+ # Issue #1889 — the type a block-form `inject` / `reduce` accumulator parameter holds across the block's runs,
325
+ # or nil when the call is not one {#try_block_inject_fold} folds. The RBS tier binds the parameter to the seed
326
+ # alone, so the statement pass and the ADR-56 write-back typed every run's accumulator as `0` for `inject(0)`,
327
+ # and a capture assigned from it (`m = a`) was written back as `0`.
328
+ #
329
+ # The fold records the answer as it types the call ({#record_inject_accumulator}): the constant arm the
330
+ # accumulator values the block receives (`0 | 1` for `[1, 2].inject(0) { |a, e| a + e }`), the nominal
331
+ # arm its seed joined with the converged accumulator. The statement pass types the call from the same scope
332
+ # just before it enters the block, so the recorded answer is read back rather than computed again — each
333
+ # computation types the body, and a nested `inject` multiplied that by its depth.
334
+ def inject_accumulator_type(call_node, receiver, arg_types)
335
+ block = call_node.block
336
+ return nil unless INJECT_METHODS.include?(call_node.name) && block.is_a?(Prism::BlockNode)
337
+
338
+ recorded = self.class.recorded_inject_accumulator(block, scope)
339
+ return recorded if recorded
340
+
341
+ try_block_inject_fold(call_node, receiver, arg_types)
342
+ self.class.recorded_inject_accumulator(block, scope)
343
+ end
344
+
345
+ INJECT_ACCUMULATOR_KEY = :__rigor_inject_accumulator__
346
+ private_constant :INJECT_ACCUMULATOR_KEY
347
+
348
+ # The accumulator type {#record_inject_accumulator} stored for `block` when the call was typed from `scope`
349
+ # itself (by identity), else nil.
350
+ def self.recorded_inject_accumulator(block, scope)
351
+ slot = Thread.current[INJECT_ACCUMULATOR_KEY]&.[](block)
352
+ slot && slot[0].equal?(scope) ? slot[1] : nil
353
+ end
354
+
324
355
  # ADR-89 WD2 — the current run's return memo bucket as `{ def_node => [MemoEntry, …] }` (only entries
325
356
  # that carry a call descriptor, i.e. every stored entry). Read by the incremental session right after a
326
357
  # recording run to harvest each analyzed callee's observed call keys → return descriptors. Returns an
@@ -1047,12 +1078,15 @@ module Rigor
1047
1078
  #
1048
1079
  # The `case ... in` pattern-matching form (`CaseMatchNode`) and the predicate-less form (`case; when
1049
1080
  # c1; ...`) bypass the `===` analysis: pattern matching has richer semantics, and a predicate-less
1050
- # `case` reduces to a `if c1; ...; elsif c2` chain that statement-level narrowing already handles.
1081
+ # `case` reduces to a `if c1; ...; elsif c2` chain. Issue #1495 — like {#type_of_conditional}, that
1082
+ # chain delegates to the statement evaluator, which narrows each arm by its conditions' edges, so
1083
+ # `[case; when x.nil? then "n"; else x end]` reads `"n" | String` as the statement form does.
1051
1084
  # Issue #1429 — each arm is typed under the subject's clause narrowing (`Narrowing.case_when_scopes`), the
1052
1085
  # scope the statement evaluator runs the arm in: `when Symbol then n` on `n: Integer | Symbol` answers
1053
1086
  # `Symbol`, and the `else` arm reads the subject every earlier clause has ruled out.
1054
1087
  def type_of_case(node)
1055
- return type_of_case_simple_union(node) if node.is_a?(Prism::CaseMatchNode) || node.predicate.nil?
1088
+ return type_of_case_simple_union(node) if node.is_a?(Prism::CaseMatchNode)
1089
+ return scope.evaluate(node, tracer: tracer).first if node.predicate.nil?
1056
1090
 
1057
1091
  subject_type = type_of(node.predicate)
1058
1092
  candidates = []
@@ -3883,6 +3917,12 @@ module Rigor
3883
3917
  # default — or `Dynamic` when the shape is open or absent; `**kwrest`, `&block`, and destructured
3884
3918
  # positionals bind `Dynamic`. Binding `Dynamic` is monotone-safe (wider in → wider out), so the only
3885
3919
  # declines left are the shapes where positional CORRESPONDENCE itself is unknowable.
3920
+ #
3921
+ # The scope carries no `source_path:`: the body is re-typed for call sites in any file, so a rule that
3922
+ # asks "did this file write it?" by comparing `scope.source_path` with census paths answers wrongly
3923
+ # through it. Record a per-file write kind in the census instead, as {ScopeIndexer::CONSTANT_MEMO} does
3924
+ # (#1276). Setting `source_path` here was rejected: plugins read it, and the return memo would become
3925
+ # caller-dependent.
3886
3926
  def build_user_method_body_scope(def_node, receiver, arg_types, self_fold_safe: false)
3887
3927
  params = def_node.parameters
3888
3928
  locals = bind_params_from_call_types(params, arg_types)
@@ -3893,7 +3933,7 @@ module Rigor
3893
3933
  # allocated a fresh frozen Scope per field, run per user-method-call inference (ADR-44). The
3894
3934
  # discovery index is inherited whole by reference (ADR-53 Track A); the hand-copied per-field list
3895
3935
  # this replaces had silently dropped `data_member_layouts` and `discovered_method_visibilities`.
3896
- Scope.new(
3936
+ body_scope = Scope.new(
3897
3937
  environment: scope.environment,
3898
3938
  locals: locals.freeze,
3899
3939
  self_type: receiver,
@@ -3911,6 +3951,7 @@ module Rigor
3911
3951
  # Issue #1358 — the callee runs in a frame of its own ({Scope#with_match_frame}).
3912
3952
  match_frame: MatchRebinding::Frame.new(def_node.body, def_node.parameters)
3913
3953
  )
3954
+ body_scope.with_repeated_or_writes(RepeatedOrWrites.straight_line_sites(def_node.body, body_scope))
3914
3955
  end
3915
3956
 
3916
3957
  # Issue #681 / #707 — the ONE place in `lib/` that answers "what `Module.nesting` was recorded for this
@@ -5436,13 +5477,25 @@ module Rigor
5436
5477
  return nil if acc.nil?
5437
5478
 
5438
5479
  captured = inject_captured_bindings(block, rest.map { |value| Type::Combinator.constant_of(value) })
5480
+ received = [acc]
5439
5481
  rest.each_with_index do |element_value, position|
5440
5482
  acc = inject_constant_step(block, acc, element_value, captured, position)
5441
5483
  return nil if acc.nil?
5484
+
5485
+ received << acc
5442
5486
  end
5487
+ received.pop if received.size > 1
5488
+ record_inject_accumulator(block, Type::Combinator.union(*received))
5443
5489
  acc
5444
5490
  end
5445
5491
 
5492
+ # Records `type` as the accumulator `block` receives when typed from this typer's scope
5493
+ # ({.recorded_inject_accumulator}). The keys are weak, so a finished file's nodes do not stay reachable, and
5494
+ # the values strong, so a record outlives a collection while its node does. Prism nodes compare by identity.
5495
+ def record_inject_accumulator(block, type)
5496
+ (Thread.current[INJECT_ACCUMULATOR_KEY] ||= ObjectSpace::WeakKeyMap.new)[block] = [scope, type].freeze
5497
+ end
5498
+
5446
5499
  # Issue #1332 — the #587 (b) captured-block binding ({#captured_block_bindings}) both inject arms lay under
5447
5500
  # every step, as the Tuple / Range per-element folds and the per-pair `HashShape` fold do. Without it each
5448
5501
  # step reads a captured variable at its entry binding, so a body that rebinds or mutates one (`n += 1`,
@@ -5550,7 +5603,9 @@ module Rigor
5550
5603
  result.nil? ? {} : { __inject_acc__: result }
5551
5604
  end
5552
5605
  )
5553
- converged[:__inject_acc__] || seed_acc
5606
+ result = converged[:__inject_acc__] || seed_acc
5607
+ record_inject_accumulator(block, Type::Combinator.union(seed_acc, result))
5608
+ result
5554
5609
  end
5555
5610
 
5556
5611
  def assemble_per_element_result(method_name, per_position, element_types)
@@ -223,6 +223,12 @@ module Rigor
223
223
  end || Type::Combinator.untyped
224
224
  end
225
225
 
226
+ # Issue #1596 — true when `name` runs code chosen by name or by a String, or a block or String it is given
227
+ # ({CODE_RUNNING_NAMES}, {BLOCK_OR_CODE_NAMES}), whatever its receiver.
228
+ def code_running_name?(name)
229
+ CODE_RUNNING_NAMES.include?(name) || BLOCK_OR_CODE_NAMES.include?(name)
230
+ end
231
+
226
232
  # True when `method_name` on a value of `type` may run foreign code ({.foreign_target?}).
227
233
  def type_method_foreign?(type, method_name, scope)
228
234
  targets = ProjectMethodOwnership.targets(type)
@@ -326,8 +332,7 @@ module Rigor
326
332
  private_class_method :scan, :guarded_ivar_write?, :scan_call, :receiver_targets, :foreign_target?,
327
333
  :universal_delegate_foreign?, :method_owner, :deferred_block_call?,
328
334
  :compound_write_foreign?,
329
- :compound_receiver_type, :compound_accessors, :compound_read_type, :variable_type,
330
- :type_method_foreign?
335
+ :compound_receiver_type, :compound_accessors, :compound_read_type, :variable_type
331
336
  end
332
337
 
333
338
  module GuardRebinding
@@ -45,7 +45,7 @@ module Rigor
45
45
  # `X.send(:define_method, :m, blk)`), whose body may be a block the file wrote anywhere, an eval of a
46
46
  # String, whose code may do any of these, and a `const_set`, which may make a class a `class` body reopens;
47
47
  # - one name: a `def` of it anywhere (`def self.gets` at the top level defines it on `main`, which
48
- # {BlockCallTiming.project_defines_anywhere?} does not record), and a Symbol or String literal naming it,
48
+ # {BlockCallTiming.project_defines_anywhere?} does not count), and a Symbol or String literal naming it,
49
49
  # which a macro may define it through (`def_delegators :@io, :gets`, `attr_reader :gets`,
50
50
  # `undef_method :gets`), other than `&:gets` and the first argument of a call that only names a method
51
51
  # (`respond_to?(:gets)`, `send(:gets)`).
@@ -11,8 +11,8 @@ module Rigor
11
11
  # [#1197](https://github.com/rigortype/rigor/issues/1197)). Four readers used to compute it in four
12
12
  # places. They disagree with each other and, in places, with Ruby. Each entry point below reproduces
13
13
  # one reader's answer exactly: this module gives the readings one place to live, and does not yet make
14
- # them agree. A change to one reading lands here, beside the other three. Examples are #1550's
15
- # named-form snapshot and the reset a bare `public`, `private` or `protected` makes.
14
+ # them agree. A change to one reading lands here, beside the other three. An example is the reset a bare
15
+ # `public`, `private` or `protected` makes.
16
16
  #
17
17
  # - The singleton def-node table (`ScopeIndexer#walk_singleton_body`) reads
18
18
  # {.each_singleton_sibling} and {.each_singleton_copy}. A bare call covers the later direct
@@ -78,23 +78,49 @@ module Rigor
78
78
  end
79
79
  end
80
80
 
81
- # The defs a named call copies onto the module's singleton, for the singleton def-node table. Yields
82
- # `name, def_node` for each Symbol or String argument that names a receiverless def among the body's
83
- # direct `statements`. The last def of that name wins wherever it sits. A def written after the call
84
- # is therefore taken, although Ruby copies the one in effect at the call (#1550), and a name defined
85
- # only after the call resolves, although Ruby raises `NameError` there. A `module_function def x`
86
- # argument names nothing here.
81
+ # The defs a named call copies onto the module's singleton, for the singleton def-node table. Ruby
82
+ # copies the method in effect at the call (#1550), so a later def of the name redefines only the
83
+ # instance method. This reading approximates that from the body's direct `statements`: it yields
84
+ # `name, def_node` for each Symbol or String argument, where `def_node` is the last receiverless def
85
+ # of that name written as a direct statement before the call.
86
+ #
87
+ # A name declines (nothing is yielded) when no def precedes the call, where Ruby raises `NameError` or
88
+ # copies a def another file may load first, and when a statement after that def and before the call
89
+ # may rebind it in a way this reading cannot name: a def nested in control flow or a block, an `alias`,
90
+ # an `undef`, or a receiverless or `self.` `alias_method`, `define_method`, `attr_*`, `remove_method`
91
+ # or `undef_method` call. Such a call or `alias` with a non-literal name anywhere before the call, even
92
+ # before the def, declines every name. A `module_function def x` argument names nothing here.
93
+ #
94
+ # It does not see `send(:define_method, ...)`, a `define_method` on an explicit module receiver, a
95
+ # `prepend`, another file's singleton defs, or that a `rescue` clause's statements may run after an
96
+ # `ensure` call's (#1969).
87
97
  def each_singleton_copy(call, statements)
88
- defs_by_name = statements.each_with_object({}) do |stmt, acc|
89
- acc[stmt.name] = stmt if stmt.is_a?(Prism::DefNode) && stmt.receiver.nil?
98
+ call_start = call.location.start_offset
99
+ in_effect = {}
100
+ statements.each do |stmt|
101
+ next unless stmt.location.end_offset <= call_start
102
+
103
+ if stmt.is_a?(Prism::DefNode)
104
+ in_effect[stmt.name] = stmt if stmt.receiver.nil?
105
+ else
106
+ each_rebound_name(stmt) { |name| in_effect[name] = :unknown }
107
+ end
90
108
  end
91
109
  call.arguments&.arguments&.each do |arg|
92
110
  name = symbol_argument_name(arg)
93
- def_node = name && defs_by_name[name]
94
- yield name, def_node if def_node
111
+ def_node = name && !in_effect.key?(ANY_NAME) && in_effect[name]
112
+ yield name, def_node if def_node.is_a?(Prism::DefNode)
95
113
  end
96
114
  end
97
115
 
116
+ # The key {.each_singleton_copy} records when a statement may rebind a name it cannot read.
117
+ ANY_NAME = Object.new.freeze
118
+
119
+ # Calls that may define, alias or remove an instance method of the receiverless module.
120
+ REBINDING_CALLS = %i[
121
+ alias_method define_method attr attr_reader attr_writer attr_accessor remove_method undef_method
122
+ ].freeze
123
+
98
124
  # The deferred-ranges reading, over one class-ish body's subtree. Appends to `offsets` the start of
99
125
  # every bare call; each def that starts after one is a module function
100
126
  # ({.deferred_module_function?}). Appends to `ranges` one row per named call, in the table's
@@ -173,6 +199,46 @@ module Rigor
173
199
  active && kind == :instance
174
200
  end
175
201
 
202
+ # Yields each instance-method name `node` may rebind on the module whose body holds it, or
203
+ # {ANY_NAME} when one is not a literal. It does not enter a nested class, module or `class <<` body,
204
+ # whose methods belong to another module.
205
+ def each_rebound_name(node, &)
206
+ case node
207
+ when Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode then nil
208
+ when Prism::DefNode then yield node.name if node.receiver.nil?
209
+ else
210
+ each_own_rebound_name(node, &)
211
+ node.compact_child_nodes.each { |child| each_rebound_name(child, &) }
212
+ end
213
+ end
214
+
215
+ # The names `node` itself may rebind, apart from its children: an `alias`, an `undef` or a
216
+ # {REBINDING_CALLS} call on the module.
217
+ def each_own_rebound_name(node, &)
218
+ case node
219
+ when Prism::AliasMethodNode then yield symbol_argument_name(node.new_name) || ANY_NAME
220
+ when Prism::UndefNode then node.names.each { |name| yield symbol_argument_name(name) || ANY_NAME }
221
+ when Prism::CallNode
222
+ each_rebinding_call_name(node, &) if REBINDING_CALLS.include?(node.name) &&
223
+ (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode))
224
+ end
225
+ end
226
+
227
+ # The names a {REBINDING_CALLS} call may rebind: the first argument of `alias_method` and
228
+ # `define_method`, every argument of the others (and its writer, for the `attr_*` family), or
229
+ # {ANY_NAME} for a non-literal one.
230
+ def each_rebinding_call_name(call)
231
+ arguments = call.arguments&.arguments || []
232
+ arguments = arguments.first(1) if %i[alias_method define_method].include?(call.name)
233
+ arguments.each do |arg|
234
+ name = symbol_argument_name(arg)
235
+ next yield(ANY_NAME) unless name
236
+
237
+ yield name
238
+ yield :"#{name}=" if call.name.start_with?("attr")
239
+ end
240
+ end
241
+
176
242
  # The prescan's call arm.
177
243
  def prescan_call(node, qualified_prefix, in_singleton_class, offsets, ranges)
178
244
  if bare?(node)
@@ -208,7 +274,8 @@ module Rigor
208
274
  Source::Literals.symbol_or_string(arg)
209
275
  end
210
276
 
211
- private_class_method :prescan_call, :prescan_eval_call, :symbol_argument_name
277
+ private_class_method :prescan_call, :prescan_eval_call, :symbol_argument_name, :each_rebound_name,
278
+ :each_own_rebound_name, :each_rebinding_call_name
212
279
  end
213
280
  end
214
281
  end