rigortype 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +125 -31
  3. data/lib/rigor/analysis/check_rules.rb +10 -18
  4. data/lib/rigor/analysis/dependency_source_inference/boundary_cross_reporter.rb +75 -0
  5. data/lib/rigor/analysis/dependency_source_inference/builder.rb +47 -21
  6. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +1 -1
  7. data/lib/rigor/analysis/dependency_source_inference/index.rb +32 -3
  8. data/lib/rigor/analysis/dependency_source_inference/walker.rb +1 -1
  9. data/lib/rigor/analysis/dependency_source_inference.rb +1 -0
  10. data/lib/rigor/analysis/diagnostic.rb +0 -2
  11. data/lib/rigor/analysis/fact_store.rb +11 -3
  12. data/lib/rigor/analysis/rule_catalog.rb +2 -2
  13. data/lib/rigor/analysis/runner.rb +114 -3
  14. data/lib/rigor/builtins/imported_refinements.rb +360 -55
  15. data/lib/rigor/cache/descriptor.rb +1 -1
  16. data/lib/rigor/cache/store.rb +1 -1
  17. data/lib/rigor/cli/diff_command.rb +1 -1
  18. data/lib/rigor/cli/sig_gen_command.rb +173 -0
  19. data/lib/rigor/cli/type_of_command.rb +1 -1
  20. data/lib/rigor/cli/type_scan_renderer.rb +1 -1
  21. data/lib/rigor/cli/type_scan_report.rb +2 -2
  22. data/lib/rigor/cli.rb +9 -1
  23. data/lib/rigor/configuration/dependencies.rb +2 -2
  24. data/lib/rigor/configuration.rb +2 -2
  25. data/lib/rigor/environment.rb +35 -4
  26. data/lib/rigor/flow_contribution/conflict.rb +2 -2
  27. data/lib/rigor/flow_contribution/element.rb +1 -1
  28. data/lib/rigor/flow_contribution/fact.rb +1 -1
  29. data/lib/rigor/flow_contribution/merge_result.rb +1 -1
  30. data/lib/rigor/flow_contribution/merger.rb +3 -3
  31. data/lib/rigor/flow_contribution.rb +2 -2
  32. data/lib/rigor/inference/block_parameter_binder.rb +0 -2
  33. data/lib/rigor/inference/coverage_scanner.rb +1 -1
  34. data/lib/rigor/inference/expression_typer.rb +67 -11
  35. data/lib/rigor/inference/fallback.rb +1 -1
  36. data/lib/rigor/inference/method_dispatcher/block_folding.rb +3 -5
  37. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +0 -12
  38. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +1 -3
  39. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +1 -1
  40. data/lib/rigor/inference/method_dispatcher/method_folding.rb +118 -0
  41. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +6 -11
  42. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +27 -11
  43. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +0 -4
  44. data/lib/rigor/inference/method_dispatcher.rb +146 -2
  45. data/lib/rigor/inference/method_parameter_binder.rb +1 -3
  46. data/lib/rigor/inference/narrowing.rb +2 -4
  47. data/lib/rigor/inference/rbs_type_translator.rb +0 -2
  48. data/lib/rigor/inference/scope_indexer.rb +14 -9
  49. data/lib/rigor/inference/statement_evaluator.rb +7 -7
  50. data/lib/rigor/plugin/io_boundary.rb +0 -2
  51. data/lib/rigor/plugin/loader.rb +2 -2
  52. data/lib/rigor/plugin/manifest.rb +30 -9
  53. data/lib/rigor/plugin/registry.rb +11 -0
  54. data/lib/rigor/plugin/services.rb +1 -1
  55. data/lib/rigor/plugin/type_node_resolver.rb +52 -0
  56. data/lib/rigor/plugin.rb +1 -0
  57. data/lib/rigor/rbs_extended/reporter.rb +91 -0
  58. data/lib/rigor/rbs_extended.rb +131 -32
  59. data/lib/rigor/scope.rb +25 -8
  60. data/lib/rigor/sig_gen/classification.rb +36 -0
  61. data/lib/rigor/sig_gen/generator.rb +1048 -0
  62. data/lib/rigor/sig_gen/layout_index.rb +108 -0
  63. data/lib/rigor/sig_gen/method_candidate.rb +62 -0
  64. data/lib/rigor/sig_gen/observation_collector.rb +391 -0
  65. data/lib/rigor/sig_gen/observed_call.rb +62 -0
  66. data/lib/rigor/sig_gen/path_mapper.rb +116 -0
  67. data/lib/rigor/sig_gen/renderer.rb +157 -0
  68. data/lib/rigor/sig_gen/type_elaborator.rb +92 -0
  69. data/lib/rigor/sig_gen/write_result.rb +48 -0
  70. data/lib/rigor/sig_gen/writer.rb +530 -0
  71. data/lib/rigor/sig_gen.rb +25 -0
  72. data/lib/rigor/type/bound_method.rb +79 -0
  73. data/lib/rigor/type/combinator.rb +195 -2
  74. data/lib/rigor/type/constant.rb +13 -0
  75. data/lib/rigor/type/hash_shape.rb +0 -2
  76. data/lib/rigor/type/union.rb +20 -1
  77. data/lib/rigor/type.rb +1 -0
  78. data/lib/rigor/type_node/generic.rb +62 -0
  79. data/lib/rigor/type_node/identifier.rb +30 -0
  80. data/lib/rigor/type_node/indexed_access.rb +41 -0
  81. data/lib/rigor/type_node/integer_literal.rb +29 -0
  82. data/lib/rigor/type_node/name_scope.rb +52 -0
  83. data/lib/rigor/type_node/resolver_chain.rb +56 -0
  84. data/lib/rigor/type_node/string_literal.rb +29 -0
  85. data/lib/rigor/type_node/symbol_literal.rb +28 -0
  86. data/lib/rigor/type_node/union.rb +42 -0
  87. data/lib/rigor/type_node.rb +29 -0
  88. data/lib/rigor/version.rb +1 -1
  89. data/lib/rigor.rb +2 -0
  90. data/sig/rigor/analysis/check_rules/always_truthy_condition_collector.rbs +10 -0
  91. data/sig/rigor/analysis/check_rules/dead_assignment_collector.rbs +10 -0
  92. data/sig/rigor/analysis/dependency_source_inference/gem_resolver.rbs +25 -0
  93. data/sig/rigor/analysis/dependency_source_inference/index.rbs +9 -0
  94. data/sig/rigor/cli/diff_command.rbs +4 -0
  95. data/sig/rigor/cli/explain_command.rbs +4 -0
  96. data/sig/rigor/cli/sig_gen_command.rbs +4 -0
  97. data/sig/rigor/cli/type_scan_command.rbs +3 -0
  98. data/sig/rigor/environment.rbs +5 -2
  99. data/sig/rigor/inference/builtins/method_catalog.rbs +4 -0
  100. data/sig/rigor/inference/builtins/numeric_catalog.rbs +3 -0
  101. data/sig/rigor/inference/builtins.rbs +2 -0
  102. data/sig/rigor/plugin/access_denied_error.rbs +3 -0
  103. data/sig/rigor/plugin/base.rbs +6 -0
  104. data/sig/rigor/plugin/fact_store.rbs +11 -0
  105. data/sig/rigor/plugin/io_boundary.rbs +4 -0
  106. data/sig/rigor/plugin/load_error.rbs +6 -0
  107. data/sig/rigor/plugin/loader.rbs +20 -0
  108. data/sig/rigor/plugin/manifest.rbs +9 -0
  109. data/sig/rigor/plugin/registry.rbs +3 -0
  110. data/sig/rigor/plugin/services.rbs +3 -0
  111. data/sig/rigor/plugin/trust_policy.rbs +4 -0
  112. data/sig/rigor/plugin/type_node_resolver.rbs +3 -0
  113. data/sig/rigor/plugin.rbs +8 -0
  114. data/sig/rigor/scope.rbs +4 -2
  115. data/sig/rigor/type.rbs +28 -6
  116. metadata +52 -1
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbs"
4
+
5
+ module Rigor
6
+ module SigGen
7
+ # Pre-scans every `.rbs` file under the configured
8
+ # `signature_paths` to build a `qualified_class_name →
9
+ # sig_file_path` map.
10
+ #
11
+ # ADR-14 path-mapper limitation surfaced repeatedly during
12
+ # the self-dogfood: the existing rigor `sig/` consolidates
13
+ # multiple `.rb` sources into one `.rbs` file (e.g.
14
+ # `sig/rigor/type.rbs` declares all 14 `Type::*` classes,
15
+ # `sig/rigor.rbs` declares `CLI::TypeOfCommand` and
16
+ # `CLI::TypeScanCommand`). The naive 1:1 mapper writes new
17
+ # files alongside the existing consolidated ones, producing
18
+ # `RBS::DuplicatedMethodDefinition` errors at lookup time.
19
+ #
20
+ # The index lets {PathMapper} route a candidate to the
21
+ # consolidated sig file when the class is already declared
22
+ # there, falling back to the 1:1 mirror only when the
23
+ # class has no existing declaration anywhere under the
24
+ # signature tree.
25
+ #
26
+ # First-found wins on duplicate declarations across files;
27
+ # RBS itself allows the same class to be declared in
28
+ # multiple files for additive member contributions, but
29
+ # the writer only needs one canonical target per class.
30
+ class LayoutIndex
31
+ # @param signature_paths [Array<String, Pathname>, nil]
32
+ # the `.rigor.yml`-configured signature directories.
33
+ # When `nil` or empty, falls back to `<project_root>/sig`
34
+ # if it exists (matching `Environment.for_project`'s
35
+ # auto-detection convention).
36
+ # @param project_root [String, Pathname]
37
+ def initialize(signature_paths:, project_root: Dir.pwd)
38
+ @signature_paths = resolve_paths(signature_paths, project_root)
39
+ @index = nil
40
+ end
41
+
42
+ # @param class_name [String] fully-qualified Ruby class
43
+ # name (e.g. `"Rigor::Type::Top"`).
44
+ # @return [Pathname, nil] absolute path of the sig file
45
+ # that already declares this class, or `nil` when no
46
+ # existing declaration is found.
47
+ def file_for(class_name)
48
+ index[class_name]
49
+ end
50
+
51
+ def empty?
52
+ index.empty?
53
+ end
54
+
55
+ private
56
+
57
+ def resolve_paths(configured, project_root)
58
+ list = Array(configured).reject { |p| p.nil? || p.to_s.empty? }
59
+ return list unless list.empty?
60
+
61
+ default = Pathname(project_root) / "sig"
62
+ default.directory? ? [default] : []
63
+ end
64
+
65
+ def index
66
+ @index ||= build_index
67
+ end
68
+
69
+ def build_index
70
+ accumulator = {}
71
+ Array(@signature_paths).each do |dir|
72
+ base = Pathname(dir)
73
+ next unless base.directory?
74
+
75
+ Dir.glob(File.join(base.to_s, "**/*.rbs"), sort: true).each do |rbs_file|
76
+ index_file(Pathname(rbs_file), accumulator)
77
+ end
78
+ end
79
+ accumulator.freeze
80
+ end
81
+
82
+ def index_file(rbs_path, accumulator)
83
+ source = rbs_path.read
84
+ _, _, decls = RBS::Parser.parse_signature(source)
85
+ record_decls(decls, [], rbs_path, accumulator)
86
+ rescue StandardError
87
+ # Bad RBS file — skip silently; the user's `rigor
88
+ # check` run will surface the real parse error
89
+ # elsewhere.
90
+ end
91
+
92
+ def record_decls(decls, prefix, rbs_path, accumulator)
93
+ decls.each { |decl| record_decl(decl, prefix, rbs_path, accumulator) }
94
+ end
95
+
96
+ def record_decl(decl, prefix, rbs_path, accumulator)
97
+ return unless decl.is_a?(RBS::AST::Declarations::Class) ||
98
+ decl.is_a?(RBS::AST::Declarations::Module)
99
+
100
+ local_name = decl.name.to_s.sub(/\A::/, "")
101
+ full = prefix.empty? ? local_name : "#{prefix.join('::')}::#{local_name}"
102
+ accumulator[full] ||= rbs_path
103
+
104
+ record_decls(decl.members, prefix + [local_name], rbs_path, accumulator)
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module SigGen
5
+ # Per-method record produced by the generator.
6
+ #
7
+ # `classification` is one of the {Classification} constants;
8
+ # the remaining fields are populated only when applicable
9
+ # to that classification.
10
+ #
11
+ # - `path` — the source `.rb` file the def came from.
12
+ # - `class_name` — qualified receiver class name (e.g.
13
+ # `"Foo::Bar"`). `nil` for top-level / DSL-block defs
14
+ # the MVP skips.
15
+ # - `method_name` — the def's `Symbol` name.
16
+ # - `kind` — `:instance` or `:singleton`.
17
+ # - `inferred_return` — `Rigor::Type` instance (or `nil`
18
+ # when the inference pass disqualified the def).
19
+ # - `declared_return_rbs` — the existing RBS-declared return
20
+ # spelling, or `nil` when no RBS declares the method.
21
+ # - `rbs` — the rendered RBS one-liner the generator would
22
+ # emit (`nil` for skipped / equivalent rows).
23
+ # - `skip_reason` — one of {Classification::SKIP_DIAGNOSTIC_IDS}
24
+ # keys when classification is `:skipped`, else `nil`.
25
+ class MethodCandidate
26
+ attr_reader :path, :class_name, :method_name, :kind, :classification,
27
+ :inferred_return, :declared_return_rbs, :rbs, :skip_reason,
28
+ :namespace_kinds, :class_shells
29
+
30
+ def initialize(path:, class_name:, method_name:, kind:, classification:, # rubocop:disable Metrics/ParameterLists
31
+ inferred_return: nil, declared_return_rbs: nil, rbs: nil, skip_reason: nil,
32
+ namespace_kinds: {}, class_shells: [])
33
+ @path = path
34
+ @class_name = class_name
35
+ @method_name = method_name
36
+ @kind = kind
37
+ @classification = classification
38
+ @inferred_return = inferred_return
39
+ @declared_return_rbs = declared_return_rbs
40
+ @rbs = rbs
41
+ @skip_reason = skip_reason
42
+ @namespace_kinds = namespace_kinds.freeze
43
+ @class_shells = class_shells.freeze
44
+ freeze
45
+ end
46
+
47
+ def to_h
48
+ {
49
+ file: path,
50
+ class: class_name,
51
+ method: method_name.to_s,
52
+ kind: kind.to_s,
53
+ classification: classification.to_s,
54
+ rbs: rbs,
55
+ inferred_return: inferred_return&.erase_to_rbs,
56
+ declared_return_rbs: declared_return_rbs,
57
+ skip_reason: skip_reason ? Classification::SKIP_DIAGNOSTIC_IDS.fetch(skip_reason) : nil
58
+ }.compact
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,391 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../environment"
6
+ require_relative "../scope"
7
+ require_relative "../type"
8
+ require_relative "../inference/scope_indexer"
9
+
10
+ module Rigor
11
+ module SigGen
12
+ # ADR-14 slice 3 — caller-side argument-type observation
13
+ # collector.
14
+ #
15
+ # Walks the user-supplied `--observe=PATH...` tree (default
16
+ # `spec/`), parses every `.rb` file with `Prism`, scope-
17
+ # indexes it the same way the main generator does, and
18
+ # records the per-call-site argument-type tuples for every
19
+ # `Prism::CallNode` whose receiver types as a
20
+ # `Type::Nominal`. The {Generator} consumes the resulting
21
+ # map to render `--params=observed` RBS:
22
+ #
23
+ # @return [Hash{[class_name, method_name] =>
24
+ # Array<Array<Rigor::Type>>}]
25
+ #
26
+ # ADR-5 clause 2 compliance: the observed union is the
27
+ # MOST PERMISSIVE parameter contract the existing callers
28
+ # prove sufficient — by construction it accepts every type
29
+ # any caller has already passed. The collector only
30
+ # surfaces the data; the default `--params=untyped` keeps
31
+ # the observation inert until the user opts in.
32
+ #
33
+ # MVP scope:
34
+ # - Explicit-receiver calls only (`foo.bar(args)`). Implicit-
35
+ # self calls inside class bodies and RSpec-style
36
+ # `let` / `subject` bindings ride on slice 5's optional
37
+ # `rigor-rspec` integration.
38
+ # - Calls whose receiver does not type as a `Type::Nominal`
39
+ # (e.g. `(some_dynamic).bar(...)`) are skipped — the
40
+ # collector cannot attribute them to a specific class.
41
+ # - Zero-argument calls give no observation; methods are
42
+ # matched by `(class_name, method_name)` only.
43
+ class ObservationCollector # rubocop:disable Metrics/ClassLength
44
+ # @param configuration [Rigor::Configuration]
45
+ # @param paths [Array<String>] observe paths (files /
46
+ # directories).
47
+ # @param source_paths [Array<String>] source-tree paths
48
+ # (defaults to `configuration.paths`) pre-walked to
49
+ # register every project-defined class so that calls
50
+ # like `Foo.new.bar(x)` in the observe tree resolve
51
+ # to a `Type::Nominal[Foo]` receiver instead of
52
+ # degrading to `Dynamic[top]` for the unknown
53
+ # constant.
54
+ def initialize(configuration:, paths:, source_paths: nil)
55
+ @configuration = configuration
56
+ @paths = paths
57
+ @source_paths = source_paths || configuration.paths
58
+ end
59
+
60
+ def collect
61
+ return {} if @paths.empty?
62
+
63
+ environment = build_environment
64
+ discovered_classes = preindex_source_classes
65
+ observations = Hash.new { |h, k| h[k] = [] }
66
+ resolve_paths(@paths).each do |path|
67
+ collect_from_file(path, environment, discovered_classes, observations)
68
+ end
69
+ observations.transform_values(&:freeze).freeze
70
+ end
71
+
72
+ private
73
+
74
+ def build_environment
75
+ Environment.for_project(
76
+ libraries: @configuration.libraries,
77
+ signature_paths: @configuration.signature_paths
78
+ )
79
+ end
80
+
81
+ def resolve_paths(args)
82
+ args.flat_map do |arg|
83
+ if File.directory?(arg)
84
+ Dir.glob(File.join(arg, "**/*.rb"), sort: true)
85
+ elsif File.file?(arg) && arg.end_with?(".rb")
86
+ [arg]
87
+ else
88
+ []
89
+ end
90
+ end.uniq
91
+ end
92
+
93
+ def collect_from_file(path, environment, discovered_classes, observations)
94
+ source = File.read(path)
95
+ parse_result = Prism.parse(source, filepath: path, version: @configuration.target_ruby)
96
+ return if parse_result.errors.any?
97
+
98
+ base_scope = Scope.empty(environment: environment).with_discovered_classes(discovered_classes)
99
+ scope_index = Inference::ScopeIndexer.index(parse_result.value, default_scope: base_scope)
100
+ bindings = collect_rspec_bindings(parse_result.value, scope_index)
101
+
102
+ walk_calls(parse_result.value, scope_index, bindings, observations)
103
+ end
104
+
105
+ # Pre-walks `@source_paths` to collect every qualified
106
+ # class / module declaration. The result feeds
107
+ # `Scope#with_discovered_classes` for each observe-tree
108
+ # scope so `Foo.new` and `Foo` resolve to the right
109
+ # singleton carrier even when no RBS sig describes
110
+ # `Foo` yet.
111
+ def preindex_source_classes
112
+ accumulator = {}
113
+ resolve_paths(@source_paths).each { |path| harvest_classes_from(path, accumulator) }
114
+ accumulator.freeze
115
+ end
116
+
117
+ def harvest_classes_from(path, accumulator)
118
+ source = File.read(path)
119
+ parse_result = Prism.parse(source, filepath: path, version: @configuration.target_ruby)
120
+ return if parse_result.errors.any?
121
+
122
+ walk_class_decls(parse_result.value, [], accumulator)
123
+ rescue StandardError
124
+ # Source-side harvest failures are tolerated silently
125
+ # — the collector still runs on whichever files
126
+ # parsed cleanly.
127
+ end
128
+
129
+ def walk_class_decls(node, prefix, accumulator)
130
+ return unless node.is_a?(Prism::Node)
131
+
132
+ if node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
133
+ name = qualified_constant_path(node.constant_path)
134
+ if name
135
+ full = (prefix + [name]).join("::")
136
+ accumulator[full] = Type::Combinator.singleton_of(full)
137
+ walk_class_decls(node.body, prefix + [name], accumulator) if node.body
138
+ return
139
+ end
140
+ end
141
+
142
+ node.compact_child_nodes.each { |child| walk_class_decls(child, prefix, accumulator) }
143
+ end
144
+
145
+ def qualified_constant_path(constant_path)
146
+ case constant_path
147
+ when Prism::ConstantReadNode
148
+ constant_path.name.to_s
149
+ when Prism::ConstantPathNode
150
+ parent = qualified_constant_path(constant_path.parent) if constant_path.parent
151
+ name = constant_path.name&.to_s
152
+ return nil if name.nil?
153
+
154
+ parent ? "#{parent}::#{name}" : name
155
+ end
156
+ end
157
+
158
+ def walk_calls(node, scope_index, bindings, observations)
159
+ return unless node.is_a?(Prism::Node)
160
+
161
+ record_call(node, scope_index, bindings, observations) if node.is_a?(Prism::CallNode)
162
+ node.compact_child_nodes.each { |child| walk_calls(child, scope_index, bindings, observations) }
163
+ end
164
+
165
+ def record_call(call_node, scope_index, bindings, observations)
166
+ receiver = call_node.receiver
167
+ return if receiver.nil?
168
+
169
+ scope = scope_index[call_node] || scope_index[receiver]
170
+ return if scope.nil?
171
+
172
+ receiver_type = resolve_receiver_type(receiver, scope, bindings)
173
+ key = observation_key(call_node, receiver_type)
174
+ return if key.nil?
175
+
176
+ observation = collect_args(call_node, scope)
177
+ return if observation.nil? || observation.empty?
178
+
179
+ observations[key] << observation
180
+ end
181
+
182
+ # ADR-14 follow-up (A): `.new` → `:initialize` routing.
183
+ # `MethodCatalog.new(path: ...)` types its receiver as
184
+ # `Type::Singleton[MethodCatalog]` and its call name as
185
+ # `:new`, but the *implicit* effect at runtime is a call
186
+ # to `MethodCatalog#initialize(path: ...)`. Route the
187
+ # observation under `[class_name, :initialize]` so the
188
+ # initialize-stub renderer can consult it.
189
+ def observation_key(call_node, receiver_type)
190
+ if receiver_type.is_a?(Type::Singleton) && call_node.name == :new
191
+ [receiver_type.class_name, :initialize]
192
+ elsif receiver_type.is_a?(Type::Nominal)
193
+ [receiver_type.class_name, call_node.name]
194
+ end
195
+ end
196
+
197
+ # ADR-14 slice 5 — RSpec-aware receiver typing.
198
+ # Resolves a CallNode receiver against the collected
199
+ # `bindings` map (built by {#collect_rspec_bindings})
200
+ # before falling back to ordinary `scope.type_of`. The
201
+ # three RSpec-shaped receivers we recognise:
202
+ #
203
+ # - Bare-name CallNode (`subject`, `other`, ...) whose
204
+ # name matches a `subject` / `let(:name)` binding —
205
+ # return the binding's recorded type.
206
+ # - `described_class.new(...)` chain — when the
207
+ # surrounding `describe Foo do … end` resolved `Foo`,
208
+ # return `Type::Nominal[Foo]`.
209
+ # - Anything else — pass through to `scope.type_of`,
210
+ # matching slice-3 behaviour.
211
+ def resolve_receiver_type(receiver, scope, bindings)
212
+ return resolve_described_class_new(bindings) if described_class_new?(receiver)
213
+ return bindings[receiver.name] if bound_call?(receiver, bindings)
214
+
215
+ safe_type_of(scope, receiver)
216
+ end
217
+
218
+ def bound_call?(receiver, bindings)
219
+ simple_no_arg_call?(receiver) && bindings.key?(receiver.name)
220
+ end
221
+
222
+ def described_class_new?(node)
223
+ return false unless node.is_a?(Prism::CallNode) && node.name == :new
224
+
225
+ described_class_reference?(node.receiver)
226
+ end
227
+
228
+ def described_class_reference?(node)
229
+ return false unless node.is_a?(Prism::CallNode) && node.name == :described_class
230
+
231
+ node.receiver.nil? && (node.arguments&.arguments || []).empty?
232
+ end
233
+
234
+ def resolve_described_class_new(bindings)
235
+ singleton = bindings[:described_class]
236
+ return nil unless singleton.is_a?(Type::Singleton)
237
+
238
+ Type::Combinator.nominal_of(singleton.class_name)
239
+ end
240
+
241
+ def simple_no_arg_call?(node)
242
+ node.is_a?(Prism::CallNode) &&
243
+ node.receiver.nil? &&
244
+ (node.arguments&.arguments || []).empty? &&
245
+ node.block.nil?
246
+ end
247
+
248
+ # Walks the spec file for `describe X do … end` /
249
+ # `RSpec.describe X do … end` blocks plus the
250
+ # `subject` / `let(:name)` declarations inside them.
251
+ # Returns a flat map `{ binding_name (Symbol) => Type }`
252
+ # plus a synthetic `:described_class` slot keyed off
253
+ # the nearest enclosing `describe`.
254
+ #
255
+ # The recogniser is intentionally lightweight: it does
256
+ # not enforce RSpec scope rules across `describe` /
257
+ # `context` blocks. Nested `describe` declarations
258
+ # overwrite the outer `described_class` for the
259
+ # remainder of the walk; same-name `let` bindings are
260
+ # last-wins. This matches the typical one-spec-file
261
+ # shape ADR-14 slice 5 targets without re-implementing
262
+ # the `rigor-rspec` plugin's full scope analyser.
263
+ def collect_rspec_bindings(root, scope_index)
264
+ bindings = {}
265
+ walk_rspec_bindings(root, bindings, scope_index)
266
+ bindings
267
+ end
268
+
269
+ def walk_rspec_bindings(node, bindings, scope_index)
270
+ return unless node.is_a?(Prism::Node)
271
+
272
+ recognise_describe(node, bindings)
273
+ recognise_subject_or_let(node, bindings, scope_index)
274
+
275
+ node.compact_child_nodes.each { |child| walk_rspec_bindings(child, bindings, scope_index) }
276
+ end
277
+
278
+ def recognise_describe(node, bindings)
279
+ return unless describe_call?(node)
280
+
281
+ constant_arg = node.arguments&.arguments&.first
282
+ name = qualified_constant_path(constant_arg) if constant_arg
283
+ bindings[:described_class] = Type::Combinator.singleton_of(name) if name
284
+ end
285
+
286
+ def describe_call?(node)
287
+ return false unless node.is_a?(Prism::CallNode) && node.name == :describe
288
+
289
+ receiver = node.receiver
290
+ receiver.nil? || (receiver.is_a?(Prism::ConstantReadNode) && receiver.name == :RSpec)
291
+ end
292
+
293
+ RSPEC_BINDING_METHODS = %i[subject let let!].freeze
294
+ private_constant :RSPEC_BINDING_METHODS
295
+
296
+ def recognise_subject_or_let(node, bindings, scope_index)
297
+ return unless node.is_a?(Prism::CallNode) && RSPEC_BINDING_METHODS.include?(node.name)
298
+ return if node.block.nil?
299
+
300
+ name = binding_name_for(node)
301
+ return if name.nil?
302
+
303
+ body_type = type_block_body(node.block, scope_index)
304
+ bindings[name] = body_type if body_type
305
+ end
306
+
307
+ def binding_name_for(call_node)
308
+ first_arg = call_node.arguments&.arguments&.first
309
+ return call_node.name == :subject ? :subject : nil if first_arg.nil?
310
+ return first_arg.unescaped.to_sym if first_arg.is_a?(Prism::SymbolNode) || first_arg.is_a?(Prism::StringNode)
311
+
312
+ nil
313
+ end
314
+
315
+ def type_block_body(block_node, scope_index)
316
+ body = block_body_node(block_node)
317
+ return nil if body.nil?
318
+
319
+ last_expr = body_last_expression(body)
320
+ return nil if last_expr.nil?
321
+
322
+ scope = scope_index[last_expr] || scope_index[block_node]
323
+ return nil if scope.nil?
324
+
325
+ safe_type_of(scope, last_expr)
326
+ end
327
+
328
+ def block_body_node(block_node)
329
+ return nil unless block_node.is_a?(Prism::BlockNode)
330
+
331
+ block_node.body
332
+ end
333
+
334
+ def body_last_expression(body)
335
+ case body
336
+ when Prism::StatementsNode then body.body.last
337
+ when Prism::BeginNode then body_last_expression(body.statements)
338
+ else body
339
+ end
340
+ end
341
+
342
+ # ADR-14 follow-up (B): walks a call's argument list
343
+ # separating positional from keyword arguments and
344
+ # returning an {ObservedCall} carrier. Splat /
345
+ # forwarded / block arguments still abort the
346
+ # observation (`nil`) — those don't map cleanly to a
347
+ # single per-position type the renderer can union.
348
+ def collect_args(call_node, scope)
349
+ positional = []
350
+ keyword = {}
351
+ args = call_node.arguments&.arguments || []
352
+ args.each do |arg|
353
+ case arg
354
+ when Prism::KeywordHashNode
355
+ pairs = read_keyword_pairs(arg, scope)
356
+ return nil if pairs.nil?
357
+
358
+ keyword.merge!(pairs)
359
+ when Prism::SplatNode, Prism::BlockArgumentNode, Prism::ForwardingArgumentsNode
360
+ return nil
361
+ else
362
+ type = safe_type_of(scope, arg)
363
+ return nil if type.nil?
364
+
365
+ positional << type
366
+ end
367
+ end
368
+ ObservedCall.new(positional: positional, keyword: keyword)
369
+ end
370
+
371
+ def read_keyword_pairs(hash_node, scope)
372
+ out = {}
373
+ hash_node.elements.each do |pair|
374
+ return nil unless pair.is_a?(Prism::AssocNode) && pair.key.is_a?(Prism::SymbolNode)
375
+
376
+ type = safe_type_of(scope, pair.value)
377
+ return nil if type.nil?
378
+
379
+ out[pair.key.unescaped.to_sym] = type
380
+ end
381
+ out
382
+ end
383
+
384
+ def safe_type_of(scope, node)
385
+ scope.type_of(node)
386
+ rescue StandardError
387
+ nil
388
+ end
389
+ end
390
+ end
391
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module SigGen
5
+ # Per-call-site argument observation produced by
6
+ # {ObservationCollector}. ADR-14 follow-up: the earlier
7
+ # MVP shape (`Array[Type]` of positional types only)
8
+ # could not represent keyword arguments — every call like
9
+ # `MethodCatalog.new(path: ..., mutating_selectors: ...)`
10
+ # discarded the whole observation via `non_positional?`.
11
+ # The new shape carries positional and keyword arg types
12
+ # in parallel so the per-position / per-keyword unions
13
+ # can each be reconstructed independently.
14
+ #
15
+ # The carrier is intentionally minimal:
16
+ # - `positional` — frozen Array of `Rigor::Type` per
17
+ # positional argument, in call-site order.
18
+ # - `keyword` — frozen Hash mapping each keyword
19
+ # argument's Symbol name to its `Rigor::Type`.
20
+ #
21
+ # Generator-side callers also accept a legacy shape
22
+ # (plain Array of types) for backward compatibility with
23
+ # specs that constructed observations directly before
24
+ # this carrier existed; `ObservedCall.from(...)` does the
25
+ # lift.
26
+ class ObservedCall
27
+ attr_reader :positional, :keyword
28
+
29
+ def initialize(positional: [], keyword: {})
30
+ @positional = positional.freeze
31
+ @keyword = keyword.freeze
32
+ freeze
33
+ end
34
+
35
+ def empty?
36
+ positional.empty? && keyword.empty?
37
+ end
38
+
39
+ def ==(other)
40
+ other.is_a?(ObservedCall) && positional == other.positional && keyword == other.keyword
41
+ end
42
+ alias eql? ==
43
+
44
+ def hash
45
+ [ObservedCall, positional, keyword].hash
46
+ end
47
+
48
+ # Lifts the legacy plain-Array shape into an
49
+ # `ObservedCall` carrier. Already-lifted values pass
50
+ # through unchanged. Used by `Generator#initialize`'s
51
+ # observations-normalisation pass so spec fixtures
52
+ # written against the slice-3 surface keep working.
53
+ def self.from(value)
54
+ case value
55
+ when ObservedCall then value
56
+ when Array then new(positional: value)
57
+ else raise ArgumentError, "expected Array or ObservedCall, got #{value.class}"
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end