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
@@ -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
@@ -25,25 +28,36 @@ module Rigor
25
28
  def build
26
29
  groups = Hash.new { |hash, key| hash[key] = [] }
27
30
  @sites.each do |site|
28
- site.data["members"].each { |name| groups[[site.data["owner"], site.data["scope"], name]] << site }
31
+ Members.scopes_of(site.data).each do |scope|
32
+ site.data["members"].each { |name| groups[[site.data["owner"], scope, name]] << site }
33
+ end
29
34
  end
35
+ @groups = groups
30
36
  result = groups.sort_by { |key, _| key }.map { |key, sites| member(key, sites) }
31
37
  mark_inline_sites
32
38
  result
33
39
  end
34
40
 
41
+ # The scopes a site's members live in: a module function (`module_function`) defines a private instance method
42
+ # and a public singleton copy from one `def`.
43
+ def self.scopes_of(data)
44
+ data["module_function"] ? %w[instance singleton] : [data["scope"]]
45
+ end
46
+
35
47
  private
36
48
 
37
49
  def member(key, sites)
38
50
  owner, scope, name = key
39
51
  contract = @contracts[key]
40
52
  also = @elsewhere.call(owner, scope, name)
41
- omitted = @omissions.count { |omission| [omission["owner"], omission["scope"], omission["name"]] == key }
53
+ omitted = @omissions.count do |omission|
54
+ [omission["owner"], omission["scope"]] == [owner, scope] && [name, nil].include?(omission["name"])
55
+ end
42
56
  single = sites.size == 1 && omitted.zero? && also.empty? ? sites.first : nil
43
57
  { "owner" => owner, "scope" => scope, "name" => name, "kind" => member_kind(sites),
44
- "visibility" => common_visibility(sites), "sites" => sites.map { |site| site.data["key"] },
58
+ "visibility" => common_visibility(sites, scope), "sites" => sites.map { |site| site.data["key"] },
45
59
  "also_defined_at" => also, "basis" => basis(contract, single),
46
- "overloads" => overloads(contract, sites.first, name, single) }
60
+ "overloads" => overloads(contract, sites.first, name, single, scope) }
47
61
  end
48
62
 
49
63
  # A member a `def` defines anywhere in the requested files is a method, whatever `attr_*` also names it.
@@ -58,28 +72,32 @@ module Rigor
58
72
  "contested"
59
73
  end
60
74
 
61
- def common_visibility(sites)
62
- values = sites.map { |site| site.data["visibility"] }.uniq
75
+ # A module function's singleton copy is public; its instance method carries the site's (private) visibility.
76
+ def common_visibility(sites, scope)
77
+ values = sites.map do |site|
78
+ copy = site.data["module_function"] && scope == "singleton"
79
+ copy ? site.data["singleton_visibility"] : site.data["visibility"]
80
+ end.uniq
63
81
  values.size == 1 ? values.first : nil
64
82
  end
65
83
 
66
84
  # A contested member without a contract has no overloads: its definitions may differ in arity, and each
67
85
  # method site carries its own parameter list.
68
- def overloads(contract, site, name, single)
86
+ def overloads(contract, site, name, single, scope)
69
87
  return [] if contract.nil? && single.nil?
70
88
 
71
- overloads = contract ? contract_overloads(contract, single) : [body_overload(site, name, single)]
89
+ overloads = contract ? contract_overloads(contract, single, scope) : [body_overload(site, name, single, scope)]
72
90
  overloads.map { |overload| overload.merge("rbs" => rbs_unless_unavailable(overload)) }
73
91
  end
74
92
 
75
93
  # A contract's overloads. A defaulted return (`inferred-return`) is inferred as for a member without a
76
94
  # contract, and its `untyped` parameters are unknown: Rigor cannot tell an authored `untyped` from a
77
95
  # defaulted one.
78
- def contract_overloads(contract, single)
96
+ def contract_overloads(contract, single, scope)
79
97
  contract.overloads.map do |overload|
80
98
  next overload.except("return_rbs") unless contract.return_inferred
81
99
 
82
- slot = inferred_return(single)
100
+ slot = inferred_return(single, scope)
83
101
  params = overload["params"]&.map do |param|
84
102
  param["type"]["type"] == "untyped" ? param.merge("type" => Slot.unavailable("not_computed")) : param
85
103
  end
@@ -92,16 +110,58 @@ module Rigor
92
110
 
93
111
  # A member with no contract: its parameters as written, each `untyped` with ADR-75's undeclared-parameter
94
112
  # cause, and the return from its one body.
95
- def body_overload(site, name, single)
113
+ def body_overload(site, name, single, scope)
96
114
  return attribute_overload(name, single) if site.data["kind"] == "attribute"
97
115
 
98
- params, block, head = Parameters.read(site.node.parameters, yields: Parameters.yields?(site.node.body))
99
- slot = inferred_return(single)
100
- block_rbs = block ? " ?{ (?) -> untyped }" : ""
116
+ node = site.data["alias_of"] ? resolved_alias(site) : site.node
117
+ return unknown_overload if node.nil?
118
+
119
+ params, block, head = Parameters.read(node.parameters, yields: Parameters.yields?(node.body))
120
+ slot = inferred_return(single, scope)
121
+ declared = !site.data["alias_of"] && @defaulted_blocks[[site.data["owner"], scope, name]]
122
+ block = declared || block
123
+ block_rbs = block_spelling(block, declared)
101
124
  { "rbs" => "#{head}#{block_rbs} -> #{return_spelling(slot)}", "params" => params, "block" => block,
102
125
  "return" => slot }
103
126
  end
104
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
+
137
+ # An alias whose original Rigor cannot pin to one `def`: nothing about its signature is known.
138
+ def unknown_overload
139
+ { "rbs" => nil, "params" => nil, "block" => nil, "return" => Slot.unavailable("not_computed") }
140
+ end
141
+
142
+ # The `def` an alias site copied, when its original has no other project definition (one site in the
143
+ # requested files, no omission naming it, none elsewhere) and no contract. Otherwise which `def` Ruby copied
144
+ # depends on load order, and `check` reads the last one it saw.
145
+ def resolved_alias(site)
146
+ node = site.data["aliased_node"]
147
+ return nil if node.nil?
148
+
149
+ owner = site.data["owner"]
150
+ old_name = site.data["alias_of"]
151
+ omitted = @omissions.any? do |omission|
152
+ [omission["owner"], omission["scope"]] == [owner, "instance"] && [old_name, nil].include?(omission["name"])
153
+ end
154
+ olds = @groups.fetch([owner, "instance", old_name], [])
155
+ return nil if omitted || olds.size != 1 || !@elsewhere.call(owner, "instance", old_name).empty?
156
+ # The original's contract is not the alias's: whether a declaration carries it over (`alias` in RBS) or
157
+ # not (`alias_method` under rbs-inline), the alias's undeclared parameters would contradict it.
158
+ return nil if @contracts[[owner, "instance", old_name]]
159
+ # An alias of an alias holds only if the alias it copied does.
160
+ return nil if olds.first.data["alias_of"] && resolved_alias(olds.first).nil?
161
+
162
+ node
163
+ end
164
+
105
165
  # An attribute without a contract: Rigor does not report the ivar's type in revision 1.
106
166
  def attribute_overload(name, _single)
107
167
  unknown = Slot.unavailable("not_computed")
@@ -113,10 +173,14 @@ module Rigor
113
173
  end
114
174
  end
115
175
 
116
- def inferred_return(single)
176
+ # A module function's singleton copy has its own return reading (`singleton_body_return`).
177
+ def inferred_return(single, scope)
117
178
  return Slot.unavailable("contested") unless single
118
179
 
119
- body = single.data["body_return"]
180
+ return Slot.unavailable("not_computed") if single.data["alias_of"] && resolved_alias(single).nil?
181
+
182
+ copy = single.data["module_function"] && scope == "singleton"
183
+ body = copy ? single.data["singleton_body_return"] : single.data["body_return"]
120
184
  return Slot.unavailable("not_computed") if body.nil?
121
185
  return body if Slot.unavailable?(body)
122
186
 
@@ -18,7 +18,7 @@ module Rigor
18
18
  # The check-equivalent computation behind `rigor lens` (docs/internal-spec/lens.md § Computation). The session
19
19
  # is the `--incremental` one rooted at the configuration's `paths:`, so it shares the snapshot with
20
20
  # `rigor check --incremental`; the targets are forced into the run and read inside it, on the seeded index.
21
- class Run
21
+ class Run # rubocop:disable Metrics/ClassLength
22
22
  # One target's answer: `status` is `ok` or a failure reason; the rest is set only for `ok`.
23
23
  Target = Data.define(:argument, :path, :absolute, :status, :reason, :reader, :diagnostics, :inputs)
24
24
  Outcome = Data.define(:targets, :member_rows, :plugins, :warm)
@@ -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
 
@@ -127,16 +128,84 @@ module Rigor
127
128
  spell: speller(scope.environment)).read
128
129
  reader.sites.each do |site|
129
130
  @site_paths[site.data["key"]] = absolute
130
- site.data["members"].each { |name| read_contract(site.data["owner"], site.data["scope"], name, scope) }
131
+ read_constant_value(site, scope) if site.data["kind"] == "constant"
132
+ Members.scopes_of(site.data).product(site.data["members"]).each do |member_scope, name|
133
+ read_contract(site.data["owner"], member_scope, name, scope)
134
+ end
131
135
  end
132
136
  @captured[absolute] = reader
133
137
  end
134
138
 
139
+ # The constant's type as `check` reads it ({Reflection.constant_type_for}): the value the project's source
140
+ # publishes for the name (every write joined, a mutated one widened) over any declaration, else its RBS
141
+ # declaration, else unknown.
142
+ def read_constant_value(site, scope)
143
+ name = site.data["name"]
144
+ published = scope.in_source_constants[name]
145
+ site.data["value"] =
146
+ if published
147
+ published_slot(published, scope.environment)
148
+ else
149
+ declared_slot(site, contract_reader(scope.environment).read_constant(name, scope.environment), scope)
150
+ end
151
+ end
152
+
153
+ # The rbs-inline plugin declares every constant of a file it reads, typing an unannotated one from its
154
+ # literal (`[]` as `Array[untyped]`), and reads only a `#:` trailing the assignment as its type: an inline
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)
161
+ return Slot.unavailable("not_computed") if declared.nil?
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)
166
+
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) }
182
+ end
183
+
184
+ def trailing_type?(site)
185
+ comments = site.contract_comments
186
+ comments.size == 1 && comments.first.location.start_line == site.node.location.end_line &&
187
+ comments.first.location.slice.start_with?("#:")
188
+ end
189
+
190
+ def published_slot(type, environment)
191
+ return Slot.unavailable("not_computed") if type.nil?
192
+ return Slot.dynamic("inferred", nil) if type.is_a?(Type::Dynamic)
193
+
194
+ spelled = speller(environment).call(type, nil)
195
+ spelled == "untyped" ? Slot.dynamic("inferred", nil) : Slot.typed(spelled, "inferred")
196
+ rescue StandardError
197
+ Slot.unavailable("not_computed")
198
+ end
199
+
135
200
  def read_contract(owner, member_scope, name, scope)
136
201
  key = [owner, member_scope, name]
137
202
  return if @contracts.key?(key)
138
203
 
139
- @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)
140
209
  end
141
210
 
142
211
  def target(entry, diagnostics, session)
@@ -187,7 +256,7 @@ module Rigor
187
256
  sites = readers.values.flat_map(&:sites)
188
257
  omissions = readers.values.flat_map(&:omissions)
189
258
  requested = readers.keys.to_set
190
- Members.new(sites: sites, omissions: omissions, contracts: @contracts,
259
+ Members.new(sites: sites, omissions: omissions, contracts: @contracts, defaulted_blocks: @defaulted_blocks,
191
260
  elsewhere: elsewhere(session, requested), site_paths: @site_paths).build
192
261
  end
193
262
 
data/lib/rigor/lens.rb CHANGED
@@ -9,7 +9,8 @@ module Rigor
9
9
  SCHEMA = "lens/v0"
10
10
  # Bumped on every breaking change to the `lens/v0` document (docs/internal-spec/lens.md § Status).
11
11
  REVISION = 1
12
- CAPABILITIES = %w[class module method attribute body_return diagnostics inputs].freeze
12
+ CAPABILITIES = %w[class module method attribute constant module_function builder alias body_return
13
+ diagnostics inputs].freeze
13
14
 
14
15
  module_function
15
16
 
@@ -63,6 +63,25 @@ module Rigor
63
63
  }
64
64
  }
65
65
  },
66
+ {
67
+ name: "rigor_lens",
68
+ description: "Map the classes, modules, methods and attributes the given Ruby files declare, with the " \
69
+ "type `rigor check` gives each one and where that type came from (sig/, an inline " \
70
+ "comment, a plugin, a library, or inference). Returns the lens/v0 JSON document " \
71
+ "(experimental). Pass every file you need in one call: each call pays one analysis run.",
72
+ inputSchema: {
73
+ type: "object",
74
+ properties: {
75
+ files: {
76
+ type: "array",
77
+ items: { type: "string" },
78
+ description: "Ruby files to map (required)"
79
+ },
80
+ config: { type: "string", description: "Path to .rigor.yml (optional)" }
81
+ },
82
+ required: ["files"]
83
+ }
84
+ },
66
85
  {
67
86
  name: "rigor_annotate",
68
87
  description: "Return the given Ruby source file with each line's last-expression type " \
@@ -109,8 +128,8 @@ module Rigor
109
128
  properties: {
110
129
  rule: {
111
130
  type: "string",
112
- description: "Rule ID, legacy alias, or family prefix (call, flow, assert, dump, def). " \
113
- "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."
114
133
  }
115
134
  }
116
135
  }
@@ -180,13 +199,12 @@ module Rigor
180
199
  out_io = StringIO.new
181
200
  err_io = StringIO.new
182
201
  require_relative "../cli"
183
- exit_code = CLI.new(argv, out: out_io, err: err_io).run
184
-
185
- is_error = exit_code == CLI::EXIT_USAGE
186
- text = out_io.string
187
- text = err_io.string if text.empty? && is_error
202
+ exit_code = with_captured_streams(out_io, err_io) { CLI.new(argv, out: out_io, err: err_io).run }
188
203
 
189
- success(id, { content: [{ type: "text", text: text }], isError: is_error })
204
+ success(id, tool_result(out_io.string, err_io.string, exit_code))
205
+ rescue SystemExit => e
206
+ # Defence against any command that exits: it must not take the server down with it.
207
+ success(id, { content: [{ type: "text", text: "Command exited (status #{e.status})" }], isError: true })
190
208
  rescue StandardError => e
191
209
  @err.puts("rigor mcp: #{e.class}: #{e.message}")
192
210
  @err.puts(e.backtrace.first(5).join("\n")) if e.backtrace
@@ -196,6 +214,26 @@ module Rigor
196
214
  })
197
215
  end
198
216
 
217
+ # A non-zero exit with nothing on stdout printed only an error; exit 1 with a document (check, lens) is a
218
+ # result, not an error.
219
+ def tool_result(out, err, exit_code)
220
+ is_error = [CLI::EXIT_USAGE, CLI::EXIT_INTERNAL_ERROR].include?(exit_code) || (out.empty? && !exit_code.zero?)
221
+ { content: [{ type: "text", text: out.empty? ? err : out }], isError: is_error }
222
+ end
223
+
224
+ # The JSON-RPC channel is the process's stdout, so nothing a command writes past its `out:` (an option
225
+ # parser's `--help`, a stray `puts`) may reach it while the command runs.
226
+ def with_captured_streams(out_io, err_io)
227
+ stdout = $stdout
228
+ stderr = $stderr
229
+ $stdout = out_io
230
+ $stderr = err_io
231
+ yield
232
+ ensure
233
+ $stdout = stdout
234
+ $stderr = stderr
235
+ end
236
+
199
237
  def build_argv(name, args) # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity,Metrics/AbcSize,Metrics/PerceivedComplexity
200
238
  effective_config = args["config"] || @config_path
201
239
 
@@ -203,49 +241,52 @@ module Rigor
203
241
  when "rigor_check"
204
242
  argv = ["check", "--format=json", "--no-stats"]
205
243
  argv << "--config=#{effective_config}" if effective_config
206
- argv += Array(args["paths"])
207
- argv
244
+ argv + ["--", *Array(args["paths"])]
208
245
 
209
246
  when "rigor_type_of"
210
247
  return nil unless args["file"] && args["line"] && args["col"]
211
248
 
212
249
  argv = ["type-of", "--format=json"]
213
250
  argv << "--config=#{effective_config}" if effective_config
214
- argv << "#{args['file']}:#{args['line']}:#{args['col']}"
215
- argv
251
+ # `--` ends the options, so a file named like a flag is a file.
252
+ argv + ["--", "#{args['file']}:#{args['line']}:#{args['col']}"]
216
253
 
217
254
  when "rigor_triage"
218
255
  argv = ["triage", "--format=json"]
219
256
  argv << "--config=#{effective_config}" if effective_config
220
257
  argv << "--top=#{args['top']}" if args["top"]
221
- argv += Array(args["paths"])
222
- argv
258
+ argv + ["--", *Array(args["paths"])]
259
+
260
+ when "rigor_lens"
261
+ files = args["files"]
262
+ return nil unless files.is_a?(Array) && !files.empty? && files.all?(String)
263
+
264
+ argv = ["lens", "--format=json"]
265
+ argv << "--config=#{effective_config}" if effective_config
266
+ # `--` ends the options, so a file named like a flag (`--version`) is a file.
267
+ argv + ["--", *files]
223
268
 
224
269
  when "rigor_annotate"
225
270
  return nil unless args["file"]
226
271
 
227
272
  argv = ["annotate", "--no-color"]
228
273
  argv << "--config=#{effective_config}" if effective_config
229
- argv << args["file"]
230
- argv
274
+ argv + ["--", args["file"]]
231
275
 
232
276
  when "rigor_sig_gen"
233
277
  argv = ["sig-gen", "--print", "--format=json"]
234
278
  argv << "--config=#{effective_config}" if effective_config
235
279
  argv << "--params=#{args['params']}" if args["params"]
236
- argv += Array(args["paths"])
237
- argv
280
+ argv + ["--", *Array(args["paths"])]
238
281
 
239
282
  when "rigor_explain"
240
283
  argv = ["explain", "--format=json"]
241
- argv << args["rule"] if args["rule"]
242
- argv
284
+ args["rule"] ? argv + ["--", args["rule"]] : argv
243
285
 
244
286
  when "rigor_coverage"
245
287
  argv = ["coverage", "--format=json"]
246
288
  argv << "--config=#{effective_config}" if effective_config
247
- argv += Array(args["paths"])
248
- argv
289
+ argv + ["--", *Array(args["paths"])]
249
290
  end
250
291
  end
251
292
 
@@ -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
@@ -15,7 +15,8 @@ module Rigor
15
15
  # under `RUBY_BOX=1` and must print the right answer. That admits a 4.1.0dev build with the fix and a
16
16
  # future 4.0.x that backports it, and refuses a development build cut before the fix — none of which a
17
17
  # `RUBY_VERSION` bound gets right. The probe costs one short-lived `ruby --disable-gems` process, paid
18
- # only when a run opts into `ruby_box`.
18
+ # only when a run opts into `ruby_box` or starts with `RUBY_BOX` already set, and only once per Ruby
19
+ # build: {cached_unsupported_reason} remembers the verdict.
19
20
  #
20
21
  # Loaded by `exe/rigor` before anything else, so this file requires nothing from Rigor.
21
22
  module BoxProbe
@@ -36,20 +37,166 @@ module Rigor
36
37
  # counted as a failure, so a wedged probe cannot hang the launcher (or `rigor lsp` behind it).
37
38
  DEADLINE_SECONDS = 10
38
39
 
40
+ # Set on the environment of every re-exec this module asks for, so the re-exec'ed launcher neither
41
+ # probes again nor re-execs again. The launcher deletes it before Rigor loads, so nothing Rigor spawns
42
+ # inherits it.
43
+ REEXEC_MARKER = "RIGOR_BOX_PROBED"
44
+
45
+ # The signals the bug's NULL-box dereference ends a child with. A child killed by any other signal
46
+ # (an OOM killer's `SIGKILL`, an operator's `SIGTERM`) is still refused, but that verdict is not cached.
47
+ CRASH_SIGNALS = %w[SEGV BUS ABRT ILL].freeze
48
+
49
+ CACHE_FORMAT = "rigor-box-probe-v1"
50
+
39
51
  module_function
40
52
 
53
+ # Decides what `exe/rigor` does before Rigor loads, editing `env` (the real `ENV` in the launcher) in
54
+ # place. Returns true when the launcher must re-exec itself with `env` as now edited.
55
+ #
56
+ # - `RUBY_BOX` already set (by hand or by a wrapper), and `Ruby::Box` active in this process: the
57
+ # process booted inside the box before any Rigor code ran, so on a Ruby without the Bug #22260 fix
58
+ # the first Ractor-shareable lambda Rigor calls segfaults. Probe (through the cache); when the Ruby
59
+ # is refused, print a notice, drop `RUBY_BOX` and the `ruby_box` request, and re-exec. When it
60
+ # passes, keep `RUBY_BOX` as the user set it.
61
+ # - `ruby_box` requested and `RUBY_BOX` unset: probe; re-exec under `RUBY_BOX=1` when it passes,
62
+ # otherwise warn and drop the request.
63
+ # - Otherwise nothing.
64
+ def needs_reexec?(env, stderr: $stderr, box_active: box_active?, cache_dir: default_cache_dir(env))
65
+ return false if env.delete(REEXEC_MARKER)
66
+
67
+ if env["RUBY_BOX"].to_s.empty?
68
+ reexec_for_box_request?(env, stderr, cache_dir)
69
+ else
70
+ reexec_without_preset_box?(env, stderr, box_active, cache_dir)
71
+ end
72
+ end
73
+
74
+ def reexec_for_box_request?(env, stderr, cache_dir)
75
+ isolation = env["RIGOR_PLUGIN_ISOLATION"].to_s
76
+ return false unless isolation == "ruby_box" || !env["RIGOR_BOX"].to_s.empty?
77
+
78
+ if (reason = cached_unsupported_reason(cache_dir: cache_dir))
79
+ # `puts` on stderr, not `warn`: `-W0` silences `warn`, and this fallback must never be silent.
80
+ stderr.puts "rigor: ruby_box plugin isolation is unavailable: #{reason}. " \
81
+ "Falling back to the configured plugins_isolation strategy (default: process)."
82
+ drop_box_request(env)
83
+ false
84
+ else
85
+ env["RUBY_BOX"] = "1"
86
+ env["RIGOR_PLUGIN_ISOLATION"] = "ruby_box" if isolation.empty?
87
+ env[REEXEC_MARKER] = "1"
88
+ true
89
+ end
90
+ end
91
+
92
+ def reexec_without_preset_box?(env, stderr, box_active, cache_dir)
93
+ return false unless box_active
94
+
95
+ reason = cached_unsupported_reason(cache_dir: cache_dir)
96
+ return false unless reason
97
+
98
+ stderr.puts "rigor: RUBY_BOX is set, but #{reason}. " \
99
+ "Re-running without RUBY_BOX; plugins will not run inside Ruby::Box."
100
+ env.delete("RUBY_BOX")
101
+ drop_box_request(env)
102
+ env[REEXEC_MARKER] = "1"
103
+ true
104
+ end
105
+
106
+ def drop_box_request(env)
107
+ env.delete("RIGOR_PLUGIN_ISOLATION") if env["RIGOR_PLUGIN_ISOLATION"].to_s == "ruby_box"
108
+ env.delete("RIGOR_BOX")
109
+ end
110
+
111
+ # Whether this process booted inside `Ruby::Box`. False on a Ruby without the feature, which ignores
112
+ # `RUBY_BOX`, so a preset variable there costs nothing.
113
+ def box_active?
114
+ return false unless defined?(::Ruby::Box) && ::Ruby::Box.respond_to?(:enabled?)
115
+
116
+ ::Ruby::Box.enabled? == true
117
+ end
118
+
119
+ # `$XDG_CACHE_HOME/rigor` when that is an absolute path, else `$HOME/.cache/rigor` when `HOME` is an
120
+ # absolute path (the passwd entry when `HOME` is unset); nil otherwise, which turns the cache off
121
+ # rather than writing under the working directory.
122
+ def default_cache_dir(env)
123
+ xdg = env["XDG_CACHE_HOME"].to_s
124
+ return File.join(xdg, "rigor") if xdg.start_with?("/")
125
+
126
+ home = env.key?("HOME") ? env["HOME"].to_s : Dir.home
127
+ File.absolute_path?(home) ? File.join(home, ".cache", "rigor") : nil
128
+ rescue ArgumentError
129
+ nil
130
+ end
131
+
132
+ # {unsupported_reason}, remembered per Ruby build so a launcher that probes on every run pays for the
133
+ # child process once. A pass is stored only when the child printed the right answer, and a refusal
134
+ # only when it is a property of the Ruby (no `Ruby::Box`, or a crash signal); a probe that could not
135
+ # run, timed out, or failed some other way is answered but not stored. An unreadable or corrupt
136
+ # cache file reads as a miss; a write that fails is ignored.
137
+ def cached_unsupported_reason(ruby = RbConfig.ruby, cache_dir:)
138
+ path = cache_dir && cache_path(cache_dir, ruby)
139
+ cached = path && read_cache(path)
140
+ return cached[:reason] if cached
141
+
142
+ reason, cacheable = verdict(ruby)
143
+ write_cache(path, reason) if path && cacheable
144
+ reason
145
+ end
146
+
147
+ # The cache file for `ruby`: keyed on the Ruby build that runs the launcher (its version, patch level,
148
+ # revision and description) together with the executable path and the probe script itself.
149
+ def cache_path(cache_dir, ruby)
150
+ require "digest/sha2"
151
+ key = [RUBY_VERSION, RUBY_PATCHLEVEL, RUBY_REVISION, RUBY_DESCRIPTION, File.expand_path(ruby), SCRIPT]
152
+ File.join(cache_dir, "ruby-box-probe", Digest::SHA256.hexdigest(key.join("\0")))
153
+ end
154
+
155
+ def read_cache(path)
156
+ format, verdict, reason = File.read(path).split("\n", 3)
157
+ return nil unless format == CACHE_FORMAT
158
+
159
+ case verdict
160
+ when "pass" then reason.nil? ? { reason: nil } : nil
161
+ when "fail" then reason.to_s.chomp.empty? ? nil : { reason: reason.chomp }
162
+ end
163
+ rescue SystemCallError, IOError, ArgumentError
164
+ nil
165
+ end
166
+
167
+ def write_cache(path, reason)
168
+ require "fileutils"
169
+ FileUtils.mkdir_p(File.dirname(path))
170
+ body = reason ? "#{CACHE_FORMAT}\nfail\n#{reason}\n" : "#{CACHE_FORMAT}\npass"
171
+ temp = "#{path}.#{::Process.pid}.tmp"
172
+ File.write(temp, body)
173
+ File.rename(temp, path)
174
+ rescue SystemCallError, IOError
175
+ nil
176
+ end
177
+
41
178
  # nil when `ruby` can run Rigor under `RUBY_BOX=1`; otherwise a short reason for the launcher's
42
179
  # warning. Only a child killed by a signal is blamed on the bug; any other failure to answer is
43
180
  # reported as the probe failing, never as a pass.
44
181
  def unsupported_reason(ruby = RbConfig.ruby)
182
+ verdict(ruby).first
183
+ end
184
+
185
+ # `[reason, cacheable]` — {unsupported_reason}'s answer, and whether it is a property of the Ruby
186
+ # build rather than of this one attempt.
187
+ def verdict(ruby)
45
188
  output, status = run(ruby)
46
- return "the Ruby::Box probe could not run #{ruby}" if status.nil?
47
- return nil if status.success? && output == EXPECTED_OUTPUT
48
- return "Ruby::Box is not available in Ruby #{RUBY_VERSION}" if status.exitstatus == 2
49
- return "the Ruby::Box probe failed (#{status})" unless status.signaled?
189
+ return ["the Ruby::Box probe could not run #{ruby}", false] if status.nil?
190
+ return [nil, true] if status.success? && output == EXPECTED_OUTPUT
191
+ return ["Ruby::Box is not available in Ruby #{RUBY_VERSION}", true] if status.exitstatus == 2
192
+ return ["the Ruby::Box probe failed (#{status})", false] unless status.signaled?
193
+
194
+ ["Ruby #{RUBY_VERSION} crashes running Ractor-shareable procs inside Ruby::Box " \
195
+ "(Ruby Bug #22260; a Ruby carrying its fix is needed)", crash_signal?(status.termsig)]
196
+ end
50
197
 
51
- "Ruby #{RUBY_VERSION} crashes running Ractor-shareable procs inside Ruby::Box " \
52
- "(Ruby Bug #22260; a Ruby carrying its fix is needed)"
198
+ def crash_signal?(signo)
199
+ CRASH_SIGNALS.any? { |name| Signal.list[name] == signo }
53
200
  end
54
201
 
55
202
  # `RUBYOPT` is cleared so a `-r` of the caller's (bootsnap, a debugger, a coverage hook) cannot fail