steep-relaxed 2.0.0.0 → 2.1.0.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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/CLAUDE.md +2 -2
  4. data/README.md +3 -3
  5. data/Rakefile +352 -143
  6. data/Steepfile +2 -0
  7. data/bin/shape_warmup_bench.rb +402 -0
  8. data/bin/steep-check.rb +4 -4
  9. data/bin/subtyping_cache_bench.rb +436 -0
  10. data/bin/typecheck_bench.rb +145 -0
  11. data/doc/release.md +264 -0
  12. data/lib/steep/ast/types/helper.rb +0 -1
  13. data/lib/steep/ast/types/literal.rb +1 -1
  14. data/lib/steep/ast/types/name.rb +1 -1
  15. data/lib/steep/cli.rb +32 -5
  16. data/lib/steep/daemon/server.rb +1 -3
  17. data/lib/steep/daemon.rb +49 -9
  18. data/lib/steep/diagnostic/ruby.rb +4 -4
  19. data/lib/steep/drivers/check.rb +0 -1
  20. data/lib/steep/drivers/checkfile.rb +4 -12
  21. data/lib/steep/drivers/init.rb +4 -3
  22. data/lib/steep/drivers/langserver.rb +34 -5
  23. data/lib/steep/drivers/query.rb +40 -2
  24. data/lib/steep/drivers/stats.rb +0 -1
  25. data/lib/steep/drivers/worker.rb +0 -7
  26. data/lib/steep/interface/builder.rb +194 -28
  27. data/lib/steep/interface/shape.rb +28 -14
  28. data/lib/steep/node_helper.rb +2 -2
  29. data/lib/steep/server/base_worker.rb +27 -30
  30. data/lib/steep/server/change_buffer.rb +0 -32
  31. data/lib/steep/server/command_socket.rb +181 -0
  32. data/lib/steep/server/custom_methods.rb +22 -22
  33. data/lib/steep/server/goto_resolver.rb +200 -0
  34. data/lib/steep/server/interaction_worker.rb +32 -12
  35. data/lib/steep/server/master.rb +709 -261
  36. data/lib/steep/server/rbs_entry_builder.rb +237 -0
  37. data/lib/steep/server/type_check_controller.rb +73 -27
  38. data/lib/steep/server/type_check_database.rb +406 -0
  39. data/lib/steep/server/type_check_worker.rb +118 -352
  40. data/lib/steep/server/worker_process.rb +8 -22
  41. data/lib/steep/services/hover_provider/content.rb +1 -1
  42. data/lib/steep/services/hover_provider/ruby.rb +1 -1
  43. data/lib/steep/services/signature_help_provider.rb +1 -1
  44. data/lib/steep/services/stats_calculator.rb +16 -35
  45. data/lib/steep/services/symbol_provider.rb +191 -0
  46. data/lib/steep/services/type_check_service.rb +10 -21
  47. data/lib/steep/source.rb +9 -10
  48. data/lib/steep/subtyping/cache.rb +16 -4
  49. data/lib/steep/subtyping/check.rb +83 -16
  50. data/lib/steep/subtyping/stats.rb +356 -0
  51. data/lib/steep/tagged_logging.rb +4 -1
  52. data/lib/steep/type_construction.rb +39 -17
  53. data/lib/steep/type_inference/case_when.rb +2 -2
  54. data/lib/steep/type_inference/logic_type_interpreter.rb +6 -3
  55. data/lib/steep/type_inference/send_args.rb +9 -5
  56. data/lib/steep/typing.rb +3 -3
  57. data/lib/steep/version.rb +1 -1
  58. data/lib/steep.rb +6 -1
  59. data/steep-relaxed.gemspec +2 -8
  60. metadata +15 -6
  61. data/lib/steep/services/goto_service.rb +0 -644
@@ -0,0 +1,402 @@
1
+ #!/usr/bin/env ruby
2
+ # Estimates the boot cost of a shape-warming zygote process: builds the shapes
3
+ # of every class/module/interface in the environment ahead of time, like a
4
+ # zygote would before forking type check workers, and measures wall-clock time
5
+ # and memory growth.
6
+ #
7
+ # Run it in the project directory to measure (the Steepfile is loaded from the
8
+ # current directory):
9
+ #
10
+ # bundle exec ruby "$(bundle info steep --path)/bin/shape_warmup_bench.rb"
11
+ #
12
+ # Env vars:
13
+ # TARGETS=app,test Steepfile targets to measure (default: app)
14
+ # VERBOSE_ERRORS=1 Include the messages of shape building errors and the number of
15
+ # project signature files in the report
16
+ # PHASES=1 Build all RBS definitions before the shapes, reporting the
17
+ # definition phase separately with time, memory, and GC time per
18
+ # phase -- the project/library times then measure the shape
19
+ # construction alone
20
+ # STACKPROF=cpu Profile the phases with stackprof (cpu, wall, or object) and
21
+ # print the report to stderr; STACKPROF_INTERVAL overrides the
22
+ # sampling interval, STACKPROF_OUT saves the raw dump to a file
23
+ # WORKING_SET_OUT=p Type check the target's source files instead of warming everything,
24
+ # and write the names of the types whose shapes were actually built to
25
+ # the file `p`; WORKING_SET_FILES=N checks only the first N files
26
+ # WORKING_SET_IN=p Warm only the types listed in the file `p`, instead of everything, to
27
+ # measure what a warmup limited to the working set costs
28
+ # DEFINITIONS_ONLY=1 Warm the RBS definitions but not the shapes, to compare how much a
29
+ # partial warmup shares (implies PHASES=1)
30
+ # FORK=N After warming, fork N children that type check their share of the
31
+ # source files, like the type check workers do, and report the memory
32
+ # each one ends up owning privately -- the number that decides how many
33
+ # workers fit in memory. FORK_FILES=N limits the files checked.
34
+ # NO_MAJOR_GC=1 Disable major GCs for the whole run with
35
+ # `GC.config(rgengc_allow_full_mark: false)` (Ruby 3.4+); the
36
+ # memory numbers then overestimate, because dead old objects
37
+ # are never collected
38
+ #
39
+ # Phases per target:
40
+ # 1. project types types declared in the signature files and inline sources of the
41
+ # target and its groups
42
+ # 2. library types everything else in the environment (gems, stdlib, core)
43
+ #
44
+ # The report contains no type names, so it is safe to share -- except with
45
+ # VERBOSE_ERRORS, whose error messages usually name the failing types. The stackprof
46
+ # report contains only Steep/RBS method names.
47
+
48
+ Encoding.default_external = Encoding::UTF_8
49
+
50
+ require "steep"
51
+ require "benchmark"
52
+ require "json"
53
+ require "objspace"
54
+
55
+ TARGET_NAMES = (ENV["TARGETS"] || "app").split(",").map(&:to_sym)
56
+ verbose_errors = ENV["VERBOSE_ERRORS"] == "1"
57
+ definitions_only = ENV["DEFINITIONS_ONLY"] == "1"
58
+ split_phases = ENV["PHASES"] == "1" || definitions_only
59
+ fork_count = ENV["FORK"]&.to_i
60
+ working_set_out = ENV["WORKING_SET_OUT"]
61
+ working_set_in = ENV["WORKING_SET_IN"]
62
+ stackprof_mode = ENV["STACKPROF"]&.to_sym
63
+
64
+ if stackprof_mode
65
+ begin
66
+ require "stackprof"
67
+ rescue LoadError
68
+ abort "STACKPROF needs the stackprof gem in the bundle"
69
+ end
70
+ end
71
+
72
+ if ENV["NO_MAJOR_GC"] == "1"
73
+ # Prototypes a boot that skips the major GCs while retaining almost everything --
74
+ # minor GCs keep collecting the young garbage
75
+ GC.respond_to?(:config) or abort "NO_MAJOR_GC needs Ruby 3.4+ (GC.config)"
76
+ GC.config(rgengc_allow_full_mark: false)
77
+ end
78
+
79
+ GC.measure_total_time = true
80
+ gc_snap = -> { [GC.stat(:time), GC.stat(:major_gc_count), GC.stat(:minor_gc_count)] }
81
+ gc_delta = ->(snap) { gc_snap.().zip(snap).map {|now, was| now - was } }
82
+
83
+ if working_set_out
84
+ # The shapes built during a type check are the ones a warmup actually has to provide
85
+ TOUCHED = {} #: Hash[untyped, bool]
86
+ Steep::Interface::Builder.class_eval do
87
+ alias_method :object_shape_without_record, :object_shape
88
+ def object_shape(type_name)
89
+ TOUCHED[type_name] = true
90
+ object_shape_without_record(type_name)
91
+ end
92
+
93
+ alias_method :singleton_shape_without_record, :singleton_shape
94
+ def singleton_shape(type_name)
95
+ TOUCHED[type_name] = true
96
+ singleton_shape_without_record(type_name)
97
+ end
98
+ end
99
+ end
100
+
101
+ steepfile = Pathname.pwd + "Steepfile"
102
+ project = Steep::Project.new(steepfile_path: steepfile)
103
+ Steep::Project::DSL.parse(project, steepfile.read, filename: steepfile.to_s)
104
+
105
+ loader = Steep::Services::FileLoader.new(base_dir: project.base_dir)
106
+
107
+ service = nil
108
+ boot_time = Benchmark.realtime do
109
+ service = Steep::Services::TypeCheckService.new(project: project)
110
+ end
111
+
112
+ changes = {}
113
+ project.targets.each do |target|
114
+ loader.each_path_in_target(target) do |path|
115
+ changes[path] ||= [Steep::Services::ContentChange.string((project.base_dir + path).read)]
116
+ end
117
+ end
118
+ update_time = Benchmark.realtime { service.update(changes: changes) }
119
+
120
+ rss_mb = -> { File.read("/proc/self/status")[/VmRSS:\s+(\d+)/, 1].to_i / 1024.0 }
121
+
122
+ # `Private_Dirty` is the memory a forked child does not share with its parent any more,
123
+ # so it is what each additional worker really costs
124
+ smaps_mb = ->(key) do
125
+ if line = File.read("/proc/self/smaps_rollup")[/^#{key}:\s+(\d+) kB/]
126
+ $1.to_i / 1024.0
127
+ else
128
+ 0.0
129
+ end
130
+ rescue Errno::ENOENT
131
+ 0.0
132
+ end
133
+ gc_memsize = -> { 2.times { GC.start }; ObjectSpace.memsize_of_all }
134
+
135
+ result = {
136
+ boot: boot_time.round(2),
137
+ update: update_time.round(2),
138
+ targets: {},
139
+ }
140
+
141
+ targets = project.targets.select {|t| TARGET_NAMES.include?(t.name) }
142
+ raise "no matching targets: #{TARGET_NAMES}" if targets.empty?
143
+
144
+ targets.each do |target|
145
+ signature_service = service.signature_services.fetch(target.name)
146
+ subtyping = signature_service.current_subtyping
147
+
148
+ unless subtyping
149
+ result[:targets][target.name] = { error: "signature has errors; no subtyping available" }
150
+ next
151
+ end
152
+
153
+ builder = subtyping.builder
154
+ env = signature_service.latest_env
155
+
156
+ # Signature and inline-source patterns of the groups don't appear in the target's own
157
+ # patterns, so both levels have to be enumerated, like FileLoader#each_path_in_target
158
+ sig_paths = [] #: Array[Pathname]
159
+ collect_paths = ->(pattern) { loader.each_path_in_patterns(pattern) {|path| sig_paths << path } }
160
+ target.groups.each do |group|
161
+ collect_paths.(group.signature_pattern)
162
+ collect_paths.(group.inline_source_pattern)
163
+ end
164
+ collect_paths.(target.signature_pattern)
165
+ collect_paths.(target.inline_source_pattern)
166
+ sig_paths.uniq!
167
+
168
+ project_names = signature_service.type_names(paths: Set.new(sig_paths), env: env).to_set
169
+
170
+ all_names = env.class_decls.keys + env.interface_decls.keys
171
+
172
+ if working_set_in
173
+ working_set = Set.new(File.readlines(working_set_in, chomp: true))
174
+ all_names = all_names.select {|name| working_set.include?(name.to_s) }
175
+ end
176
+
177
+ project_set, library_set = all_names.partition {|name| project_names.include?(name) }
178
+
179
+ warm = ->(names) do
180
+ errors = 0
181
+ error_tally = Hash.new(0)
182
+ time = Benchmark.realtime do
183
+ names.each do |name|
184
+ begin
185
+ if name.class?
186
+ builder.object_shape(name)
187
+ builder.singleton_shape(name)
188
+ elsif name.interface?
189
+ builder.object_shape(name)
190
+ end
191
+ rescue => exn
192
+ errors += 1
193
+ error_tally["#{exn.class}: #{exn.message}"[0, 200]] += 1 if verbose_errors
194
+ end
195
+ end
196
+ end
197
+ [time, errors, error_tally]
198
+ end
199
+
200
+ warm_definitions = ->(names) do
201
+ definition_builder = builder.factory.definition_builder
202
+ errors = 0
203
+ time = Benchmark.realtime do
204
+ names.each do |name|
205
+ begin
206
+ if name.class?
207
+ definition_builder.build_instance(name)
208
+ definition_builder.build_singleton(name)
209
+ elsif name.interface?
210
+ definition_builder.build_interface(name)
211
+ end
212
+ rescue => _
213
+ errors += 1
214
+ end
215
+ end
216
+ end
217
+ [time, errors]
218
+ end
219
+
220
+ # Samples accumulate across the start/stop pairs, so the forced GCs of the memory
221
+ # measurements between the phases stay out of the profile
222
+ profile_phase = ->(callable) do
223
+ if stackprof_mode
224
+ interval = (ENV["STACKPROF_INTERVAL"] || (stackprof_mode == :object ? 100 : 1000)).to_i
225
+ StackProf.start(mode: stackprof_mode, raw: false, interval: interval)
226
+ begin
227
+ callable.call
228
+ ensure
229
+ StackProf.stop
230
+ end
231
+ else
232
+ callable.call
233
+ end
234
+ end
235
+
236
+ if working_set_out
237
+ source_paths = [] #: Array[Pathname]
238
+ loader.each_path_in_target(target) do |path|
239
+ source_paths << path if target.possible_source_file?(path)
240
+ end
241
+ if limit = ENV["WORKING_SET_FILES"]
242
+ source_paths = source_paths.first(limit.to_i)
243
+ end
244
+
245
+ check_time = Benchmark.realtime do
246
+ source_paths.each do |path|
247
+ begin
248
+ service.typecheck_source(path: path, target: target)
249
+ rescue => _
250
+ end
251
+ end
252
+ end
253
+
254
+ File.write(working_set_out, TOUCHED.each_key.map(&:to_s).sort.join("\n"))
255
+
256
+ result[:targets][target.name] = {
257
+ checked_files: source_paths.size,
258
+ check_time: check_time.round(2),
259
+ environment_types: all_names.size,
260
+ types_touched: TOUCHED.size,
261
+ touched_ratio: (TOUCHED.size * 100.0 / all_names.size).round(1),
262
+ rss_mb: rss_mb.().round(0),
263
+ }
264
+ next
265
+ end
266
+
267
+ if split_phases
268
+ before_definitions = gc_memsize.()
269
+ gc0 = gc_snap.()
270
+ definitions_time, definitions_errors = profile_phase.(-> { warm_definitions.(all_names) })
271
+ definitions_gc = gc_delta.(gc0)
272
+ end
273
+
274
+ before = gc_memsize.()
275
+
276
+ if definitions_only
277
+ project_time, project_errors, project_error_tally = 0.0, 0, {}
278
+ library_time, library_errors, library_error_tally = 0.0, 0, {}
279
+ project_gc = library_gc = [0, 0, 0]
280
+ after_project = after_library = before
281
+ else
282
+ gc0 = gc_snap.()
283
+ project_time, project_errors, project_error_tally = profile_phase.(-> { warm.(project_set) })
284
+ project_gc = gc_delta.(gc0)
285
+ after_project = gc_memsize.()
286
+ gc0 = gc_snap.()
287
+ library_time, library_errors, library_error_tally = profile_phase.(-> { warm.(library_set) })
288
+ library_gc = gc_delta.(gc0)
289
+ after_library = gc_memsize.()
290
+ end
291
+
292
+ compact_time = Benchmark.realtime { GC.compact }
293
+
294
+ result[:targets][target.name] = {
295
+ project_types: project_set.size,
296
+ project_time: project_time.round(2),
297
+ project_mb: ((after_project - before) / 1024.0 / 1024).round(1),
298
+ project_errors: project_errors,
299
+ library_types: library_set.size,
300
+ library_time: library_time.round(2),
301
+ library_mb: ((after_library - after_project) / 1024.0 / 1024).round(1),
302
+ library_errors: library_errors,
303
+ object_shape_cache: builder.object_shape_cache.size,
304
+ singleton_shape_cache: builder.singleton_shape_cache.size,
305
+ gc_compact_time: compact_time.round(2),
306
+ rss_mb: rss_mb.().round(0),
307
+ }
308
+
309
+ if split_phases
310
+ gc_report = ->(prefix, (ms, major, minor)) do
311
+ { :"#{prefix}_gc_ms" => ms, :"#{prefix}_major_gc" => major, :"#{prefix}_minor_gc" => minor }
312
+ end
313
+
314
+ result[:targets][target.name].merge!(
315
+ definitions_time: definitions_time.round(2),
316
+ definitions_mb: ((before - before_definitions) / 1024.0 / 1024).round(1),
317
+ definitions_errors: definitions_errors,
318
+ **gc_report.(:definitions, definitions_gc),
319
+ **gc_report.(:project, project_gc),
320
+ **gc_report.(:library, library_gc),
321
+ )
322
+ end
323
+
324
+ if fork_count
325
+ # `Process.warmup` promotes everything to the old generation and compacts, so the pages
326
+ # the children inherit are as stable as they get
327
+ warmup_time = Benchmark.realtime { Process.warmup if Process.respond_to?(:warmup) }
328
+
329
+ source_paths = [] #: Array[Pathname]
330
+ loader.each_path_in_target(target) do |path|
331
+ source_paths << path if target.possible_source_file?(path)
332
+ end
333
+ if limit = ENV["FORK_FILES"]
334
+ source_paths = source_paths.first(limit.to_i)
335
+ end
336
+
337
+ parent_rss = rss_mb.()
338
+ readers = source_paths.each_slice((source_paths.size.to_f / fork_count).ceil).map do |paths|
339
+ reader, writer = IO.pipe
340
+
341
+ fork do
342
+ reader.close
343
+ time = Benchmark.realtime do
344
+ paths.each do |path|
345
+ begin
346
+ service.typecheck_source(path: path, target: target)
347
+ rescue => _
348
+ end
349
+ end
350
+ end
351
+ writer.write(
352
+ JSON.generate(
353
+ files: paths.size,
354
+ check_time: time.round(2),
355
+ private_mb: smaps_mb.("Private_Dirty").round(1),
356
+ shared_mb: smaps_mb.("Shared_Clean").round(1),
357
+ rss_mb: rss_mb.().round(0)
358
+ )
359
+ )
360
+ writer.close
361
+ exit!(0)
362
+ end
363
+
364
+ writer.close
365
+ reader
366
+ end
367
+
368
+ children = readers.map do |reader|
369
+ json = reader.read
370
+ reader.close
371
+ JSON.parse(json, symbolize_names: true)
372
+ end
373
+ Process.waitall
374
+
375
+ result[:targets][target.name].merge!(
376
+ warmup_call_time: warmup_time.round(2),
377
+ parent_rss_mb: parent_rss.round(0),
378
+ workers: children.size,
379
+ worker_check_time_max: children.map {|c| c[:check_time] }.max,
380
+ worker_private_mb_avg: (children.sum {|c| c[:private_mb] } / children.size).round(1),
381
+ worker_private_mb_max: children.map {|c| c[:private_mb] }.max,
382
+ worker_rss_mb_avg: (children.sum {|c| c[:rss_mb] } / children.size).round(0)
383
+ )
384
+ end
385
+
386
+ if verbose_errors
387
+ result[:targets][target.name][:project_sig_paths] = sig_paths.size
388
+ top_errors = ->(tally) { tally.sort_by {|_, count| -count }.first(20).to_h }
389
+ result[:targets][target.name][:project_error_details] = top_errors.(project_error_tally)
390
+ result[:targets][target.name][:library_error_details] = top_errors.(library_error_tally)
391
+ end
392
+ end
393
+
394
+ if stackprof_mode
395
+ profile = StackProf.results
396
+ if profile
397
+ File.binwrite(ENV["STACKPROF_OUT"], Marshal.dump(profile)) if ENV["STACKPROF_OUT"]
398
+ StackProf::Report.new(profile).print_text(false, 30, nil, nil, nil, nil, $stderr)
399
+ end
400
+ end
401
+
402
+ puts JSON.pretty_generate(result)
data/bin/steep-check.rb CHANGED
@@ -67,7 +67,7 @@ class Command
67
67
  target.options.load_collection_lock
68
68
 
69
69
  signature_service = Steep::Project::Target.construct_env_loader(options: target.options, project: project).yield_self do |loader|
70
- Steep::Services::SignatureService.load_from(loader)
70
+ Steep::Services::SignatureService.load_from(loader, implicitly_returns_nil: target.implicitly_returns_nil)
71
71
  end
72
72
 
73
73
  env = signature_service.latest_env
@@ -76,8 +76,8 @@ class Command
76
76
 
77
77
  signature_files.each do |path, content|
78
78
  buffer = RBS::Buffer.new(name: path.to_s, content: content)
79
- buffer, dirs, decls = RBS::Parser.parse_signature(buffer)
80
- env.add_signature(buffer: buffer, directives: dirs, decls: decls)
79
+ _, dirs, decls = RBS::Parser.parse_signature(buffer)
80
+ env.add_source(RBS::Source::RBS.new(buffer, dirs, decls))
81
81
  new_decls.merge(decls)
82
82
  end
83
83
 
@@ -94,7 +94,7 @@ class Command
94
94
 
95
95
  definition_builder = RBS::DefinitionBuilder.new(env: env)
96
96
  factory = AST::Types::Factory.new(builder: definition_builder)
97
- builder = Interface::Builder.new(factory)
97
+ builder = Interface::Builder.new(factory, implicitly_returns_nil: target.implicitly_returns_nil)
98
98
  subtyping = Subtyping::Check.new(builder: builder)
99
99
 
100
100
  typings = {}