rigortype 0.4.1 → 0.4.2

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 (182) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/io.yml +1 -0
  4. data/data/builtins/ruby_core/proc.yml +4 -0
  5. data/data/builtins/ruby_core/random.yml +4 -0
  6. data/data/builtins/ruby_core/struct.yml +3 -0
  7. data/data/core_overlay/env.rbs +34 -0
  8. data/data/core_overlay/gc.rbs +50 -0
  9. data/data/core_overlay/hash.rbs +45 -0
  10. data/data/core_overlay/integer.rbs +31 -0
  11. data/data/core_overlay/io_buffer.rbs +27 -0
  12. data/data/core_overlay/kernel.rbs +40 -0
  13. data/data/core_overlay/match_data.rbs +28 -0
  14. data/data/core_overlay/module.rbs +51 -0
  15. data/data/core_overlay/object_space.rbs +29 -0
  16. data/data/core_overlay/proc.rbs +45 -0
  17. data/data/core_overlay/range.rbs +40 -0
  18. data/data/core_overlay/source_range.rbs +78 -0
  19. data/data/core_overlay/string.rbs +86 -0
  20. data/data/vendored_gem_sigs/prime/integer-extension.rbs +41 -0
  21. data/data/vendored_gem_sigs/prime/prime.rbs +372 -0
  22. data/docs/handbook/01-getting-started.md +3 -3
  23. data/docs/handbook/06-classes.md +3 -1
  24. data/docs/handbook/11-sig-gen.md +6 -0
  25. data/docs/handbook/appendix-liskov.md +5 -0
  26. data/docs/manual/03-configuration.md +1 -1
  27. data/docs/manual/04-diagnostics.md +2 -1
  28. data/docs/manual/06-baseline.md +2 -0
  29. data/docs/manual/07-plugins.md +1 -1
  30. data/docs/manual/18-removing-dead-code.md +32 -2
  31. data/docs/manual/plugins/README.md +6 -1
  32. data/docs/manual/plugins/rigor-activerecord.md +21 -0
  33. data/docs/manual/plugins/rigor-alba.md +60 -0
  34. data/docs/manual/plugins/rigor-minitest.md +22 -4
  35. data/docs/manual/plugins/rigor-typelizer.md +71 -0
  36. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +27 -3
  37. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +21 -92
  38. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +275 -0
  39. data/lib/rigor/analysis/check_rules/rule_ids.rb +4 -0
  40. data/lib/rigor/analysis/check_rules/source_arity.rb +21 -2
  41. data/lib/rigor/analysis/check_rules.rb +289 -57
  42. data/lib/rigor/analysis/dependency_recorder.rb +15 -0
  43. data/lib/rigor/analysis/dependency_source_inference/builder.rb +8 -1
  44. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +21 -0
  45. data/lib/rigor/analysis/dependency_source_inference/index.rb +36 -11
  46. data/lib/rigor/analysis/dependency_source_inference/walker.rb +196 -27
  47. data/lib/rigor/analysis/erb_template_detector.rb +2 -1
  48. data/lib/rigor/analysis/incremental.rb +29 -9
  49. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +30 -5
  50. data/lib/rigor/analysis/incremental_run_slot.rb +3 -1
  51. data/lib/rigor/analysis/incremental_session.rb +14 -1
  52. data/lib/rigor/analysis/reachability/graph.rb +123 -23
  53. data/lib/rigor/analysis/reachability/scan.rb +290 -36
  54. data/lib/rigor/analysis/rule_catalog.rb +43 -0
  55. data/lib/rigor/analysis/run_cache_key.rb +16 -1
  56. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +87 -5
  57. data/lib/rigor/analysis/runner/pool_coordinator.rb +50 -11
  58. data/lib/rigor/analysis/runner/run_snapshots.rb +6 -1
  59. data/lib/rigor/analysis/runner.rb +34 -3
  60. data/lib/rigor/analysis/worker_session.rb +7 -4
  61. data/lib/rigor/cache/incremental_snapshot.rb +22 -0
  62. data/lib/rigor/cli/annotate_command.rb +5 -0
  63. data/lib/rigor/cli/baseline_command.rb +7 -18
  64. data/lib/rigor/cli/check_command.rb +2 -1
  65. data/lib/rigor/cli/trace_command.rb +5 -0
  66. data/lib/rigor/cli/type_of_command.rb +5 -3
  67. data/lib/rigor/cli/unused_command.rb +13 -7
  68. data/lib/rigor/cli.rb +10 -2
  69. data/lib/rigor/configuration/severity_profile.rb +6 -0
  70. data/lib/rigor/configuration.rb +46 -2
  71. data/lib/rigor/environment/gated_signature_guard.rb +43 -0
  72. data/lib/rigor/environment/gated_signature_plan.rb +339 -0
  73. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  74. data/lib/rigor/environment/rbs_loader.rb +283 -14
  75. data/lib/rigor/environment/required_features.rb +206 -0
  76. data/lib/rigor/environment.rb +21 -3
  77. data/lib/rigor/inference/acceptance.rb +28 -3
  78. data/lib/rigor/inference/builtins/cruby_definers.rb +61 -0
  79. data/lib/rigor/inference/builtins/method_catalog.rb +3 -0
  80. data/lib/rigor/inference/def_node_resolver.rb +23 -1
  81. data/lib/rigor/inference/expression_typer.rb +215 -16
  82. data/lib/rigor/inference/global_write_census.rb +7 -4
  83. data/lib/rigor/inference/guard_rebinding.rb +1 -1
  84. data/lib/rigor/inference/in_effect_refinements/proc_literals.rb +83 -0
  85. data/lib/rigor/inference/in_effect_refinements/refine_defs.rb +54 -0
  86. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +282 -0
  87. data/lib/rigor/inference/in_effect_refinements/walk.rb +276 -0
  88. data/lib/rigor/inference/in_effect_refinements.rb +256 -0
  89. data/lib/rigor/inference/key_presence_guard/scan.rb +281 -0
  90. data/lib/rigor/inference/key_presence_guard.rb +303 -0
  91. data/lib/rigor/inference/macro_block_self_type.rb +59 -12
  92. data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +36 -0
  93. data/lib/rigor/inference/method_dispatcher/arithmetic_sequence_elements.rb +73 -0
  94. data/lib/rigor/inference/method_dispatcher/imprecise_argument.rb +68 -0
  95. data/lib/rigor/inference/method_dispatcher/integer_step_block_params.rb +81 -0
  96. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -0
  97. data/lib/rigor/inference/method_dispatcher/keyword_arguments.rb +267 -0
  98. data/lib/rigor/inference/method_dispatcher/method_folding.rb +2 -1
  99. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +122 -105
  100. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +259 -28
  101. data/lib/rigor/inference/method_dispatcher/splat_arity.rb +71 -0
  102. data/lib/rigor/inference/method_dispatcher.rb +44 -11
  103. data/lib/rigor/inference/module_function_state.rb +2 -1
  104. data/lib/rigor/inference/narrowing.rb +20 -6
  105. data/lib/rigor/inference/object_mixins.rb +180 -0
  106. data/lib/rigor/inference/project_patched_methods.rb +10 -5
  107. data/lib/rigor/inference/project_patched_scanner.rb +71 -3
  108. data/lib/rigor/inference/refine_census.rb +239 -0
  109. data/lib/rigor/inference/refine_self.rb +317 -0
  110. data/lib/rigor/inference/refined_dispatch/own_method.rb +403 -0
  111. data/lib/rigor/inference/refined_dispatch.rb +272 -0
  112. data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +13 -0
  113. data/lib/rigor/inference/scope_indexer.rb +687 -60
  114. data/lib/rigor/inference/statement_evaluator.rb +71 -20
  115. data/lib/rigor/inference/string_mutation.rb +9 -3
  116. data/lib/rigor/inference/synthetic_method_scanner.rb +2 -1
  117. data/lib/rigor/inference/version_guard.rb +19 -10
  118. data/lib/rigor/language_server/project_context.rb +31 -0
  119. data/lib/rigor/language_server/server.rb +18 -0
  120. data/lib/rigor/plugin/inflector.rb +1 -1
  121. data/lib/rigor/plugin/io_boundary.rb +42 -3
  122. data/lib/rigor/plugin/macro/block_as_method.rb +51 -8
  123. data/lib/rigor/project_environment.rb +29 -6
  124. data/lib/rigor/protection/mutation_cache.rb +1 -1
  125. data/lib/rigor/protection/mutator.rb +1 -1
  126. data/lib/rigor/reflection/constant_ancestors.rb +127 -11
  127. data/lib/rigor/reflection/constant_path.rb +13 -2
  128. data/lib/rigor/reflection.rb +40 -8
  129. data/lib/rigor/scope/discovery_index.rb +40 -1
  130. data/lib/rigor/scope/resolution_chain.rb +159 -11
  131. data/lib/rigor/scope.rb +189 -10
  132. data/lib/rigor/sig_gen/classification.rb +4 -1
  133. data/lib/rigor/sig_gen/generator.rb +55 -5
  134. data/lib/rigor/sig_gen/method_candidate.rb +2 -2
  135. data/lib/rigor/sig_gen/skip_reason_catalog.rb +12 -0
  136. data/lib/rigor/source/alias_names.rb +52 -0
  137. data/lib/rigor/source/literals.rb +3 -2
  138. data/lib/rigor/source/symbol_name.rb +20 -0
  139. data/lib/rigor/type/combinator.rb +34 -2
  140. data/lib/rigor/type/float_range.rb +4 -1
  141. data/lib/rigor/type/nominal.rb +6 -1
  142. data/lib/rigor/version.rb +1 -1
  143. data/plugins/rigor-ac-library-rb/lib/rigor/plugin/ac_library_rb.rb +28 -0
  144. data/plugins/rigor-ac-library-rb/lib/rigor-ac-library-rb.rb +3 -0
  145. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/convolution.rbs +7 -0
  146. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/data_structures.rbs +160 -0
  147. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/graph.rbs +94 -0
  148. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/math.rbs +42 -0
  149. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/modint.rbs +71 -0
  150. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/object.rbs +37 -0
  151. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/segtree.rbs +83 -0
  152. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +2 -1
  153. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/association_roots.rb +100 -0
  154. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +94 -10
  155. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +43 -2
  156. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +3 -1
  157. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_collector.rb +149 -0
  158. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +74 -0
  159. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_index.rb +104 -0
  160. data/plugins/rigor-alba/lib/rigor/plugin/alba.rb +85 -0
  161. data/plugins/rigor-alba/lib/rigor-alba.rb +3 -0
  162. data/plugins/rigor-alba/sig/alba.rbs +4 -0
  163. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +2 -2
  164. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +29 -12
  165. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +7 -6
  166. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +130 -21
  167. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +6 -4
  168. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +4 -4
  169. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +1 -1
  170. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1 -1
  171. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +31 -0
  172. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_collector.rb +102 -0
  173. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +87 -0
  174. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_index.rb +76 -0
  175. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +73 -0
  176. data/plugins/rigor-typelizer/lib/rigor-typelizer.rb +3 -0
  177. data/sig/rigor/analysis/dependency_source_inference/index.rbs +1 -1
  178. data/sig/rigor/plugin/io_boundary.rbs +3 -0
  179. data/sig/rigor/scope.rbs +27 -4
  180. data/sig/rigor/source.rbs +4 -0
  181. data/sig/rigor.rbs +3 -1
  182. metadata +66 -1
@@ -0,0 +1,42 @@
1
+ # ac-library-rb's math and string algorithms, instance methods of `AcLibraryRb` that a caller reaches by
2
+ # including the module.
3
+ module AcLibraryRb
4
+ # A callable taking two arguments: a `Proc`, a lambda or a `Method`.
5
+ interface _Call2[A, B, R]
6
+ def call: (A, B) -> R
7
+ end
8
+
9
+ # An object `Deque#replace` can read elements from.
10
+ interface _ToA[T]
11
+ def to_a: () -> Array[T]
12
+ end
13
+
14
+ def convolution: (Array[Integer] a, Array[Integer] b, ?mod: Integer, ?k: Integer, ?z: Integer) -> Array[Integer]
15
+
16
+ # `[remainder, modulus]`, or `[0, 0]` when there is no solution.
17
+ def crt: (Array[Integer] r, Array[Integer] m) -> [Integer, Integer]
18
+
19
+ def inv_gcd: (Integer a, Integer b) -> [Integer, Integer]
20
+
21
+ def floor_sum: (Integer n, Integer m, Integer a, Integer b) -> Integer
22
+
23
+ def floor_sum_unsigned: (Integer n, Integer m, Integer a, Integer b) -> Integer
24
+
25
+ def inv_mod: (Integer x, Integer m) -> Integer
26
+
27
+ def _inv_gcd: (Integer a, Integer b) -> [Integer, Integer]
28
+
29
+ def pow_mod: (Integer x, Integer n, Integer m) -> Integer
30
+
31
+ def suffix_array: (String s) -> Array[Integer]
32
+ | (Array[untyped] a, ?Integer? upper) -> Array[Integer]
33
+
34
+ def sa_is: (Array[Integer] s, Integer upper) -> Array[Integer]
35
+
36
+ def sa_is_induce: (Array[Integer] s, Array[bool] ls, Array[Integer] sum_l, Array[Integer] sum_s,
37
+ Array[Integer] lms) -> Array[Integer]
38
+
39
+ def lcp_array: (String | Array[untyped] s, Array[Integer] sa) -> Array[Integer]
40
+
41
+ def z_algorithm: (String | Array[untyped] s) -> Array[Integer]
42
+ end
@@ -0,0 +1,71 @@
1
+ module AcLibraryRb
2
+ # An integer modulo the global modulus set by `ModInt.set_mod`.
3
+ class ModInt < ::Numeric
4
+ def self.set_mod: (Integer mod) -> bool
5
+
6
+ def self.mod=: (Integer mod) -> bool
7
+
8
+ def self.mod: () -> Integer
9
+
10
+ def self.raw: (_ToI val) -> ModInt
11
+
12
+ def self.prime?: (Integer n) -> bool
13
+
14
+ def self.inv_gcd: (Integer a, Integer b) -> [Integer, Integer]
15
+
16
+ attr_accessor val: Integer
17
+ alias to_i val
18
+
19
+ def initialize: (?_ToI val) -> void
20
+
21
+ def inc!: () -> self
22
+
23
+ def dec!: () -> self
24
+
25
+ def add!: (_ToI other) -> self
26
+
27
+ def sub!: (_ToI other) -> self
28
+
29
+ def mul!: (_ToI other) -> self
30
+
31
+ def div!: (_ToI other) -> self
32
+
33
+ def +@: () -> self
34
+
35
+ def -@: () -> ModInt
36
+
37
+ # Answers the Integer `0` when the modulus is 1. Declared `ModInt` all the same: a union return reached
38
+ # `1 + m ** 2` as an argument, where overload selection reads a union whole (#1356), typed the sum `Integer`
39
+ # and reported `call.undefined-method` for `.val` on correct code.
40
+ def **: (Integer other) -> ModInt
41
+ alias pow **
42
+
43
+ def inv: () -> ModInt
44
+
45
+ def coerce: (_ToI other) -> [ModInt, ModInt]
46
+
47
+ def +: (_ToI other) -> ModInt
48
+
49
+ def -: (_ToI other) -> ModInt
50
+
51
+ def *: (_ToI other) -> ModInt
52
+
53
+ def /: (_ToI other) -> ModInt
54
+
55
+ def ==: (untyped other) -> bool
56
+
57
+ def pred: () -> ModInt
58
+
59
+ def succ: () -> ModInt
60
+
61
+ def zero?: () -> bool
62
+
63
+ def dup: () -> ModInt
64
+
65
+ def to_int: () -> Integer
66
+
67
+ def to_s: () -> String
68
+
69
+ def inspect: () -> String
70
+ end
71
+ end
@@ -0,0 +1,37 @@
1
+ # The core extensions under `ac-library-rb/core_ext/`. `ac-library-rb/modint` loads the `ModInt` conversions;
2
+ # `Integer#divisors` and the `Array` conversions need `ac-library-rb/core_ext/all` (or `integer`).
3
+ class Object
4
+ private
5
+
6
+ def ModInt: (_ToI val) -> AcLibraryRb::ModInt
7
+ end
8
+
9
+ class Array[unchecked out Elem]
10
+ def to_fenwick_tree: () -> AcLibraryRb::FenwickTree
11
+ alias to_fetree to_fenwick_tree
12
+
13
+ # The element type is not carried over: a literal array would pin it to the literals it starts with.
14
+ def to_priority_queue: () -> AcLibraryRb::PriorityQueue[untyped]
15
+ alias to_pq to_priority_queue
16
+ end
17
+
18
+ class Integer
19
+ # `ModInt#coerce` makes an Integer on the left answer a `ModInt`.
20
+ def +: (AcLibraryRb::ModInt) -> AcLibraryRb::ModInt | ...
21
+ def -: (AcLibraryRb::ModInt) -> AcLibraryRb::ModInt | ...
22
+ def *: (AcLibraryRb::ModInt) -> AcLibraryRb::ModInt | ...
23
+ def /: (AcLibraryRb::ModInt) -> AcLibraryRb::ModInt | ...
24
+
25
+ def divisors: () -> Array[Integer]
26
+
27
+ def each_divisor: () { (Integer) -> void } -> Array[Integer]
28
+ | () -> Enumerator[Integer, Array[Integer]]
29
+
30
+ def to_modint: () -> AcLibraryRb::ModInt
31
+ alias to_m to_modint
32
+ end
33
+
34
+ class String
35
+ def to_modint: () -> AcLibraryRb::ModInt
36
+ alias to_m to_modint
37
+ end
@@ -0,0 +1,83 @@
1
+ module AcLibraryRb
2
+ # A segment tree over the monoid `(S, op, e)`.
3
+ class Segtree[S]
4
+ attr_reader d: Array[S]
5
+ attr_reader op: _Call2[S, S, S]
6
+ attr_reader n: Integer
7
+ attr_reader leaf_size: Integer
8
+ attr_reader log: Integer
9
+
10
+ def initialize: (Array[S] | Integer v, S e) { (S, S) -> S } -> void
11
+ | (Array[S] | Integer v, _Call2[S, S, S] op, S e) -> void
12
+
13
+ def set: (Integer pos, S x) -> void
14
+ alias []= set
15
+
16
+ def get: (Integer pos) -> S
17
+ alias [] get
18
+
19
+ def prod: (Integer l, Integer r) -> S
20
+ | (Range[Integer] range) -> S
21
+
22
+ def all_prod: () -> S
23
+
24
+ def max_right: (Integer l) { (S) -> boolish } -> Integer
25
+
26
+ def min_left: (Integer r) { (S) -> boolish } -> Integer
27
+
28
+ def update: (Integer k) -> S
29
+ end
30
+
31
+ class SegTree = Segtree
32
+
33
+ # A lazy segment tree over the monoid `(S, op, e)` acted on by the maps `(F, composition, id)`, where
34
+ # `mapping.call(f, x)` applies `f` to `x` and `composition.call(f, g)` is `f` after `g`.
35
+ class LazySegtree[S, F]
36
+ attr_reader d: Array[S]
37
+ attr_reader lz: Array[F]
38
+ attr_reader e: S
39
+ attr_reader id: F
40
+ attr_accessor op: _Call2[S, S, S]
41
+ attr_accessor mapping: _Call2[F, S, S]?
42
+ attr_accessor composition: _Call2[F, F, F]?
43
+
44
+ # The first form is chosen when the second argument is a `Proc`.
45
+ def initialize: (Array[S] | Integer v, ^(S, S) -> S op, S e, _Call2[F, S, S] mapping,
46
+ _Call2[F, F, F] composition, F id) -> void
47
+ | (Array[S] | Integer v, S e, F id, ?_Call2[S, S, S]? op, ?_Call2[F, S, S]? mapping,
48
+ ?_Call2[F, F, F]? composition) ?{ (S, S) -> S } -> void
49
+
50
+ def set_mapping: () { (F, S) -> S } -> Proc
51
+
52
+ def set_composition: () { (F, F) -> F } -> Proc
53
+
54
+ def set: (Integer pos, S x) -> void
55
+ alias []= set
56
+
57
+ def get: (Integer pos) -> S
58
+ alias [] get
59
+
60
+ def prod: (Integer l, Integer r) -> S
61
+ | (Range[Integer] range) -> S
62
+
63
+ def all_prod: () -> S
64
+
65
+ def apply: (Integer pos, F f) -> void
66
+ | (Integer l, Integer r, F f) -> void
67
+ | (Range[Integer] range, F f) -> void
68
+
69
+ def range_apply: (Integer l, Integer r, F f) -> void
70
+
71
+ def max_right: (Integer l) { (S) -> boolish } -> Integer
72
+
73
+ def min_left: (Integer r) { (S) -> boolish } -> Integer
74
+
75
+ def update: (Integer k) -> S
76
+
77
+ def all_apply: (Integer k, F f) -> void
78
+
79
+ def push: (Integer k) -> void
80
+ end
81
+
82
+ class LazySegTree = LazySegtree
83
+ end
@@ -309,8 +309,9 @@ module Rigor
309
309
  args
310
310
  end
311
311
 
312
+ # The name is interned later (`filter_name.to_sym`), so a name no Symbol can hold reads as nil.
312
313
  def literal_symbol_or_string(node)
313
- Rigor::Source::Literals.symbol_or_string_name(node)
314
+ Rigor::Source::Literals.symbol_or_string(node)&.to_s
314
315
  end
315
316
 
316
317
  def filter_lookup_violation(call_node, arg_node, filter_name, methods, spell_checker, ambiguous_filters:)
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ class Activerecord < Rigor::Plugin::Base
6
+ # The model classes ActiveRecord loads BY NAME from an association (`belongs_to :user` → `User`), for
7
+ # `rigor unused` (ADR-102 WD3). The name is derived at runtime and never written in source, so a
8
+ # reference index sees a model reached only this way as unreferenced.
9
+ #
10
+ # The rows come from the {ModelIndex}'s association list, which {ModelDiscoverer} already extracts
11
+ # (class-body declarations and `with_options` groups only — not `class << self`, a `def` or another
12
+ # block). This module only resolves them, mirroring ActiveRecord:
13
+ #
14
+ # - `class_name:` (String or Symbol) wins; `"::Foo"` is rooted.
15
+ # - otherwise `has_many` / `has_and_belongs_to_many` → `name.singularize.camelize`, and
16
+ # `belongs_to` / `has_one` → `name.camelize` (`Reflection#derive_class_name`).
17
+ # - the name is tried as nested in the owner itself, then in each enclosing namespace innermost first,
18
+ # then at top level (`ActiveRecord::Inheritance#compute_type`).
19
+ # - a root is published only when the project declares a model of that name.
20
+ #
21
+ # Declined — no root, never a guess: a polymorphic association (no single target), a non-literal
22
+ # `class_name:` / `**opts`, `anonymous_class:`, a `with_options` group with non-literal options, and a
23
+ # `through:` association without a literal `class_name:` / `source_type:`. Without them a `through:` target is the SOURCE association's class on the through model, and
24
+ # that association (declared there, hence in this same index) roots it itself.
25
+ #
26
+ # A computed name equal to the owner's own demodulized name declines too (Rails 7.2+ tries `::Name`
27
+ # first; the target is then the owner itself or a top-level class).
28
+ #
29
+ # ActiveRecord takes the first candidate that is a defined constant. The index knows only models, so the
30
+ # walk stops (declines) at a candidate that is not a model but is a namespace of one: Rails would pick
31
+ # that module and fail, never the next candidate. A non-model class at an earlier candidate is invisible
32
+ # here.
33
+ #
34
+ # The roots are flat: an association on a model `rigor unused` itself reports as dead still roots its
35
+ # target, exactly as the other plugins' roots do.
36
+ module AssociationRoots
37
+ COLLECTION_MACROS = %i[has_many has_and_belongs_to_many].freeze
38
+ ROOTED_MACROS = (COLLECTION_MACROS + %i[belongs_to has_one]).freeze
39
+ VALID_NAME = /\A(?:::)?[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*\z/
40
+ private_constant :VALID_NAME
41
+
42
+ module_function
43
+
44
+ # Sorted, unique model class names. Raises {Rigor::Plugin::Inflector::Unavailable} when a name had to
45
+ # be inflected and could not be.
46
+ def call(model_index)
47
+ namespaces = model_index.class_names.flat_map { |name| prefixes(name) }.to_set
48
+ roots = model_index.entries.each_value.flat_map do |entry|
49
+ entry.associations.filter_map { |row| resolve(entry.class_name, row, model_index, namespaces) }
50
+ end
51
+ roots.uniq.sort
52
+ end
53
+
54
+ def prefixes(name)
55
+ segments = name.split("::")
56
+ (1...segments.length).map { |n| segments.first(n).join("::") }
57
+ end
58
+
59
+ def resolve(owner, row, model_index, namespaces)
60
+ return nil unless ROOTED_MACROS.include?(row[:macro])
61
+ return nil if row[:polymorphic] || row[:roots_declined]
62
+
63
+ type_name = type_name(row)
64
+ return nil if type_name.nil? || !VALID_NAME.match?(type_name)
65
+ # Rails 7.2+ (`MacroReflection#_klass`) tries `::Name` first when the computed name is the owner's
66
+ # own demodulized name, so the target may be the owner or a top-level class: decline.
67
+ return nil if type_name == owner.split("::").last
68
+
69
+ candidates(owner, type_name).each do |candidate|
70
+ return candidate if model_index.model?(candidate)
71
+ return nil if namespaces.include?(candidate)
72
+ end
73
+ nil
74
+ end
75
+
76
+ def type_name(row)
77
+ option = row[:class_name_option]
78
+ option = row[:source_type_option] if option.nil? && row[:through]
79
+ return nil if option == :dynamic
80
+ return option unless option.nil?
81
+ return nil if row[:through]
82
+
83
+ name = row[:name].to_s
84
+ name = Rigor::Plugin::Inflector.singularize(name) if COLLECTION_MACROS.include?(row[:macro])
85
+ Rigor::Plugin::Inflector.camelize(name)
86
+ end
87
+
88
+ # `compute_type`: a rooted name is looked up as is; otherwise the owner ITSELF first (`Post has_many
89
+ # :comments` tries `Post::Comment`), then its enclosing namespaces innermost first, then top level.
90
+ def candidates(owner, type_name)
91
+ return [type_name.delete_prefix("::")] if type_name.start_with?("::")
92
+
93
+ segments = owner.split("::")
94
+ nested = segments.length.downto(1).map { |n| "#{segments.first(n).join('::')}::#{type_name}" }
95
+ nested + [type_name]
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -940,7 +940,7 @@ module Rigor
940
940
  node.name == :define_singleton_method
941
941
  arg0 = node.arguments&.arguments&.first
942
942
  name = Rigor::Source::Literals.symbol_name(arg0) || (arg0.is_a?(Prism::StringNode) ? arg0.unescaped : nil)
943
- key = TABLE_NAME_DECORATOR_METHODS[name.to_sym] if name
943
+ key = TABLE_NAME_DECORATOR_METHODS[Rigor::Source::SymbolName.of(name)] if name
944
944
  if key
945
945
  readers << key
946
946
  decorators[key] = node.block.is_a?(Prism::BlockNode) ? literal_def_value(node.block.body) : { computed: true }
@@ -1263,9 +1263,10 @@ module Rigor
1263
1263
  # association / enum / validation declared inside is invisible to the discoverer, turning
1264
1264
  # `where(<assoc>: ...)` into a false `unknown-column`. Nested `with_options` blocks recurse.
1265
1265
  #
1266
- # The options the `with_options` call itself carries (e.g. `with_options class_name: 'Account'`)
1267
- # are NOT merged into the nested calls — discovering the declaration name is what clears the
1268
- # false positive; the merged-option target precision is a separate refinement.
1266
+ # This method returns only the nodes and does NOT merge the group's own options (e.g.
1267
+ # `with_options class_name: 'Account'`); {#association_calls} is the variant that does, and it is
1268
+ # what the association rows use. The other lookups (enums, scopes, validations, callbacks) ignore
1269
+ # group options.
1269
1270
  def declaration_calls(body)
1270
1271
  return [] if body.nil?
1271
1272
 
@@ -1291,20 +1292,42 @@ module Rigor
1291
1292
  return [] if body.nil?
1292
1293
 
1293
1294
  rows = []
1294
- declaration_calls(body).each do |node|
1295
+ association_calls(body, [], false).each do |node, inherited, opaque|
1295
1296
  kind = ASSOCIATION_METHODS[node.name]
1296
1297
  next if kind.nil?
1297
1298
  next if node.receiver # skip `self.has_many` and similar
1298
1299
 
1299
- row = build_association_row(node, kind)
1300
+ row = build_association_row(node, kind, inherited, opaque)
1300
1301
  rows << row unless row.nil?
1301
1302
  end
1302
1303
  rows
1303
1304
  end
1304
1305
 
1305
- def build_association_row(node, kind)
1306
- args = node.arguments&.arguments
1307
- return nil if args.nil? || args.empty?
1306
+ # {#declaration_calls} that also carries the options of the enclosing `with_options(...)` groups, as
1307
+ # `[node, option_args, opaque]`. `option_args` are the keyword-hash arguments of those groups, which
1308
+ # ActiveRecord merges under the call's own options; `opaque` is true when a group passes anything
1309
+ # else (`with_options opts do`), so its options are unknown.
1310
+ def association_calls(body, inherited, opaque)
1311
+ return [] if body.nil?
1312
+
1313
+ body.compact_child_nodes.flat_map do |node|
1314
+ next [] unless node.is_a?(Prism::CallNode)
1315
+
1316
+ if node.name == :with_options && node.block.is_a?(Prism::BlockNode)
1317
+ args = node.arguments&.arguments || []
1318
+ hashes = args.grep(Prism::KeywordHashNode)
1319
+ association_calls(node.block.body, hashes + inherited, opaque || hashes.size != args.size)
1320
+ else
1321
+ [[node, inherited, opaque]]
1322
+ end
1323
+ end
1324
+ end
1325
+
1326
+ def build_association_row(node, kind, inherited, opaque)
1327
+ own = node.arguments&.arguments
1328
+ return nil if own.nil? || own.empty?
1329
+
1330
+ args = own + inherited
1308
1331
 
1309
1332
  name = Rigor::Source::Literals.symbol_name(args.first)
1310
1333
  return nil if name.nil?
@@ -1322,7 +1345,65 @@ module Rigor
1322
1345
  end
1323
1346
 
1324
1347
  { name: name, kind: kind, target: target, polymorphic: polymorphic,
1325
- nullable: association_nullable?(node.name, args) }
1348
+ nullable: association_nullable?(node.name, args),
1349
+ macro: node.name, class_name_option: class_name_option(args),
1350
+ through: association_key?(args, "through"),
1351
+ source_type_option: literal_option(args, "source_type"),
1352
+ roots_declined: opaque || association_key?(args, "anonymous_class") || own_options_unknown?(own) }
1353
+ end
1354
+
1355
+ # The `class_name:` option as ActiveRecord's `compute_type` will read it: the literal String (a
1356
+ # Symbol is `to_s`'d by Rails too), `nil` when the option is absent, and `:dynamic` when it is
1357
+ # present but not a literal — the `:reachability_roots` consumer must decline then, where the
1358
+ # `target` above falls back to the inferred name.
1359
+ # A `**opts` splat may carry a `class_name:` too, so it reads as present-but-unknown.
1360
+ def class_name_option(args)
1361
+ literal_option(args, "class_name") ||
1362
+ (args.any? { |a| a.is_a?(Prism::KeywordHashNode) && a.elements.any?(Prism::AssocSplatNode) } ? :dynamic : nil)
1363
+ end
1364
+
1365
+ def literal_option(args, key)
1366
+ args.each do |arg|
1367
+ next unless arg.is_a?(Prism::KeywordHashNode)
1368
+
1369
+ arg.elements.each do |pair|
1370
+ next unless pair.is_a?(Prism::AssocNode) && Source::Literals.symbol_named?(pair.key, key)
1371
+
1372
+ value = pair.value
1373
+ return value.unescaped if value.is_a?(Prism::StringNode) || value.is_a?(Prism::SymbolNode)
1374
+
1375
+ return :dynamic
1376
+ end
1377
+ end
1378
+ nil
1379
+ end
1380
+
1381
+ ROOT_OPTION_KEYS = %w[class_name polymorphic through source_type anonymous_class].freeze
1382
+ LITERAL_OPTION_VALUES = [Prism::StringNode, Prism::SymbolNode, Prism::TrueNode, Prism::FalseNode].freeze
1383
+ private_constant :ROOT_OPTION_KEYS, :LITERAL_OPTION_VALUES
1384
+
1385
+ # Whether the association call's OWN arguments hide an option that decides its target class: a
1386
+ # positional options hash or variable, a `**splat`, or a non-literal value for one of the keys. A
1387
+ # `with_options` group's literal value must not stand in for it.
1388
+ def own_options_unknown?(own)
1389
+ own.drop(1).any? do |arg|
1390
+ next false if arg.is_a?(Prism::LambdaNode) # the scope argument carries no class option
1391
+ next true unless arg.is_a?(Prism::KeywordHashNode)
1392
+
1393
+ arg.elements.any? do |pair|
1394
+ next true unless pair.is_a?(Prism::AssocNode)
1395
+
1396
+ ROOT_OPTION_KEYS.any? { |key| Source::Literals.symbol_named?(pair.key, key) } &&
1397
+ LITERAL_OPTION_VALUES.none? { |klass| pair.value.is_a?(klass) }
1398
+ end
1399
+ end
1400
+ end
1401
+
1402
+ def association_key?(args, key)
1403
+ args.any? do |arg|
1404
+ arg.is_a?(Prism::KeywordHashNode) &&
1405
+ arg.elements.any? { |pair| pair.is_a?(Prism::AssocNode) && Source::Literals.symbol_named?(pair.key, key) }
1406
+ end
1326
1407
  end
1327
1408
 
1328
1409
  # Whether a `:singular` association's accessor can return `nil`. `has_one` genuinely can (no
@@ -1356,6 +1437,9 @@ module Rigor
1356
1437
 
1357
1438
  return true if pair.value.is_a?(Prism::TrueNode)
1358
1439
  return false if pair.value.is_a?(Prism::FalseNode)
1440
+
1441
+ # The call's own non-literal value shadows a `with_options` group's literal one.
1442
+ return nil
1359
1443
  end
1360
1444
  end
1361
1445
  nil
@@ -7,6 +7,7 @@ require_relative "activerecord/schema_parser"
7
7
  require_relative "activerecord/structure_sql_parser"
8
8
  require_relative "activerecord/model_index"
9
9
  require_relative "activerecord/model_discoverer"
10
+ require_relative "activerecord/association_roots"
10
11
  require_relative "activerecord/analyzer"
11
12
  require_relative "activerecord/effects"
12
13
 
@@ -64,6 +65,11 @@ module Rigor
64
65
  manifest(
65
66
  id: "activerecord",
66
67
  target_gems: ["activerecord"],
68
+ # 0.14.0, 2026-10-10 (#1721) — the plugin publishes `:reachability_roots`: the models ActiveRecord loads
69
+ # by name from an association. The association rows gain `macro`, `class_name_option`, `through` and
70
+ # `source_type_option`, so a cached 0.13.0 payload lacks the fields the roots are derived from and
71
+ # would publish none; the bump (part of the producer cache KEY) is what stops a warm run serving it.
72
+ #
67
73
  # 0.13.0, 2026-09-24 (#1321) — the class-side `Model.find` with two or more arguments returns
68
74
  # `Array[Model]` instead of the model, and its `model-call` note says so. A model that defines its own
69
75
  # `self.find` keeps its own answer; the note reads syntax alone and does not see that override. No
@@ -118,7 +124,7 @@ module Rigor
118
124
  # a scope lambda body / class-method body now contributes `Relation[Model]` via `scope.self_type`
119
125
  # instead of falling through to `Kernel#select` (the IO multiplexer, `Array[String]` return). Plus
120
126
  # `:select` added to the relation-entry-point list.
121
- version: "0.13.0",
127
+ version: "0.14.0",
122
128
  description: "Types ActiveRecord finders against the project's db/schema.rb and AR models.",
123
129
  config_schema: {
124
130
  "schema_file" => { kind: :string, default: "db/schema.rb" },
@@ -128,7 +134,7 @@ module Rigor
128
134
  "model_search_paths" => { kind: :array, default: ["app/models"] },
129
135
  "model_base_classes" => { kind: :array, default: %w[ApplicationRecord ActiveRecord::Base] }
130
136
  },
131
- produces: [:model_index],
137
+ produces: %i[model_index reachability_roots],
132
138
  # ADR-25 — the bundled `ActiveRecord::Relation` RBS that relation-typed call sites (`has_many`
133
139
  # accessors, `Model.where`, scopes) dispatch against.
134
140
  signature_paths: ["sig"],
@@ -241,6 +247,9 @@ module Rigor
241
247
  # this, so the disclosure is in the coordinator's table before a single worker is forked.
242
248
  disclose_load_errors
243
249
  return if index.nil? || index.empty?
250
+
251
+ # Roots do not read columns, so reduced mode (Redmine) publishes them too.
252
+ publish_association_roots(services, index)
244
253
  # Reduced mode stays UNPUBLISHED. Every consumer reads `columns:` as authoritative and fires on a
245
254
  # key missing from it — rigor-actionpack's `permit(:title)` check and rigor-shoulda-matchers'
246
255
  # `have_db_column(:title)` / `validate_presence_of(:title)` matchers both do — so publishing the
@@ -256,6 +265,18 @@ module Rigor
256
265
  )
257
266
  end
258
267
 
268
+ # ADR-102 WD3 — the models an association loads by name; see {AssociationRoots}. An unavailable
269
+ # inflector (ADR-39: decline, never approximate) leaves the report as it was.
270
+ def publish_association_roots(services, index)
271
+ roots = AssociationRoots.call(index)
272
+ return if roots.empty?
273
+
274
+ services.fact_store.publish(plugin_id: manifest.id, name: :reachability_roots, value: roots)
275
+ rescue Rigor::Plugin::Inflector::Unavailable
276
+ nil
277
+ end
278
+ private :publish_association_roots
279
+
259
280
  def diagnostics_for_file(path:, scope:, root:)
260
281
  index = model_index
261
282
  # The schema-load disclosure is independent of whether an index was built: reduced mode still
@@ -401,6 +422,7 @@ module Rigor
401
422
 
402
423
  if call_node.receiver
403
424
  class_call_return_type(call_node, scope, index) ||
425
+ relation_splat_find_return_type(call_node, scope) ||
404
426
  relation_call_return_type(call_node, scope, index) ||
405
427
  instance_call_return_type(call_node, scope, index)
406
428
  else
@@ -670,6 +692,25 @@ module Rigor
670
692
  relation_of(model_name)
671
693
  end
672
694
 
695
+ # `relation.find(*ids)` answers by argument count as `Model.find` does (`finder_return_type`), counting a splat or
696
+ # a `**` hash as one argument, which is the policy the bundled `Relation#find` documents: `find(*ids)` is the
697
+ # element, and `find(id, *ids)` and `find(*args, **opts)` are Arrays. The engine lets a splat stand for any
698
+ # number of arguments (#1801), so through the RBS overloads `find(*ids)` would join `Dynamic[Array[Elem] |
699
+ # Elem]`, on which a following `update` or `save` no longer resolves on the model and loses its
700
+ # `io.db.write`. Only a block-less call with a splat is answered here; every other `find` stays with the RBS.
701
+ def relation_splat_find_return_type(call_node, scope)
702
+ return nil unless call_node.name == :find && call_node.block.nil?
703
+ return nil unless call_node.arguments&.arguments&.any?(Prism::SplatNode)
704
+
705
+ model_name = relation_element_class_name(scope.type_of(call_node.receiver))
706
+ return nil if model_name.nil?
707
+
708
+ model = Rigor::Type::Combinator.nominal_of(model_name)
709
+ return model if call_argument_count(call_node) < 2
710
+
711
+ Rigor::Type::Combinator.nominal_of("Array", type_args: [model])
712
+ end
713
+
673
714
  # Whether `name` is a declared `scope` on ANY model in the index. A run-lifetime memoised Set so the
674
715
  # per-call check in `relation_call_return_type` stays O(1).
675
716
  def scope_name?(name, index)
@@ -292,7 +292,9 @@ module ActiveRecord
292
292
  # loses its `io.db.write`. So a single argument stays `Elem`. A
293
293
  # splat and a `**` hash each count as one argument, so `find(*ids)`
294
294
  # is `Elem`, while `find(id, *ids)` and `find(*args, **opts)` are
295
- # Arrays even when the splat or the hash turns out empty.
295
+ # Arrays even when the splat or the hash turns out empty. The engine
296
+ # reads a splat as any number of arguments (#1801), so the plugin
297
+ # answers a splat call by this count (`relation_splat_find_return_type`).
296
298
  #
297
299
  # With a block `find` is `Enumerable#find` (`return super if
298
300
  # block_given?`), so the id overloads do not apply: no argument