rigortype 0.4.3 → 0.4.5

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 (172) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/data/core_overlay/ostruct.rbs +40 -0
  4. data/docs/handbook/11-sig-gen.md +104 -15
  5. data/docs/manual/02-cli-reference.md +43 -12
  6. data/docs/manual/03-configuration.md +20 -7
  7. data/docs/manual/04-diagnostics.md +56 -3
  8. data/docs/manual/05-inspecting-types.md +2 -2
  9. data/docs/manual/06-baseline.md +1 -1
  10. data/docs/manual/07-plugins.md +3 -2
  11. data/docs/manual/08-skills.md +7 -0
  12. data/docs/manual/10-mcp-server.md +40 -4
  13. data/docs/manual/18-removing-dead-code.md +23 -2
  14. data/docs/manual/plugins/rigor-rails-routes.md +2 -2
  15. data/docs/manual/plugins/rigor-rspec.md +101 -1
  16. data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
  17. data/exe/rigor +12 -17
  18. data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
  19. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +111 -7
  20. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +190 -0
  21. data/lib/rigor/analysis/check_rules/rule_ids.rb +60 -1
  22. data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
  23. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +13 -4
  24. data/lib/rigor/analysis/check_rules.rb +274 -51
  25. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  26. data/lib/rigor/analysis/incremental.rb +61 -0
  27. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
  28. data/lib/rigor/analysis/incremental_session.rb +270 -28
  29. data/lib/rigor/analysis/path_expansion.rb +3 -1
  30. data/lib/rigor/analysis/reachability/graph.rb +111 -14
  31. data/lib/rigor/analysis/reachability/scan.rb +305 -23
  32. data/lib/rigor/analysis/rule_catalog.rb +22 -4
  33. data/lib/rigor/analysis/run_cache_key.rb +31 -7
  34. data/lib/rigor/analysis/run_cache_probe.rb +5 -3
  35. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +48 -12
  36. data/lib/rigor/analysis/runner/pool_coordinator.rb +179 -31
  37. data/lib/rigor/analysis/runner/project_pre_passes.rb +29 -4
  38. data/lib/rigor/analysis/runner.rb +102 -72
  39. data/lib/rigor/analysis/severity_stamp.rb +45 -1
  40. data/lib/rigor/analysis/worker_session.rb +69 -2
  41. data/lib/rigor/cache/descriptor.rb +79 -12
  42. data/lib/rigor/cache/engine_source.rb +3 -2
  43. data/lib/rigor/cache/incremental_snapshot.rb +39 -5
  44. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  45. data/lib/rigor/cache/store.rb +4 -3
  46. data/lib/rigor/cli/command.rb +3 -1
  47. data/lib/rigor/cli/docs_command.rb +3 -2
  48. data/lib/rigor/cli/doctor_command.rb +1 -1
  49. data/lib/rigor/cli/explain_command.rb +3 -2
  50. data/lib/rigor/cli/mcp_command.rb +1 -1
  51. data/lib/rigor/cli/plugin_command.rb +2 -1
  52. data/lib/rigor/cli/plugins_command.rb +2 -1
  53. data/lib/rigor/cli/sig_gen_command.rb +32 -11
  54. data/lib/rigor/cli/skill_command.rb +2 -1
  55. data/lib/rigor/cli/skill_describe.rb +3 -2
  56. data/lib/rigor/cli/unused_command.rb +10 -7
  57. data/lib/rigor/cli.rb +3 -2
  58. data/lib/rigor/config_audit.rb +163 -22
  59. data/lib/rigor/configuration/dependencies.rb +19 -11
  60. data/lib/rigor/configuration/severity_profile.rb +20 -2
  61. data/lib/rigor/configuration.rb +29 -3
  62. data/lib/rigor/effects/signature_sources.rb +3 -1
  63. data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
  64. data/lib/rigor/environment/gated_signature_guard.rb +2 -1
  65. data/lib/rigor/environment/gated_signature_plan.rb +2 -1
  66. data/lib/rigor/environment/installed_gem_set.rb +2 -1
  67. data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
  68. data/lib/rigor/environment/rbs_loader.rb +67 -11
  69. data/lib/rigor/inference/block_call_timing.rb +12 -1
  70. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  71. data/lib/rigor/inference/captured_locals.rb +67 -13
  72. data/lib/rigor/inference/clause_widening.rb +90 -0
  73. data/lib/rigor/inference/definer_resolution.rb +101 -25
  74. data/lib/rigor/inference/expression_typer.rb +94 -9
  75. data/lib/rigor/inference/guard_rebinding.rb +7 -2
  76. data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
  77. data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
  78. data/lib/rigor/inference/macro_block_self_type.rb +20 -6
  79. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +170 -35
  80. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
  81. data/lib/rigor/inference/method_dispatcher.rb +67 -3
  82. data/lib/rigor/inference/module_function_state.rb +213 -51
  83. data/lib/rigor/inference/narrowing.rb +235 -75
  84. data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
  85. data/lib/rigor/inference/project_patched_methods.rb +168 -4
  86. data/lib/rigor/inference/project_patched_mixins.rb +162 -0
  87. data/lib/rigor/inference/project_patched_scanner.rb +51 -12
  88. data/lib/rigor/inference/project_patched_visibility.rb +214 -0
  89. data/lib/rigor/inference/project_seed_tables.rb +120 -0
  90. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
  91. data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
  92. data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
  93. data/lib/rigor/inference/repeated_or_writes.rb +414 -1
  94. data/lib/rigor/inference/return_barrier.rb +16 -0
  95. data/lib/rigor/inference/scope_indexer.rb +1061 -122
  96. data/lib/rigor/inference/self_call_ivar_writes.rb +346 -0
  97. data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
  98. data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
  99. data/lib/rigor/inference/statement_evaluator.rb +512 -150
  100. data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
  101. data/lib/rigor/lens/anchor.rb +3 -3
  102. data/lib/rigor/lens/contract_reader.rb +60 -2
  103. data/lib/rigor/lens/document.rb +4 -3
  104. data/lib/rigor/lens/file_reader.rb +452 -73
  105. data/lib/rigor/lens/members.rb +81 -17
  106. data/lib/rigor/lens/run.rb +73 -4
  107. data/lib/rigor/lens.rb +2 -1
  108. data/lib/rigor/mcp/server.rb +63 -22
  109. data/lib/rigor/plugin/base.rb +3 -2
  110. data/lib/rigor/plugin/box_probe.rb +154 -7
  111. data/lib/rigor/plugin/io_boundary.rb +42 -6
  112. data/lib/rigor/plugin/isolation.rb +4 -3
  113. data/lib/rigor/project_environment.rb +6 -3
  114. data/lib/rigor/protection/discovery_seed.rb +9 -36
  115. data/lib/rigor/protection/mutation_cache.rb +8 -2
  116. data/lib/rigor/rooted_glob.rb +27 -0
  117. data/lib/rigor/scope/discovery_index.rb +5 -0
  118. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
  119. data/lib/rigor/scope/resolution_chain.rb +40 -5
  120. data/lib/rigor/scope.rb +56 -8
  121. data/lib/rigor/sig_gen/alias_index.rb +2 -1
  122. data/lib/rigor/sig_gen/classification.rb +4 -1
  123. data/lib/rigor/sig_gen/generator.rb +167 -22
  124. data/lib/rigor/sig_gen/layout_index.rb +40 -1
  125. data/lib/rigor/sig_gen/method_candidate.rb +8 -0
  126. data/lib/rigor/sig_gen/observation_collector.rb +137 -11
  127. data/lib/rigor/sig_gen/path_mapper.rb +18 -2
  128. data/lib/rigor/sig_gen/renderer.rb +13 -155
  129. data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
  130. data/lib/rigor/sig_gen/write_reporter.rb +203 -0
  131. data/lib/rigor/sig_gen/write_result.rb +3 -1
  132. data/lib/rigor/sig_gen/writer.rb +50 -35
  133. data/lib/rigor/signature_path_audit.rb +4 -2
  134. data/lib/rigor/type/constant.rb +10 -1
  135. data/lib/rigor/version.rb +1 -1
  136. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  137. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +1 -1
  138. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
  139. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
  140. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
  141. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
  142. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
  143. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
  144. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
  145. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
  146. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
  147. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
  148. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +1 -1
  149. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  150. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  151. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +57 -6
  152. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
  153. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
  154. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
  155. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
  156. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
  157. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
  158. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
  159. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
  160. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
  161. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
  162. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
  163. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
  164. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
  165. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
  166. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
  167. data/sig/rigor/plugin/io_boundary.rbs +5 -2
  168. data/sig/rigor/rooted_glob.rbs +7 -0
  169. data/sig/rigor/scope.rbs +4 -3
  170. data/skills/rigor-doctor/references/01-checks.md +23 -11
  171. data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
  172. metadata +16 -1
@@ -3,6 +3,7 @@
3
3
  require "digest"
4
4
 
5
5
  require_relative "access_denied_error"
6
+ require_relative "../rooted_glob"
6
7
 
7
8
  module Rigor
8
9
  module Plugin
@@ -33,6 +34,8 @@ module Rigor
33
34
  # for a plugin that asks "which of these N candidate files exists here?" — rigor-actionpack tries
34
35
  # nine template extensions per `render` call — where N per-path probe rows would put thousands of
35
36
  # validations on every warm run and one listing row puts one per directory.
37
+ # - `#list_directory_kinds(path)` — the KIND-aware listing (#1959): each entry with its `lstat` kind, and one
38
+ # `:kinds` row that moves when an entry appears, vanishes or changes kind under the same name.
36
39
  # - `#open_url(url)` — fetches the URL when the policy permits it (`network_policy: :allowlist` plus an
37
40
  # `allowed_url_hosts` match) and raises {AccessDeniedError} otherwise. v0.1.2 ships the allowlist
38
41
  # surface; the default project policy still has `network_policy: :disabled` so plugins that want network
@@ -189,7 +192,7 @@ module Rigor
189
192
  # @return the absolute paths directly under `path` in `Dir.glob` order; empty when not a directory
190
193
  def list_directory(path)
191
194
  absolute = File.absolute_path(path.to_s)
192
- entries = Dir.glob(File.join(absolute, "*"))
195
+ entries = RootedGlob.glob(absolute, "*")
193
196
  unless @policy.allow_read?(absolute)
194
197
  record_refusal(absolute)
195
198
  return entries
@@ -199,6 +202,34 @@ module Rigor
199
202
  entries
200
203
  end
201
204
 
205
+ # Issue #1959 — the KIND-aware listing. Returns each entry directly under `path` with what it is, a symlink
206
+ # not followed ({Cache::Descriptor::GlobEntry.lstat_kind}: `:file`, `:directory`, `:symlink` or `:other`, and
207
+ # `:absent` for an entry that vanished between the listing and its `lstat`),
208
+ # and records one `:kinds` {Cache::Descriptor::GlobEntry} over `path/*`. The row reads stale when an entry
209
+ # appears, disappears, or keeps its name and changes kind; NOT when an entry's bytes or stat tuple move, nor
210
+ # when anything below a subdirectory changes. It is the row for a walk that decides per top-level entry
211
+ # whether to descend: rigor-ffi skips `vendor/` and does not follow a symlinked directory, so a file `gen`
212
+ # replaced by a directory `gen/`, or a symlink replaced by a real directory, changes what it reads while
213
+ # every name stays put. {#list_directory}'s `:stat` row leaves directories out, and {#glob}'s `:names` row
214
+ # reads names alone.
215
+ #
216
+ # Like {#list_directory}, dot entries are not listed, and an out-of-policy directory is listed truthfully
217
+ # and contributes no row.
218
+ #
219
+ # @param path — project directory; relative paths expand against the working directory
220
+ # @return a Hash from each absolute entry path, in `Dir.glob` order, to its kind; empty when not a directory
221
+ def list_directory_kinds(path)
222
+ absolute = File.absolute_path(path.to_s)
223
+ entries = RootedGlob.glob(absolute, "*").to_h { |entry| [entry, Cache::Descriptor::GlobEntry.lstat_kind(entry)] }
224
+ unless @policy.allow_read?(absolute)
225
+ record_refusal(absolute)
226
+ return entries
227
+ end
228
+
229
+ record_glob_entry(absolute, "*", mode: :kinds)
230
+ entries
231
+ end
232
+
202
233
  # Issue #1652 — the MEMBERSHIP fingerprint of a `Dir.glob` pattern under `root`, `**` included. Returns the
203
234
  # matching paths and records one `:names` {Cache::Descriptor::GlobEntry} over `root` and `pattern`: the row
204
235
  # reads stale when a match appears or disappears, in a subdirectory created after the read too, and NOT
@@ -209,8 +240,13 @@ module Rigor
209
240
  #
210
241
  # The pattern must stay under `root`, since the policy is checked against `root` alone: a `..` segment, a
211
242
  # brace (`{..,x}`) or a backslash escape (`.\\./`) raises {AccessDeniedError} (`:read_outside_scope`).
212
- # The last two are refused outright because `Dir.glob` can spell `..` with them. Out of policy, `root` is
213
- # globbed truthfully and contributes no row, as in {#list_directory}.
243
+ # The last two are refused outright because `Dir.glob` can spell `..` with them. A leading `/` is read under
244
+ # `root`, as the joined spelling always read it (`/lib/**/*.rb` is `root/lib/**/*.rb`). Out of policy, `root`
245
+ # is globbed truthfully and contributes no row, as in {#list_directory}.
246
+ #
247
+ # `root` is read literally (#1959): {RootedGlob} escapes its glob metacharacters before the join, so a root
248
+ # spelled `fx[1]` or a directory named `{..,zz}` neither matches a sibling nor leaves itself.
249
+ # {#list_directory} lists the same way, and {Cache::Descriptor::GlobEntry} re-globs the row the same way.
214
250
  #
215
251
  # @param root — project directory; relative paths expand against the working directory
216
252
  # @param pattern — a `Dir.glob` pattern relative to `root`, such as `"ext/**/extconf.rb"`
@@ -227,7 +263,7 @@ module Rigor
227
263
  )
228
264
  end
229
265
 
230
- entries = Dir.glob(File.join(absolute, pattern))
266
+ entries = RootedGlob.glob(absolute, pattern)
231
267
  unless @policy.allow_read?(absolute)
232
268
  record_refusal(absolute)
233
269
  return entries
@@ -390,8 +426,8 @@ module Rigor
390
426
  self.class.attribute(entry)
391
427
  end
392
428
 
393
- # ADR-45 WD1c (#629) — the listing row for {#list_directory} (and the `:names` row for {#glob}),
394
- # deduplicated per (root, pattern, mode) slot.
429
+ # ADR-45 WD1c (#629) — the listing row for {#list_directory} (and the `:names` row for {#glob} and the
430
+ # `:kinds` row for {#list_directory_kinds}), deduplicated per (root, pattern, mode) slot.
395
431
  # Recomputed on each call rather than `||=`d, so a directory listed again after a mid-run mutation
396
432
  # carries the signature of the state the LAST reader saw.
397
433
  def record_glob_entry(root, pattern, mode: :stat)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "box"
4
+ require_relative "../rooted_glob"
4
5
 
5
6
  module Rigor
6
7
  module Plugin
@@ -74,8 +75,8 @@ module Rigor
74
75
  # requiring the gem, which is what the caller is about to do.
75
76
  def bundle_require_paths(bundle_root)
76
77
  bundle_gem_dirs(bundle_root).flat_map do |gem_home|
77
- specs = Dir.glob(File.join(gem_home, "specifications", "*.gemspec"))
78
- .filter_map { |file| Gem::Specification.load(file) }
78
+ specs = RootedGlob.glob(gem_home, "specifications/*.gemspec")
79
+ .filter_map { |file| Gem::Specification.load(file) }
79
80
  specs.group_by(&:name)
80
81
  .flat_map { |_, versions| versions.max_by(&:version).full_require_paths }
81
82
  end
@@ -88,7 +89,7 @@ module Rigor
88
89
  return [] if bundle_root.nil? || bundle_root.to_s.empty?
89
90
 
90
91
  root = bundle_root.to_s
91
- (Dir.glob(File.join(root, "ruby", "*")) + [root])
92
+ (RootedGlob.glob(root, "ruby/*") + [root])
92
93
  .select { |dir| File.directory?(File.join(dir, "specifications")) }
93
94
  end
94
95
 
@@ -56,9 +56,12 @@ module Rigor
56
56
  # `source_rbs_synthesizer` runs over them at env-build time; an empty list contributes no synthesized RBS.
57
57
  # @param feature_sources — path => source text the required-feature scan reads in place of that file (an
58
58
  # editor buffer bound with `--tmp-file` / `--instead-of`).
59
- # @param project_patched_methods — the `pre_eval:` registry (see {.project_patched_methods}); nil keeps
60
- # the unpatched universe.
61
- def build(configuration:, source_files:, feature_sources: {}, project_patched_methods: nil)
59
+ # @param project_patched_methods — the `pre_eval:` registry. Defaults to the configuration's own (see
60
+ # {.project_patched_methods}), so every probe command (`type-scan`, `trace`, `annotate`, `sig-gen`) types a
61
+ # patched method as `rigor check` does (issue #1895); `type-of` passes its buffer-aware one. An explicit
62
+ # nil keeps the unpatched universe.
63
+ def build(configuration:, source_files:, feature_sources: {},
64
+ project_patched_methods: ProjectEnvironment.project_patched_methods(configuration))
62
65
  features = required_features(configuration, source_files, sources: feature_sources)
63
66
  Environment.for_project(
64
67
  project_patched_methods: project_patched_methods,
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../inference/parameter_inference_collector"
4
4
  require_relative "../inference/scope_indexer"
5
+ require_relative "../inference/project_seed_tables"
5
6
 
6
7
  module Rigor
7
8
  module Protection
@@ -80,47 +81,19 @@ module Rigor
80
81
  seed_tables(index).freeze
81
82
  end
82
83
 
83
- # The whole-project discovery tables, from the single-walk combined pass (one parse per file, both
84
- # collectors driven over the same tree) that {Analysis::Runner::ProjectPrePasses#discover} uses. Empty
85
- # tables are dropped so an empty seed stays `{}` and every consumer's "seed nothing" branch keeps working.
86
- #
87
- # `discovered_class_sources` is carried as the runner carries it on every run: Tier 2 never records edges,
88
- # but an ancestor-order reader asks whether a class is declared in more than one file. The mixin tables
89
- # (`discovered_includes`, `discovered_prepends`, `discovered_extends`) ride the same fold, so a singleton
90
- # `extend` reads the same from a per-file scope under coverage and protection as it does under `check`.
84
+ # The whole-project discovery tables, from the single-walk combined pass (one parse per file) that
85
+ # {Analysis::Runner::ProjectPrePasses#discover} uses, mapped by the one list {Inference::ProjectSeedTables}
86
+ # keeps (issue #1614). Empty tables are dropped so an empty seed stays `{}` and every consumer's "seed nothing"
87
+ # branch keeps working.
91
88
  def discovery_tables(paths)
92
89
  seed_tables(Inference::ScopeIndexer.discovered_project_index_for_paths(paths))
93
90
  end
94
91
 
95
- # Maps a `{ classes:, def_index: }` discovery index onto the {Scope::DiscoveryIndex} slot names both
96
- # consumers take. Shared by the whole-walk ({#discovery_tables}) and bundle-fold ({#tables_for_buffer})
97
- # producers so a mutant's seed and the parent's seed can never disagree about shape.
92
+ # Maps a `{ classes:, def_index: }` discovery index onto the {Scope::DiscoveryIndex} slot names both consumers
93
+ # take. Shared by the whole-walk ({#discovery_tables}) and bundle-fold ({#tables_for_buffer}) producers so a
94
+ # mutant's seed and the parent's seed can never disagree about shape.
98
95
  def seed_tables(index)
99
- def_index = index.fetch(:def_index)
100
- tables = { discovered_classes: index.fetch(:classes) }
101
- %i[
102
- def_nodes def_nestings singleton_def_nodes def_sources singleton_def_sources superclasses includes
103
- extends prepends unpositioned_mixins class_sources
104
- method_visibilities methods parameter_envelopes data_member_layouts struct_member_layouts
105
- deferred_ranges refinements global_write_census
106
- ].each do |slot|
107
- tables[seed_key(slot)] = def_index.fetch(slot)
108
- end
109
- # ADR-119 WD1 — the pairs travel as one: an unpaired empty table is dropped here, a pair only when both
110
- # halves are empty (`compact_pairs`), so a non-empty sibling is never stranded by an empty member.
111
- tables.merge!(def_index.fetch(:siblings))
112
- Scope::DiscoveryIndex.compact_pairs(
113
- tables.reject { |name, table| !Scope::DiscoveryIndex.paired?(name) && (table.nil? || table.empty?) }
114
- )
115
- end
116
-
117
- UNPREFIXED_SLOTS = %i[data_member_layouts struct_member_layouts unpositioned_mixins].freeze
118
- private_constant :UNPREFIXED_SLOTS
119
-
120
- # The def-index slot names match the {Scope::DiscoveryIndex} ones under a `discovered_` prefix, except the
121
- # two member-layout tables and the unpositioned-mixin table, which carry the same name on both sides.
122
- def seed_key(slot)
123
- UNPREFIXED_SLOTS.include?(slot) ? slot : :"discovered_#{slot}"
96
+ Inference::ProjectSeedTables.from_index(index)
124
97
  end
125
98
  end
126
99
  end
@@ -209,7 +209,13 @@ module Rigor
209
209
  def table_digest(project_scan)
210
210
  payload = {
211
211
  "synthetic" => project_scan.synthetic_method_index.to_h,
212
- "patched" => patched_rows(project_scan.project_patched_methods)
212
+ "patched" => patched_rows(project_scan.project_patched_methods),
213
+ "patched_mixins" => project_scan.project_patched_methods.mixins.map do |mixin|
214
+ mixin.to_h.values.map(&:to_s)
215
+ end,
216
+ "patched_overrides" => project_scan.project_patched_methods.overrides.map do |key, visibility|
217
+ [*key, visibility].map(&:to_s)
218
+ end
213
219
  }
214
220
  Digest::SHA256.hexdigest(JSON.generate(payload))
215
221
  rescue StandardError
@@ -221,7 +227,7 @@ module Rigor
221
227
  def patched_rows(patched)
222
228
  patched.by_key.map do |(class_name, method_name, kind), entry|
223
229
  [class_name.to_s, method_name.to_s, kind.to_s, entry.source_path.to_s, entry.source_line,
224
- entry.return_type&.describe(:short).to_s, entry.alias_of.to_s]
230
+ entry.return_type&.describe(:short).to_s, entry.alias_of.to_s, entry.visibility.to_s]
225
231
  end.sort
226
232
  end
227
233
 
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ # `Dir.glob(File.join(root, pattern))` with `root` read as a literal path (#1956, #1959).
5
+ #
6
+ # Joining a directory into the pattern makes `Dir.glob` read the directory's own characters as glob syntax:
7
+ # a project at `ds [x]` or `ds{a}` matches nothing, `ds*` matches its siblings, `fx[1]` matches `fx1`, and
8
+ # a directory literally named `{..,zz}` expands into the parent tree. Each glob metacharacter of `root` is
9
+ # backslash-escaped before the join, so `root` matches only itself while `pattern` keeps exactly the meaning the
10
+ # joined spelling gave it: a leading `/` collapses into the join (`/etc/*` reads `root/etc/*`), and so does one
11
+ # inside a brace alternative (`{/etc,lib}/*` reads `root/etc/*` and `root/lib/*`). `Dir.glob(pattern, base:)`
12
+ # would not do that: it reads an absolute pattern, or an absolute alternative, outside `base`. A root without
13
+ # metacharacters is joined unchanged, so the results are the joined glob's strings in its order, `root` kept as
14
+ # given (relative, symlinked or with a trailing `/`).
15
+ module RootedGlob
16
+ METACHARACTERS = /[\[\]{}*?\\]/
17
+
18
+ module_function
19
+
20
+ # @param root — a directory path (String or Pathname), taken literally
21
+ # @param pattern — a `Dir.glob` pattern relative to `root`
22
+ # @return the matches spelled under `root`, in `Dir.glob` order; empty when `root` is not a directory
23
+ def glob(root, pattern)
24
+ Dir.glob(File.join(root.to_s.gsub(METACHARACTERS, "\\\\\\0"), pattern.to_s))
25
+ end
26
+ end
27
+ end
@@ -231,6 +231,11 @@ module Rigor
231
231
  # (the meta-new walk does not route `Parent` through ancestor discovery), so a reader that must place `K` in a
232
232
  # hierarchy reads its superclass as unknown, as it reads the `nil` the table records for `class K < <expr>`.
233
233
  ENVELOPE_META_SUPERCLASS_MARK = :"<meta-superclass>"
234
+ # Issue #1979 — the kind of a `[ENVELOPE_CONSTRUCTED_KIND, constant_name]` bucket entry: `class K < <expr>` whose
235
+ # superclass expression is a core constructor call (`Data.define(...)`, `Struct.new(...)`, `Class.new(...)`)
236
+ # naming that constant, as the receiver or as `Class.new`'s single bare-constant argument (`Object` for none).
237
+ # Read only by the #1596 proof's subclass walk (`RefinedDispatch::OwnMethod.each_subclass`).
238
+ ENVELOPE_CONSTRUCTED_KIND = :constructed_from
234
239
  ENVELOPE_MODULE_MARK = :"<module>"
235
240
  ENVELOPE_DYNAMIC_MARK = :"<dynamic>"
236
241
  ENVELOPE_OBJECT_EXTENDED_MARK = :"<object-extended>"
@@ -84,6 +84,20 @@ module Rigor
84
84
  false
85
85
  end
86
86
 
87
+ # {.external_lacks?} on the side an entry stands for: an external superclass on a singleton chain
88
+ # (`:singleton`, its whole tail) is asked for a singleton method, an extended module (`:instance`) and every
89
+ # instance-side entry for an instance method.
90
+ def external_lacks_on?(scope, candidates, side, name)
91
+ return external_lacks?(scope, candidates, name) if side == :instance
92
+
93
+ known = candidates.find { |candidate| Rigor::Reflection.rbs_class_known?(candidate, scope: scope) }
94
+ return false if known.nil?
95
+
96
+ Rigor::Reflection.singleton_method_definition(known, name, scope: scope).nil?
97
+ rescue StandardError
98
+ false
99
+ end
100
+
87
101
  # One verdict's working state: the scope, the resolver, and the edges the tests read.
88
102
  class Context
89
103
  attr_reader :mark, :edges
@@ -300,17 +300,24 @@ module Rigor
300
300
  # `Kernel` or `Enumerable` on any reopening. So each candidate name files the negative method edge of every
301
301
  # {Relevance::HOOKS} name on its singleton side (`method:Comparable.included`, the key a new `def
302
302
  # self.included` satisfies); a file adding any other method to the module re-checks nothing, and once the
303
- # module is declared its sites carry later edits and the removal. The edges are a function of the chain alone,
304
- # so a consumer files them once per chain ({Analysis::DependencyRecorder.file_chain_once}, #1590). A hook
305
- # written another way — an instance `def included` in a module extended onto the external, a
306
- # `define_singleton_method(:included)` — satisfies no such key and is not seen.
303
+ # module is declared its sites carry later edits and the removal. #1661 — each name also files `mixin:<name>`,
304
+ # which `IncrementalSession#mark_affected` fires when the module's unpositioned mixin edges move: a new file
305
+ # that taints it (`module Comparable; include(*MODS); end`) without a hook. The key is the qualified candidate,
306
+ # the spelling the chain resolves a project entry by. The unpositioned table may key the owner by a relative
307
+ # spelling (`Constants` inside `class File`), so the producer also fires every recorded `mixin:` key ending in
308
+ # `::<owner>` (`IncrementalSession#mixin_keys`); a taint on a project `Foo::Base` still re-checks no reader of
309
+ # an external `Lib::Base`. An ambiguous spelling has no candidate and files none: no project module can resolve
310
+ # it, so no taint can mark it. The edges are a function of the chain alone, so a consumer files them once per
311
+ # chain ({Analysis::DependencyRecorder.file_chain_once}, #1590). A hook written another way — an instance `def
312
+ # included` in a module extended onto the external, a `define_singleton_method(:included)` — satisfies no such
313
+ # key and is not seen.
307
314
  def record_hooks(scope)
308
315
  externals = (@memo[:externals] ||= @entries.select(&:external?).freeze)
309
316
  return if externals.empty?
310
317
 
311
318
  Analysis::DependencyRecorder.file_chain_once(self, :hooks, scope.discovery) do
312
319
  externals.each do |entry|
313
- entry.candidates.each { |name| Analysis::DependencyRecorder.read_keys(ResolutionChain.hook_keys(name)) }
320
+ entry.candidates.each { |name| Analysis::DependencyRecorder.read_keys(ResolutionChain.external_keys(name)) }
314
321
  end
315
322
  end
316
323
  end
@@ -393,12 +400,18 @@ module Rigor
393
400
  end
394
401
 
395
402
  @hook_keys = {}
403
+ @external_keys = {}
396
404
 
397
405
  # The frozen `method:Name.hook` negative keys of `name`'s {Relevance::HOOKS}, built once per name.
398
406
  def self.hook_keys(name)
399
407
  @hook_keys[name] ||= Relevance::HOOKS.map { |hook| "method:#{name}.#{hook}".freeze }.freeze
400
408
  end
401
409
 
410
+ # {hook_keys} plus the `mixin:<name>` key an external entry files for its unpositioned mixins (#1661).
411
+ def self.external_keys(name)
412
+ @external_keys[name] ||= (hook_keys(name) + ["mixin:#{name}".freeze]).freeze
413
+ end
414
+
402
415
  def record_head(scope, start, side)
403
416
  ResolutionChain.record_class(scope, @root) unless start.zero? && @entries.first&.name == @root
404
417
  start.times { |index| ResolutionChain.record_entry(scope, @entries[index], side) }
@@ -440,6 +453,28 @@ module Rigor
440
453
  @entries[@level_starts[index]...finish]
441
454
  end
442
455
 
456
+ # Issue #1624 — whether the chain's last class has a superclass the tables cannot name, so ancestors the chain
457
+ # does not list follow it where a chain otherwise ends in the implicit `Object`: a superclass written as an
458
+ # expression (`class K < Struct.new(:a)`, `< Data.define(:a)`, `< Class.new(Base)`, `< pick_base`), which the
459
+ # superclass table records as `nil`, or a class a constant write creates (`K = Class.new(Base)`,
460
+ # `K = Struct.new(:a)`, `K = Data.define(:a)`), which has no superclass entry. The constructor's methods
461
+ # (`members`, the member readers, a block's `def`s) and everything it inherits live in that unlisted part.
462
+ # Every fact read here is filed under the last class's class edge, which a read reaching the tail has passed.
463
+ def opaque_tail?(scope)
464
+ @memo.fetch(:opaque_tail) do
465
+ last = level_count.positive? ? @level_classes[level_count - 1] : nil
466
+ @memo[:opaque_tail] = !last.nil? && ResolutionChain.opaque_superclass?(scope, last)
467
+ end
468
+ end
469
+
470
+ def self.opaque_superclass?(scope, name)
471
+ superclasses = scope.discovery.discovered_superclasses
472
+ return superclasses[name].nil? if superclasses.key?(name)
473
+
474
+ scope.discovery.discovered_parameter_envelopes[name]&.key?(DiscoveryIndex::ENVELOPE_META_SUPERCLASS_MARK) ||
475
+ !(scope.struct_member_layout(name) || scope.data_member_layout(name)).nil?
476
+ end
477
+
443
478
  # Each complete level up to the first superclass the project does not declare, as `[class_name,
444
479
  # modules, externals]`: the level's class, the project modules around it (each once), and the candidate
445
480
  # lists of the ancestors in it the project does not declare — the shape `call.wrong-arity` reads.
data/lib/rigor/scope.rb CHANGED
@@ -876,13 +876,18 @@ module Rigor
876
876
  STDOUT_ALIASES = { :$stdout => :$>, :$> => :$stdout }.freeze
877
877
  private_constant :STDOUT_ALIASES
878
878
 
879
- # Issue #1429 — {#forget_guard_narrowings} for the one name `[kind, name]`.
879
+ # Issue #1429 — {#forget_guard_narrowings} for the one name `[kind, name]`. Issue #1596 — an `:ivar` kind too,
880
+ # for a self-call proven to write only some instance variables.
880
881
  def forget_guard_narrowing_of(kind, name)
881
882
  key = [kind, name]
882
883
  pre_guard = @guard_records[key]
883
884
  return self if pre_guard.nil?
884
885
 
885
- if kind == :global
886
+ if kind == :ivar
887
+ current = @ivars[name]
888
+ ivars = current ? @ivars.merge(name => Type::Combinator.union(pre_guard, current)).freeze : @ivars
889
+ rebuild(ivars: ivars, guard_records: @guard_records.except(key).freeze)
890
+ elsif kind == :global
886
891
  current = @globals[name]
887
892
  globals = current ? @globals.merge(name => Type::Combinator.union(pre_guard, current)).freeze : @globals
888
893
  rebuild(globals: globals, guard_records: @guard_records.except(key).freeze)
@@ -1208,13 +1213,28 @@ module Rigor
1208
1213
  # wrong-arity rules consult this map to suppress diagnostics for methods the user has defined dynamically,
1209
1214
  # even when no RBS sig describes them.
1210
1215
  # A name defined on both sides of one class records {DiscoveryIndex::METHOD_KIND_BOTH} and matches either kind.
1216
+ #
1217
+ # Issue #2121 — a hit is an ADR-46 read. The table carries what no `def` symbol edge covers — an `attr_*`, an
1218
+ # `alias` or a `define_method` (a cross-file `def` is withheld from it) — so an answer that read a hit records
1219
+ # the class edge to the files declaring `class_name` and the name edge `defines:<method_name>`: deleting the
1220
+ # `attr_accessor` re-checks the caller it silenced, whether or not the deleting file declares the class. A miss
1221
+ # records nothing here; the readers that act on one record their own negative edges.
1211
1222
  def discovered_method?(class_name, method_name, kind)
1212
1223
  table = @discovery.discovered_methods[class_name.to_s]
1213
1224
  return false unless table
1214
1225
 
1215
1226
  recorded = table[method_name.to_sym]
1216
- recorded == kind || recorded == DiscoveryIndex::METHOD_KIND_BOTH
1227
+ return false unless recorded == kind || recorded == DiscoveryIndex::METHOD_KIND_BOTH
1228
+
1229
+ record_discovered_method(class_name, method_name) if Analysis::DependencyRecorder.active?
1230
+ true
1231
+ end
1232
+
1233
+ def record_discovered_method(class_name, method_name)
1234
+ record_class_dependency(class_name)
1235
+ Analysis::DependencyRecorder.read_name(:defines, method_name)
1217
1236
  end
1237
+ private :record_discovered_method
1218
1238
 
1219
1239
  # ADR-34 § "Decision" — predicate identifying a toplevel-shaped scope (no enclosing `class` / `module` body).
1220
1240
  # True at the top of a file AND inside a top-level `def` body (since toplevel defs leave `self_type` nil per
@@ -1405,6 +1425,27 @@ module Rigor
1405
1425
  call_node)
1406
1426
  end
1407
1427
 
1428
+ # Issue #1569 — whether `def_node`, written in this file, is a module function of `class_name`: a `:both`
1429
+ # deferred-range row over the def (a bare `module_function` before it, or `module_function def x`;
1430
+ # `ModuleFunctionState.prescan_deferred`), or a `:singleton` row for the name after the def
1431
+ # (`module_function :x`) whose singleton copy is this def (`ModuleFunctionState.each_singleton_copy`).
1432
+ def module_function_def?(class_name, def_node)
1433
+ path = source_path
1434
+ return false if path.nil?
1435
+
1436
+ ranges = discovered_deferred_ranges[path]
1437
+ return false if ranges.nil?
1438
+
1439
+ start = def_node.location.start_offset
1440
+ ranges.any? do |(row_start, _finish, name, kind, owner)|
1441
+ next false unless name == def_node.name && owner == class_name.to_s
1442
+
1443
+ (kind == :both && row_start == start) ||
1444
+ (kind == :singleton && row_start > start &&
1445
+ singleton_def_for(class_name, name)&.location&.start_offset == start)
1446
+ end
1447
+ end
1448
+
1408
1449
  # Shared ordering half of the two `*_def_shadows_call?` predicates, over this file's
1409
1450
  # `discovered_deferred_ranges` (issue #1097). `exists` — a recorded `"path:line"` site or the
1410
1451
  # method-existence table — says a def of `method_name`/`kind` on `class_name` is known at all; a
@@ -1912,8 +1953,15 @@ module Rigor
1912
1953
  # Issue #992 — one class's `discovered_parameter_envelopes` bucket (`{[kind, method_name] => envelope}` plus
1913
1954
  # the class-wide marks), or an empty Hash. Records the ADR-46 class edge first: a verdict read off the
1914
1955
  # bucket depends on every file that declares the class, and a `memoize :f` added to one of them moves it.
1956
+ #
1957
+ # Issue #1833 — and the name edge `mark:<last segment>`: a file that does not declare the class may still mark it
1958
+ # (`x.singleton_class.prepend(Comparable)`, `o.extend(Helper)` in a method body, a `refine` block), and
1959
+ # `IncrementalSession#mark_affected` fires the key when such a mark appears or vanishes.
1915
1960
  def parameter_envelopes_of(class_name)
1916
- record_class_dependency(class_name) if Analysis::DependencyRecorder.active?
1961
+ if Analysis::DependencyRecorder.active?
1962
+ record_class_dependency(class_name)
1963
+ Analysis::DependencyRecorder.read_last_segment(:mark, class_name.to_s)
1964
+ end
1917
1965
  @discovery.discovered_parameter_envelopes[class_name.to_s] || EMPTY_PARAMETER_ENVELOPES
1918
1966
  end
1919
1967
 
@@ -1921,10 +1969,10 @@ module Rigor
1921
1969
  private_constant :EMPTY_PARAMETER_ENVELOPES
1922
1970
 
1923
1971
  # v0.1.2 — per-class table mapping `method_name (Symbol) → :public | :private | :protected`. Populated by
1924
- # `ScopeIndexer` for every `def` it sees inside a class body, with the visibility taken from the surrounding
1925
- # `private` / `protected` / `public` modifier state plus any post-hoc `private :name, ...` named-argument
1926
- # calls. Consumed by the `def.method-visibility-mismatch` rule so explicit-non-self calls to a private method
1927
- # surface a diagnostic.
1972
+ # `ScopeIndexer` for every `def` and `attr_*` member it sees inside a class body, with the visibility taken from
1973
+ # a wrapping `private def` / `private attr_reader` call, else the surrounding `private` / `protected` / `public`
1974
+ # modifier state, plus any post-hoc `private :name, ...` named-argument calls. Consumed by the
1975
+ # `def.method-visibility-mismatch` rule so explicit-non-self calls to a private method surface a diagnostic.
1928
1976
  def discovered_method_visibility(class_name, method_name)
1929
1977
  table = @discovery.discovered_method_visibilities[class_name.to_s]
1930
1978
  return nil unless table
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../inference/rbs_type_translator"
4
4
  require_relative "type_elaborator"
5
+ require_relative "../rooted_glob"
5
6
 
6
7
  module Rigor
7
8
  module SigGen
@@ -101,7 +102,7 @@ module Rigor
101
102
  dir = path.is_a?(Pathname) ? path : Pathname(path.to_s)
102
103
  next [] unless dir.directory?
103
104
 
104
- Dir.glob(dir.join("**", "*.rbs").to_s).map { |p| File.expand_path(p) }
105
+ RootedGlob.glob(dir, "**/*.rbs").map { |p| File.expand_path(p) }
105
106
  end.to_set
106
107
  end
107
108
 
@@ -60,7 +60,10 @@ module Rigor
60
60
  inline_differs: "sig.skipped.inline-differs",
61
61
  # #1676 — a plain top-level `def` defines a private `Object` method and has no class to write a
62
62
  # declaration under, so it is reported rather than silently dropped. Whether to emit it stays open.
63
- top_level_def: "sig.skipped.top-level-def"
63
+ top_level_def: "sig.skipped.top-level-def",
64
+ # #1968 — a bare `public` / `private` / `protected` that may not run (in control flow or a block) sits
65
+ # between a bare `module_function` and the def, so whether the def is a module function is unknown.
66
+ uncertain_module_function: "sig.skipped.uncertain-module-function"
64
67
  }.freeze
65
68
  end
66
69
  end