rigortype 0.4.5 → 0.4.6

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 (136) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/docs/handbook/11-sig-gen.md +11 -2
  4. data/docs/manual/02-cli-reference.md +46 -7
  5. data/docs/manual/03-configuration.md +2 -1
  6. data/docs/manual/04-diagnostics.md +1 -1
  7. data/docs/manual/10-mcp-server.md +9 -1
  8. data/docs/manual/12-caching.md +8 -1
  9. data/docs/manual/plugins/rigor-actionmailer.md +30 -0
  10. data/docs/manual/plugins/rigor-actionpack.md +30 -0
  11. data/docs/manual/plugins/rigor-devise.md +15 -0
  12. data/docs/manual/plugins/rigor-rspec.md +2 -1
  13. data/lib/rigor/analysis/buffer_binding.rb +14 -0
  14. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +30 -9
  15. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +142 -15
  16. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +4 -0
  17. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +66 -3
  18. data/lib/rigor/analysis/check_rules/rebinding_block_callees/dispatch.rb +112 -0
  19. data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +394 -0
  20. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +7 -0
  21. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +51 -0
  22. data/lib/rigor/analysis/check_rules.rb +93 -8
  23. data/lib/rigor/analysis/dependency_recorder.rb +11 -0
  24. data/lib/rigor/analysis/edit_replay.rb +67 -0
  25. data/lib/rigor/analysis/incremental.rb +37 -0
  26. data/lib/rigor/analysis/incremental_session.rb +175 -27
  27. data/lib/rigor/analysis/path_expansion.rb +41 -0
  28. data/lib/rigor/analysis/require_relative_closure.rb +100 -0
  29. data/lib/rigor/analysis/rule_catalog.rb +5 -1
  30. data/lib/rigor/analysis/run_disclosure_credit.rb +80 -0
  31. data/lib/rigor/analysis/run_disclosure_ledger.rb +155 -0
  32. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +6 -2
  33. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +11 -3
  34. data/lib/rigor/analysis/runner/pool_coordinator.rb +17 -4
  35. data/lib/rigor/analysis/runner/project_pre_passes.rb +45 -3
  36. data/lib/rigor/analysis/runner/scan_environment.rb +51 -0
  37. data/lib/rigor/analysis/runner.rb +156 -49
  38. data/lib/rigor/analysis/worker_session.rb +14 -2
  39. data/lib/rigor/cache/incremental_snapshot.rb +30 -7
  40. data/lib/rigor/cli/check_command.rb +93 -4
  41. data/lib/rigor/cli/diff_command.rb +2 -1
  42. data/lib/rigor/cli/lens_command.rb +5 -5
  43. data/lib/rigor/cli/mcp_command.rb +2 -1
  44. data/lib/rigor/cli/options.rb +20 -3
  45. data/lib/rigor/cli/plugins_command.rb +5 -0
  46. data/lib/rigor/cli/plugins_renderer.rb +24 -2
  47. data/lib/rigor/cli/show_bleedingedge_command.rb +2 -1
  48. data/lib/rigor/cli/sig_gen_command.rb +46 -7
  49. data/lib/rigor/cli/type_of_command.rb +2 -1
  50. data/lib/rigor/cli/type_of_project_scope.rb +27 -0
  51. data/lib/rigor/config_audit.rb +16 -5
  52. data/lib/rigor/configuration.rb +14 -4
  53. data/lib/rigor/environment/rbs_loader.rb +115 -6
  54. data/lib/rigor/environment/required_features.rb +136 -29
  55. data/lib/rigor/environment/ruby_library_roots.rb +122 -0
  56. data/lib/rigor/environment.rb +4 -2
  57. data/lib/rigor/inference/callee_mutation_chain.rb +115 -0
  58. data/lib/rigor/inference/deep_erasure.rb +138 -0
  59. data/lib/rigor/inference/def_return_typer.rb +159 -9
  60. data/lib/rigor/inference/definer_resolution.rb +17 -3
  61. data/lib/rigor/inference/error_info.rb +17 -3
  62. data/lib/rigor/inference/expression_typer.rb +62 -13
  63. data/lib/rigor/inference/global_write_census.rb +36 -1
  64. data/lib/rigor/inference/ivar_writer_census.rb +195 -0
  65. data/lib/rigor/inference/loop_value_record.rb +53 -0
  66. data/lib/rigor/inference/match_rebinding/frame.rb +19 -0
  67. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +110 -9
  68. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +6 -2
  69. data/lib/rigor/inference/method_dispatcher/set_folding.rb +53 -20
  70. data/lib/rigor/inference/method_dispatcher.rb +46 -16
  71. data/lib/rigor/inference/module_function_state/spellings.rb +64 -0
  72. data/lib/rigor/inference/module_function_state.rb +12 -0
  73. data/lib/rigor/inference/mutation_widening.rb +5 -2
  74. data/lib/rigor/inference/narrowing.rb +66 -5
  75. data/lib/rigor/inference/object_mixins.rb +13 -1
  76. data/lib/rigor/inference/project_seed_tables.rb +2 -1
  77. data/lib/rigor/inference/project_yielder.rb +128 -0
  78. data/lib/rigor/inference/scope_indexer/certainty.rb +9 -0
  79. data/lib/rigor/inference/scope_indexer/declared_ivar_types.rb +64 -0
  80. data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +162 -0
  81. data/lib/rigor/inference/scope_indexer/unseen_ivar_writers.rb +133 -0
  82. data/lib/rigor/inference/scope_indexer.rb +480 -103
  83. data/lib/rigor/inference/self_call_ivar_writes.rb +18 -6
  84. data/lib/rigor/inference/statement_evaluator.rb +658 -90
  85. data/lib/rigor/inference/synthetic_method_index.rb +23 -0
  86. data/lib/rigor/inference/synthetic_method_scanner/project_members.rb +130 -0
  87. data/lib/rigor/inference/synthetic_method_scanner.rb +72 -12
  88. data/lib/rigor/language_server/buffer_table.rb +6 -0
  89. data/lib/rigor/language_server/diagnostic_publisher.rb +20 -6
  90. data/lib/rigor/language_server/project_context.rb +59 -3
  91. data/lib/rigor/language_server/server.rb +5 -3
  92. data/lib/rigor/mcp/server.rb +9 -1
  93. data/lib/rigor/plugin/base.rb +26 -11
  94. data/lib/rigor/plugin/manifest.rb +29 -3
  95. data/lib/rigor/plugin/registry.rb +38 -0
  96. data/lib/rigor/protection/discovery_seed.rb +57 -2
  97. data/lib/rigor/rooted_glob.rb +10 -1
  98. data/lib/rigor/scope.rb +14 -5
  99. data/lib/rigor/sig_gen/generator.rb +308 -37
  100. data/lib/rigor/sig_gen/observation_collector.rb +4 -1
  101. data/lib/rigor/sig_gen/rbs_validity.rb +1 -1
  102. data/lib/rigor/sig_gen/renderer.rb +6 -3
  103. data/lib/rigor/sig_gen/type_elaborator.rb +21 -5
  104. data/lib/rigor/type/combinator.rb +4 -3
  105. data/lib/rigor/type/constant.rb +45 -3
  106. data/lib/rigor/type/hash_shape.rb +14 -9
  107. data/lib/rigor/type/struct_instance.rb +3 -2
  108. data/lib/rigor/version.rb +1 -1
  109. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +60 -43
  110. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +20 -1
  111. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +13 -0
  112. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +4 -2
  113. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +5 -3
  114. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +6 -5
  115. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +102 -11
  116. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +4 -2
  117. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +1 -1
  118. data/sig/rigor/analysis/baseline.rbs +3 -2
  119. data/sig/rigor/effects/file_collection.rbs +5 -6
  120. data/sig/rigor/effects/registry.rbs +8 -6
  121. data/sig/rigor/effects/summary.rbs +6 -5
  122. data/sig/rigor/environment.rbs +5 -3
  123. data/sig/rigor/inference/optimistic_origin.rbs +11 -0
  124. data/sig/rigor/inference.rbs +6 -0
  125. data/sig/rigor/plugin/additional_initializer.rbs +5 -5
  126. data/sig/rigor/plugin/effect_ancestry.rbs +6 -4
  127. data/sig/rigor/plugin/effect_attribution.rbs +25 -10
  128. data/sig/rigor/plugin/effect_edge.rbs +12 -5
  129. data/sig/rigor/plugin/effect_entry_points.rbs +8 -3
  130. data/sig/rigor/plugin/io_boundary.rbs +4 -4
  131. data/sig/rigor/plugin/manifest.rbs +16 -18
  132. data/sig/rigor/plugin/protocol_contract.rbs +10 -9
  133. data/sig/rigor/plugin/registry.rbs +1 -1
  134. data/sig/rigor/scope.rbs +31 -50
  135. data/sig/rigor/type.rbs +0 -6
  136. metadata +20 -1
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../analysis/path_expansion"
4
+ require_relative "../rooted_glob"
4
5
 
5
6
  module Rigor
6
7
  class Environment
@@ -42,6 +43,36 @@ module Rigor
42
43
  # anything.
43
44
  PATTERN = /(?:(?<![\w.$@])|(?<=Kernel\.))require[ \t]*\(?[ \t]*(["'])(#{FEATURE_ALTERNATION})\1/
44
45
 
46
+ # Issue #2174 — the gems Ruby 4.0 bundles (its `gems/bundled_gems` list, which an installation does not
47
+ # keep): installed beside the interpreter, but loaded only by an explicit `require`. Gems installed later
48
+ # into the same directory are deliberately not read from it.
49
+ BUNDLED_GEMS = %w[
50
+ abbrev base64 benchmark bigdecimal csv debug drb fiddle getoptlong irb logger matrix minitest mutex_m
51
+ net-ftp net-imap net-pop net-smtp nkf observer ostruct power_assert prime pstore racc rake rbs rdoc readline
52
+ reline repl_type_completor resolv-replace rexml rinda rss syslog test-unit typeprof win32ole
53
+ ].freeze
54
+
55
+ # Issue #2174 — the first path segments of the features Ruby ships: the `.rb` files and directories of the
56
+ # interpreter's library directory, the extensions and directories of its architecture directory, and the
57
+ # bundled gems' names (`net-ftp` → `net`). Read once, at load time and on the main Ractor, since
58
+ # `RbConfig::CONFIG` is not shareable; empty when the directories cannot be read.
59
+ SHIPPED_SEGMENTS = begin
60
+ require "rbconfig"
61
+ segments = Set.new
62
+ [RbConfig::CONFIG["rubylibdir"], RbConfig::CONFIG["archdir"]].compact.each do |dir|
63
+ next unless File.directory?(dir)
64
+
65
+ Dir.children(dir).each { |entry| segments << entry.sub(/\.(?:rb|so|bundle|dll)\z/, "") }
66
+ end
67
+ BUNDLED_GEMS.each { |name| segments << name.split("-").first }
68
+ segments.to_set(&:freeze).freeze
69
+ rescue StandardError
70
+ Set.new.freeze
71
+ end
72
+
73
+ # A `require` of any literal feature, read as {PATTERN} reads one; {.features_of} keeps those Ruby ships.
74
+ ANY_FEATURE = %r{(?:(?<![\w.$@])|(?<=Kernel\.))require[ \t]*\(?[ \t]*(["'])([\w-]+(?:/[\w-]+)*)\1}
75
+
45
76
  module_function
46
77
 
47
78
  # The features a run over `configuration` requires: every file under the configured paths, plus
@@ -53,37 +84,51 @@ module Rigor
53
84
  def for_configuration(configuration, extra_files = [], sources: {})
54
85
  files = Analysis::PathExpansion.ruby_files(configuration.paths, configuration.exclude_patterns,
55
86
  base: configuration.exclude_base)
56
- scan(files + Array(extra_files).map(&:to_s), sources: sources)
87
+ scan(files + Array(extra_files).map(&:to_s), sources: sources,
88
+ sig_paths: configured_sig_paths(configuration))
57
89
  rescue StandardError
58
90
  []
59
91
  end
60
92
 
93
+ # The project signature roots the environment loads: the configured `signature_paths:`, else `./sig` when it
94
+ # exists (`Environment.project_signature_roots`, which this leaf file does not load).
95
+ def configured_sig_paths(configuration)
96
+ configured = configuration.signature_paths
97
+ return Array(configured).map(&:to_s) if configured
98
+
99
+ File.directory?("sig") ? ["sig"] : []
100
+ end
101
+
61
102
  # @param files — source paths (Strings or Pathnames); an unreadable one contributes nothing.
62
103
  # @param sources — path => source text, scanned instead of the file; its paths are scanned even when
63
104
  # `files` does not list them.
64
- # @return the sorted, frozen features some file requires.
65
- def scan(files, sources: {})
105
+ # @param sig_paths — the project's signature roots, whose `.rbs` declaration headers count as the project's
106
+ # own names ({.relevant}).
107
+ # @return the sorted, frozen features some file requires, a shipped one kept only when {.relevant}.
108
+ def scan(files, sources: {}, sig_paths: [])
66
109
  sources = sources.to_h { |path, text| [File.expand_path(path.to_s), text] }
67
110
  paths = (Array(files).map { |path| source_key(File.expand_path(path.to_s), sources) } + sources.keys).uniq
68
111
  found = Set.new
112
+ names = Set.new
69
113
  paths.each do |path|
70
- break if found.size == VENDORED_DIRS.size
71
-
72
- found.merge(sources.key?(path) ? features_of(sources[path]) : memoized_features_in(path))
114
+ features, declared = sources.key?(path) ? scan_source(sources[path]) : memoized_scan_in(path)
115
+ found.merge(features)
116
+ names.merge(declared)
73
117
  end
74
118
  prune_memo(paths)
75
- found.to_a.sort.freeze
119
+ relevant(found, names.merge(sig_names(sig_paths))).sort.freeze
76
120
  end
77
121
 
78
- # @return path (absolute) => the features that file requires, for every file under the configured paths
79
- # that requires any. A long-lived reader (the language server) keeps this and overlays its open buffers.
122
+ # @return path (absolute) => `[features, declared names]` ({.scan_source}) for every file under the
123
+ # configured paths that has either. A long-lived reader (the language server) keeps this, overlays its open
124
+ # buffers and passes the union to {.relevant}.
80
125
  def feature_files(configuration)
81
126
  listed = Analysis::PathExpansion.ruby_files(configuration.paths, configuration.exclude_patterns,
82
127
  base: configuration.exclude_base)
83
128
  files = listed.map { |path| File.expand_path(path.to_s) }
84
129
  result = files.each_with_object({}) do |path, acc|
85
- features = memoized_features_in(path)
86
- acc[path] = features unless features.empty?
130
+ scanned = memoized_scan_in(path)
131
+ acc[path] = scanned unless scanned.all?(&:empty?)
87
132
  end
88
133
  prune_memo(files)
89
134
  result
@@ -91,6 +136,42 @@ module Rigor
91
136
  {}
92
137
  end
93
138
 
139
+ # Issue #2174 — `features` with each shipped feature kept only when one of `names` (constant names the
140
+ # project's source or signatures declare a class or module under) is a root the feature's library declares
141
+ # ({RubyLibraryRoots}). Only such a feature can open a class (`RbsLoader#ruby_library_reopen?` asks it of a
142
+ # class every declaration of which is the project's), so dropping the rest changes no diagnostic, and keeps a
143
+ # `require "json"` edit in a project that reopens no library class out of the incremental snapshot's
144
+ # fingerprint and the language server's environment key. The listed features ({VENDORED_DIRS}) are kept.
145
+ def relevant(features, names)
146
+ return features.to_a if features.all? { |feature| VENDORED_DIRS.key?(feature) }
147
+
148
+ segments = names.empty? ? EMPTY_SEGMENTS : library_segments(names)
149
+ features.select { |feature| VENDORED_DIRS.key?(feature) || segments.include?(feature.split("/").first) }
150
+ end
151
+
152
+ # The feature segments of the libraries that declare one of `names` as a root. Loaded lazily: the roots read
153
+ # the `rbs` stdlib tree, which a run with no shipped `require` never needs.
154
+ def library_segments(names)
155
+ require_relative "ruby_library_roots"
156
+ roots = RubyLibraryRoots.shared
157
+ names.each_with_object(Set.new) { |name, acc| acc.merge(roots.fetch(name, EMPTY_SEGMENTS)) }
158
+ end
159
+
160
+ EMPTY_SEGMENTS = [].freeze
161
+
162
+ # The constant names the `.rbs` files under `sig_paths` declare a class or module under.
163
+ def sig_names(sig_paths)
164
+ Array(sig_paths).each_with_object(Set.new) do |root, acc|
165
+ next unless File.directory?(root.to_s)
166
+
167
+ RootedGlob.glob(root.to_s, "**/*.rbs").each do |path|
168
+ acc.merge(declared_names(File.binread(path)))
169
+ rescue SystemCallError, IOError
170
+ next
171
+ end
172
+ end
173
+ end
174
+
94
175
  # Paths are compared expanded, so a relative `--instead-of=lib/a.rb` replaces the configured
95
176
  # `/project/lib/a.rb`; a source whose real path matches (a symlinked directory) replaces it too. The real
96
177
  # path is asked only for a file that shares a source's basename, so a scan does not resolve every path.
@@ -114,8 +195,8 @@ module Rigor
114
195
  # build; a long-lived language server on every rebuild), so on the main Ractor each file's answer is kept
115
196
  # against its stat and the file is re-read only when the stat moved. A non-main Ractor may not touch the
116
197
  # module's state and reads the file every time.
117
- def memoized_features_in(path)
118
- return features_in(path) unless Ractor.current == Ractor.main
198
+ def memoized_scan_in(path)
199
+ return scan_in(path) unless Ractor.current == Ractor.main
119
200
 
120
201
  stat = File.stat(path)
121
202
  stamp = [stat.mtime.to_r, stat.ctime.to_r, stat.size, stat.ino]
@@ -123,13 +204,15 @@ module Rigor
123
204
  cached = memo[path]
124
205
  return cached.last if cached && cached.first == stamp
125
206
 
126
- features = features_in(path)
127
- memo[path] = [stamp, features]
128
- features
207
+ scanned = scan_in(path)
208
+ memo[path] = [stamp, scanned]
209
+ scanned
129
210
  rescue SystemCallError
130
- []
211
+ NOTHING
131
212
  end
132
213
 
214
+ NOTHING = [[].freeze, [].freeze].freeze
215
+
133
216
  # Keeps the memo to the files the latest scan was asked about, so a long-lived language server holds one
134
217
  # entry per project file rather than one per file it ever saw.
135
218
  def prune_memo(paths)
@@ -139,28 +222,51 @@ module Rigor
139
222
  @memo.select! { |path, _| keep.include?(path) }
140
223
  end
141
224
 
142
- # @return the features one file requires.
143
- def features_in(path)
144
- features_of(File.binread(path))
225
+ # @return `[features, declared names]` for one file ({.scan_source}).
226
+ def scan_in(path)
227
+ scan_source(File.binread(path))
145
228
  rescue SystemCallError, IOError
146
- []
229
+ NOTHING
230
+ end
231
+
232
+ # @return `[features, declared names]`: what {.features_of} reads, and the constant names a `class` or
233
+ # `module` header in `source` spells, each segment on its own (`class Net::SMTP` gives `Net` and `SMTP`).
234
+ def scan_source(source)
235
+ [features_of(source), declared_names(source)].freeze
236
+ end
237
+
238
+ # A header on a line of its own, or inside a comment (an rbs-inline `# @rbs!` block declares
239
+ # `# class Matrix`). Over-matching a comment only keeps more features.
240
+ HEADER = /^[ \t]*(?:#[ \t]*)?(?:class|module)[ \t]+(?:::)?([A-Z]\w*(?:::[A-Z]\w*)*)/
241
+
242
+ def declared_names(source)
243
+ bytes = source.b
244
+ return [] unless bytes.include?("class") || bytes.include?("module")
245
+
246
+ bytes.scan(HEADER).flat_map { |(name)| name.split("::") }
247
+ .uniq.map { |name| name.dup.force_encoding(Encoding::UTF_8).freeze }.freeze
147
248
  end
148
249
 
149
- # @return the features `source` requires.
250
+ # @return the features `source` requires: the listed ones ({VENDORED_DIRS}), and, for issue #2174, every
251
+ # feature Ruby ships ({SHIPPED_SEGMENTS}), which `RbsLoader#ruby_library_reopen?` reads to tell a reopened
252
+ # library class from a project class of the same name. Only the listed ones become library tokens.
150
253
  def features_of(source)
151
- # A plain substring test first: almost no file mentions any listed feature, and it is far cheaper than
152
- # the regexp on a file that does not.
153
- return [] unless VENDORED_DIRS.each_key.any? { |feature| source.include?(feature) }
254
+ # A plain substring test first: a file with no `require` at all is the common case, and it is far cheaper
255
+ # than the regexp on a file that has none.
256
+ return [] unless source.include?("require")
154
257
 
155
258
  bytes = source.b
156
259
  named = []
157
260
  position = 0
158
- while (match = PATTERN.match(bytes, position))
261
+ while (match = ANY_FEATURE.match(bytes, position))
159
262
  named << match[2] unless commented?(bytes, match.begin(0))
160
263
  position = match.end(0)
161
264
  end
162
- # The table's own frozen keys, so the result is shareable across Ractors whatever the source's encoding.
163
- VENDORED_DIRS.each_key.select { |feature| named.include?(feature) }
265
+ # The table's own frozen keys, and frozen copies of the rest, so the result is shareable across Ractors
266
+ # whatever the source's encoding.
267
+ listed = VENDORED_DIRS.each_key.select { |feature| named.include?(feature) }
268
+ shipped = named.select { |feature| SHIPPED_SEGMENTS.include?(feature.split("/").first) }
269
+ (listed + shipped.map { |feature| feature.dup.force_encoding(Encoding::UTF_8).freeze }).uniq
164
270
  end
165
271
 
166
272
  # Whether the match at `offset` sits on a line whose first non-blank character is `#`: a whole-line comment.
@@ -176,7 +282,8 @@ module Rigor
176
282
  # The library tokens for `features` and for any listed feature the configuration's `libraries:` names.
177
283
  def tokens(features, configured_libraries)
178
284
  configured = Array(configured_libraries).map(&:to_s).select { |name| VENDORED_DIRS.key?(name) }
179
- (Array(features) | configured).sort.map { |feature| token(feature) }
285
+ listed = Array(features).select { |feature| VENDORED_DIRS.key?(feature) }
286
+ (listed | configured).sort.map { |feature| token(feature) }
180
287
  end
181
288
 
182
289
  def token(feature)
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbs"
4
+ require "rbconfig"
5
+ require_relative "missing_gem_constant_index"
6
+ require_relative "required_features"
7
+ require_relative "../rooted_glob"
8
+
9
+ module Rigor
10
+ class Environment
11
+ # Issue #2174 — which library Ruby ships declares each root constant name. Three sources are read, and no others:
12
+ #
13
+ # - every library of the `rbs` gem's `stdlib/` tree, loaded into this run or not, read from its `.rbs` files
14
+ # (`net-http` declares `Net`);
15
+ # - every top-level `.rb` file of the interpreter's library directory (`RbConfig::CONFIG["rubylibdir"]`), the
16
+ # default gems' and the standard library's entry files, read from their top-level declarations;
17
+ # - the entry file of each gem Ruby bundles ({RequiredFeatures::BUNDLED_GEMS}), found under the interpreter's
18
+ # gem directory. A gem installed there later is not one of them and is not read.
19
+ #
20
+ # Each root maps to the first path segments of the features that load it (`Matrix` → `matrix`, `Net` → `net`),
21
+ # which {RbsLoader#ruby_library_reopen?} intersects with the features the project's source requires
22
+ # ({RequiredFeatures.features_of}).
23
+ #
24
+ # Ownership is read, never guessed by camelizing a library name, for the reason {MissingGemConstantIndex}
25
+ # gives; that module's reader parses the `.rb` entry files. No library code runs. Everything fails open to
26
+ # "no library", which keeps the pre-#2174 reading. Core names (`Array`, `Kernel`) appear too, harmlessly:
27
+ # core signatures always load, so no project-only class carries one.
28
+ #
29
+ # The directories are resolved at load time, on the main Ractor: `RbConfig::CONFIG` and `Gem.default_dir`
30
+ # read non-shareable constants and raise `Ractor::IsolationError` inside a pool worker. The scan itself runs
31
+ # lazily (about 0.1 s), memoised per loader, and only for a class about to be reported.
32
+ module RubyLibraryRoots
33
+ STDLIB_ROOT = begin
34
+ ::RBS::Repository::DEFAULT_STDLIB_ROOT.to_s.dup.freeze
35
+ rescue StandardError
36
+ nil
37
+ end
38
+
39
+ RUBY_LIB_DIR = begin
40
+ RbConfig::CONFIG["rubylibdir"].to_s.dup.freeze
41
+ rescue StandardError
42
+ nil
43
+ end
44
+
45
+ RUBY_GEMS_DIR = begin
46
+ File.join(Gem.default_dir, "gems").freeze
47
+ rescue StandardError
48
+ nil
49
+ end
50
+
51
+ module_function
52
+
53
+ # {.build}, built once per process on the main Ractor, where the language server and the incremental
54
+ # snapshot fingerprint ask it repeatedly; a non-main Ractor may not keep module state and builds its own.
55
+ def shared
56
+ return build unless Ractor.current == Ractor.main
57
+
58
+ @shared ||= build
59
+ end
60
+
61
+ # The frozen map of root constant name to the frozen set of feature segments that load it.
62
+ def build
63
+ roots = Hash.new { |hash, key| hash[key] = Set.new }
64
+ stdlib_roots(roots)
65
+ ruby_lib_roots(roots)
66
+ bundled_gem_roots(roots)
67
+ roots.each_value(&:freeze)
68
+ roots.default_proc = nil
69
+ roots.freeze
70
+ end
71
+
72
+ def stdlib_roots(roots)
73
+ return if STDLIB_ROOT.nil? || !File.directory?(STDLIB_ROOT)
74
+
75
+ Dir.children(STDLIB_ROOT).each do |library|
76
+ segment = library.split("-").first
77
+ RootedGlob.glob(File.join(STDLIB_ROOT, library), "*/**/*.rbs").each do |path|
78
+ stdlib_file_roots(path).each { |root| roots[root] << segment }
79
+ end
80
+ end
81
+ end
82
+
83
+ def stdlib_file_roots(path)
84
+ _, _, decls = ::RBS::Parser.parse_signature(File.read(path))
85
+ decls.filter_map { |decl| root_of(decl) if named_declaration?(decl) }
86
+ rescue StandardError
87
+ []
88
+ end
89
+
90
+ def ruby_lib_roots(roots)
91
+ return if RUBY_LIB_DIR.nil? || !File.directory?(RUBY_LIB_DIR)
92
+
93
+ RootedGlob.glob(RUBY_LIB_DIR, "*.rb").each do |path|
94
+ segment = File.basename(path, ".rb")
95
+ MissingGemConstantIndex.top_level_root_constants(path).each { |root| roots[root] << segment }
96
+ end
97
+ end
98
+
99
+ def bundled_gem_roots(roots)
100
+ return if RUBY_GEMS_DIR.nil? || !File.directory?(RUBY_GEMS_DIR)
101
+
102
+ Dir.children(RUBY_GEMS_DIR).each do |basename|
103
+ gem_name = basename[/\A(.+?)-\d/, 1]
104
+ next if gem_name.nil? || !RequiredFeatures::BUNDLED_GEMS.include?(gem_name)
105
+
106
+ segment = gem_name.split("-").first
107
+ MissingGemConstantIndex.entry_files(File.join(RUBY_GEMS_DIR, basename), gem_name).each do |path|
108
+ MissingGemConstantIndex.top_level_root_constants(path).each { |root| roots[root] << segment }
109
+ end
110
+ end
111
+ end
112
+
113
+ def named_declaration?(decl)
114
+ decl.is_a?(::RBS::AST::Declarations::Class) || decl.is_a?(::RBS::AST::Declarations::Module)
115
+ end
116
+
117
+ def root_of(decl)
118
+ decl.name.to_s.delete_prefix("::").split("::").first
119
+ end
120
+ end
121
+ end
122
+ end
@@ -268,7 +268,7 @@ module Rigor
268
268
  # {Environment::RbsLoader} so constant lookups (and, in later v0.0.9 slices, other reflection
269
269
  # artefacts) consult the cache. Pass `nil` (the default) to skip caching for this environment.
270
270
  # rubocop:disable-next Metrics/MethodLength, Metrics/ParameterLists
271
- def for_project(root: Dir.pwd, libraries: [], signature_paths: nil, cache_store: nil,
271
+ def for_project(root: Dir.pwd, libraries: [], signature_paths: nil, cache_store: nil, # rubocop:disable Metrics/AbcSize
272
272
  plugin_registry: nil, dependency_source_index: nil,
273
273
  rbs_extended_reporter: nil, boundary_cross_reporter: nil,
274
274
  source_rbs_synthesis_reporter: nil,
@@ -364,7 +364,9 @@ module Rigor
364
364
  # Issue #610 — the plugin-contributed subset is loaded LAST and may stand down against a colliding
365
365
  # generic arity. Only the bundled plugins' own `sig/` is deferred: the user's `signature_paths:`,
366
366
  # the bundle walk and `rbs collection install` all stay authoritative.
367
- deferred_signature_paths: plugin_sig_paths
367
+ deferred_signature_paths: plugin_sig_paths,
368
+ project_signature_paths: resolved_paths,
369
+ required_features: required_features || RequiredFeatures.scan(source_files, sig_paths: resolved_paths)
368
370
  )
369
371
  # Issues #824 / #1075 — the rows ADR-112 WD5's consistency rule reports for members the project's
370
372
  # own `sig/` and an inline source both declare: an error per contradiction (or per refinement that
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Inference
5
+ # Issue #2104 — the callee floor's census made transitive. A method that hands its parameter to another method
6
+ # which mutates it (`def stripes(acc) = poke(acc)`, `def poke(acc) = (acc[0] = "x")`) mutates that parameter as
7
+ # surely as one that writes it itself, so the caller's argument is floored after `stripes(acc)` too.
8
+ #
9
+ # This module is the closure over a parameter-flow graph, independent of how the graph is read. `own` answers a
10
+ # def's own census, `{ name => slot }` (a slot is a positional index or a keyword symbol); `edges` answers the
11
+ # {Edge}s along which a def hands one of its parameters to a resolved callee. A node is one parameter of one def,
12
+ # and the walk follows only the edges that hand on the parameter the node is, so a def's hand-offs of its OTHER
13
+ # parameters cost nothing. A node is mutated when its def mutates it or it hands itself to a mutated node: the
14
+ # least fixpoint, so a recursive or mutually recursive chain terminates and a cycle with no mutation on it mutates
15
+ # nothing.
16
+ #
17
+ # The walk reads at most {MAX_NODES} nodes per closure. A hand-off to a node past the cap is read as no hand-off,
18
+ # as the one-level floor reads a callee it cannot resolve: flooring there would drop a literal shape on evidence
19
+ # the walk never saw, and a floored `Array[Dynamic[top]]` then fails a declared tuple on correct code.
20
+ module CalleeMutationChain
21
+ # `name` and `slot` are the forwarding def's parameter, `callee` the def it is handed to, and `callee_names`
22
+ # the callee's parameters it may bind there.
23
+ Edge = Data.define(:name, :slot, :callee, :callee_names)
24
+
25
+ MAX_NODES = 32
26
+
27
+ module_function
28
+
29
+ def close(root, own:, edges:)
30
+ census = own.call(root)
31
+ handed = edges.call(root).reject { |edge| census.key?(edge.name) }
32
+ return census if handed.empty?
33
+
34
+ flow = Flow.new(own, edges)
35
+ handed.each { |edge| flow.visit(root, edge.name) }
36
+ flow.settle
37
+ handed.each_with_object(census.dup) do |edge, result|
38
+ result[edge.name] = edge.slot if flow.mutated?(root, edge.name)
39
+ end
40
+ end
41
+
42
+ # The parameter-flow graph one closure reads: per def, per parameter name, whether the node is mutated and the
43
+ # nodes it hands itself on to.
44
+ class Flow
45
+ Node = Struct.new(:mutated, :successors)
46
+
47
+ def initialize(own, edges)
48
+ @own = own
49
+ @edges = edges
50
+ @nodes = {}.compare_by_identity
51
+ @count = 0
52
+ end
53
+
54
+ def mutated?(def_node, name)
55
+ node = @nodes.dig(def_node, name)
56
+ !node.nil? && node.mutated
57
+ end
58
+
59
+ # Reads the nodes reachable from `def_node`'s `name`, breadth first, while fewer than {MAX_NODES} are read.
60
+ def visit(def_node, name)
61
+ queue = [[def_node, name]]
62
+ until queue.empty?
63
+ current, parameter = queue.shift
64
+ next if known?(current, parameter)
65
+ break if @count >= MAX_NODES
66
+
67
+ queue.concat(add(current, parameter))
68
+ end
69
+ end
70
+
71
+ def settle
72
+ changed = true
73
+ while changed
74
+ changed = false
75
+ each_node do |node|
76
+ next if node.mutated
77
+ next unless node.successors.any? { |callee, name| mutated?(callee, name) }
78
+
79
+ node.mutated = true
80
+ changed = true
81
+ end
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ def known?(def_node, name)
88
+ by_name = @nodes[def_node]
89
+ !by_name.nil? && by_name.key?(name)
90
+ end
91
+
92
+ # Records the node and answers its successors. A node its def mutates needs none.
93
+ def add(def_node, name)
94
+ @count += 1
95
+ mutated = @own.call(def_node).key?(name)
96
+ successors = mutated ? [] : successors_of(def_node, name)
97
+ (@nodes[def_node] ||= {})[name] = Node.new(mutated, successors)
98
+ successors
99
+ end
100
+
101
+ def successors_of(def_node, name)
102
+ @edges.call(def_node).each_with_object([]) do |edge, successors|
103
+ next unless edge.name == name
104
+
105
+ edge.callee_names.each { |callee_name| successors << [edge.callee, callee_name] }
106
+ end
107
+ end
108
+
109
+ def each_node(&)
110
+ @nodes.each_value { |by_name| by_name.each_value(&) }
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "../type"
5
+ require_relative "../source/node_walker"
6
+ require_relative "mutation_rejoin"
7
+ require_relative "receiver_alias"
8
+
9
+ module Rigor
10
+ module Inference
11
+ # A loop hands its collection's own elements to its body (issue #2262), so a body that mutates one in place
12
+ # changes a shape that the collection's binding still carries one level down. This module is the two halves of
13
+ # dropping that shape: whether the body mutates an element, and the type with every shape dropped at every depth.
14
+ module DeepErasure
15
+ module_function
16
+
17
+ # {MutationWidening.widen_tuple} / {MutationWidening.widen_hash_shape} applied at every depth: each `Tuple` /
18
+ # `HashShape` the type holds, as a union member or a type argument, drops to its nominal, so `[[1]]` reads
19
+ # `Array[Array[Integer]]`. A loop's element is the collection's own object, so a body that mutates it in place
20
+ # (`x << 2`) changes a shape one level down that the collection's binding still carries.
21
+ def widen(type)
22
+ case type
23
+ when Type::Tuple then widen_args(MutationWidening.widen_tuple(type))
24
+ when Type::HashShape then widen_args(MutationWidening.widen_hash_shape(type))
25
+ when Type::Nominal then widen_args(type)
26
+ when Type::Difference then widen(type.base)
27
+ when Type::Union then Type::Combinator.union(*type.members.map { |member| widen(member) })
28
+ else Type::Combinator.widen_value_pinned(type)
29
+ end
30
+ end
31
+
32
+ def widen_args(nominal)
33
+ return nominal unless nominal.is_a?(Type::Nominal) && !nominal.type_args.empty?
34
+
35
+ Type::Combinator.nominal_of(nominal.class_name, type_args: nominal.type_args.map { |arg| widen(arg) })
36
+ end
37
+
38
+ # The parameter nodes whose names a block (or a `for` index) binds to the collection's elements.
39
+ ELEMENT_PARAMETER_NODES = [
40
+ Prism::RequiredParameterNode, Prism::OptionalParameterNode, Prism::RestParameterNode,
41
+ Prism::KeywordRestParameterNode, Prism::LocalVariableTargetNode
42
+ ].freeze
43
+ ELEMENT_WRITE_NODES = [
44
+ Prism::IndexOperatorWriteNode, Prism::IndexOrWriteNode, Prism::IndexAndWriteNode,
45
+ Prism::CallOperatorWriteNode, Prism::CallOrWriteNode, Prism::CallAndWriteNode,
46
+ Prism::IndexTargetNode, Prism::CallTargetNode
47
+ ].freeze
48
+ private_constant :ELEMENT_PARAMETER_NODES, :ELEMENT_WRITE_NODES
49
+
50
+ # True when `body` mutates in place a value that a name in `names` (a loop's element) holds: a mutator
51
+ # (`MutationWidening::SHAPE_MUTATORS`) called on a read or an index chain rooted at it (`x << 1`,
52
+ # `x[0].push(1)`, `x[0] = 1`), or an operator write on one (`x[0] += 1`). Every name a block or a
53
+ # `for` inside the body binds counts as an element too (`x.each { |y| y << 1 }`, `for c in x; c << 1; end`),
54
+ # and so does `it` / `_1` wherever it is read, and an accumulator parameter (`inject([]) { |acc, e| acc << e }`):
55
+ # the rule is wider than "an element", which only loses precision. It does not follow an alias (`y = x`) or
56
+ # an element passed to a call (`add(x)`), which the straight-line widening does not either.
57
+ def element_mutation?(body, names)
58
+ return false if body.nil?
59
+
60
+ names |= nested_parameter_names(body)
61
+ Source::NodeWalker.each(body).any? { |node| element_mutation_site?(node, names) }
62
+ end
63
+
64
+ # The names `parameters` (a block's parameter list, or a `for` index) binds.
65
+ def parameter_names(parameters)
66
+ return [] if parameters.nil?
67
+
68
+ Source::NodeWalker.each(parameters).filter_map do |node|
69
+ node.name if ELEMENT_PARAMETER_NODES.include?(node.class)
70
+ end
71
+ end
72
+
73
+ def nested_parameter_names(body)
74
+ Source::NodeWalker.each(body).flat_map do |node|
75
+ case node
76
+ when Prism::BlockNode then parameter_names(node.parameters)
77
+ when Prism::ForNode then parameter_names(node.index)
78
+ else []
79
+ end
80
+ end
81
+ end
82
+
83
+ def element_mutation_site?(node, names)
84
+ if node.is_a?(Prism::CallNode)
85
+ MutationWidening::SHAPE_MUTATORS.include?(node.name) && element_rooted?(node.receiver, names)
86
+ else
87
+ ELEMENT_WRITE_NODES.include?(node.class) && element_rooted?(node.receiver, names)
88
+ end
89
+ end
90
+
91
+ def element_rooted?(node, names)
92
+ node = node.receiver while node.is_a?(Prism::CallNode) && node.receiver
93
+ node = node.body.body.last while node.is_a?(Prism::ParenthesesNode) && node.body.is_a?(Prism::StatementsNode)
94
+ case node
95
+ when Prism::LocalVariableReadNode then names.include?(node.name) || /\A_[1-9]\z/.match?(node.name)
96
+ when Prism::ItLocalVariableReadNode then true
97
+ else false
98
+ end
99
+ end
100
+
101
+ # A block call hands each of its receiver's elements to the block's parameters, and a body that mutates one in
102
+ # place (`a.each { |x| x << 2 }`) changes the shape the receiver's binding still carries one level down
103
+ # (`[[1]]`). The receiver's variables drop their shapes at every depth ({widen}), as a `for` loop's
104
+ # value does ({StatementEvaluator#for_collection_after}).
105
+ def widen_element_aliases(receiver, body, names, scope)
106
+ return scope unless element_mutation?(body, names)
107
+
108
+ widen_receiver(receiver, scope)
109
+ end
110
+
111
+ def widen_receiver(receiver, scope)
112
+ ReceiverAlias.candidates(receiver).reduce(scope) { |acc, read| widen_read(read, acc) }
113
+ end
114
+
115
+ # `a.each(&:strip!)`: a block argument naming a mutator mutates every element it is handed.
116
+ def symbol_proc_mutation?(block)
117
+ block.is_a?(Prism::BlockArgumentNode) && block.expression.is_a?(Prism::SymbolNode) &&
118
+ MutationWidening::SHAPE_MUTATORS.include?(block.expression.unescaped.to_sym)
119
+ end
120
+
121
+ def widen_symbol_proc(call_node, scope) = widen_receiver(call_node.receiver, scope)
122
+
123
+ def widen_read(read, scope)
124
+ getter, builder = MutationWidening::ALIAS_ACCESSORS[read.class]
125
+ return scope if getter.nil?
126
+
127
+ name = ReceiverAlias.read_name(read)
128
+ current = scope.public_send(getter, name)
129
+ return scope if current.nil?
130
+
131
+ widened = widen(current)
132
+ return scope if widened == current
133
+
134
+ MutationRejoin.rebind(scope, builder, name, widened, pre_state: current, kind: getter)
135
+ end
136
+ end
137
+ end
138
+ end