Skip to content

Commit 5e70a0f

Browse files
self review
1 parent f26c364 commit 5e70a0f

4 files changed

Lines changed: 174 additions & 55 deletions

File tree

.github/workflows/ci.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,6 @@ jobs:
108108
run: bundle exec rake TESTOPTS="--verbose"
109109

110110
- name: Check RBI types with Sorbet
111-
if: ${{ matrix.checkTarget }}
112111
working-directory: ./temporalio
113112
run: |
114113
cd extra/sorbet_check

README.md

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1419,28 +1419,21 @@ The SDK ships two sets of type signatures:
14191419
* **RBI** (`rbi/temporalio.rbi`) -- Enriched Sorbet types derived from the RBS signatures. Must be updated manually
14201420
when the RBS changes (see below).
14211421

1422-
The RBI file is validated in CI by running `srb tc` against `extra/sorbet_check/check_types.rb`. If a public API
1423-
signature changes and the RBI is not updated, this check will fail.
1422+
The RBI is validated in CI two ways:
1423+
1424+
* **Static** -- `srb tc` checks that `extra/sorbet_check/check_types.rb` typechecks against the RBI. This catches
1425+
inconsistencies within the RBI itself (missing classes, wrong param types, etc.).
1426+
* **Runtime** -- The test suite runs with `TEMPORAL_SORBET_RUNTIME_CHECK=1`, which applies every RBI signature to the
1427+
real implementation at runtime via `SigApplicator`. This catches drift between the RBI and actual code (e.g. a
1428+
renamed parameter, a changed return type, or a missing method).
14241429

14251430
**When adding or changing a public method:**
14261431

14271432
1. Update the RBS file in `sig/` as usual. Verify with `bundle exec rake steep`.
1428-
2. Update `rbi/temporalio.rbi` to match. Apply the following type mappings:
1429-
1430-
| RBS | RBI (Sorbet) |
1431-
|-----|-------------|
1432-
| `String?` | `T.nilable(String)` |
1433-
| `Integer \| Float` / `duration` | `T.any(Integer, Float)` |
1434-
| `singleton(Foo)` | `T.class_of(Foo)` |
1435-
| `bool` | `T::Boolean` |
1436-
| `untyped` | `T.untyped` |
1437-
| `Array[X]` | `T::Array[X]` |
1438-
| `Hash[K, V]` | `T::Hash[K, V]` |
1439-
| `X \| Y` | `T.any(X, Y)` |
1440-
| Enum `::enum` | `Integer` |
1441-
1433+
2. Update `rbi/temporalio.rbi` to match.
14421434
3. If the new method is important for users, add a usage example to `extra/sorbet_check/check_types.rb`.
14431435
4. Run the Sorbet check: `cd extra/sorbet_check && bundle exec srb tc`.
1436+
5. Run tests with runtime type checking enabled `TEMPORAL_SORBET_RUNTIME_CHECK=1 bundle exec rake test`
14441437

14451438
**What NOT to include in the RBI:**
14461439
* `Temporalio::Internal::*` types (excluded entirely)

temporalio/test/support/sig_applicator.rb

Lines changed: 39 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ module SigApplicator
4444

4545
class << self
4646
def apply_all!
47+
@mutex.synchronize { @type_errors.clear }
4748
configure_error_handler!
4849
register_summary_hook!
4950

@@ -145,14 +146,12 @@ def apply_scope(node, errors)
145146
return [0, 0] if SKIP_PREFIXES.any? { |prefix| class_name.start_with?(prefix) }
146147
return [0, 0] if SKIP_CLASSES.include?(class_name)
147148

148-
missing_name = false
149-
klass = begin
150-
Object.const_get(class_name)
149+
begin
150+
klass = Object.const_get(class_name)
151151
rescue NameError
152152
errors << "#{class_name}: class not found"
153-
missing_name = true
153+
return [0, 0]
154154
end
155-
return [0, 0] if missing_name
156155

157156
applied = 0
158157
skipped = 0
@@ -192,47 +191,19 @@ def apply_method_sig(target, class_name, method_node, errors, class_method: fals
192191

193192
return :skipped if SKIP_METHODS.include?(full_name)
194193

195-
missing_method = false
196-
original = begin
197-
target.instance_method(method_name)
194+
begin
195+
original = target.instance_method(method_name)
198196
rescue NameError
199197
errors << "#{full_name}: method not found"
200-
missing_method = true
198+
return false
201199
end
202-
return false if missing_method
203200

204-
# Skip when there's a block param mismatch between the sig and actual
205-
# method. Common causes:
206-
# - Anonymous block forwarding (Ruby 3.1+ `def foo(&)`)
207-
# - Methods using yield with no block param
208-
# - Sigs that omit block params the method declares
209-
actual_params = original.parameters
210-
actual_block = actual_params.find { |kind, _| kind == :block }
211-
sig_block_params = method_node.sigs.flat_map { |sig| sig.params.select { |p| p.type&.include?('T.proc') } }
212-
actual_has_block = !actual_block.nil?
213-
actual_block_anonymous = actual_block && (actual_block[1].nil? || actual_block[1] == :&)
214-
sig_has_block = sig_block_params.any?
215-
return :skipped if actual_block_anonymous && sig_has_block
216-
return :skipped if actual_has_block && !sig_has_block
217-
return :skipped if !actual_has_block && sig_has_block
218-
219-
# Skip setter methods where Ruby creates unnamed params (attr_writer)
220-
has_unnamed_params = actual_params.any? { |_kind, name| name.nil? }
221-
return :skipped if has_unnamed_params && method_name.end_with?('=')
222-
223-
# Skip when the actual method only has rest/unnamed params but the sig
224-
# declares specific named params. This happens with synthetic methods
225-
# (e.g., Data.define generates .new, .[], #initialize, #with with a
226-
# single splat) where the RBI provides typed keyword params for better
227-
# static checking but the runtime signature is incompatible.
228-
non_block_params = actual_params.reject { |kind, _| kind == :block } # rubocop:disable Style/HashExcept
229-
all_rest_or_unnamed = non_block_params.all? { |kind, _| kind == :rest || kind == :keyrest }
230-
sig_named_params = method_node.sigs.flat_map { |s| s.params.reject { |p| p.type&.include?('T.proc') } }
231-
return :skipped if all_rest_or_unnamed && non_block_params.any? && sig_named_params.any?
201+
return :skipped if skip_method?(original, method_node, method_name)
232202

233203
target.extend(T::Sig)
234204

235205
method_node.sigs.each do |sig|
206+
# RBI::Sig#string serializes back to valid T::Sig DSL source
236207
sig_source = sig.string
237208
begin
238209
target.class_eval(sig_source)
@@ -252,5 +223,35 @@ def apply_method_sig(target, class_name, method_node, errors, class_method: fals
252223

253224
true
254225
end
226+
227+
# Determines whether a method should be skipped for sig application based
228+
# on parameter shape mismatches between the RBI sig and the actual method.
229+
def skip_method?(original, method_node, method_name)
230+
actual_params = original.parameters
231+
232+
# Block param mismatch: anonymous block forwarding (Ruby 3.1+ `def foo(&)`),
233+
# methods using yield with no block param, or sigs that omit declared blocks.
234+
actual_block = actual_params.find { |kind, _| kind == :block }
235+
sig_block_params = method_node.sigs.flat_map { |sig| sig.params.select { |p| p.type&.include?('T.proc') } }
236+
actual_has_block = !actual_block.nil?
237+
actual_block_anonymous = actual_block && (actual_block[1].nil? || actual_block[1] == :&)
238+
sig_has_block = sig_block_params.any?
239+
return true if actual_block_anonymous && sig_has_block
240+
return true if actual_has_block != sig_has_block
241+
242+
# Setter methods where Ruby creates unnamed params (attr_writer)
243+
has_unnamed_params = actual_params.any? { |_kind, name| name.nil? }
244+
return true if has_unnamed_params && method_name.end_with?('=')
245+
246+
# Synthetic methods (e.g., Data.define generates .new, .[], #initialize,
247+
# #with with a single splat) where the RBI provides typed keyword params
248+
# for better static checking but the runtime signature is incompatible.
249+
non_block_params = actual_params.reject { |kind, _| kind == :block } # rubocop:disable Style/HashExcept
250+
all_rest_or_unnamed = non_block_params.all? { |kind, _| kind == :rest || kind == :keyrest }
251+
sig_named_params = method_node.sigs.flat_map { |s| s.params.reject { |p| p.type&.include?('T.proc') } }
252+
return true if all_rest_or_unnamed && non_block_params.any? && sig_named_params.any?
253+
254+
false
255+
end
255256
end
256257
end
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# frozen_string_literal: true
2+
3+
require 'minitest/autorun'
4+
require 'rbi'
5+
require 'support/sig_applicator'
6+
7+
module Support
8+
class SigApplicatorTest < Minitest::Test
9+
# --- Block param mismatch skips ---
10+
11+
def test_skips_anonymous_block_with_sig_block
12+
klass = Class.new do
13+
def foo(&); end
14+
end
15+
method_node = parse_method(
16+
'class X; sig { params(blk: T.proc.void).void }; def foo(&blk); end; end'
17+
)
18+
original = klass.instance_method(:foo)
19+
assert skip_method?(original, method_node, :foo)
20+
end
21+
22+
def test_skips_method_with_block_but_sig_without
23+
klass = Class.new do
24+
def foo(&); end
25+
end
26+
method_node = parse_method('class X; sig { void }; def foo; end; end')
27+
original = klass.instance_method(:foo)
28+
assert skip_method?(original, method_node, :foo)
29+
end
30+
31+
def test_skips_sig_with_block_but_method_without
32+
klass = Class.new do
33+
def foo; end
34+
end
35+
method_node = parse_method(
36+
'class X; sig { params(blk: T.proc.void).void }; def foo(&blk); end; end'
37+
)
38+
original = klass.instance_method(:foo)
39+
assert skip_method?(original, method_node, :foo)
40+
end
41+
42+
def test_does_not_skip_matching_named_block
43+
klass = Class.new do
44+
def foo(&blk); end # rubocop:disable Naming/BlockForwarding
45+
end
46+
method_node = parse_method(
47+
'class X; sig { params(blk: T.proc.void).void }; def foo(&blk); end; end'
48+
)
49+
original = klass.instance_method(:foo)
50+
refute skip_method?(original, method_node, :foo)
51+
end
52+
53+
# --- Setter / unnamed param skips ---
54+
55+
def test_skips_attr_writer_with_unnamed_params
56+
klass = Class.new { attr_writer :bar }
57+
method_node = parse_method(
58+
'class X; sig { params(value: Integer).void }; def bar=(value); end; end'
59+
)
60+
original = klass.instance_method(:bar=)
61+
assert skip_method?(original, method_node, :bar=)
62+
end
63+
64+
def test_does_not_skip_regular_setter
65+
klass = Class.new do
66+
def bar=(value); end
67+
end
68+
method_node = parse_method(
69+
'class X; sig { params(value: Integer).void }; def bar=(value); end; end'
70+
)
71+
original = klass.instance_method(:bar=)
72+
refute skip_method?(original, method_node, :bar=)
73+
end
74+
75+
# --- Synthetic rest-param skips ---
76+
77+
def test_skips_rest_only_method_with_named_sig_params
78+
klass = Class.new do
79+
def initialize(*args); end
80+
end
81+
method_node = parse_method(<<~RBI)
82+
class X
83+
sig { params(name: String, age: Integer).void }
84+
def initialize(name:, age:); end
85+
end
86+
RBI
87+
original = klass.instance_method(:initialize)
88+
assert skip_method?(original, method_node, :initialize)
89+
end
90+
91+
def test_does_not_skip_when_params_match
92+
klass = Class.new do
93+
def foo(val1, val2); end
94+
end
95+
method_node = parse_method(<<~RBI)
96+
class X
97+
sig { params(val1: Integer, val2: String).returns(String) }
98+
def foo(val1, val2); end
99+
end
100+
RBI
101+
original = klass.instance_method(:foo)
102+
refute skip_method?(original, method_node, :foo)
103+
end
104+
105+
def test_does_not_skip_no_param_method
106+
klass = Class.new do
107+
def foo; end
108+
end
109+
method_node = parse_method('class X; sig { returns(String) }; def foo; end; end')
110+
original = klass.instance_method(:foo)
111+
refute skip_method?(original, method_node, :foo)
112+
end
113+
114+
private
115+
116+
def parse_method(source)
117+
tree = RBI::Parser.parse_string(source)
118+
klass = tree.nodes.first
119+
klass.nodes.find { |n| n.is_a?(RBI::Method) }
120+
end
121+
122+
def skip_method?(original, method_node, method_name)
123+
SigApplicator.send(:skip_method?, original, method_node, method_name)
124+
end
125+
end
126+
end

0 commit comments

Comments
 (0)