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
@@ -23,7 +23,9 @@ module Rigor
23
23
  # - a statement, and the body of `(…)`, `begin` / `rescue` / `else` / `ensure`;
24
24
  # - an assignment's value — every variable-write form, and an index `||=` / `&&=` / `op=`, but not its
25
25
  # receiver or index arguments;
26
- # - an `if` / `unless` / `case` predicate and every branch, but not a `when` condition or an `in` guard;
26
+ # - an `if` / `unless` / `case` predicate and every branch, but not a `when` condition or an `in` guard (since
27
+ # issue #1963 the evaluator threads a `when` test that runs code in a `case` with a subject; the scan does
28
+ # not admit it yet, which floors a name needlessly rather than letting a pin through);
27
29
  # - both operands of `&&` / `||`;
28
30
  # - a `while` / `until` predicate and body, a `for` index, collection and body;
29
31
  # - a multi-assign value and its local and instance-variable targets, and the LOCAL a `rescue => e`
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "slot"
4
+ require_relative "parameters"
4
5
  require_relative "../reflection"
5
6
 
6
7
  module Rigor
@@ -34,6 +35,26 @@ module Rigor
34
35
  unreadable("definition_build_failed", [])
35
36
  end
36
37
 
38
+ # The block an otherwise defaulted inline declaration (`rigor:v1:inferred-signature`) declares, as a
39
+ # `{required, type}` slot, or nil. The rbs-inline plugin defaults an unannotated `&block` to `?{ (?) ->
40
+ # untyped }`, so any other block came from a `# @rbs &block:` annotation, and `check` binds the block's
41
+ # parameters from it. A block spelled like the default carries nothing either way.
42
+ def read_defaulted_block(owner, scope, name, lookup_scope)
43
+ method = lookup(owner, scope, name, lookup_scope)
44
+ defs = method ? method.defs.select { |definition| definition.defined_in.to_s.delete_prefix("::") == owner } : []
45
+ return nil unless defs.size == 1 && annotations_of(method, defs).include?(INFERRED_SIGNATURE)
46
+
47
+ block = defs.first.type.block
48
+ return nil if block.nil? || default_block?(block)
49
+
50
+ provenance, path = @provenance.classify(buffer_name(defs.first))
51
+ return nil if provenance.nil?
52
+
53
+ { "required" => block.required, "type" => Slot.from_rbs(block_type(block), provenance, path) }
54
+ rescue StandardError
55
+ nil
56
+ end
57
+
37
58
  # The declared type of the constant `name` (qualified), as a slot with its declaration's provenance, or nil
38
59
  # when no RBS or inline declaration names it.
39
60
  def read_constant(name, environment)
@@ -102,14 +123,36 @@ module Rigor
102
123
  def overload(method_type, provenance, path, return_inferred, annotations)
103
124
  function = method_type.type
104
125
  params = function.respond_to?(:required_positionals) ? params_of(function, provenance, path) : nil
105
- block = method_type.block && { "required" => method_type.block.required,
106
- "type" => Slot.from_rbs(method_type.block.type.to_s, provenance, path) }
126
+ partly_defaulted = return_inferred && provenance == "inline"
127
+ block = method_type.block && block_slot(method_type.block, provenance, path, partly_defaulted)
107
128
  prefix = annotations.map { |string| "%a{#{string}} " }.join
108
129
  return_slot = return_inferred ? nil : Slot.from_rbs(function.return_type.to_s, provenance, path)
109
130
  { "rbs" => "#{prefix}#{method_type}", "params" => params, "block" => block, "return" => return_slot,
110
131
  "return_rbs" => function.return_type.to_s }
111
132
  end
112
133
 
134
+ # A block's function type as `RBS::MethodType#to_s` spells it inside the braces (`(Integer) -> void`, with a
135
+ # `[self: T]` binding when declared). Neither `RBS::Types::Function` nor `UntypedFunction` has a `to_s`.
136
+ def block_type(block)
137
+ self_binding = ::RBS::Types::SelfTypeBindingHelper.self_type_binding_to_s(block.self_type)
138
+ "(#{block.type.param_to_s}) #{self_binding}-> #{block.type.return_to_s}"
139
+ end
140
+
141
+ # Under an inline declaration the rbs-inline plugin defaulted in part (`partly_defaulted`), a block shaped
142
+ # like its default was not written: it reads as an undeclared block does.
143
+ def block_slot(block, provenance, path, partly_defaulted)
144
+ if partly_defaulted && default_block?(block)
145
+ return { "required" => false, "type" => Slot.dynamic("inferred", Parameters::UNDECLARED) }
146
+ end
147
+
148
+ { "required" => block.required, "type" => Slot.from_rbs(block_type(block), provenance, path) }
149
+ end
150
+
151
+ def default_block?(block)
152
+ !block.required && block.type.is_a?(::RBS::Types::UntypedFunction) && block.self_type.nil? &&
153
+ block.type.return_type.is_a?(::RBS::Types::Bases::Any)
154
+ end
155
+
113
156
  def params_of(function, provenance, path)
114
157
  (positional_params(function) + keyword_params(function)).map do |kind, name, param|
115
158
  { "name" => name&.to_s, "kind" => kind, "type" => Slot.from_rbs(param.type.to_s, provenance, path) }
@@ -5,6 +5,7 @@ require "json"
5
5
  require_relative "../version"
6
6
  require_relative "../cache/engine_source"
7
7
  require_relative "../analysis/run_cache_key"
8
+ require_relative "../rooted_glob"
8
9
 
9
10
  module Rigor
10
11
  module Lens
@@ -60,7 +61,7 @@ module Rigor
60
61
  end
61
62
 
62
63
  def signature_digest
63
- listing = signature_roots.flat_map { |root| Dir.glob(File.join(root, "**", "*.rbs")) }.sort.map do |path|
64
+ listing = signature_roots.flat_map { |root| RootedGlob.glob(root, "**/*.rbs") }.sort.map do |path|
64
65
  "#{relative(path)}\t#{Lens.digest(File.binread(path))}\n"
65
66
  end
66
67
  Lens.digest(listing.join)
@@ -417,15 +417,25 @@ module Rigor
417
417
  fields.each do |field, field_node|
418
418
  # rbs-inline reads a `#:` trailing a field on its own line of a multi-line call as that field's type.
419
419
  trailing = field_node.location.start_line != start_line
420
+ members = attribute_members(field, attribute)
421
+ visibility = builder_field_visibility(members)
420
422
  add_site(field_node, { "kind" => "attribute", "name" => field, "owner" => name, "scope" => "instance",
421
- "attribute" => attribute, "members" => attribute_members(field, attribute),
422
- "module_function" => false, "visibility" => "public", "params" => nil,
423
+ "attribute" => attribute, "members" => members,
424
+ "module_function" => false, "visibility" => visibility, "params" => nil,
423
425
  "block" => nil, "body_return" => nil }, contracts: trailing ? :trailing : false)
424
426
  end
425
427
  block = node.value.block
426
428
  walk_body(block.body, nesting + [name], singleton: false, kind: :builder) if block&.body
427
429
  end
428
430
 
431
+ # Ruby forces a field reader named `initialize_copy` (say) private, but not its `Struct` writer: a `Data`
432
+ # field reads `private`, a `Struct` one, whose site covers both, `null`.
433
+ def builder_field_visibility(members)
434
+ return "public" unless members.intersect?(FORCED_PRIVATE)
435
+
436
+ members.all? { |member| FORCED_PRIVATE.include?(member) } ? "private" : nil
437
+ end
438
+
429
439
  # Whether a constant, class or module written here lands somewhere the nesting does not name: a builder
430
440
  # block, which Ruby `class_eval`s (defs go to the new class, but constants stay with the lexical scope), or a
431
441
  # `class << self` body (constants go to the singleton class). Such a declaration is an omission.
@@ -14,10 +14,13 @@ module Rigor
14
14
  # @param contracts — `{[owner, scope, name] => ContractReader::Contract or nil}`
15
15
  # @param elsewhere — callable `(owner, scope, name) -> [{path, line}]`: other project files defining it
16
16
  # @param site_paths — `{site key => absolute path of its file}`, to match an inline contract's buffer
17
- def initialize(sites:, omissions:, contracts:, elsewhere:, site_paths:)
17
+ # @param defaulted_blocks — `{[owner, scope, name] => {required, type}}`: the block a member's otherwise
18
+ # defaulted inline declaration declares (ContractReader#read_defaulted_block)
19
+ def initialize(sites:, omissions:, contracts:, elsewhere:, site_paths:, defaulted_blocks: {})
18
20
  @sites = sites
19
21
  @omissions = omissions
20
22
  @contracts = contracts
23
+ @defaulted_blocks = defaulted_blocks
21
24
  @elsewhere = elsewhere
22
25
  @site_paths = site_paths
23
26
  end
@@ -115,11 +118,22 @@ module Rigor
115
118
 
116
119
  params, block, head = Parameters.read(node.parameters, yields: Parameters.yields?(node.body))
117
120
  slot = inferred_return(single, scope)
118
- block_rbs = block ? " ?{ (?) -> untyped }" : ""
121
+ declared = !site.data["alias_of"] && @defaulted_blocks[[site.data["owner"], scope, name]]
122
+ block = declared || block
123
+ block_rbs = block_spelling(block, declared)
119
124
  { "rbs" => "#{head}#{block_rbs} -> #{return_spelling(slot)}", "params" => params, "block" => block,
120
125
  "return" => slot }
121
126
  end
122
127
 
128
+ # A block an otherwise defaulted declaration declares reads as declared (`{ (Integer) -> void }`); one the
129
+ # definition merely takes reads as `?{ (?) -> untyped }`.
130
+ def block_spelling(block, declared)
131
+ return "" if block.nil?
132
+ return " ?{ (?) -> untyped }" unless declared
133
+
134
+ " #{'?' unless declared['required']}{ #{declared['type']['type']} }"
135
+ end
136
+
123
137
  # An alias whose original Rigor cannot pin to one `def`: nothing about its signature is known.
124
138
  def unknown_overload
125
139
  { "rbs" => nil, "params" => nil, "block" => nil, "return" => Slot.unavailable("not_computed") }
@@ -78,6 +78,7 @@ module Rigor
78
78
  def reset_captures
79
79
  @captured = {}
80
80
  @contracts = {}
81
+ @defaulted_blocks = {}
81
82
  @site_paths = {}
82
83
  end
83
84
 
@@ -145,18 +146,39 @@ module Rigor
145
146
  if published
146
147
  published_slot(published, scope.environment)
147
148
  else
148
- declared_slot(site, contract_reader(scope.environment).read_constant(name, scope.environment))
149
+ declared_slot(site, contract_reader(scope.environment).read_constant(name, scope.environment), scope)
149
150
  end
150
151
  end
151
152
 
152
153
  # The rbs-inline plugin declares every constant of a file it reads, typing an unannotated one from its
153
154
  # literal (`[]` as `Array[untyped]`), and reads only a `#:` trailing the assignment as its type: an inline
154
- # declaration is the constant's own only when that is the site's one contract comment.
155
- def declared_slot(site, declared)
155
+ # declaration is the constant's own only when that is the site's one contract comment. Otherwise it is the
156
+ # plugin's default, which `check` reads all the same: that type, `inferred`. A `Class.new` / `Module.new`
157
+ # value is the exception, which `check` types through its own write reading, not the default `untyped`.
158
+ # An inline declaration from another file says nothing about this site: when the name is written in two
159
+ # files, the one the environment kept decides, and this site's own comment may say otherwise.
160
+ def declared_slot(site, declared, scope)
156
161
  return Slot.unavailable("not_computed") if declared.nil?
157
- return Slot.unavailable("not_computed") if declared["provenance"] == "inline" && !trailing_type?(site)
162
+ return declared unless declared["provenance"] == "inline"
163
+ return Slot.unavailable("not_computed") unless own_inline_declaration?(site, scope)
164
+ return declared if trailing_type?(site)
165
+ return Slot.unavailable("not_computed") if anonymous_namespace?(site.node.value)
158
166
 
159
- declared
167
+ published_slot(scope.environment.constant_for_name(site.data["name"]), scope.environment)
168
+ end
169
+
170
+ def own_inline_declaration?(site, scope)
171
+ buffer = scope.environment.rbs_loader&.constant_declaration(site.data["name"])&.last
172
+ path = @site_paths.fetch(site.data["key"])
173
+ buffer&.end_with?(":#{path}") || false
174
+ end
175
+
176
+ def anonymous_namespace?(node)
177
+ return false unless node.is_a?(Prism::Node)
178
+ return true if node.is_a?(Prism::CallNode) && node.name == :new && node.receiver &&
179
+ %w[Class Module].include?(node.receiver.slice.delete_prefix("::"))
180
+
181
+ node.compact_child_nodes.any? { |child| anonymous_namespace?(child) }
160
182
  end
161
183
 
162
184
  def trailing_type?(site)
@@ -166,6 +188,7 @@ module Rigor
166
188
  end
167
189
 
168
190
  def published_slot(type, environment)
191
+ return Slot.unavailable("not_computed") if type.nil?
169
192
  return Slot.dynamic("inferred", nil) if type.is_a?(Type::Dynamic)
170
193
 
171
194
  spelled = speller(environment).call(type, nil)
@@ -178,7 +201,11 @@ module Rigor
178
201
  key = [owner, member_scope, name]
179
202
  return if @contracts.key?(key)
180
203
 
181
- @contracts[key] = contract_reader(scope.environment).read(owner, member_scope.to_sym, name, scope)
204
+ reader = contract_reader(scope.environment)
205
+ @contracts[key] = reader.read(owner, member_scope.to_sym, name, scope)
206
+ return if @contracts[key]
207
+
208
+ @defaulted_blocks[key] = reader.read_defaulted_block(owner, member_scope.to_sym, name, scope)
182
209
  end
183
210
 
184
211
  def target(entry, diagnostics, session)
@@ -229,7 +256,7 @@ module Rigor
229
256
  sites = readers.values.flat_map(&:sites)
230
257
  omissions = readers.values.flat_map(&:omissions)
231
258
  requested = readers.keys.to_set
232
- Members.new(sites: sites, omissions: omissions, contracts: @contracts,
259
+ Members.new(sites: sites, omissions: omissions, contracts: @contracts, defaulted_blocks: @defaulted_blocks,
233
260
  elsewhere: elsewhere(session, requested), site_paths: @site_paths).build
234
261
  end
235
262
 
@@ -128,8 +128,8 @@ module Rigor
128
128
  properties: {
129
129
  rule: {
130
130
  type: "string",
131
- description: "Rule ID, legacy alias, or family prefix (call, flow, assert, dump, def). " \
132
- "Omit to list every rule."
131
+ description: "Rule ID, legacy alias, or family prefix (call, flow, def, assert, dump, global, " \
132
+ "suppression, static, effect). Omit to list every rule."
133
133
  }
134
134
  }
135
135
  }
@@ -13,6 +13,7 @@ require_relative "../cache/engine_source"
13
13
  # plugin class body can be evaluated before anything else pulled the cache layer in.
14
14
  require_relative "../cache/store"
15
15
  require_relative "../source/node_walker"
16
+ require_relative "../rooted_glob"
16
17
 
17
18
  module Rigor
18
19
  module Plugin
@@ -1003,7 +1004,7 @@ module Rigor
1003
1004
  # plugin code calls `watch:`.
1004
1005
  #
1005
1006
  # @param roots — search roots (relative to the project root, or absolute paths)
1006
- # @param patterns — glob suffixes joined under each root via `File.join(root, pattern)`.
1007
+ # @param patterns — glob suffixes matched under each root, the root read literally (#1956).
1007
1008
  # Multiple patterns union into one descriptor (`"**/*.erb", "**/*.html"` etc.).
1008
1009
  def glob_descriptor(roots, *patterns)
1009
1010
  files = collect_glob_files(Array(roots), patterns)
@@ -1163,7 +1164,7 @@ module Rigor
1163
1164
  absolute = File.absolute_path(root.to_s)
1164
1165
  next [] unless File.directory?(absolute)
1165
1166
 
1166
- patterns.flat_map { |pattern| Dir.glob(File.join(absolute, pattern.to_s)) }
1167
+ patterns.flat_map { |pattern| RootedGlob.glob(absolute, pattern) }
1167
1168
  end
1168
1169
  matched.uniq.sort.select { |path| File.file?(path) }
1169
1170
  end
@@ -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
 
@@ -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