rigortype 0.4.4 → 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 (147) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/docs/handbook/11-sig-gen.md +90 -17
  4. data/docs/manual/02-cli-reference.md +39 -9
  5. data/docs/manual/03-configuration.md +10 -8
  6. data/docs/manual/04-diagnostics.md +16 -5
  7. data/docs/manual/05-inspecting-types.md +2 -2
  8. data/docs/manual/06-baseline.md +1 -1
  9. data/docs/manual/07-plugins.md +2 -1
  10. data/docs/manual/08-skills.md +7 -0
  11. data/docs/manual/10-mcp-server.md +8 -1
  12. data/docs/manual/18-removing-dead-code.md +18 -7
  13. data/docs/manual/plugins/rigor-rspec.md +101 -1
  14. data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
  15. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +111 -7
  16. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +190 -0
  17. data/lib/rigor/analysis/check_rules/rule_ids.rb +3 -1
  18. data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
  19. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +13 -4
  20. data/lib/rigor/analysis/check_rules.rb +228 -15
  21. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  22. data/lib/rigor/analysis/incremental.rb +49 -0
  23. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
  24. data/lib/rigor/analysis/incremental_session.rb +166 -24
  25. data/lib/rigor/analysis/path_expansion.rb +3 -1
  26. data/lib/rigor/analysis/reachability/graph.rb +90 -13
  27. data/lib/rigor/analysis/reachability/scan.rb +279 -39
  28. data/lib/rigor/analysis/rule_catalog.rb +17 -4
  29. data/lib/rigor/analysis/runner/pool_coordinator.rb +69 -16
  30. data/lib/rigor/analysis/runner.rb +69 -65
  31. data/lib/rigor/analysis/severity_stamp.rb +11 -0
  32. data/lib/rigor/analysis/worker_session.rb +12 -1
  33. data/lib/rigor/cache/descriptor.rb +40 -7
  34. data/lib/rigor/cache/engine_source.rb +3 -2
  35. data/lib/rigor/cache/incremental_snapshot.rb +26 -8
  36. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  37. data/lib/rigor/cache/store.rb +4 -3
  38. data/lib/rigor/cli/command.rb +3 -1
  39. data/lib/rigor/cli/docs_command.rb +3 -2
  40. data/lib/rigor/cli/doctor_command.rb +1 -1
  41. data/lib/rigor/cli/explain_command.rb +3 -2
  42. data/lib/rigor/cli/plugin_command.rb +2 -1
  43. data/lib/rigor/cli/plugins_command.rb +2 -1
  44. data/lib/rigor/cli/sig_gen_command.rb +32 -11
  45. data/lib/rigor/cli/skill_command.rb +2 -1
  46. data/lib/rigor/cli/skill_describe.rb +3 -2
  47. data/lib/rigor/cli/unused_command.rb +10 -7
  48. data/lib/rigor/cli.rb +3 -2
  49. data/lib/rigor/config_audit.rb +60 -15
  50. data/lib/rigor/configuration/severity_profile.rb +20 -2
  51. data/lib/rigor/configuration.rb +29 -3
  52. data/lib/rigor/effects/signature_sources.rb +3 -1
  53. data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
  54. data/lib/rigor/environment/gated_signature_guard.rb +2 -1
  55. data/lib/rigor/environment/gated_signature_plan.rb +2 -1
  56. data/lib/rigor/environment/installed_gem_set.rb +2 -1
  57. data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
  58. data/lib/rigor/environment/rbs_loader.rb +21 -1
  59. data/lib/rigor/inference/block_call_timing.rb +7 -1
  60. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  61. data/lib/rigor/inference/captured_locals.rb +44 -13
  62. data/lib/rigor/inference/clause_widening.rb +90 -0
  63. data/lib/rigor/inference/definer_resolution.rb +87 -20
  64. data/lib/rigor/inference/expression_typer.rb +35 -5
  65. data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
  66. data/lib/rigor/inference/macro_block_self_type.rb +20 -6
  67. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +170 -35
  68. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
  69. data/lib/rigor/inference/method_dispatcher.rb +67 -3
  70. data/lib/rigor/inference/module_function_state.rb +137 -42
  71. data/lib/rigor/inference/narrowing.rb +54 -10
  72. data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
  73. data/lib/rigor/inference/project_patched_methods.rb +168 -4
  74. data/lib/rigor/inference/project_patched_mixins.rb +162 -0
  75. data/lib/rigor/inference/project_patched_scanner.rb +51 -12
  76. data/lib/rigor/inference/project_patched_visibility.rb +214 -0
  77. data/lib/rigor/inference/project_seed_tables.rb +120 -0
  78. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
  79. data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
  80. data/lib/rigor/inference/reflective_ivar_write.rb +6 -6
  81. data/lib/rigor/inference/return_barrier.rb +16 -0
  82. data/lib/rigor/inference/scope_indexer.rb +675 -105
  83. data/lib/rigor/inference/self_call_ivar_writes.rb +11 -6
  84. data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
  85. data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
  86. data/lib/rigor/inference/statement_evaluator.rb +446 -139
  87. data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
  88. data/lib/rigor/lens/contract_reader.rb +45 -2
  89. data/lib/rigor/lens/document.rb +2 -1
  90. data/lib/rigor/lens/file_reader.rb +12 -2
  91. data/lib/rigor/lens/members.rb +16 -2
  92. data/lib/rigor/lens/run.rb +34 -7
  93. data/lib/rigor/mcp/server.rb +2 -2
  94. data/lib/rigor/plugin/base.rb +3 -2
  95. data/lib/rigor/plugin/io_boundary.rb +42 -6
  96. data/lib/rigor/plugin/isolation.rb +4 -3
  97. data/lib/rigor/protection/discovery_seed.rb +9 -36
  98. data/lib/rigor/protection/mutation_cache.rb +8 -2
  99. data/lib/rigor/rooted_glob.rb +27 -0
  100. data/lib/rigor/scope/discovery_index.rb +5 -0
  101. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
  102. data/lib/rigor/scope/resolution_chain.rb +40 -5
  103. data/lib/rigor/scope.rb +45 -2
  104. data/lib/rigor/sig_gen/alias_index.rb +2 -1
  105. data/lib/rigor/sig_gen/classification.rb +4 -1
  106. data/lib/rigor/sig_gen/generator.rb +167 -22
  107. data/lib/rigor/sig_gen/layout_index.rb +5 -2
  108. data/lib/rigor/sig_gen/observation_collector.rb +137 -11
  109. data/lib/rigor/sig_gen/renderer.rb +13 -155
  110. data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
  111. data/lib/rigor/sig_gen/write_reporter.rb +203 -0
  112. data/lib/rigor/sig_gen/write_result.rb +3 -1
  113. data/lib/rigor/sig_gen/writer.rb +5 -3
  114. data/lib/rigor/signature_path_audit.rb +4 -2
  115. data/lib/rigor/type/constant.rb +10 -1
  116. data/lib/rigor/version.rb +1 -1
  117. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  118. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +1 -1
  119. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
  120. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
  121. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
  122. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
  123. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
  124. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
  125. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
  126. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
  127. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
  128. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +1 -1
  129. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  130. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +38 -9
  131. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
  132. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
  133. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
  134. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
  135. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
  136. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
  137. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
  138. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
  139. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
  140. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
  141. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
  142. data/sig/rigor/plugin/io_boundary.rbs +5 -2
  143. data/sig/rigor/rooted_glob.rbs +7 -0
  144. data/sig/rigor/scope.rbs +4 -3
  145. data/skills/rigor-doctor/references/01-checks.md +23 -11
  146. data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
  147. metadata +13 -1
@@ -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
@@ -1213,13 +1213,28 @@ module Rigor
1213
1213
  # wrong-arity rules consult this map to suppress diagnostics for methods the user has defined dynamically,
1214
1214
  # even when no RBS sig describes them.
1215
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.
1216
1222
  def discovered_method?(class_name, method_name, kind)
1217
1223
  table = @discovery.discovered_methods[class_name.to_s]
1218
1224
  return false unless table
1219
1225
 
1220
1226
  recorded = table[method_name.to_sym]
1221
- 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)
1222
1236
  end
1237
+ private :record_discovered_method
1223
1238
 
1224
1239
  # ADR-34 § "Decision" — predicate identifying a toplevel-shaped scope (no enclosing `class` / `module` body).
1225
1240
  # True at the top of a file AND inside a top-level `def` body (since toplevel defs leave `self_type` nil per
@@ -1410,6 +1425,27 @@ module Rigor
1410
1425
  call_node)
1411
1426
  end
1412
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
+
1413
1449
  # Shared ordering half of the two `*_def_shadows_call?` predicates, over this file's
1414
1450
  # `discovered_deferred_ranges` (issue #1097). `exists` — a recorded `"path:line"` site or the
1415
1451
  # method-existence table — says a def of `method_name`/`kind` on `class_name` is known at all; a
@@ -1917,8 +1953,15 @@ module Rigor
1917
1953
  # Issue #992 — one class's `discovered_parameter_envelopes` bucket (`{[kind, method_name] => envelope}` plus
1918
1954
  # the class-wide marks), or an empty Hash. Records the ADR-46 class edge first: a verdict read off the
1919
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.
1920
1960
  def parameter_envelopes_of(class_name)
1921
- 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
1922
1965
  @discovery.discovered_parameter_envelopes[class_name.to_s] || EMPTY_PARAMETER_ENVELOPES
1923
1966
  end
1924
1967
 
@@ -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
@@ -8,6 +8,7 @@ require_relative "../project_environment"
8
8
  require_relative "../scope"
9
9
  require_relative "../reflection"
10
10
  require_relative "../type"
11
+ require_relative "../inference/reflective_ivar_write"
11
12
  require_relative "../source/constant_path"
12
13
  require_relative "../source/literals"
13
14
  require_relative "../source/node_children"
@@ -25,6 +26,7 @@ require_relative "inline_declarations"
25
26
  require_relative "layout_index"
26
27
  require_relative "method_candidate"
27
28
  require_relative "writer"
29
+ require_relative "../rooted_glob"
28
30
 
29
31
  module Rigor
30
32
  module SigGen
@@ -98,6 +100,7 @@ module Rigor
98
100
  # (`build_candidate`, `method_def_prefix`) a never-nil invariant without per-call-site defensive guards.
99
101
  @namespace_kinds = {}
100
102
  @module_function_methods = Set.new
103
+ @uncertain_module_function_defs = Set.new.compare_by_identity
101
104
  @class_shells = Set.new
102
105
  @class_superclasses = {}
103
106
  @meta_layouts = {}
@@ -446,7 +449,7 @@ module Rigor
446
449
  def resolve_paths(args)
447
450
  args.flat_map do |arg|
448
451
  if File.directory?(arg)
449
- Dir.glob(File.join(arg, "**/*.rb"), sort: true)
452
+ RootedGlob.glob(arg, "**/*.rb")
450
453
  elsif File.file?(arg) && arg.end_with?(".rb")
451
454
  [arg]
452
455
  else
@@ -463,10 +466,7 @@ module Rigor
463
466
  base_scope = Scope.empty(environment: environment)
464
467
  scope_index = Inference::ScopeIndexer.index(parse_result.value, default_scope: base_scope)
465
468
 
466
- @namespace_kinds = {}
467
- @module_function_methods = Set.new
468
- @class_shells = Set.new
469
- @class_superclasses = {}
469
+ reset_file_maps
470
470
  @meta_layouts = collect_meta_layouts(scope_index)
471
471
  register_meta_classes
472
472
  defs = collect_method_definitions(parse_result.value, scope_index)
@@ -488,6 +488,15 @@ module Rigor
488
488
  collect_attr_candidates(parse_result.value, path, scope_index, obs_ivar_map)
489
489
  end
490
490
 
491
+ # The per-file scratch state {#analyse_file} starts each file with.
492
+ def reset_file_maps
493
+ @namespace_kinds = {}
494
+ @module_function_methods = Set.new
495
+ @uncertain_module_function_defs = Set.new.compare_by_identity
496
+ @class_shells = Set.new
497
+ @class_superclasses = {}
498
+ end
499
+
491
500
  # Walks the AST collecting `(def_node, class_name, kind)` tuples for every `def` Rigor can re-type. Slice 1
492
501
  # covered instance `def foo` methods inside a nameable `class` / `module` body. Slice 4 extends this to
493
502
  # singleton-side methods via `def self.foo` and `class << self; def foo; end`. A plain top-level `def` is
@@ -732,6 +741,8 @@ module Rigor
732
741
  class_name = prefix.join("::")
733
742
  if Inference::ModuleFunctionState.sig_gen_module_function?(module_function_active, kind)
734
743
  @module_function_methods << [class_name, node.name]
744
+ elsif Inference::ModuleFunctionState.sig_gen_uncertain?(module_function_active, kind)
745
+ @uncertain_module_function_defs << node
735
746
  end
736
747
  out << [node, class_name, kind]
737
748
  end
@@ -783,9 +794,13 @@ module Rigor
783
794
  # private methods are implementation details, not part of the type contract downstream consumers (Steep,
784
795
  # IDE, gem users) read. The default now skips private and protected methods; the `:include_private` flag
785
796
  # restores the slice-4 behaviour for callers that want every method.
797
+ #
798
+ # A module function renders as `def self?.name`, which RBS reads as a public singleton method and a private
799
+ # instance method, so a `private` written before the `module_function` toggle does not exclude it (#1968).
786
800
  def visibility_excludes?(def_node, class_name, kind, scope_index)
787
801
  return false if kind == :singleton
788
802
  return false if @include_private
803
+ return false if @module_function_methods.include?([class_name, def_node.name])
789
804
 
790
805
  scope = scope_index[def_node] || scope_index.each_value.first
791
806
  return false if scope.nil?
@@ -960,6 +975,9 @@ module Rigor
960
975
  # #1676 — a top-level `def` is collected with no class; it is reported, never typed.
961
976
  def classify_collected_def(path, def_node, class_name, kind, scope_index)
962
977
  return skipped(path, def_node, class_name, kind, :top_level_def) if class_name.nil?
978
+ if @uncertain_module_function_defs.include?(def_node)
979
+ return skipped(path, def_node, class_name, kind, :uncertain_module_function)
980
+ end
963
981
 
964
982
  classify_def(path, def_node, class_name, kind, scope_index)
965
983
  end
@@ -1421,12 +1439,17 @@ module Rigor
1421
1439
  # same position a method with no declaration at all is in. Over a declaration the author wrote, though, a
1422
1440
  # VALUE literal the body proves (`-> 1`, `-> "x"`, `-> :k`) is an implementation detail the `untyped` was
1423
1441
  # abstracting over, so it is widened to its class (`1 | 2` to `Integer`, `"S" | nil` to `(String | nil)`; #1489).
1424
- # `nil`, `true` and `false` are honest types and stay, as does a computed `Float`. An `untyped` that is only
1425
- # the inline reader's synthesis ({#synthesized_declaration?}) is no word of the author's and keeps the
1426
- # strictest carrier. `declared_return_rbs` carries `"untyped"` so the `[tighter, was: untyped]` print tag
1427
- # says a declaration existed.
1442
+ # `true` and `false` widen to `bool` (#1891): a predicate's base `-> true` made every subclass override
1443
+ # returning `false` a `def.override-return-widened`. A return that is only `nil` has no such widening — it is
1444
+ # the shape of a template method a subclass fills in — so it is declined and the `untyped` stays. `nil` in a
1445
+ # union (`T?`) and a computed `Float` are honest types and stay. An `untyped` that is only the inline reader's
1446
+ # synthesis ({#synthesized_declaration?}) is no word of the author's and keeps the strictest carrier.
1447
+ # `declared_return_rbs` carries `"untyped"` so the `[tighter, was: untyped]` print tag says a declaration
1448
+ # existed.
1428
1449
  def declared_untyped_candidate(path, def_node, class_name, kind, inferred, method_def, rendered: nil)
1429
1450
  unless synthesized_declaration?(method_def)
1451
+ return equivalent(path, def_node, class_name, kind, inferred, "untyped") if bare_nil?(inferred)
1452
+
1430
1453
  widened = widen_value_literals(inferred)
1431
1454
  unless widened.equal?(inferred)
1432
1455
  # A `rendered` line was spelled from the unwidened type; there is nothing to rebuild it from.
@@ -1446,6 +1469,7 @@ module Rigor
1446
1469
  def widen_value_literals(type)
1447
1470
  case type
1448
1471
  when Type::Constant
1472
+ return widen_boolean_literal if [true, false].include?(type.value)
1449
1473
  return type unless VALUE_LITERAL_CLASSES.any? { |klass| type.value.instance_of?(klass) }
1450
1474
 
1451
1475
  Type::Combinator.nominal_of(type.value.class.name)
@@ -1457,6 +1481,14 @@ module Rigor
1457
1481
  end
1458
1482
  end
1459
1483
 
1484
+ def widen_boolean_literal
1485
+ Type::Combinator.union(Type::Combinator.constant_of(true), Type::Combinator.constant_of(false))
1486
+ end
1487
+
1488
+ def bare_nil?(type)
1489
+ type.is_a?(Type::Constant) && type.value.nil?
1490
+ end
1491
+
1460
1492
  # A declaration only the inline reader's synthesis (a `virtual:` buffer) supplies — ADR-93 gives every `def` an
1461
1493
  # `untyped` member, annotated or not — is no word of the author's about the return, so it keeps the proposal
1462
1494
  # a method with no declaration at all gets: the strictest carrier the body proves.
@@ -1590,11 +1622,13 @@ module Rigor
1590
1622
  end
1591
1623
 
1592
1624
  # `Type::Constant#erase_to_rbs` spells the values RBS writes as literal types (`true` / `false` /
1593
- # `nil`, an Integer, a Symbol, a String) as that literal and every other carrier as its value's class
1594
- # name. Asking which of the two branches it took keeps the list of literal-spellable classes from
1595
- # forking a second copy into this file.
1625
+ # `nil`, an Integer, a Symbol, a String) as that literal and every other carrier as a nominal. Asking
1626
+ # which of the two branches it took keeps the list of literal-spellable classes from forking a second
1627
+ # copy into this file. A Range's nominal carries its element type (`Range[Integer]`, #1891), so it is
1628
+ # excluded by class rather than by comparing the erasure with the class name: like a Float it is a
1629
+ # nominal, not a pinned literal.
1596
1630
  def rbs_literal?(type)
1597
- type.is_a?(Type::Constant) && type.erase_to_rbs != type.value.class.name
1631
+ type.is_a?(Type::Constant) && !type.value.is_a?(Range) && type.erase_to_rbs != type.value.class.name
1598
1632
  end
1599
1633
 
1600
1634
  # Heuristic added after the third-round self-dogfood: `FallbackTracer#size` body is `@events.size`, where
@@ -1837,7 +1871,8 @@ module Rigor
1837
1871
  # pre-pass resolved the type to `nil` or `Dynamic[top]` — typically because `@ivar = param` inside
1838
1872
  # `initialize` typed the param as `untyped` — the observation-derived type is substituted. This lets
1839
1873
  # `attr_reader :name` emit a concrete type when `ClassName.new("alice")` call sites are visible to the
1840
- # observation scan.
1874
+ # observation scan. {#build_observed_ivar_map} leaves out an ivar anything else writes, so a writer-backed
1875
+ # field is skipped (issue #1410).
1841
1876
  def ivar_type_lookup(scope_index, class_name, obs_ivar_map = {})
1842
1877
  any_scope = scope_index.each_value.first
1843
1878
  return ->(_) {} if any_scope.nil?
@@ -1854,12 +1889,107 @@ module Rigor
1854
1889
  # ivars assigned directly from `def initialize` parameters. Only populated when `@observations` is
1855
1890
  # non-empty (i.e. `--params=observed` was supplied). Matches the pattern `@ivar_name = param_name` where
1856
1891
  # `param_name` is a required / optional positional or keyword parameter of `initialize`.
1892
+ #
1893
+ # Issue #1410: the observed arguments describe the field only while those `@ivar = param_name` writes are
1894
+ # all that writes it. An `attr_writer` / `attr_accessor`, a write in another instance method or block, or
1895
+ # any other write in `initialize` lets the field hold values the observation never saw, so such an ivar is
1896
+ # left out and its accessor is skipped as `untyped_return`, as a hand-written setter is.
1857
1897
  def build_observed_ivar_map(root)
1858
1898
  return {} if @observations.empty?
1859
1899
 
1860
1900
  result = {}
1861
1901
  collect_init_ivar_obs(root, [], result)
1862
- result
1902
+ other_writes = Hash.new { |h, k| h[k] = Set.new }
1903
+ collect_other_ivar_writes(root, [], false, other_writes)
1904
+ result.to_h do |class_name, map|
1905
+ others = other_writes[class_name]
1906
+ [class_name, others.include?(ANY_IVAR) ? {} : map.reject { |attr_name, _| others.include?(:"@#{attr_name}") }]
1907
+ end
1908
+ end
1909
+
1910
+ IVAR_WRITE_NODES = [
1911
+ Prism::InstanceVariableWriteNode, Prism::InstanceVariableOperatorWriteNode,
1912
+ Prism::InstanceVariableOrWriteNode, Prism::InstanceVariableAndWriteNode, Prism::InstanceVariableTargetNode
1913
+ ].freeze
1914
+ ANY_IVAR = :*
1915
+ private_constant :IVAR_WRITE_NODES, :ANY_IVAR
1916
+
1917
+ # Records into `other_writes[class_name]` every instance ivar a class writes other than through an
1918
+ # `initialize`'s `@ivar = param_name`: the ivars an implicit-self `attr_writer` / `attr_accessor` names,
1919
+ # and every write inside an instance `def` or a block, an `instance_variable_set` on `self` included
1920
+ # ({#written_ivar}). `def self.x`, a `class << self` body and a bare class-body write set the class
1921
+ # object's ivars and do not count.
1922
+ def collect_other_ivar_writes(node, prefix, in_block, other_writes)
1923
+ return unless node.is_a?(Prism::Node)
1924
+
1925
+ case node
1926
+ when Prism::ClassNode, Prism::ModuleNode
1927
+ child_prefix = Source::ConstantPath.declaration_prefix(prefix, node.constant_path)
1928
+ return collect_other_ivar_writes(node.body, child_prefix, false, other_writes) if child_prefix
1929
+ when Prism::SingletonClassNode then return
1930
+ when Prism::DefNode then return record_def_ivar_writes(node, prefix, other_writes)
1931
+ when Prism::BlockNode, Prism::LambdaNode then in_block = true
1932
+ end
1933
+ record_class_level_write(node, prefix, in_block, other_writes) unless prefix.empty?
1934
+
1935
+ node.rigor_each_child { |child| collect_other_ivar_writes(child, prefix, in_block, other_writes) }
1936
+ end
1937
+
1938
+ # The ivars an `attr_writer` / `attr_accessor` on `self` names, or the ivar a write in a block sets.
1939
+ def record_class_level_write(node, prefix, in_block, other_writes)
1940
+ writes = other_writes[prefix.join("::")]
1941
+ attr_writer_names(node).each { |name| writes << :"@#{name}" }
1942
+ name = written_ivar(node) if in_block
1943
+ writes << name if name
1944
+ end
1945
+
1946
+ ATTR_WRITER_DECLARATIONS = %i[attr_writer attr_accessor].freeze
1947
+ SEND_NAMES = %i[send public_send __send__].freeze
1948
+ private_constant :ATTR_WRITER_DECLARATIONS, :SEND_NAMES
1949
+
1950
+ # The literal names `attr_writer :x` / `attr_accessor :x` declares when called on `self`, implicitly or as
1951
+ # `self.`, or through `send` / `public_send` / `__send__` with the declaration's name as a literal first
1952
+ # argument (`send(:attr_writer, :x)`). Any other call declares none this check sees.
1953
+ def attr_writer_names(node)
1954
+ return [] unless node.is_a?(Prism::CallNode) && (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode))
1955
+
1956
+ names = Source::Literals.symbol_arguments(node)
1957
+ return names if ATTR_WRITER_DECLARATIONS.include?(node.name)
1958
+ return [] unless SEND_NAMES.include?(node.name)
1959
+
1960
+ ATTR_WRITER_DECLARATIONS.include?(Source::Literals.symbol_arg(node, 0)) ? names.drop(1) : []
1961
+ end
1962
+
1963
+ # Every ivar write in an instance `def` counts, except, in `initialize`, the `@ivar = param_name` write
1964
+ # {#scan_ivar_param_assignments} keys the observation on.
1965
+ def record_def_ivar_writes(def_node, prefix, other_writes)
1966
+ return if def_node.body.nil? || def_node.receiver || prefix.empty?
1967
+
1968
+ credited = Set.new.compare_by_identity
1969
+ if def_node.name == :initialize && def_node.parameters
1970
+ params = build_init_param_index(def_node.parameters).keys.to_set
1971
+ scan_ivar_param_assignments(def_node.body, params, {}, credited)
1972
+ end
1973
+ each_node(def_node.body) do |node|
1974
+ name = written_ivar(node)
1975
+ other_writes[prefix.join("::")] << name if name && !credited.include?(node)
1976
+ end
1977
+ end
1978
+
1979
+ # The ivar `node` writes: a write node's name, an `instance_variable_set` on `self`'s literal name, or
1980
+ # `ANY_IVAR` for one whose name the analysis cannot read ({Inference::ReflectiveIvarWrite}).
1981
+ def written_ivar(node)
1982
+ return node.name if IVAR_WRITE_NODES.any? { |klass| node.is_a?(klass) }
1983
+ return nil unless Inference::ReflectiveIvarWrite.self_write?(node)
1984
+
1985
+ Inference::ReflectiveIvarWrite.literal_name(node) || ANY_IVAR
1986
+ end
1987
+
1988
+ def each_node(node, &)
1989
+ return unless node.is_a?(Prism::Node)
1990
+
1991
+ yield node
1992
+ node.rigor_each_child { |child| each_node(child, &) }
1863
1993
  end
1864
1994
 
1865
1995
  def collect_init_ivar_obs(node, prefix, result)
@@ -1945,21 +2075,36 @@ module Rigor
1945
2075
 
1946
2076
  # Walk a def body for direct `@ivar = local_var` assignments where `local_var` is one of the listed
1947
2077
  # parameter names. Records ivar_name (Symbol with `@` prefix) → param_name. Does not recurse into nested
1948
- # defs / classes / modules.
1949
- def scan_ivar_param_assignments(node, param_names, result)
2078
+ # defs / classes / modules. A read counts only when it reaches the method's own parameter: its
2079
+ # `LocalVariableReadNode#depth` equals the number of blocks / lambdas between it and the method, so a
2080
+ # block parameter of the same name (`each { |count| @count = count }`) is not the constructor argument
2081
+ # (#1410). `credited`, when given, collects every write node the result is keyed on.
2082
+ def scan_ivar_param_assignments(node, param_names, result, credited = nil, scopes = 0)
1950
2083
  return unless node.is_a?(Prism::Node)
1951
2084
 
1952
- if node.is_a?(Prism::InstanceVariableWriteNode) &&
1953
- node.value.is_a?(Prism::LocalVariableReadNode) &&
1954
- param_names.include?(node.value.name)
1955
- result[node.name] ||= node.value.name
2085
+ ivar, param = param_assignment(node, param_names, scopes)
2086
+ if ivar
2087
+ result[ivar] ||= param
2088
+ credited << node if credited && result[ivar] == param
1956
2089
  end
1957
2090
 
1958
2091
  return if node.is_a?(Prism::DefNode) ||
1959
2092
  node.is_a?(Prism::ClassNode) ||
1960
2093
  node.is_a?(Prism::ModuleNode)
1961
2094
 
1962
- node.rigor_each_child { |c| scan_ivar_param_assignments(c, param_names, result) }
2095
+ scopes += 1 if node.is_a?(Prism::BlockNode) || node.is_a?(Prism::LambdaNode)
2096
+ node.rigor_each_child { |c| scan_ivar_param_assignments(c, param_names, result, credited, scopes) }
2097
+ end
2098
+
2099
+ # `[ivar, param]` for `@ivar = param` whose read reaches the method's own parameter `param`, else nil.
2100
+ def param_assignment(node, param_names, scopes)
2101
+ return nil unless node.is_a?(Prism::InstanceVariableWriteNode)
2102
+
2103
+ read = node.value
2104
+ return nil unless read.is_a?(Prism::LocalVariableReadNode) && read.depth == scopes
2105
+ return nil unless param_names.include?(read.name)
2106
+
2107
+ [node.name, read.name]
1963
2108
  end
1964
2109
 
1965
2110
  def build_attr_candidates(call_name, class_name, attr_name, ivar_type, ctx)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rbs"
4
+ require_relative "../rooted_glob"
4
5
 
5
6
  module Rigor
6
7
  module SigGen
@@ -27,12 +28,14 @@ module Rigor
27
28
  class LayoutIndex
28
29
  # The `[method_name, kind]` keys a member declaration answers for, `kind` being `:instance` or
29
30
  # `:singleton`. A `def self?.f` answers for both kinds; an `attr_accessor` answers for its reader and its
30
- # writer. Shared with {Writer}, which asks the same question of the target file it is merging into.
31
+ # writer; an `alias new old` (`alias self.new self.old`) answers for its new name only, under its own kind
32
+ # (#1975). Shared with {Writer}, which asks the same question of the target file it is merging into.
31
33
  def self.member_keys(member)
32
34
  case member
33
35
  when RBS::AST::Members::MethodDefinition
34
36
  kinds = member.kind == :singleton_instance ? %i[instance singleton] : [member.kind]
35
37
  kinds.map { |kind| [member.name, kind] }
38
+ when RBS::AST::Members::Alias then [[member.new_name, member.kind]]
36
39
  when RBS::AST::Members::AttrReader then [[member.name, member.kind]]
37
40
  when RBS::AST::Members::AttrWriter then [[:"#{member.name}=", member.kind]]
38
41
  when RBS::AST::Members::AttrAccessor then [[member.name, member.kind], [:"#{member.name}=", member.kind]]
@@ -92,7 +95,7 @@ module Rigor
92
95
  base = Pathname(dir)
93
96
  next unless base.directory?
94
97
 
95
- Dir.glob(File.join(base.to_s, "**/*.rbs"), sort: true).each do |rbs_file|
98
+ RootedGlob.glob(base, "**/*.rbs").each do |rbs_file|
96
99
  index_file(Pathname(rbs_file), accumulator)
97
100
  end
98
101
  end