rigortype 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +186 -513
  3. data/lib/rigor/analysis/check_rules.rb +20 -0
  4. data/lib/rigor/analysis/runner.rb +67 -9
  5. data/lib/rigor/analysis/worker_session.rb +13 -4
  6. data/lib/rigor/cache/rbs_descriptor.rb +21 -2
  7. data/lib/rigor/cache/rbs_environment.rb +2 -1
  8. data/lib/rigor/cli/annotate_command.rb +274 -0
  9. data/lib/rigor/cli/baseline_command.rb +36 -16
  10. data/lib/rigor/cli/coverage_command.rb +126 -0
  11. data/lib/rigor/cli/coverage_renderer.rb +162 -0
  12. data/lib/rigor/cli/coverage_report.rb +75 -0
  13. data/lib/rigor/cli/mcp_command.rb +70 -0
  14. data/lib/rigor/cli/prism_colorizer.rb +111 -0
  15. data/lib/rigor/cli.rb +134 -6
  16. data/lib/rigor/environment/rbs_loader.rb +46 -5
  17. data/lib/rigor/environment/reporters.rb +3 -2
  18. data/lib/rigor/environment.rb +168 -5
  19. data/lib/rigor/inference/builtins/method_catalog.rb +17 -1
  20. data/lib/rigor/inference/builtins/time_catalog.rb +10 -1
  21. data/lib/rigor/inference/def_return_typer.rb +98 -0
  22. data/lib/rigor/inference/expression_typer.rb +308 -18
  23. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +109 -0
  24. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +178 -10
  25. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +53 -1
  26. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
  27. data/lib/rigor/inference/method_dispatcher/math_folding.rb +149 -0
  28. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +20 -1
  29. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +81 -0
  30. data/lib/rigor/inference/method_dispatcher/set_folding.rb +81 -0
  31. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +431 -9
  32. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +126 -0
  33. data/lib/rigor/inference/method_dispatcher/time_folding.rb +56 -0
  34. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +67 -0
  35. data/lib/rigor/inference/method_dispatcher.rb +148 -1
  36. data/lib/rigor/inference/method_parameter_binder.rb +67 -10
  37. data/lib/rigor/inference/narrowing.rb +29 -10
  38. data/lib/rigor/inference/precision_scanner.rb +131 -0
  39. data/lib/rigor/inference/statement_evaluator.rb +29 -3
  40. data/lib/rigor/mcp/loop.rb +43 -0
  41. data/lib/rigor/mcp/server.rb +263 -0
  42. data/lib/rigor/mcp.rb +16 -0
  43. data/lib/rigor/plugin/base.rb +67 -5
  44. data/lib/rigor/plugin/loader.rb +22 -1
  45. data/lib/rigor/plugin/manifest.rb +101 -10
  46. data/lib/rigor/plugin/protocol_contract.rb +185 -0
  47. data/lib/rigor/plugin/registry.rb +87 -0
  48. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
  49. data/lib/rigor/sig_gen/generator.rb +150 -75
  50. data/lib/rigor/triage/catalogue.rb +2 -2
  51. data/lib/rigor/type/combinator.rb +57 -0
  52. data/lib/rigor/type/constant.rb +29 -2
  53. data/lib/rigor/version.rb +1 -1
  54. data/sig/rigor/analysis/baseline.rbs +39 -0
  55. data/sig/rigor/environment.rbs +3 -2
  56. data/sig/rigor/type.rbs +4 -0
  57. data/sig/rigor.rbs +2 -0
  58. metadata +42 -1
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ # ADR-28 declaration: "every instance/singleton method named
6
+ # `method_name`, defined in a source file matching `path_glob`,
7
+ # is implicitly required to satisfy the declared parameter +
8
+ # return-type protocol."
9
+ #
10
+ # Authored on a plugin manifest:
11
+ #
12
+ # manifest(
13
+ # id: "web",
14
+ # version: "0.1.0",
15
+ # protocol_contracts: [
16
+ # Rigor::Plugin::ProtocolContract.new(
17
+ # path_glob: "lib/controller/**/*.rb",
18
+ # method_name: :get,
19
+ # param_types: [{ index: 0, type_name: "Rack::Request" }],
20
+ # return_type_name: "Rack::Response"
21
+ # )
22
+ # ]
23
+ # )
24
+ #
25
+ # The contract drives two distinct engine behaviours (ADR-28
26
+ # § "provide-and-check"):
27
+ #
28
+ # - **provide** — when the inference engine binds the parameter
29
+ # list of a matching `def`, {Rigor::Inference::MethodParameterBinder}
30
+ # substitutes the declared `param_types` for the usual
31
+ # `Dynamic[Top]` fallback, so the method body is analysed as
32
+ # if the parameter carried its protocol type.
33
+ # - **check** — the contributing plugin's `#diagnostics_for_file`
34
+ # hook confirms the method exists and its inferred return type
35
+ # conforms to `return_type_name`.
36
+ #
37
+ # ## Fields
38
+ #
39
+ # - `path_glob` — `File.fnmatch` glob (String) selecting the
40
+ # source files the contract applies to, relative to the
41
+ # analysed project root (e.g. `"lib/controller/**/*.rb"`).
42
+ # - `method_name` — Symbol; the instance (or singleton) method
43
+ # the contract constrains.
44
+ # - `singleton` — Boolean; `true` constrains `def self.<name>`,
45
+ # `false` (default) constrains instance methods.
46
+ # - `param_types` — Array of `ParamType` (positional index →
47
+ # fully-qualified type name). The type names resolve against
48
+ # the analysed project's environment lazily, at consumption
49
+ # time, so the contract value object stays independent of
50
+ # environment construction order.
51
+ # - `return_type_name` — fully-qualified type name (String) the
52
+ # method's inferred return type must conform to.
53
+ # - `severity` — Symbol diagnostic severity for contract
54
+ # violations (`:error` default).
55
+ #
56
+ # ## Ractor-shareability
57
+ #
58
+ # Every field is frozen at construction (ADR-15 Phase 1); the
59
+ # nested `ParamType` is a frozen `Data`. `Ractor.shareable?`
60
+ # returns true after `#initialize`, so the contract survives
61
+ # `Plugin::Registry.materialize` into a worker Ractor.
62
+ class ProtocolContract
63
+ VALID_SEVERITIES = %i[error warning info].freeze
64
+
65
+ # One positional-parameter provision: the zero-based index of
66
+ # the parameter and the fully-qualified name of the type it
67
+ # carries under the protocol.
68
+ ParamType = Data.define(:index, :type_name)
69
+
70
+ attr_reader :path_glob, :method_name, :singleton, :param_types, :return_type_name, :severity
71
+
72
+ def initialize(path_glob:, method_name:, return_type_name: nil, param_types: [], singleton: false,
73
+ severity: :error)
74
+ validate_path_glob!(path_glob)
75
+ validate_method_name!(method_name)
76
+ validate_return_type_name!(return_type_name)
77
+ validate_severity!(severity)
78
+
79
+ @path_glob = path_glob.dup.freeze
80
+ @method_name = method_name.to_sym
81
+ @singleton = singleton ? true : false
82
+ @param_types = coerce_param_types(param_types)
83
+ @return_type_name = return_type_name.nil? ? nil : return_type_name.dup.freeze
84
+ @severity = severity.to_sym
85
+ freeze
86
+ end
87
+
88
+ # Returns a copy with `path_glob` replaced. Plugins use this to
89
+ # honour a per-project config override of the convention path
90
+ # without rebuilding the whole contract by hand.
91
+ def with_path_glob(glob)
92
+ ProtocolContract.new(
93
+ path_glob: glob,
94
+ method_name: method_name,
95
+ return_type_name: return_type_name,
96
+ param_types: param_types.map { |pt| { index: pt.index, type_name: pt.type_name } },
97
+ singleton: singleton,
98
+ severity: severity
99
+ )
100
+ end
101
+
102
+ def to_h
103
+ {
104
+ "path_glob" => path_glob,
105
+ "method_name" => method_name.to_s,
106
+ "singleton" => singleton,
107
+ "param_types" => param_types.map { |pt| { "index" => pt.index, "type_name" => pt.type_name } },
108
+ "return_type_name" => return_type_name,
109
+ "severity" => severity.to_s
110
+ }
111
+ end
112
+
113
+ def ==(other)
114
+ other.is_a?(ProtocolContract) && to_h == other.to_h
115
+ end
116
+ alias eql? ==
117
+
118
+ def hash
119
+ to_h.hash
120
+ end
121
+
122
+ private
123
+
124
+ def validate_path_glob!(value)
125
+ return if value.is_a?(String) && !value.empty?
126
+
127
+ raise ArgumentError,
128
+ "Plugin::ProtocolContract#path_glob must be a non-empty String, got #{value.inspect}"
129
+ end
130
+
131
+ def validate_method_name!(value)
132
+ return if value.is_a?(Symbol) || (value.is_a?(String) && !value.empty?)
133
+
134
+ raise ArgumentError,
135
+ "Plugin::ProtocolContract#method_name must be a Symbol/non-empty String, got #{value.inspect}"
136
+ end
137
+
138
+ def validate_return_type_name!(value)
139
+ return if value.nil?
140
+ return if value.is_a?(String) && !value.empty?
141
+
142
+ raise ArgumentError,
143
+ "Plugin::ProtocolContract#return_type_name must be a non-empty String or nil, got #{value.inspect}"
144
+ end
145
+
146
+ def validate_severity!(value)
147
+ return if VALID_SEVERITIES.include?(value.to_sym)
148
+
149
+ raise ArgumentError,
150
+ "Plugin::ProtocolContract#severity must be one of #{VALID_SEVERITIES.inspect}, got #{value.inspect}"
151
+ rescue NoMethodError
152
+ raise ArgumentError,
153
+ "Plugin::ProtocolContract#severity must be one of #{VALID_SEVERITIES.inspect}, got #{value.inspect}"
154
+ end
155
+
156
+ def coerce_param_types(param_types)
157
+ unless param_types.is_a?(Array)
158
+ raise ArgumentError,
159
+ "Plugin::ProtocolContract#param_types must be an Array, got #{param_types.inspect}"
160
+ end
161
+
162
+ param_types.map { |entry| coerce_param_type(entry) }.freeze
163
+ end
164
+
165
+ def coerce_param_type(entry)
166
+ return entry if entry.is_a?(ParamType)
167
+
168
+ unless entry.is_a?(Hash)
169
+ raise ArgumentError,
170
+ "Plugin::ProtocolContract param_types entry must be a Hash or ParamType, got #{entry.inspect}"
171
+ end
172
+
173
+ index = entry[:index] || entry["index"]
174
+ type_name = entry[:type_name] || entry["type_name"]
175
+ unless index.is_a?(Integer) && index >= 0 && type_name.is_a?(String) && !type_name.empty?
176
+ raise ArgumentError,
177
+ "Plugin::ProtocolContract param_types entry needs an Integer index >= 0 and a " \
178
+ "non-empty String type_name, got #{entry.inspect}"
179
+ end
180
+
181
+ ParamType.new(index: index, type_name: type_name.dup.freeze)
182
+ end
183
+ end
184
+ end
185
+ end
@@ -104,7 +104,94 @@ module Rigor
104
104
  Inference::HktRegistry.new(registrations: registrations, definitions: definitions)
105
105
  end
106
106
 
107
+ # ADR-25 — flat, ordered list of every loaded plugin's
108
+ # resolved RBS signature directories (absolute paths), in
109
+ # plugin registration order. `Environment.for_project`
110
+ # merges these into the signature-path set fed to
111
+ # `RbsLoader`, alongside the configuration's `signature_paths:`
112
+ # and the `bundler:` / `rbs_collection:` discovery output.
113
+ def signature_paths
114
+ plugins.flat_map(&:signature_paths)
115
+ end
116
+
117
+ # ADR-26 — the aggregate set of "open" receiver class names
118
+ # declared across loaded plugins (manifest `open_receivers:`).
119
+ # A class is open when a plugin vouches that it responds
120
+ # beyond its RBS-declared method surface. `open_receiver?`
121
+ # is the membership predicate `Analysis::CheckRules` consults
122
+ # to skip the `call.undefined-method` rule for such a class.
123
+ def open_receivers
124
+ plugins.flat_map { |plugin| plugin.manifest.open_receivers }
125
+ end
126
+
127
+ def open_receiver?(class_name)
128
+ return false if class_name.nil?
129
+
130
+ open_receivers.include?(class_name.to_s)
131
+ end
132
+
133
+ # ADR-28 — flat, ordered list of every loaded plugin's
134
+ # path-scoped method-protocol contracts, in plugin
135
+ # registration order. Read from each plugin's
136
+ # `#protocol_contracts` (which the manifest backs by default
137
+ # but a plugin MAY override to fold in per-project config).
138
+ # Consumed by `Inference::MethodParameterBinder` (the
139
+ # parameter-type provision) and by contributing plugins'
140
+ # `#diagnostics_for_file` hooks (the presence + return-type
141
+ # check).
142
+ def protocol_contracts
143
+ plugins.flat_map(&:protocol_contracts)
144
+ end
145
+
146
+ # ADR-28 — the subset of `protocol_contracts` whose
147
+ # `path_glob` matches `path`. Contract globs are authored
148
+ # project-root-relative (`lib/controller/**/*.rb`); the
149
+ # analyzer may hand this method either a project-relative
150
+ # path (`rigor check` run from the project root) or an
151
+ # absolute one (run from elsewhere, or a spec tmpdir), so the
152
+ # glob is matched both directly and as a `**/`-prefixed path
153
+ # suffix. `File::FNM_PATHNAME` keeps `*` from crossing `/`;
154
+ # `File::FNM_EXTGLOB` enables `{a,b}` groups. Returns `[]` for
155
+ # a nil path so the binder can call this unconditionally.
156
+ def contracts_for_path(path)
157
+ return [] if path.nil?
158
+
159
+ path_s = path.to_s
160
+ protocol_contracts.select { |contract| path_matches_glob?(contract.path_glob, path_s) }
161
+ end
162
+
163
+ # ADR-32 WD4 + WD5 — flat ordered list of
164
+ # `[plugin, callable]` pairs for every loaded plugin that
165
+ # declares a `source_rbs_synthesizer:` in its manifest. The
166
+ # engine invokes each callable once per analysed Ruby source
167
+ # file at env-build time; non-nil return strings are merged
168
+ # into the RBS environment as virtual signature sources.
169
+ # The full plugin instance is carried alongside the
170
+ # callable so the engine's cache layer (WD5) can compose
171
+ # `plugin.plugin_entry` into its per-file descriptor — a
172
+ # config change to the plugin (e.g. flipping
173
+ # `require_magic_comment:`) invalidates the dependent
174
+ # synthesizer cache without any plugin-side bookkeeping.
175
+ def source_rbs_synthesizers
176
+ plugins.filter_map do |plugin|
177
+ synthesizer = plugin.manifest.source_rbs_synthesizer
178
+ next nil if synthesizer.nil?
179
+
180
+ [plugin, synthesizer]
181
+ end
182
+ end
183
+
184
+ FNMATCH_FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB
185
+ private_constant :FNMATCH_FLAGS
186
+
107
187
  EMPTY = new.freeze
188
+
189
+ private
190
+
191
+ def path_matches_glob?(glob, path)
192
+ File.fnmatch?(glob, path, FNMATCH_FLAGS) ||
193
+ File.fnmatch?(File.join("**", glob), path, FNMATCH_FLAGS)
194
+ end
108
195
  end
109
196
  end
110
197
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ # ADR-32 WD6 — per-run accumulator for failures encountered by
6
+ # a plugin's `Manifest#source_rbs_synthesizer` callable. The
7
+ # synthesizer returns `[:error, message]` on parse failure
8
+ # (per its contract); `Environment.for_project` routes the
9
+ # tuple through `#record` here. `Analysis::Runner` queries
10
+ # `#entries` after analysis and emits one
11
+ # `source-rbs-synthesis-failed` `:info` diagnostic per
12
+ # entry so the user sees which files contributed nothing
13
+ # and why.
14
+ #
15
+ # Empty by default. The Runner only emits diagnostics when
16
+ # at least one entry is recorded — projects without
17
+ # synthesizer-emitting plugins pay zero cost.
18
+ #
19
+ # Thread-/Ractor-safety: this reporter is per-`WorkerSession`
20
+ # in pool mode, so concurrent writes from one Ractor's
21
+ # `collect_virtual_rbs` loop are serialised by the worker
22
+ # body itself. The sequential path shares a single reporter
23
+ # across the run; entries are appended one at a time during
24
+ # env build (before any per-file analysis runs), so no
25
+ # locking is needed.
26
+ class SourceRbsSynthesisReporter
27
+ Entry = Data.define(:plugin_id, :path, :message)
28
+
29
+ def initialize
30
+ @entries = []
31
+ end
32
+
33
+ def record(plugin_id:, path:, message:)
34
+ @entries << Entry.new(
35
+ plugin_id: plugin_id.to_s.dup.freeze,
36
+ path: path.to_s.dup.freeze,
37
+ message: message.to_s.dup.freeze
38
+ )
39
+ nil
40
+ end
41
+
42
+ def entries
43
+ @entries.dup.freeze
44
+ end
45
+
46
+ def empty?
47
+ @entries.empty?
48
+ end
49
+ end
50
+ end
51
+ end
@@ -7,6 +7,7 @@ require_relative "../environment"
7
7
  require_relative "../scope"
8
8
  require_relative "../reflection"
9
9
  require_relative "../type"
10
+ require_relative "../inference/def_return_typer"
10
11
  require_relative "../inference/scope_indexer"
11
12
  require_relative "../inference/rbs_type_translator"
12
13
 
@@ -118,7 +119,8 @@ module Rigor
118
119
  candidates_from_defs = defs.filter_map do |def_node, class_name, kind|
119
120
  classify_def(path, def_node, class_name, kind, scope_index)
120
121
  end
121
- candidates_from_defs + collect_attr_candidates(parse_result.value, path, scope_index)
122
+ obs_ivar_map = build_observed_ivar_map(parse_result.value)
123
+ candidates_from_defs + collect_attr_candidates(parse_result.value, path, scope_index, obs_ivar_map)
122
124
  end
123
125
 
124
126
  # Walks the AST collecting `(def_node, class_name, kind)`
@@ -501,73 +503,11 @@ module Rigor
501
503
  # `V | nil` via `return nil unless ...`). The walk
502
504
  # excludes nested `DefNode` / lambda / block scopes
503
505
  # whose returns belong to different methods.
506
+ # Delegates to {Rigor::Inference::DefReturnTyper} — the same
507
+ # body-typing + explicit-return-union the `rigor annotate`
508
+ # def-line annotator uses.
504
509
  def infer_return_type(def_node, scope_index)
505
- body = def_node.body
506
- return nil if body.nil?
507
-
508
- last = body_last_expression(body)
509
- return nil if last.nil?
510
-
511
- inner_scope = scope_index[last] || scope_index[body] || scope_index[def_node]
512
- return nil if inner_scope.nil?
513
-
514
- last_type = inner_scope.type_of(last)
515
- union_with_explicit_returns(body, last_type, scope_index)
516
- rescue StandardError
517
- nil
518
- end
519
-
520
- def body_last_expression(body)
521
- case body
522
- when Prism::StatementsNode then body.body.last
523
- when Prism::BeginNode then body_last_expression(body.statements)
524
- else body
525
- end
526
- end
527
-
528
- def union_with_explicit_returns(body, last_type, scope_index)
529
- return_types = []
530
- collect_return_types(body, scope_index, return_types)
531
- return last_type if return_types.empty?
532
-
533
- Type::Combinator.union(last_type, *return_types)
534
- end
535
-
536
- RETURN_BARRIER_NODES = [Prism::DefNode, Prism::LambdaNode, Prism::BlockNode].freeze
537
- private_constant :RETURN_BARRIER_NODES
538
-
539
- def collect_return_types(node, scope_index, out)
540
- return unless node.is_a?(Prism::Node)
541
- return if RETURN_BARRIER_NODES.any? { |klass| node.is_a?(klass) }
542
-
543
- type_return_node(node, scope_index, out) if node.is_a?(Prism::ReturnNode)
544
- node.compact_child_nodes.each { |c| collect_return_types(c, scope_index, out) }
545
- end
546
-
547
- def type_return_node(return_node, scope_index, out)
548
- args = return_node.arguments&.arguments || []
549
- if args.empty?
550
- out << Type::Combinator.constant_of(nil)
551
- return
552
- end
553
-
554
- scope = scope_index[return_node] || scope_index[args.first]
555
- return if scope.nil?
556
-
557
- # `return a, b` packs into a Tuple at runtime; the MVP
558
- # only handles the single-value form. Multi-arg returns
559
- # contribute no type to keep the implementation
560
- # focused.
561
- return unless args.size == 1
562
-
563
- type = safe_return_type_of(scope, args.first)
564
- out << type unless type.nil?
565
- end
566
-
567
- def safe_return_type_of(scope, node)
568
- scope.type_of(node)
569
- rescue StandardError
570
- nil
510
+ Inference::DefReturnTyper.call(def_node, scope_index)
571
511
  end
572
512
 
573
513
  def dynamic_top?(type)
@@ -746,7 +686,7 @@ module Rigor
746
686
  def computed_literal_tightening?(inferred, def_node)
747
687
  return false unless inferred.is_a?(Type::Constant)
748
688
 
749
- last = body_last_expression(def_node.body)
689
+ last = Inference::DefReturnTyper.body_last_expression(def_node.body)
750
690
  !direct_literal_node?(last)
751
691
  end
752
692
 
@@ -898,11 +838,15 @@ module Rigor
898
838
 
899
839
  # Per-file context the attr_* walker threads through its
900
840
  # recursive descent. Keeps parameter lists in check.
901
- AttrWalkContext = Struct.new(:path, :scope_index, :out, keyword_init: true)
841
+ # `obs_ivar_map` carries the observation-derived fallback types
842
+ # built by {#build_observed_ivar_map}; it is empty when sig-gen
843
+ # is invoked without `--params=observed`.
844
+ AttrWalkContext = Struct.new(:path, :scope_index, :obs_ivar_map, :out, keyword_init: true)
902
845
  private_constant :AttrWalkContext
903
846
 
904
- def collect_attr_candidates(root, path, scope_index)
905
- ctx = AttrWalkContext.new(path: path, scope_index: scope_index, out: [])
847
+ def collect_attr_candidates(root, path, scope_index, obs_ivar_map = {})
848
+ ctx = AttrWalkContext.new(path: path, scope_index: scope_index,
849
+ obs_ivar_map: obs_ivar_map, out: [])
906
850
  walk_attr_calls(root, [], false, ctx)
907
851
  ctx.out
908
852
  end
@@ -941,7 +885,7 @@ module Rigor
941
885
  symbol_names = extract_symbol_arguments(call_node)
942
886
  return if symbol_names.empty?
943
887
 
944
- ivar_lookup = ivar_type_lookup(ctx.scope_index, class_name)
888
+ ivar_lookup = ivar_type_lookup(ctx.scope_index, class_name, ctx.obs_ivar_map)
945
889
  symbol_names.each do |attr_name|
946
890
  ivar_type = ivar_lookup.call(attr_name)
947
891
  ctx.out.concat(build_attr_candidates(call_node.name, class_name, attr_name, ivar_type, ctx))
@@ -961,12 +905,143 @@ module Rigor
961
905
  # before any statement evaluation runs, so the lookup
962
906
  # works even when attr_* declarations come before the
963
907
  # corresponding ivar writes lexically.
964
- def ivar_type_lookup(scope_index, class_name)
908
+ #
909
+ # When `obs_ivar_map` is non-empty (i.e. `--params=observed`
910
+ # was used), it acts as a fallback: if the ivar pre-pass
911
+ # resolved the type to `nil` or `Dynamic[top]` — typically
912
+ # because `@ivar = param` inside `initialize` typed the param
913
+ # as `untyped` — the observation-derived type is substituted.
914
+ # This lets `attr_reader :name` emit a concrete type when
915
+ # `ClassName.new("alice")` call sites are visible to the
916
+ # observation scan.
917
+ def ivar_type_lookup(scope_index, class_name, obs_ivar_map = {})
965
918
  any_scope = scope_index.each_value.first
966
919
  return ->(_) {} if any_scope.nil?
967
920
 
968
- ivars = any_scope.class_ivars_for(class_name)
969
- ->(attr_name) { ivars[:"@#{attr_name}"] }
921
+ ivars = any_scope.class_ivars_for(class_name)
922
+ obs_ivars = obs_ivar_map[class_name] || {}
923
+ lambda do |attr_name|
924
+ type = ivars[:"@#{attr_name}"]
925
+ type.nil? || dynamic_top?(type) ? obs_ivars[attr_name] : type
926
+ end
927
+ end
928
+
929
+ # Build a { class_name => { attr_name_sym => Type } } map that
930
+ # records observation-derived types for ivars assigned directly
931
+ # from `def initialize` parameters. Only populated when
932
+ # `@observations` is non-empty (i.e. `--params=observed` was
933
+ # supplied). Matches the pattern `@ivar_name = param_name` where
934
+ # `param_name` is a required / optional positional or keyword
935
+ # parameter of `initialize`.
936
+ def build_observed_ivar_map(root)
937
+ return {} if @observations.empty?
938
+
939
+ result = {}
940
+ collect_init_ivar_obs(root, [], result)
941
+ result
942
+ end
943
+
944
+ def collect_init_ivar_obs(node, prefix, result)
945
+ return unless node.is_a?(Prism::Node)
946
+
947
+ case node
948
+ when Prism::ClassNode, Prism::ModuleNode
949
+ name = qualified_constant_path(node.constant_path)
950
+ if name
951
+ collect_init_ivar_obs(node.body, prefix + [name], result) if node.body
952
+ return
953
+ end
954
+ when Prism::DefNode
955
+ if node.name == :initialize && !prefix.empty?
956
+ class_name = prefix.join("::")
957
+ map = ivar_obs_from_initialize(class_name, node)
958
+ result[class_name] = (result[class_name] || {}).merge(map) unless map.empty?
959
+ end
960
+ return
961
+ end
962
+
963
+ node.compact_child_nodes.each { |c| collect_init_ivar_obs(c, prefix, result) }
964
+ end
965
+
966
+ # Derive { attr_name_sym => Type } for a single `def initialize`
967
+ # by matching `@ivar = param_name` assignments against the
968
+ # available `[class_name, :initialize]` observations.
969
+ def ivar_obs_from_initialize(class_name, def_node)
970
+ obs_list = @observations[[class_name, :initialize]]
971
+ return {} if obs_list.nil? || obs_list.empty?
972
+ return {} if def_node.body.nil? || def_node.parameters.nil?
973
+
974
+ param_index = build_init_param_index(def_node.parameters)
975
+ return {} if param_index.empty?
976
+
977
+ ivar_to_param = {}
978
+ scan_ivar_param_assignments(def_node.body, param_index.keys.to_set, ivar_to_param)
979
+ build_ivar_obs_type_map(ivar_to_param, param_index, obs_list)
980
+ end
981
+
982
+ # Map `{ ivar_name => param_name }` → `{ attr_name_sym => Type }`
983
+ # by looking up each param's observation types and unioning them.
984
+ def build_ivar_obs_type_map(ivar_to_param, param_index, obs_list)
985
+ ivar_to_param.filter_map do |ivar_name, param_name|
986
+ types = collect_param_obs_types(obs_list, param_name, param_index[param_name])
987
+ next if types.empty?
988
+
989
+ attr_name = ivar_name.to_s.delete_prefix("@").to_sym
990
+ [attr_name, types.reduce { |acc, t| Type::Combinator.union(acc, t) }]
991
+ end.to_h
992
+ end
993
+
994
+ # Collect observed argument types for a single parameter across all
995
+ # call-site observations. Returns an array of Type objects (may be empty).
996
+ def collect_param_obs_types(obs_list, param_name, param_info)
997
+ case param_info[:kind]
998
+ when :positional then obs_list.filter_map { |obs| obs.positional[param_info[:index]] }
999
+ when :keyword then obs_list.filter_map { |obs| obs.keyword[param_name] }
1000
+ else []
1001
+ end
1002
+ end
1003
+
1004
+ # Map param_name_sym → { kind: :positional, index: N } or
1005
+ # { kind: :keyword } for required / optional positionals and
1006
+ # required / optional keywords of a ParametersNode.
1007
+ def build_init_param_index(parameters)
1008
+ index = {}
1009
+ offset = 0
1010
+
1011
+ (parameters.requireds || []).each_with_index do |p, i|
1012
+ index[p.name] = { kind: :positional, index: offset + i } if p.respond_to?(:name)
1013
+ end
1014
+ offset += parameters.requireds&.size || 0
1015
+
1016
+ (parameters.optionals || []).each_with_index do |p, i|
1017
+ index[p.name] = { kind: :positional, index: offset + i } if p.respond_to?(:name)
1018
+ end
1019
+
1020
+ (parameters.keywords || []).each do |kw|
1021
+ index[kw.name] = { kind: :keyword } if kw.respond_to?(:name)
1022
+ end
1023
+
1024
+ index
1025
+ end
1026
+
1027
+ # Walk a def body for direct `@ivar = local_var` assignments
1028
+ # where `local_var` is one of the listed parameter names.
1029
+ # Records ivar_name (Symbol with `@` prefix) → param_name.
1030
+ # Does not recurse into nested defs / classes / modules.
1031
+ def scan_ivar_param_assignments(node, param_names, result)
1032
+ return unless node.is_a?(Prism::Node)
1033
+
1034
+ if node.is_a?(Prism::InstanceVariableWriteNode) &&
1035
+ node.value.is_a?(Prism::LocalVariableReadNode) &&
1036
+ param_names.include?(node.value.name)
1037
+ result[node.name] ||= node.value.name
1038
+ end
1039
+
1040
+ return if node.is_a?(Prism::DefNode) ||
1041
+ node.is_a?(Prism::ClassNode) ||
1042
+ node.is_a?(Prism::ModuleNode)
1043
+
1044
+ node.compact_child_nodes.each { |c| scan_ivar_param_assignments(c, param_names, result) }
970
1045
  end
971
1046
 
972
1047
  def build_attr_candidates(call_name, class_name, attr_name, ivar_type, ctx)
@@ -122,8 +122,8 @@ module Rigor
122
122
  diagnostic_count: matched.size,
123
123
  summary: "undefined-method on core classes (#{top_methods(matched)}) — " \
124
124
  "ActiveSupport monkey-patches these",
125
- action: "Wire the rigor-activesupport-core-ext RBS bundle via " \
126
- "`signature_paths:` in .rigor.yml."
125
+ action: "Add rigor-activesupport-core-ext to `plugins:` in .rigor.yml " \
126
+ "(it is an RBS-bundle plugin — ADR-25)."
127
127
  ), matched]
128
128
  end
129
129
 
@@ -256,6 +256,63 @@ module Rigor
256
256
  refined.base.class_name == "String"
257
257
  end
258
258
 
259
+ # Returns true when `type` is statically known to be a
260
+ # non-empty String — i.e. its value can never be `""`.
261
+ # Used at String binary-operator dispatch sites to propagate
262
+ # the non-empty guarantee through `+` and `*`.
263
+ #
264
+ # - `Constant[s]` where `s != ""` — a concrete non-empty literal.
265
+ # - `Difference[Nominal[String], Constant[""]]` — the canonical
266
+ # `non-empty-string` carrier.
267
+ # - `Intersection[…]` — any member suffices (set-theoretic subset).
268
+ # - `Union[…]` — all members must qualify (the join may include "").
269
+ def non_empty_string_compatible?(type)
270
+ case type
271
+ when Constant then type.value.is_a?(String) && !type.value.empty?
272
+ when Difference then non_empty_string_difference?(type)
273
+ when Intersection then type.members.any? { |m| non_empty_string_compatible?(m) }
274
+ when Union then !type.members.empty? && type.members.all? { |m| non_empty_string_compatible?(m) }
275
+ else false
276
+ end
277
+ end
278
+
279
+ def non_empty_string_difference?(diff)
280
+ return false unless diff.base.is_a?(Nominal) && diff.base.class_name == "String"
281
+ return false unless diff.removed.is_a?(Constant)
282
+
283
+ diff.removed.value == ""
284
+ end
285
+
286
+ # Returns true when `type` is statically known to be a
287
+ # non-zero Integer — i.e. its value can never be `0`.
288
+ # Used at Integer arithmetic dispatch sites to propagate
289
+ # the non-zero guarantee through `*` and identity methods.
290
+ #
291
+ # - `Constant[n]` where `n != 0` — a concrete non-zero literal.
292
+ # - `Difference[Nominal[Integer], Constant[0]]` — the canonical
293
+ # `non-zero-int` carrier.
294
+ # - `IntegerRange` that does not cover 0 — both `positive-int`
295
+ # ([1,+∞)) and `negative-int` ([-∞,-1]) qualify.
296
+ # - `Intersection[…]` — any member suffices.
297
+ # - `Union[…]` — all members must qualify.
298
+ def non_zero_int_compatible?(type)
299
+ case type
300
+ when Constant then type.value.is_a?(Integer) && !type.value.zero?
301
+ when Difference then non_zero_int_difference?(type)
302
+ when IntegerRange then !type.covers?(0)
303
+ when Intersection then type.members.any? { |m| non_zero_int_compatible?(m) }
304
+ when Union then !type.members.empty? && type.members.all? { |m| non_zero_int_compatible?(m) }
305
+ else false
306
+ end
307
+ end
308
+
309
+ def non_zero_int_difference?(diff)
310
+ return false unless diff.base.is_a?(Nominal) && diff.base.class_name == "Integer"
311
+ return false unless diff.removed.is_a?(Constant)
312
+
313
+ diff.removed.value.zero?
314
+ end
315
+
259
316
  # Normalised intersection. Flattens nested Intersections,
260
317
  # drops `Top` members, collapses to `Bot` if any member is
261
318
  # `Bot`, deduplicates structurally-equal members, sorts the