rigortype 0.1.19 → 0.2.0

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 (166) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -23
  3. data/lib/rigor/analysis/check_rules/rule_walk.rb +3 -21
  4. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +24 -15
  5. data/lib/rigor/analysis/check_rules.rb +492 -71
  6. data/lib/rigor/analysis/dependency_source_inference/index.rb +4 -7
  7. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -18
  8. data/lib/rigor/analysis/dependency_source_inference.rb +3 -12
  9. data/lib/rigor/analysis/fact_store.rb +5 -4
  10. data/lib/rigor/analysis/rule_catalog.rb +153 -6
  11. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +17 -17
  12. data/lib/rigor/analysis/runner/project_pre_passes.rb +9 -8
  13. data/lib/rigor/analysis/runner.rb +17 -6
  14. data/lib/rigor/analysis/self_call_resolution_recorder.rb +3 -4
  15. data/lib/rigor/analysis/worker_session.rb +10 -14
  16. data/lib/rigor/builtins/predefined_constant_refinements.rb +151 -0
  17. data/lib/rigor/cache/store.rb +5 -3
  18. data/lib/rigor/cli/annotate_command.rb +28 -7
  19. data/lib/rigor/cli/baseline_command.rb +4 -3
  20. data/lib/rigor/cli/check_command.rb +115 -16
  21. data/lib/rigor/cli/coverage_command.rb +148 -16
  22. data/lib/rigor/cli/coverage_scan.rb +57 -0
  23. data/lib/rigor/cli/explain_command.rb +2 -0
  24. data/lib/rigor/cli/lsp_command.rb +3 -7
  25. data/lib/rigor/cli/mutation_protection_renderer.rb +63 -0
  26. data/lib/rigor/cli/mutation_protection_report.rb +73 -0
  27. data/lib/rigor/cli/options.rb +9 -0
  28. data/lib/rigor/cli/plugins_command.rb +2 -1
  29. data/lib/rigor/cli/protection_renderer.rb +63 -0
  30. data/lib/rigor/cli/protection_report.rb +68 -0
  31. data/lib/rigor/cli/sig_gen_command.rb +2 -1
  32. data/lib/rigor/cli/trace_command.rb +2 -1
  33. data/lib/rigor/cli/triage_command.rb +2 -1
  34. data/lib/rigor/cli/type_of_command.rb +1 -1
  35. data/lib/rigor/cli/type_scan_command.rb +2 -1
  36. data/lib/rigor/cli.rb +3 -2
  37. data/lib/rigor/configuration/dependencies.rb +2 -4
  38. data/lib/rigor/configuration.rb +45 -7
  39. data/lib/rigor/environment/bundle_sig_discovery.rb +61 -13
  40. data/lib/rigor/environment/class_registry.rb +4 -3
  41. data/lib/rigor/environment/constant_type_cache_holder.rb +43 -0
  42. data/lib/rigor/environment/lockfile_resolver.rb +1 -1
  43. data/lib/rigor/environment/rbs_collection_discovery.rb +1 -2
  44. data/lib/rigor/environment/rbs_coverage_report.rb +2 -1
  45. data/lib/rigor/environment/rbs_loader.rb +49 -5
  46. data/lib/rigor/environment.rb +17 -7
  47. data/lib/rigor/flow_contribution/fact.rb +1 -1
  48. data/lib/rigor/flow_contribution.rb +3 -5
  49. data/lib/rigor/inference/acceptance.rb +17 -9
  50. data/lib/rigor/inference/block_parameter_binder.rb +2 -3
  51. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -2
  52. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -2
  53. data/lib/rigor/inference/builtins/method_catalog.rb +19 -0
  54. data/lib/rigor/inference/builtins/string_catalog.rb +9 -1
  55. data/lib/rigor/inference/expression_typer.rb +20 -28
  56. data/lib/rigor/inference/hkt_body.rb +8 -11
  57. data/lib/rigor/inference/hkt_body_parser.rb +10 -12
  58. data/lib/rigor/inference/hkt_registry.rb +10 -11
  59. data/lib/rigor/inference/method_dispatcher/call_context.rb +1 -4
  60. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +156 -21
  61. data/lib/rigor/inference/method_dispatcher/data_folding.rb +9 -73
  62. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -7
  63. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +10 -16
  64. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +25 -13
  65. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +93 -0
  66. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -3
  67. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +24 -22
  68. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +90 -15
  69. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +303 -0
  70. data/lib/rigor/inference/method_dispatcher.rb +40 -48
  71. data/lib/rigor/inference/mutation_widening.rb +5 -11
  72. data/lib/rigor/inference/narrowing.rb +14 -16
  73. data/lib/rigor/inference/parameter_inference_collector.rb +367 -0
  74. data/lib/rigor/inference/project_patched_methods.rb +4 -7
  75. data/lib/rigor/inference/project_patched_scanner.rb +2 -13
  76. data/lib/rigor/inference/protection_scanner.rb +86 -0
  77. data/lib/rigor/inference/scope_indexer.rb +129 -55
  78. data/lib/rigor/inference/statement_evaluator.rb +244 -114
  79. data/lib/rigor/inference/struct_fold_safety.rb +181 -0
  80. data/lib/rigor/inference/synthetic_method.rb +7 -7
  81. data/lib/rigor/language_server/completion_provider.rb +6 -12
  82. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -4
  83. data/lib/rigor/language_server/document_symbol_provider.rb +3 -3
  84. data/lib/rigor/language_server/hover_provider.rb +2 -3
  85. data/lib/rigor/language_server/hover_renderer.rb +2 -11
  86. data/lib/rigor/language_server/server.rb +9 -17
  87. data/lib/rigor/language_server.rb +4 -5
  88. data/lib/rigor/plugin/base.rb +10 -8
  89. data/lib/rigor/plugin/macro/block_as_method.rb +3 -4
  90. data/lib/rigor/plugin/macro/heredoc_template.rb +4 -7
  91. data/lib/rigor/plugin/macro/trait_registry.rb +3 -6
  92. data/lib/rigor/plugin/macro.rb +4 -5
  93. data/lib/rigor/plugin/manifest.rb +45 -66
  94. data/lib/rigor/plugin/registry.rb +6 -7
  95. data/lib/rigor/plugin/type_node_resolver.rb +6 -8
  96. data/lib/rigor/protection/mutation_scanner.rb +120 -0
  97. data/lib/rigor/protection/mutator.rb +246 -0
  98. data/lib/rigor/rbs_extended.rb +24 -36
  99. data/lib/rigor/reflection.rb +4 -7
  100. data/lib/rigor/scope/discovery_index.rb +14 -2
  101. data/lib/rigor/scope.rb +54 -11
  102. data/lib/rigor/sig_gen/observed_call.rb +3 -3
  103. data/lib/rigor/sig_gen/writer.rb +40 -2
  104. data/lib/rigor/source/constant_path.rb +62 -0
  105. data/lib/rigor/source.rb +1 -0
  106. data/lib/rigor/type/bound_method.rb +2 -11
  107. data/lib/rigor/type/combinator.rb +16 -3
  108. data/lib/rigor/type/constant.rb +2 -11
  109. data/lib/rigor/type/data_class.rb +2 -11
  110. data/lib/rigor/type/data_instance.rb +2 -11
  111. data/lib/rigor/type/hash_shape.rb +2 -11
  112. data/lib/rigor/type/integer_range.rb +2 -11
  113. data/lib/rigor/type/intersection.rb +2 -11
  114. data/lib/rigor/type/nominal.rb +2 -11
  115. data/lib/rigor/type/plain_lattice.rb +37 -0
  116. data/lib/rigor/type/refined.rb +72 -13
  117. data/lib/rigor/type/singleton.rb +2 -11
  118. data/lib/rigor/type/struct_class.rb +75 -0
  119. data/lib/rigor/type/struct_instance.rb +93 -0
  120. data/lib/rigor/type/tuple.rb +5 -15
  121. data/lib/rigor/type.rb +2 -0
  122. data/lib/rigor/version.rb +1 -1
  123. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  124. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +3 -3
  125. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +3 -3
  126. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +5 -13
  127. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +11 -17
  128. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +7 -10
  129. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +3 -2
  130. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  131. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +6 -8
  132. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +5 -7
  133. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +1 -2
  134. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +9 -11
  135. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +8 -9
  136. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +13 -12
  137. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +3 -4
  138. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +8 -8
  139. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +9 -11
  140. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +7 -8
  141. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +7 -9
  142. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +12 -13
  143. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +15 -23
  144. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +3 -3
  145. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +3 -3
  146. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +2 -4
  147. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +27 -11
  148. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +1 -1
  149. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -6
  150. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +12 -18
  151. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +5 -5
  152. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -4
  153. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +1 -1
  154. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  155. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +19 -14
  156. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +0 -1
  157. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +5 -4
  158. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +2 -3
  159. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +7 -11
  160. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +4 -5
  161. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +6 -9
  162. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +5 -15
  163. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +28 -41
  164. data/sig/rigor/scope.rbs +9 -1
  165. data/sig/rigor/type.rbs +36 -1
  166. metadata +19 -1
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../trinary"
4
+
5
+ module Rigor
6
+ module Type
7
+ # Supplies the lattice-membership trio for the "plain" carriers — the
8
+ # concrete value types that are neither a lattice extreme (`Top` /
9
+ # `Bot` / `Dynamic`) nor a wrapper that computes membership from an
10
+ # inner type.
11
+ #
12
+ # Every such carrier answers `top` / `bot` / `dynamic` with the same
13
+ # `Trinary.no` ("this value is not that lattice point"), so the trio
14
+ # lived as a byte-identical copy in a dozen carriers. The extremes
15
+ # override the relevant member (`Top#top` / `Bot#bot` /
16
+ # `Dynamic#dynamic` answer `Trinary.yes`) and the delegators (`App`,
17
+ # `Difference`, `Refined`, `Union`) compute `dynamic` from their inner
18
+ # type(s); none of those include this module.
19
+ #
20
+ # Mirrors the existing {AcceptanceRouter} / `ValueSemantics` mixins —
21
+ # narrow trait sharing, never carrier inheritance (which the type-object
22
+ # contract forbids).
23
+ module PlainLattice
24
+ def top
25
+ Trinary.no
26
+ end
27
+
28
+ def bot
29
+ Trinary.no
30
+ end
31
+
32
+ def dynamic
33
+ Trinary.no
34
+ end
35
+ end
36
+ end
37
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "prism"
4
+
3
5
  require_relative "../trinary"
4
6
  require_relative "../value_semantics"
5
7
  require_relative "acceptance_router"
@@ -104,18 +106,31 @@ module Rigor
104
106
  # so callers can pass any `Constant#value` without a
105
107
  # type-prefilter.
106
108
  #
107
- # Plugin-contributed predicates land here once ADR-2 is
108
- # in flight; today the table is closed over the v0.0.4
109
- # built-in catalogue.
109
+ # Plugin-contributed predicates are not yet wired; today
110
+ # the table covers the built-in catalogue.
110
111
  #
111
112
  # Recogniser policy:
112
113
  #
113
- # - `:numeric` is deliberately conservative only decimal
114
- # integer and plain-decimal-fraction strings are
115
- # recognised, mirroring `imported-built-in-types.md`'s
116
- # "Rigor's numeric-string predicate" wording. Looser
117
- # forms (scientific, hex, rational) MAY join later
118
- # without breaking the registry contract.
114
+ # - `:numeric` recognises a string that is a *single Ruby
115
+ # numeric literal* exactly the syntax that, written in
116
+ # Ruby source, evaluates to an `Integer` / `Float` /
117
+ # `Rational` / `Complex`. The recogniser delegates to the
118
+ # real Ruby parser ({Refined.ruby_numeric_literal?} via
119
+ # Prism), so it tracks Ruby's grammar precisely: decimal /
120
+ # `0x` hex / `0o` (or leading-zero) octal / `0b` binary /
121
+ # `0d` decimal integers, underscore digit separators
122
+ # (`1_000`), decimal fractions and scientific floats
123
+ # (`1.5`, `1E-5`), and the `r` rational / `i` imaginary
124
+ # suffixes (`1r`, `2i`, `0xffr`). A single leading sign is
125
+ # folded into the literal (`-1`, `+1.5`), but a doubled
126
+ # sign (`--1`, `++1`) parses as a unary-operator chain — a
127
+ # `CallNode`, not a literal — and is rejected, as are
128
+ # multi-dot junk (`1.2.3`), partial literals (`0x`, `1_`),
129
+ # whitespace-padded strings, and — crucially — non-ASCII
130
+ # "digits" (full-width `1`, superscript `²`, other Unicode
131
+ # number characters): Ruby's lexer only accepts `[0-9]` in
132
+ # a numeric literal, so those are `CallNode`s too. The
133
+ # stricter base-N predicates below remain proper subsets.
119
134
  # - `:decimal_int` is "what `Integer(s, 10)` would parse
120
135
  # without remainder" — one or more decimal digits,
121
136
  # optional leading sign, no whitespace, no fractional
@@ -127,20 +142,64 @@ module Rigor
127
142
  # not octal-int-string. This matches the typical user
128
143
  # intent — a refinement marks a string that "looks like
129
144
  # octal", not "happens to be base-8 valid".
130
- NUMERIC_STRING_PATTERN = /\A-?\d+(?:\.\d+)?\z/
131
145
  DECIMAL_INT_STRING_PATTERN = /\A-?\d+\z/
132
146
  OCTAL_INT_STRING_PATTERN = /\A-?(?:0[oO][0-7]+|0[0-7]+)\z/
133
147
  HEX_INT_STRING_PATTERN = /\A-?0[xX][0-9a-fA-F]+\z/
134
- private_constant :NUMERIC_STRING_PATTERN, :DECIMAL_INT_STRING_PATTERN,
148
+ private_constant :DECIMAL_INT_STRING_PATTERN,
135
149
  :OCTAL_INT_STRING_PATTERN, :HEX_INT_STRING_PATTERN
136
150
 
151
+ # Prism node classes that represent a numeric literal. A
152
+ # string is a numeric-string exactly when the parser reduces
153
+ # the whole input to a single one of these (the leading sign
154
+ # is already folded into the literal by the parser).
155
+ NUMERIC_LITERAL_NODES = [
156
+ Prism::IntegerNode,
157
+ Prism::FloatNode,
158
+ Prism::RationalNode,
159
+ Prism::ImaginaryNode
160
+ ].freeze
161
+ private_constant :NUMERIC_LITERAL_NODES
162
+
163
+ # Cheap pre-filter applied before invoking the parser: every
164
+ # Ruby numeric literal starts with an ASCII digit, optionally
165
+ # preceded by exactly one sign. Strings that fail this never
166
+ # reach Prism (the common non-numeric case stays allocation-
167
+ # and parse-free).
168
+ NUMERIC_LITERAL_PREFIX = /\A[+-]?\d/
169
+ private_constant :NUMERIC_LITERAL_PREFIX
170
+
171
+ # @param value [Object] typically a `Constant#value`
172
+ # @return [Boolean] true when `value` is a String that is a
173
+ # single, complete Ruby numeric literal. Total over
174
+ # arbitrary input — never raises (Prism reports malformed
175
+ # input through `errors`, it does not throw).
176
+ def self.ruby_numeric_literal?(value)
177
+ return false unless value.is_a?(String)
178
+ return false if value.empty?
179
+ # A numeric literal carries no whitespace; reject any
180
+ # leading / trailing / interior space so the *whole* string
181
+ # must be the literal (Prism would otherwise accept a
182
+ # trailing-space `"1 "`).
183
+ return false if value.match?(/\s/)
184
+ return false unless value.match?(NUMERIC_LITERAL_PREFIX)
185
+
186
+ result = Prism.parse(value)
187
+ return false unless result.errors.empty?
188
+
189
+ body = result.value.statements&.body
190
+ return false unless body && body.size == 1
191
+
192
+ node = body.first
193
+ NUMERIC_LITERAL_NODES.any? { |klass| node.is_a?(klass) }
194
+ end
195
+
137
196
  PREDICATES = {
138
197
  lowercase: ->(v) { v.is_a?(String) && v == v.downcase },
139
198
  not_lowercase: ->(v) { v.is_a?(String) && v != v.downcase },
140
199
  uppercase: ->(v) { v.is_a?(String) && v == v.upcase },
141
200
  not_uppercase: ->(v) { v.is_a?(String) && v != v.upcase },
142
- numeric: ->(v) { v.is_a?(String) && NUMERIC_STRING_PATTERN.match?(v) },
143
- not_numeric: ->(v) { v.is_a?(String) && !NUMERIC_STRING_PATTERN.match?(v) },
201
+ numeric: ->(v) { ruby_numeric_literal?(v) },
202
+ not_numeric: ->(v) { v.is_a?(String) && !ruby_numeric_literal?(v) },
144
203
  decimal_int: ->(v) { v.is_a?(String) && DECIMAL_INT_STRING_PATTERN.match?(v) },
145
204
  octal_int: ->(v) { v.is_a?(String) && OCTAL_INT_STRING_PATTERN.match?(v) },
146
205
  hex_int: ->(v) { v.is_a?(String) && HEX_INT_STRING_PATTERN.match?(v) },
@@ -3,6 +3,7 @@
3
3
  require_relative "../trinary"
4
4
  require_relative "../value_semantics"
5
5
  require_relative "acceptance_router"
6
+ require_relative "plain_lattice"
6
7
 
7
8
  module Rigor
8
9
  module Type
@@ -34,17 +35,7 @@ module Rigor
34
35
  "singleton(#{class_name})"
35
36
  end
36
37
 
37
- def top
38
- Trinary.no
39
- end
40
-
41
- def bot
42
- Trinary.no
43
- end
44
-
45
- def dynamic
46
- Trinary.no
47
- end
38
+ include Rigor::Type::PlainLattice
48
39
 
49
40
  include Rigor::Type::AcceptanceRouter
50
41
 
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../trinary"
4
+ require_relative "../value_semantics"
5
+ require_relative "acceptance_router"
6
+ require_relative "plain_lattice"
7
+
8
+ module Rigor
9
+ module Type
10
+ # The class object produced by `Struct.new(:x, :y)` (ADR-48 Struct
11
+ # follow-up). The mutable sibling of {DataClass}: it models the *class*
12
+ # (the value bound to `Point` in `Point = Struct.new(:x, :y)`, or the
13
+ # anonymous superclass in `class Point < Struct.new(:x, :y)`), carrying
14
+ # the ordered member-name list so `Point.new(...)` can materialise a
15
+ # {StructInstance}.
16
+ #
17
+ # `keyword_init` records the `Struct.new(..., keyword_init: true)` flag
18
+ # so `.new` only materialises a precise instance for the matching call
19
+ # form — a positional `.new(1, 2)` on a `keyword_init: true` struct, or
20
+ # a keyword `.new(x: 1)` on a positional struct, is a different runtime
21
+ # shape and must degrade rather than fold a wrong member map.
22
+ #
23
+ # `class_name` carries the binding name when known (the named-subclass
24
+ # form) and is `nil` for the anonymous result of a bare `Struct.new(...)`
25
+ # before it is assigned to a constant.
26
+ #
27
+ # Equality and hashing are structural over the member list, the class
28
+ # name, and the keyword-init flag.
29
+ #
30
+ # See docs/adr/48-data-struct-value-folding.md § "Struct follow-up".
31
+ class StructClass
32
+ attr_reader :members, :class_name, :keyword_init
33
+
34
+ # @param members [Array<Symbol>] ordered member names.
35
+ # @param class_name [String, nil] the bound class name, or nil for
36
+ # the anonymous `Struct.new(...)` result.
37
+ # @param keyword_init [Boolean] the `keyword_init:` flag.
38
+ def initialize(members, class_name = nil, keyword_init: false)
39
+ unless members.is_a?(Array) && members.all?(Symbol)
40
+ raise ArgumentError, "members must be an Array of Symbols, got #{members.inspect}"
41
+ end
42
+ unless class_name.nil? || (class_name.is_a?(String) && !class_name.empty?)
43
+ raise ArgumentError, "class_name must be a non-empty String or nil, got #{class_name.inspect}"
44
+ end
45
+
46
+ @members = members.dup.freeze
47
+ @class_name = class_name&.freeze
48
+ @keyword_init = keyword_init ? true : false
49
+ freeze
50
+ end
51
+
52
+ def describe(_verbosity = :short)
53
+ return "singleton(#{class_name})" if class_name
54
+
55
+ "Struct.new(#{members.map(&:inspect).join(', ')})"
56
+ end
57
+
58
+ def erase_to_rbs
59
+ "singleton(#{class_name || 'Struct'})"
60
+ end
61
+
62
+ include Rigor::Type::PlainLattice
63
+
64
+ include Rigor::Type::AcceptanceRouter
65
+
66
+ include Rigor::ValueSemantics
67
+
68
+ value_fields :members, :class_name, :keyword_init
69
+
70
+ def inspect
71
+ "#<Rigor::Type::StructClass #{describe(:short)}>"
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../trinary"
4
+ require_relative "../value_semantics"
5
+ require_relative "acceptance_router"
6
+ require_relative "plain_lattice"
7
+
8
+ module Rigor
9
+ module Type
10
+ # A `Struct.new` value instance (ADR-48 Struct follow-up) —
11
+ # `Point.new(1, 2)`. The mutable sibling of {DataInstance}: a closed,
12
+ # total, class-tagged member map (member name -> value type),
13
+ # HashShape-shaped but nominal.
14
+ #
15
+ # Unlike {DataInstance}, a `Struct` instance is **mutable** — `s.x = v`,
16
+ # `s[:x] = v`, and escape can invalidate the member map. The folding
17
+ # tier therefore only projects member reads off a **fresh** instance
18
+ # (the transient receiver of a `.new(...).x` / `.with(...).x` chain,
19
+ # which provably cannot have been mutated between materialisation and
20
+ # the read); a read off a *stored* binding degrades to `Dynamic[top]`
21
+ # rather than fold a possibly-stale member value. Promoting the
22
+ # fold to mutation-free bound locals is the deferred slice 3 (see ADR).
23
+ #
24
+ # That mutability-gating lives in the dispatch tier (`StructFolding`),
25
+ # not the carrier: the carrier itself just records the member map. Like
26
+ # {DataInstance}, non-folded methods project to the `Struct` nominal (or
27
+ # the tagged class) through {RbsDispatch}'s `receiver_descriptor`, so
28
+ # non-member calls resolve without mis-firing undefined-method.
29
+ #
30
+ # Equality and hashing are structural over the (member -> type) map and
31
+ # the class name.
32
+ #
33
+ # See docs/adr/48-data-struct-value-folding.md § "Struct follow-up".
34
+ class StructInstance
35
+ attr_reader :members, :class_name
36
+
37
+ # @param members [Hash{Symbol => Rigor::Type}] ordered member -> type
38
+ # map. Every declared member is present (Struct instances are total).
39
+ # @param class_name [String, nil] the tagging class name, or nil for
40
+ # an instance of an anonymous `Struct.new(...)` class.
41
+ def initialize(members, class_name = nil)
42
+ unless members.is_a?(Hash) && members.each_key.all?(Symbol)
43
+ raise ArgumentError, "members must be a Hash with Symbol keys, got #{members.inspect}"
44
+ end
45
+ unless class_name.nil? || (class_name.is_a?(String) && !class_name.empty?)
46
+ raise ArgumentError, "class_name must be a non-empty String or nil, got #{class_name.inspect}"
47
+ end
48
+
49
+ @members = members.dup.freeze
50
+ @class_name = class_name&.freeze
51
+ freeze
52
+ end
53
+
54
+ # @return [Array<Symbol>] ordered member names.
55
+ def member_names
56
+ members.keys
57
+ end
58
+
59
+ # @return [Rigor::Type, nil] the member's value type, or nil when the
60
+ # name is not a declared member.
61
+ def member_type(name)
62
+ members[name]
63
+ end
64
+
65
+ def describe(verbosity = :short)
66
+ rendered = members.map { |name, type| "#{name}: #{type.describe(verbosity)}" }
67
+ "#{class_name || 'Struct'}(#{rendered.join(', ')})"
68
+ end
69
+
70
+ # Erases to the tagging class nominal (conservative: the structural
71
+ # members are not RBS-expressible as a class instance). The
72
+ # anonymous case erases to the `Struct` supertype.
73
+ def erase_to_rbs
74
+ name = class_name
75
+ return "Struct" if name.nil?
76
+
77
+ name
78
+ end
79
+
80
+ include Rigor::Type::PlainLattice
81
+
82
+ include Rigor::Type::AcceptanceRouter
83
+
84
+ include Rigor::ValueSemantics
85
+
86
+ value_fields :members, :class_name
87
+
88
+ def inspect
89
+ "#<Rigor::Type::StructInstance #{describe(:short)}>"
90
+ end
91
+ end
92
+ end
93
+ end
@@ -3,6 +3,7 @@
3
3
  require_relative "../trinary"
4
4
  require_relative "../value_semantics"
5
5
  require_relative "acceptance_router"
6
+ require_relative "plain_lattice"
6
7
 
7
8
  module Rigor
8
9
  module Type
@@ -17,10 +18,9 @@ module Rigor
17
18
  #
18
19
  # Slice 5 phase 1 introduces the carrier and surfaces it from the
19
20
  # `ArrayNode` literal handler when every element is a non-splat
20
- # value. Tuple-aware refinements for `tuple[0]`, `tuple.first`, and
21
- # destructuring assignment are deferred to Slice 5 phase 2; they
22
- # will run as a higher-priority dispatch tier above
23
- # {Rigor::Inference::MethodDispatcher::RbsDispatch}.
21
+ # value. Tuple-aware refinements (`tuple[0]`, `tuple.first`,
22
+ # destructuring) are implemented in `ShapeDispatch`, which runs
23
+ # above {Rigor::Inference::MethodDispatcher::RbsDispatch}.
24
24
  #
25
25
  # Equality and hashing are structural across an ordered, frozen
26
26
  # element list. The empty Tuple `Tuple[]` is permitted; the array
@@ -53,17 +53,7 @@ module Rigor
53
53
  "[#{elements.map(&:erase_to_rbs).join(', ')}]"
54
54
  end
55
55
 
56
- def top
57
- Trinary.no
58
- end
59
-
60
- def bot
61
- Trinary.no
62
- end
63
-
64
- def dynamic
65
- Trinary.no
66
- end
56
+ include Rigor::Type::PlainLattice
67
57
 
68
58
  include Rigor::Type::AcceptanceRouter
69
59
 
data/lib/rigor/type.rb CHANGED
@@ -21,6 +21,8 @@ require_relative "type/tuple"
21
21
  require_relative "type/hash_shape"
22
22
  require_relative "type/data_class"
23
23
  require_relative "type/data_instance"
24
+ require_relative "type/struct_class"
25
+ require_relative "type/struct_instance"
24
26
  require_relative "type/union"
25
27
  require_relative "type/difference"
26
28
  require_relative "type/refined"
data/lib/rigor/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rigor
4
- VERSION = "0.1.19"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -26,7 +26,7 @@ module Rigor
26
26
  # `stream_for` call) so the analyzer knows it can't
27
27
  # be sure of every stream name.
28
28
  #
29
- # Limitations (intentional for v0.1.0):
29
+ # Intentional limitations:
30
30
  #
31
31
  # - Direct-superclass match only.
32
32
  # - Public-vs-private is not tracked; the framework
@@ -69,9 +69,9 @@ module Rigor
69
69
  # True when at least one discovered channel uses a
70
70
  # dynamic stream registration. The analyzer treats
71
71
  # this as "we can't be sure any literal name is
72
- # missing" and downgrades unknown-stream from
73
- # `:warning` to `:info` (or drops it entirely;
74
- # current behaviour: skip warnings).
72
+ # missing" and skips the `unknown-stream` warning
73
+ # entirely absence of a literal match doesn't prove
74
+ # the name is wrong.
75
75
  def any_dynamic_streams?
76
76
  @entries.any?(&:dynamic_streams)
77
77
  end
@@ -41,7 +41,7 @@ module Rigor
41
41
  # `stream_for record`) — the absence of a literal
42
42
  # match doesn't prove absence.
43
43
  #
44
- # ## Limitations (v0.1.0)
44
+ # ## Limitations
45
45
  #
46
46
  # - **Direct-superclass match only.** Indirect
47
47
  # inheritance (`AdminChannel < BaseChannel <
@@ -51,8 +51,8 @@ module Rigor
51
51
  # ActionCable actions are invoked from JS via
52
52
  # `subscription.perform("action_name", data)`; we
53
53
  # don't analyse JS so the action-method index is
54
- # currently informational only (future cross-plugin
55
- # handoff to a hypothetical JS-side analyzer).
54
+ # informational only (deferred: cross-plugin handoff
55
+ # to a JS-side analyzer).
56
56
  # - **`broadcast_to` arity isn't checked.** The method
57
57
  # takes any record + any data hash; there's no
58
58
  # useful arity envelope.
@@ -301,19 +301,11 @@ module Rigor
301
301
  [entry.method_name, entry]
302
302
  end
303
303
 
304
- # Merge in actions from include'd modules. The
305
- # discoverer pre-collected every module's defs as
306
- # `module_actions` keyed by fully-qualified module
307
- # name. We resolve each include against that map —
308
- # tries the full include name first, then walks down
309
- # the class's lexical chain looking for a nested
310
- # match (e.g. `Emails::Issues` inside `class Notify`
311
- # at top-level resolves to top-level `Emails::Issues`).
312
- # Includes we cannot resolve are silently skipped;
313
- # the per-mailer `unresolved_includes?` predicate
314
- # below (consumed by the analyzer) downgrades
315
- # `unknown-action` to silence when any include is
316
- # unresolved.
304
+ # Merge actions from include'd modules (pre-collected
305
+ # in `module_actions` keyed by fully-qualified name).
306
+ # Unresolvable includes are tracked; `unresolved_includes?`
307
+ # (consumed by the analyzer) downgrades `unknown-action`
308
+ # to silence when any include remains unresolved.
317
309
  unresolved_includes = []
318
310
  includes.each do |include_name|
319
311
  inc_actions = module_actions[include_name]
@@ -33,9 +33,8 @@ module Rigor
33
33
  # Phase 2 — filter-chain DSL methods. Each takes a
34
34
  # variadic list of filter names (Symbols / Strings) plus
35
35
  # optional `only:` / `except:` / `if:` / `unless:`
36
- # modifiers. The validation key is the filter NAMES; the
37
- # modifiers are accepted but their action-name argument
38
- # is not yet validated (Phase 2.5).
36
+ # modifiers. Only the filter NAMES are validated; the
37
+ # `only:`/`except:` action-name arguments are not (deferred).
39
38
  FILTER_DSL_METHODS = %i[
40
39
  before_action after_action around_action
41
40
  skip_before_action skip_after_action skip_around_action
@@ -43,18 +42,14 @@ module Rigor
43
42
  ].freeze
44
43
 
45
44
  # Phase 3 — render-target template extensions checked in
46
- # priority order. The first six cover the templating
47
- # engines used by the projects this plugin is regularly
48
- # exercised against: ERB (Rails default — `.html.erb`,
49
- # `.text.erb`), HAML (Mastodon, Solidus admin
50
- # `.html.haml`), Slim, and JSON (`.json.jbuilder` plus a
51
- # raw `.json.erb` for hand-rolled API responses). When a
52
- # template exists under any of these extensions, the
53
- # missing-template diagnostic stays silent.
54
- # Configurable extension list is queued — see the
55
- # `external-author plugin SKILL` track (v0.2.0). For now
56
- # this set is wide enough to cover the surveyed real-world
57
- # projects without leaking FPs.
45
+ # priority order. Covers the engines used by surveyed
46
+ # projects: ERB (Rails default `.html.erb`, `.text.erb`),
47
+ # HAML (Mastodon, Solidus admin — `.html.haml`), Slim, and
48
+ # JSON (`.json.jbuilder` plus `.json.erb` for hand-rolled API
49
+ # responses). When a template exists under any of these
50
+ # extensions, the missing-template diagnostic stays silent.
51
+ # A configurable extension list is deferred; this set is wide
52
+ # enough to cover surveyed real-world projects without FPs.
58
53
  RENDER_TEMPLATE_EXTENSIONS = %w[
59
54
  .html.erb
60
55
  .text.erb
@@ -167,8 +162,7 @@ module Rigor
167
162
  # list (looked up via the model_index fact published by
168
163
  # `rigor-activerecord`). Calls whose `:require` argument is a
169
164
  # non-literal Symbol are passed through; namespaced models
170
- # (`params.require(:admin_user)` → `Admin::User`) are deferred to a
171
- # Phase 1.5 follow-up.
165
+ # (`params.require(:admin_user)` → `Admin::User`) are deferred.
172
166
  #
173
167
  # @param call_node [Prism::Node]
174
168
  # @param model_index [Hash{String => Hash}]
@@ -68,17 +68,14 @@ module Rigor
68
68
  class Actionpack < Rigor::Plugin::Base
69
69
  manifest(
70
70
  id: "actionpack",
71
- # Bumped 2026-06-02 — ADR-37 node_rule migration. The four
72
- # phases (helper / filter / render / strong-params) now run
73
- # per-call over the engine-owned walk instead of the
74
- # hand-rolled `diagnostics_for_file` traversal; the enclosing
75
- # controller is read from the node-rule `NodeContext` ancestors.
76
- # Nested-module qualification is preserved — a
77
- # `module Admin; class DomainBlocksController; end` file still
71
+ # ADR-37: the four phases (helper / filter / render /
72
+ # strong-params) run per-call over the engine-owned walk;
73
+ # the enclosing controller is read from the node-rule
74
+ # `NodeContext` ancestors. Nested-module qualification is
75
+ # preserved `module Admin; class DomainBlocksController`
78
76
  # resolves as `Admin::DomainBlocksController` (matching the
79
- # `ControllerDiscoverer`), so render paths
80
- # (`admin/domain_blocks/new`) and filter-chain validation on
81
- # nested controllers are unchanged.
77
+ # `ControllerDiscoverer`), so render paths and filter-chain
78
+ # validation on nested controllers are correct.
82
79
  version: "0.8.0",
83
80
  description: "Validates Action Pack route-helper calls and filter chains inside controllers.",
84
81
  config_schema: {
@@ -12,8 +12,9 @@ module Rigor
12
12
  # (`Float::INFINITY` for the upper bound when `*args`
13
13
  # is present). `keyword_required` lists any required
14
14
  # keyword arguments — Active Job supports keyword args
15
- # but they're rare in user code, so the analyzer only
16
- # validates positional arity for v0.1.0.
15
+ # but they're rare in user code, so the analyzer
16
+ # validates positional arity only (keyword arity
17
+ # validation is deferred).
17
18
  class JobIndex
18
19
  Entry = Data.define(:class_name, :min_arity, :max_arity, :keyword_required) do
19
20
  # Flexible-friendly textual form of the arity for
@@ -227,8 +227,8 @@ module Rigor
227
227
  # Recognised single-instance and collection association
228
228
  # DSL methods. The kind drives the eventual return-type
229
229
  # contribution: singular associations narrow to
230
- # `Nominal[Target] | nil`, plural ones currently degrade
231
- # to the RBS envelope (relation types are a future track).
230
+ # `Nominal[Target] | nil`, plural ones narrow to
231
+ # `ActiveRecord::Relation[Target]`.
232
232
  #
233
233
  # `composed_of` value-object aggregations and
234
234
  # `delegated_type` roles are folded in here too — both
@@ -447,8 +447,8 @@ module Rigor
447
447
 
448
448
  # `scope :active, -> { ... }`. Records the scope name
449
449
  # only (the body is intentionally NOT introspected —
450
- # scopes return ActiveRecord::Relation, which Rigor
451
- # doesn't carry a precise type for yet).
450
+ # the caller contributes `ActiveRecord::Relation[Model]`
451
+ # based on the name alone via `class_scope_return_type`).
452
452
  def lookup_scopes(body)
453
453
  return [] if body.nil?
454
454
 
@@ -75,10 +75,9 @@ module Rigor
75
75
  "model_base_classes" => { kind: :array, default: %w[ApplicationRecord ActiveRecord::Base] }
76
76
  },
77
77
  produces: [:model_index],
78
- # ADR-25 — the bundled `ActiveRecord::Relation` RBS, the
79
- # type `flow_contribution_for`'s relation-typed call sites
80
- # (`has_many` accessors, `Model.where`, scopes) dispatch
81
- # against.
78
+ # ADR-25 — the bundled `ActiveRecord::Relation` RBS that
79
+ # relation-typed call sites (`has_many` accessors,
80
+ # `Model.where`, scopes) dispatch against.
82
81
  signature_paths: ["sig"],
83
82
  # ADR-26 — `ActiveRecord::Relation` is an "open" receiver:
84
83
  # it delegates an unbounded set of user-defined scopes /
@@ -89,7 +88,7 @@ module Rigor
89
88
  )
90
89
 
91
90
  # The class the bundled `sig/active_record/relation.rbs`
92
- # describes; `flow_contribution_for` contributes
91
+ # describes; `dynamic_return` contributes
93
92
  # `ActiveRecord::Relation[Model]` for relation-returning
94
93
  # call sites (`has_many` accessors, `Model.where`, scopes).
95
94
  RELATION_CLASS_NAME = "ActiveRecord::Relation"
@@ -261,9 +260,8 @@ module Rigor
261
260
  names
262
261
  end
263
262
 
264
- # The migrated body of the legacy `flow_contribution_for` —
265
- # same resolution order, returning the bare type the
266
- # `dynamic_return` contract expects.
263
+ # Resolution body for `dynamic_return` — same four-path
264
+ # order, returning the bare type the contract expects.
267
265
  def contribution_return_type(call_node, scope)
268
266
  return nil unless call_node.is_a?(Prism::CallNode)
269
267
 
@@ -11,13 +11,11 @@ module Rigor
11
11
  # plugin recognised so users can verify the model →
12
12
  # attachment mapping the plugin sees.
13
13
  #
14
- # No `:error` diagnostics in this slice — the
15
- # `dynamic_return` return-type narrowing carries
16
- # the type-checking value; surfacing unknown attachment
17
- # names as errors requires a coupled receiver-class
18
- # narrowing pass that the integration spec doesn't yet
19
- # rely on. A future slice can add `unknown-attachment`
20
- # similar to `rigor-activerecord`'s `unknown-column`.
14
+ # No `:error` diagnostics here — the `dynamic_return`
15
+ # return-type narrowing carries the type-checking value.
16
+ # An `unknown-attachment` rule (similar to
17
+ # `rigor-activerecord`'s `unknown-column`) is deferred:
18
+ # it requires a coupled receiver-class narrowing pass.
21
19
  class Analyzer
22
20
  attr_reader :diagnostics
23
21