rigortype 0.4.5 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/docs/handbook/11-sig-gen.md +11 -2
  4. data/docs/manual/02-cli-reference.md +46 -7
  5. data/docs/manual/03-configuration.md +2 -1
  6. data/docs/manual/04-diagnostics.md +1 -1
  7. data/docs/manual/10-mcp-server.md +9 -1
  8. data/docs/manual/12-caching.md +8 -1
  9. data/docs/manual/plugins/rigor-actionmailer.md +30 -0
  10. data/docs/manual/plugins/rigor-actionpack.md +30 -0
  11. data/docs/manual/plugins/rigor-devise.md +15 -0
  12. data/docs/manual/plugins/rigor-rspec.md +2 -1
  13. data/lib/rigor/analysis/buffer_binding.rb +14 -0
  14. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +30 -9
  15. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +142 -15
  16. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +4 -0
  17. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +66 -3
  18. data/lib/rigor/analysis/check_rules/rebinding_block_callees/dispatch.rb +112 -0
  19. data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +394 -0
  20. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +7 -0
  21. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +51 -0
  22. data/lib/rigor/analysis/check_rules.rb +93 -8
  23. data/lib/rigor/analysis/dependency_recorder.rb +11 -0
  24. data/lib/rigor/analysis/edit_replay.rb +67 -0
  25. data/lib/rigor/analysis/incremental.rb +37 -0
  26. data/lib/rigor/analysis/incremental_session.rb +175 -27
  27. data/lib/rigor/analysis/path_expansion.rb +41 -0
  28. data/lib/rigor/analysis/require_relative_closure.rb +100 -0
  29. data/lib/rigor/analysis/rule_catalog.rb +5 -1
  30. data/lib/rigor/analysis/run_disclosure_credit.rb +80 -0
  31. data/lib/rigor/analysis/run_disclosure_ledger.rb +155 -0
  32. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +6 -2
  33. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +11 -3
  34. data/lib/rigor/analysis/runner/pool_coordinator.rb +17 -4
  35. data/lib/rigor/analysis/runner/project_pre_passes.rb +45 -3
  36. data/lib/rigor/analysis/runner/scan_environment.rb +51 -0
  37. data/lib/rigor/analysis/runner.rb +156 -49
  38. data/lib/rigor/analysis/worker_session.rb +14 -2
  39. data/lib/rigor/cache/incremental_snapshot.rb +30 -7
  40. data/lib/rigor/cli/check_command.rb +93 -4
  41. data/lib/rigor/cli/diff_command.rb +2 -1
  42. data/lib/rigor/cli/lens_command.rb +5 -5
  43. data/lib/rigor/cli/mcp_command.rb +2 -1
  44. data/lib/rigor/cli/options.rb +20 -3
  45. data/lib/rigor/cli/plugins_command.rb +5 -0
  46. data/lib/rigor/cli/plugins_renderer.rb +24 -2
  47. data/lib/rigor/cli/show_bleedingedge_command.rb +2 -1
  48. data/lib/rigor/cli/sig_gen_command.rb +46 -7
  49. data/lib/rigor/cli/type_of_command.rb +2 -1
  50. data/lib/rigor/cli/type_of_project_scope.rb +27 -0
  51. data/lib/rigor/config_audit.rb +16 -5
  52. data/lib/rigor/configuration.rb +14 -4
  53. data/lib/rigor/environment/rbs_loader.rb +115 -6
  54. data/lib/rigor/environment/required_features.rb +136 -29
  55. data/lib/rigor/environment/ruby_library_roots.rb +122 -0
  56. data/lib/rigor/environment.rb +4 -2
  57. data/lib/rigor/inference/callee_mutation_chain.rb +115 -0
  58. data/lib/rigor/inference/deep_erasure.rb +138 -0
  59. data/lib/rigor/inference/def_return_typer.rb +159 -9
  60. data/lib/rigor/inference/definer_resolution.rb +17 -3
  61. data/lib/rigor/inference/error_info.rb +17 -3
  62. data/lib/rigor/inference/expression_typer.rb +62 -13
  63. data/lib/rigor/inference/global_write_census.rb +36 -1
  64. data/lib/rigor/inference/ivar_writer_census.rb +195 -0
  65. data/lib/rigor/inference/loop_value_record.rb +53 -0
  66. data/lib/rigor/inference/match_rebinding/frame.rb +19 -0
  67. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +110 -9
  68. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +6 -2
  69. data/lib/rigor/inference/method_dispatcher/set_folding.rb +53 -20
  70. data/lib/rigor/inference/method_dispatcher.rb +46 -16
  71. data/lib/rigor/inference/module_function_state/spellings.rb +64 -0
  72. data/lib/rigor/inference/module_function_state.rb +12 -0
  73. data/lib/rigor/inference/mutation_widening.rb +5 -2
  74. data/lib/rigor/inference/narrowing.rb +66 -5
  75. data/lib/rigor/inference/object_mixins.rb +13 -1
  76. data/lib/rigor/inference/project_seed_tables.rb +2 -1
  77. data/lib/rigor/inference/project_yielder.rb +128 -0
  78. data/lib/rigor/inference/scope_indexer/certainty.rb +9 -0
  79. data/lib/rigor/inference/scope_indexer/declared_ivar_types.rb +64 -0
  80. data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +162 -0
  81. data/lib/rigor/inference/scope_indexer/unseen_ivar_writers.rb +133 -0
  82. data/lib/rigor/inference/scope_indexer.rb +480 -103
  83. data/lib/rigor/inference/self_call_ivar_writes.rb +18 -6
  84. data/lib/rigor/inference/statement_evaluator.rb +658 -90
  85. data/lib/rigor/inference/synthetic_method_index.rb +23 -0
  86. data/lib/rigor/inference/synthetic_method_scanner/project_members.rb +130 -0
  87. data/lib/rigor/inference/synthetic_method_scanner.rb +72 -12
  88. data/lib/rigor/language_server/buffer_table.rb +6 -0
  89. data/lib/rigor/language_server/diagnostic_publisher.rb +20 -6
  90. data/lib/rigor/language_server/project_context.rb +59 -3
  91. data/lib/rigor/language_server/server.rb +5 -3
  92. data/lib/rigor/mcp/server.rb +9 -1
  93. data/lib/rigor/plugin/base.rb +26 -11
  94. data/lib/rigor/plugin/manifest.rb +29 -3
  95. data/lib/rigor/plugin/registry.rb +38 -0
  96. data/lib/rigor/protection/discovery_seed.rb +57 -2
  97. data/lib/rigor/rooted_glob.rb +10 -1
  98. data/lib/rigor/scope.rb +14 -5
  99. data/lib/rigor/sig_gen/generator.rb +308 -37
  100. data/lib/rigor/sig_gen/observation_collector.rb +4 -1
  101. data/lib/rigor/sig_gen/rbs_validity.rb +1 -1
  102. data/lib/rigor/sig_gen/renderer.rb +6 -3
  103. data/lib/rigor/sig_gen/type_elaborator.rb +21 -5
  104. data/lib/rigor/type/combinator.rb +4 -3
  105. data/lib/rigor/type/constant.rb +45 -3
  106. data/lib/rigor/type/hash_shape.rb +14 -9
  107. data/lib/rigor/type/struct_instance.rb +3 -2
  108. data/lib/rigor/version.rb +1 -1
  109. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +60 -43
  110. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +20 -1
  111. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +13 -0
  112. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +4 -2
  113. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +5 -3
  114. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +6 -5
  115. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +102 -11
  116. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +4 -2
  117. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +1 -1
  118. data/sig/rigor/analysis/baseline.rbs +3 -2
  119. data/sig/rigor/effects/file_collection.rbs +5 -6
  120. data/sig/rigor/effects/registry.rbs +8 -6
  121. data/sig/rigor/effects/summary.rbs +6 -5
  122. data/sig/rigor/environment.rbs +5 -3
  123. data/sig/rigor/inference/optimistic_origin.rbs +11 -0
  124. data/sig/rigor/inference.rbs +6 -0
  125. data/sig/rigor/plugin/additional_initializer.rbs +5 -5
  126. data/sig/rigor/plugin/effect_ancestry.rbs +6 -4
  127. data/sig/rigor/plugin/effect_attribution.rbs +25 -10
  128. data/sig/rigor/plugin/effect_edge.rbs +12 -5
  129. data/sig/rigor/plugin/effect_entry_points.rbs +8 -3
  130. data/sig/rigor/plugin/io_boundary.rbs +4 -4
  131. data/sig/rigor/plugin/manifest.rbs +16 -18
  132. data/sig/rigor/plugin/protocol_contract.rbs +10 -9
  133. data/sig/rigor/plugin/registry.rbs +1 -1
  134. data/sig/rigor/scope.rbs +31 -50
  135. data/sig/rigor/type.rbs +0 -6
  136. metadata +20 -1
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "command"
4
+ require_relative "options"
4
5
 
5
6
  require "json"
6
7
  require "optionparser"
@@ -63,7 +64,7 @@ module Rigor
63
64
  options[:current_path] = path
64
65
  end
65
66
  opt.on("--config=PATH", "Path to .rigor.yml. Forwarded to the implicit `rigor check` run.") do |path|
66
- options[:config] = path
67
+ options[:config] = Options.config_path(path)
67
68
  end
68
69
  end.parse!(@argv)
69
70
  options
@@ -22,11 +22,6 @@ module Rigor
22
22
  return CLI::EXIT_USAGE if options.nil?
23
23
 
24
24
  config = options.fetch(:config)
25
- if config && !File.file?(config)
26
- usage_error("no configuration file at #{config}")
27
- return CLI::EXIT_USAGE
28
- end
29
-
30
25
  configuration = Configuration.load(config)
31
26
  config_path = options.fetch(:config) || Configuration.discover
32
27
  # The working directory: Rigor auto-detects sig/ and the lockfiles there (lens.md § Serialization).
@@ -56,6 +51,11 @@ module Rigor
56
51
  options
57
52
  rescue OptionParser::ParseError => e
58
53
  usage_error(e.message)
54
+ rescue Rigor::ConfigurationError => e
55
+ # The shared --config check (CLI::Options.config_path). Mapped here the way Rigor::CLI#run maps it, so a
56
+ # direct LensCommand run exits with the usage code too.
57
+ @err.puts("rigor: #{e.message}")
58
+ nil
59
59
  end
60
60
 
61
61
  def usage_error(message)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "command"
4
+ require_relative "options"
4
5
  require_relative "../runtime/jit"
5
6
 
6
7
  require "optionparser"
@@ -54,7 +55,7 @@ module Rigor
54
55
  end
55
56
  opts.on("--config=PATH",
56
57
  "Session-level default config path (individual tool calls may override)") do |value|
57
- options[:config] = value
58
+ options[:config] = Options.config_path(value)
58
59
  end
59
60
  end
60
61
  parser.parse!(@argv)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../analysis/buffer_binding"
4
+ require_relative "../configuration_error"
4
5
 
5
6
  module Rigor
6
7
  class CLI
@@ -10,15 +11,31 @@ module Rigor
10
11
  # `--instead-of` flag pair and the buffer-binding resolution that validates it. That resolution used to be copied
11
12
  # verbatim into both `Rigor::CLI` and `TypeOfCommand`, so a fix to one (the paired-flag check, the readability
12
13
  # check, the error wording) could silently miss the other. Centralising it keeps the two editor-mode entry points in
13
- # lockstep.
14
+ # lockstep. It also owns the `--config` check (#1992), so every command and the MCP tools that pass `--config`
15
+ # read the same rule.
14
16
  module Options
15
17
  module_function
16
18
 
17
19
  # Defines the standard `--config=PATH` flag on `parser`, writing the path into `options[:config]`. Used by every
18
20
  # subcommand that loads a `.rigor.yml`; the few whose `--config` help text is intentionally bespoke (`diff`,
19
- # `mcp`, `show-bleedingedge`) keep their own `parser.on` rather than this shared wording.
21
+ # `mcp`, `show-bleedingedge`) keep their own `parser.on` and call {.config_path} for the same check.
20
22
  def add_config(parser, options)
21
- parser.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
23
+ parser.on("--config=PATH", "Path to the Rigor configuration file") do |value|
24
+ options[:config] = config_path(value)
25
+ end
26
+ end
27
+
28
+ # The `--config` value, checked where the flag is read (#1992). A named file that is not there is an error, not
29
+ # a request for the defaults: the run would otherwise analyse under a configuration the caller did not ask for,
30
+ # and an MCP caller would get a result for it with no sign anything was missing. Auto-discovery, with no
31
+ # `--config` given, is not affected.
32
+ def config_path(value)
33
+ raise ConfigurationError, "empty config path" if value.strip.empty?
34
+ raise ConfigurationError, "config path is a directory: #{value}" if File.directory?(value)
35
+ raise ConfigurationError, "config file not found: #{value}" unless File.exist?(value)
36
+ raise ConfigurationError, "config file not readable: #{value}" unless File.readable?(value)
37
+
38
+ value
22
39
  end
23
40
 
24
41
  # Defines the `--tmp-file` / `--instead-of` editor-mode flag pair on `parser`, writing into `options`.
@@ -233,6 +233,8 @@ module Rigor
233
233
  signature_paths: signature_path_rows(plugin),
234
234
  open_receivers: manifest.open_receivers.dup,
235
235
  owns_receivers: manifest.owns_receivers.dup,
236
+ per_invocation_classes: manifest.per_invocation_classes.dup,
237
+ per_invocation_non_action_macros: manifest.per_invocation_non_action_macros.map(&:to_s),
236
238
  produces: manifest.produces.map(&:to_s),
237
239
  consumes: manifest.consumes.map { |c| "#{c.plugin_id}/#{c.name}#{'?' if c.optional}" },
238
240
  source_rbs_synthesizer: !manifest.source_rbs_synthesizer.nil?
@@ -265,6 +267,8 @@ module Rigor
265
267
  signature_paths: [],
266
268
  open_receivers: [],
267
269
  owns_receivers: [],
270
+ per_invocation_classes: [],
271
+ per_invocation_non_action_macros: [],
268
272
  produces: [],
269
273
  consumes: [],
270
274
  block_as_methods: 0,
@@ -345,6 +349,7 @@ module Rigor
345
349
  config: {},
346
350
  signature_paths: [],
347
351
  open_receivers: [], owns_receivers: [], produces: [], consumes: [],
352
+ per_invocation_classes: [], per_invocation_non_action_macros: [],
348
353
  block_as_methods: 0, heredoc_templates: 0, trait_registries: 0,
349
354
  type_node_resolvers: 0,
350
355
  hkt_registrations: 0, hkt_definitions: 0,
@@ -185,12 +185,26 @@ module Rigor
185
185
  lines = []
186
186
  lines << " open_receivers: #{row[:open_receivers].join(', ')}" if row[:open_receivers].any?
187
187
  lines << " owns_receivers: #{row[:owns_receivers].join(', ')}" if row[:owns_receivers].any?
188
+ lines.concat(per_invocation_lines(row))
188
189
  lines << " produces: #{row[:produces].join(', ')}" if row[:produces].any?
189
190
  lines << " consumes: #{row[:consumes].join(', ')}" if row[:consumes].any?
190
191
  lines.concat(narrow_protocol_lines(row))
191
192
  lines
192
193
  end
193
194
 
195
+ # ADR-125 — the per-invocation classes a plugin vouches for, and the macros whose arguments name a method that is
196
+ # not one of their actions.
197
+ def per_invocation_lines(row)
198
+ lines = []
199
+ if row[:per_invocation_classes].any?
200
+ lines << " per_invocation_classes: #{row[:per_invocation_classes].join(', ')}"
201
+ end
202
+ if row[:per_invocation_non_action_macros].any?
203
+ lines << " per_invocation_non_action_macros: #{row[:per_invocation_non_action_macros].join(', ')}"
204
+ end
205
+ lines
206
+ end
207
+
194
208
  # ADR-37 narrow extension protocols (node_rule / dynamic_return / narrowing_facts). Surfaced in the full report
195
209
  # alongside the declarative surfaces; `--capabilities` is the focused view.
196
210
  def narrow_protocol_lines(row)
@@ -249,8 +263,6 @@ module Rigor
249
263
  "signature_paths" => row[:signature_paths].map do |sp|
250
264
  { "path" => sp[:path], "exists" => sp[:exists], "rbs_files" => sp[:rbs_files] }
251
265
  end,
252
- "open_receivers" => row[:open_receivers],
253
- "owns_receivers" => row[:owns_receivers],
254
266
  "produces" => row[:produces],
255
267
  "consumes" => row[:consumes],
256
268
  "block_as_methods" => row[:block_as_methods],
@@ -265,6 +277,16 @@ module Rigor
265
277
  "dynamic_return_receivers" => row[:dynamic_return_receivers],
266
278
  "narrowing_facts_methods" => row[:narrowing_facts_methods],
267
279
  "load_error" => row[:load_error]
280
+ }.merge(receiver_json(row))
281
+ end
282
+
283
+ # ADR-125 — the per-invocation keys are additive; an empty array when the plugin declares none.
284
+ def receiver_json(row)
285
+ {
286
+ "open_receivers" => row[:open_receivers],
287
+ "owns_receivers" => row[:owns_receivers],
288
+ "per_invocation_classes" => row[:per_invocation_classes],
289
+ "per_invocation_non_action_macros" => row[:per_invocation_non_action_macros]
268
290
  }
269
291
  end
270
292
 
@@ -6,6 +6,7 @@ require "optionparser"
6
6
  require_relative "../bleeding_edge"
7
7
  require_relative "../configuration"
8
8
  require_relative "command"
9
+ require_relative "options"
9
10
 
10
11
  module Rigor
11
12
  class CLI
@@ -44,7 +45,7 @@ module Rigor
44
45
  options[:format] = fmt
45
46
  end
46
47
  opt.on("--config=PATH", "Path to a .rigor.yml (default: auto-discovery).") do |path|
47
- options[:config] = path
48
+ options[:config] = Options.config_path(path)
48
49
  end
49
50
  end.parse!(@argv)
50
51
  options
@@ -3,6 +3,7 @@
3
3
  require "optionparser"
4
4
 
5
5
  require_relative "../configuration"
6
+ require_relative "../analysis/path_expansion"
6
7
  require_relative "../effects/config_envelopes"
7
8
  require_relative "../effects/registry"
8
9
  require_relative "options"
@@ -55,8 +56,11 @@ module Rigor
55
56
  configuration = Configuration.load(options.fetch(:config))
56
57
  paths = @argv.empty? ? configuration.paths : @argv
57
58
 
59
+ path_errors = path_diagnostics(paths, configuration)
60
+ report_path_errors(path_errors, options)
61
+
58
62
  observation_roots = observation_roots(configuration, options)
59
- observations = collect_observations(configuration, options, observation_roots)
63
+ observations = collect_observations(configuration, options, observation_roots, paths)
60
64
  generator = SigGen::Generator.new(configuration: configuration, paths: paths,
61
65
  observations: observations,
62
66
  include_private: options.fetch(:include_private),
@@ -69,15 +73,20 @@ module Rigor
69
73
  when :write then dispatch_write(candidates, configuration, options)
70
74
  when :check then dispatch_check(candidates, configuration, options)
71
75
  else
72
- dispatch_print_or_diff(candidates, mode, options, observation_roots)
76
+ dispatch_print_or_diff(candidates, mode, options, observation_roots, path_errors)
73
77
  0
74
78
  end
79
+ status = 1 if path_errors.any?(&:error?)
80
+ report_run_notes(candidates, generator, options)
81
+ status
82
+ end
83
+
84
+ def report_run_notes(candidates, generator, options)
75
85
  report_skipped(candidates, options)
76
86
  report_inline_declared(candidates, options)
77
87
  report_withheld_annotations(candidates, options)
78
88
  report_unrenderable(generator.unrenderable)
79
89
  report_unresolvable_superclasses(generator.unresolvable_superclasses)
80
- status
81
90
  end
82
91
 
83
92
  private
@@ -220,13 +229,38 @@ module Rigor
220
229
  unresolvable.sort.each { |class_name, superclass| @err.puts(" #{class_name} < #{superclass}") }
221
230
  end
222
231
 
223
- def dispatch_print_or_diff(candidates, mode, options, observation_roots)
232
+ # #1991 — the path errors `rigor check` reports for the arguments: a path that is neither a directory nor a
233
+ # `.rb` file. Warned about beside files that will be read, an error when nothing will be, as `check` grades it.
234
+ def path_diagnostics(paths, configuration)
235
+ bad = Analysis::PathExpansion.unusable(paths)
236
+ return [] if bad.empty?
237
+
238
+ any_files = !Analysis::PathExpansion.ruby_files(paths, configuration.exclude_patterns,
239
+ base: configuration.exclude_base).empty?
240
+ Analysis::PathExpansion.diagnostics(bad, any_files: any_files)
241
+ end
242
+
243
+ # Without this a missing path was read as empty, so the run said "No candidates" and exited 0 (#1991). JSON
244
+ # print/diff carries the errors in its payload (see {#path_errors_in_payload?}); every other form says them on
245
+ # stderr, as `rigor check` does.
246
+ def report_path_errors(path_errors, options)
247
+ return if path_errors_in_payload?(options)
248
+
249
+ path_errors.each { |diagnostic| @err.puts(diagnostic.to_s) }
250
+ end
251
+
252
+ def path_errors_in_payload?(options)
253
+ options.fetch(:format) == "json" && %w[print diff].include?(options.fetch(:mode))
254
+ end
255
+
256
+ def dispatch_print_or_diff(candidates, mode, options, observation_roots, path_errors)
224
257
  SigGen::Renderer.new(out: @out).render(
225
258
  candidates: candidates,
226
259
  mode: mode,
227
260
  format: options.fetch(:format),
228
261
  selection: options.fetch(:selection),
229
- observation: observation_report(observation_roots, options)
262
+ observation: observation_report(observation_roots, options),
263
+ diagnostics: path_errors_in_payload?(options) ? path_errors : []
230
264
  )
231
265
  end
232
266
 
@@ -291,7 +325,11 @@ module Rigor
291
325
  # specified, observe the project's test roots: `test_paths:`, or whichever of `spec/` and `test/` exist. With no
292
326
  # root at all the run still succeeds, but says so — otherwise every parameter stays `untyped` with nothing
293
327
  # naming the reason.
294
- def collect_observations(configuration, options, observe_paths)
328
+ #
329
+ # The collector pre-indexes the classes of the paths the run generates for as well as the configured ones, so
330
+ # a call site on a class named only on the command line (`sig-gen ../lib` from a subdirectory, where no
331
+ # config file is discovered and the default `paths:` resolve under the subdirectory) attaches (#2078).
332
+ def collect_observations(configuration, options, observe_paths, paths)
295
333
  return {} if options.fetch(:params) != "observed"
296
334
 
297
335
  if observe_paths.empty?
@@ -299,7 +337,8 @@ module Rigor
299
337
  else
300
338
  warn_missing_test_roots(observe_paths)
301
339
  end
302
- SigGen::ObservationCollector.new(configuration: configuration, paths: observe_paths).collect
340
+ SigGen::ObservationCollector.new(configuration: configuration, paths: observe_paths,
341
+ source_paths: (configuration.paths + paths).uniq).collect
303
342
  end
304
343
 
305
344
  # The roots `--params=observed` reads: `--observe=PATH...` when given, else the project's test roots.
@@ -17,6 +17,7 @@ require_relative "type_of_template_probe"
17
17
  require_relative "command"
18
18
  require_relative "options"
19
19
  require_relative "../project_environment"
20
+ require_relative "type_of_project_scope"
20
21
 
21
22
  module Rigor
22
23
  class CLI
@@ -88,7 +89,7 @@ module Rigor
88
89
  def execute(targets:, options:, buffer: nil)
89
90
  configuration = Configuration.load(options.fetch(:config))
90
91
  environment = project_environment(targets.map(&:file).uniq, configuration, buffer)
91
- base_scope = Scope.empty(environment: environment)
92
+ base_scope = TypeOfProjectScope.build(environment: environment, configuration: configuration, buffer: buffer)
92
93
 
93
94
  results_by_target = Array.new(targets.length)
94
95
  targets.each_with_index.group_by { |target, _index| target.file }.each do |file, indexed_targets|
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../scope"
4
+ require_relative "../protection/discovery_seed"
5
+
6
+ module Rigor
7
+ class CLI
8
+ # Issue #512 — the scope `rigor type-of` probes with: single-file, plus the cross-file tables a prior
9
+ # `rigor check --incremental` left in the snapshot ({Protection::DiscoverySeed.from_snapshot}) when there are
10
+ # any. A probe never re-parses the project for its seed, so without a reusable snapshot this is the bare
11
+ # `Scope.empty` it always was.
12
+ #
13
+ # The seed is the full {Inference::ProjectSeedTables} fold, so a `contested_*` sibling travels with the member
14
+ # it qualifies (ADR-119): this is a recorded whole-index consumer in `admission_census.yml`.
15
+ module TypeOfProjectScope
16
+ module_function
17
+
18
+ def build(environment:, configuration:, buffer: nil)
19
+ base = Scope.empty(environment: environment)
20
+ seed = Protection::DiscoverySeed.from_snapshot(configuration: configuration, buffer: buffer)
21
+ return base if seed.nil? || seed.empty?
22
+
23
+ base.with_discovery(base.discovery.with(**seed))
24
+ end
25
+ end
26
+ end
27
+ end
@@ -156,9 +156,8 @@ module Rigor
156
156
  disable = Array(configuration.disabled_rules).filter_map do |token|
157
157
  disable_token_warning(token.to_s, plugin_ids)
158
158
  end
159
- overrides = configuration.severity_overrides.keys.filter_map do |key|
160
- override_key_warning(key.to_s, plugin_ids)
161
- end
159
+ keys = configuration.severity_overrides.keys.map(&:to_s)
160
+ overrides = keys.filter_map { |key| override_key_warning(key, plugin_ids, keys) }
162
161
  disable + overrides
163
162
  end
164
163
 
@@ -172,14 +171,26 @@ module Rigor
172
171
  # `plugin.`-qualified key naming an unconfigured plugin, and any key outside the built-in vocabulary that is
173
172
  # not a qualified plugin id (a plugin's bare rule included), is inert. A reserved run-level row is the home of
174
173
  # this map, so naming one is never flagged; `all` is not a family wildcard here, so it is.
175
- def self.override_key_warning(key, plugin_ids)
176
- reason = override_key_problem(key, plugin_ids)
174
+ def self.override_key_warning(key, plugin_ids, present = [])
175
+ reason = legacy_alias_problem(key, present) || override_key_problem(key, plugin_ids)
177
176
  return nil if reason.nil?
178
177
 
179
178
  Warning.new(kind: :severity_override, message: "severity_overrides: #{key.inspect} #{reason}",
180
179
  fields: { "token" => key })
181
180
  end
182
181
 
182
+ # #2005 — `disable:` expands a legacy alias to its canonical id, but the severity resolver matches canonical ids
183
+ # only, so an alias key reaches no row. Name the canonical id rather than accept the key. `present` holds the
184
+ # keys the map already has: when the canonical id is one of them, the advice to write it is already followed.
185
+ def self.legacy_alias_problem(key, present)
186
+ canonical = Analysis::CheckRules::LEGACY_RULE_ALIASES[key]
187
+ return nil if canonical.nil?
188
+
189
+ reason = "is a legacy alias of `#{canonical}`; the override has no effect"
190
+ present.include?(canonical) ? reason : "#{reason}, so write `#{canonical}`"
191
+ end
192
+ private_class_method :legacy_alias_problem
193
+
183
194
  def self.override_key_problem(key, plugin_ids)
184
195
  return "is not a recognized rule id; the override has no effect" if key == "all"
185
196
  return nil unless Array(Analysis::CheckRules.resolve_rule_token(key)).empty?
@@ -321,12 +321,22 @@ module Rigor
321
321
 
322
322
  # The test roots a run uses: the declared `test_paths:` as written (already resolved against the
323
323
  # config file's directory by {.load}), or — when the key is unset — whichever of
324
- # {CONVENTIONAL_TEST_PATHS} is a directory under `root`, as absolute paths. `root` is the directory
325
- # the run treats as the project root (the CLI's CWD), as for `signature_paths:` auto-detection.
326
- def resolved_test_paths(root: Dir.pwd)
324
+ # {CONVENTIONAL_TEST_PATHS} is a directory under a root, as absolute paths. The roots, unless `root` names
325
+ # one, are the directory of the config file in use, the one `paths:` and a declared `test_paths:` resolve
326
+ # against, and then the working directory, read only when the first holds neither. Auto-detecting against
327
+ # the working directory alone found nothing for a run from a subdirectory, or for the MCP server started
328
+ # elsewhere, though both name the config file (#2078); a config file kept in a subdirectory
329
+ # (`--config=config/rigor.yml`) still finds the project's `spec/` from the project root.
330
+ def resolved_test_paths(root: nil)
327
331
  return test_paths unless test_paths.nil?
328
332
 
329
- CONVENTIONAL_TEST_PATHS.map { |dir| File.absolute_path(dir, root) }.select { |dir| File.directory?(dir) }
333
+ roots = root ? [root] : [exclude_base, Dir.pwd].uniq
334
+ roots.each do |dir|
335
+ found = CONVENTIONAL_TEST_PATHS.map { |test_dir| File.absolute_path(test_dir, dir) }
336
+ .select { File.directory?(it) }
337
+ return found unless found.empty?
338
+ end
339
+ []
330
340
  end
331
341
 
332
342
  # Loads a configuration file.
@@ -10,6 +10,7 @@ require_relative "member_consistency"
10
10
  require_relative "required_features"
11
11
  require_relative "gated_signature_guard"
12
12
  require_relative "gated_signature_plan"
13
+ require_relative "ruby_library_roots"
13
14
  require_relative "../rooted_glob"
14
15
 
15
16
  module Rigor
@@ -1653,9 +1654,10 @@ module Rigor
1653
1654
  # arity. Read back by the env-cache producer, which rebuilds the env from the loader's readers on
1654
1655
  # a miss — a list it cannot read is a stand-down that runs only on a `--no-cache` build.
1655
1656
  def initialize(libraries: [], signature_paths: [], cache_store: nil, virtual_rbs: [],
1656
- deferred_signature_paths: [])
1657
+ deferred_signature_paths: [], project_signature_paths: [], required_features: [])
1657
1658
  @libraries = libraries.map(&:to_s).freeze
1658
1659
  @signature_paths = signature_paths.map { |p| Pathname(p) }.freeze
1660
+ assign_reopen_inputs(project_signature_paths, required_features)
1659
1661
  # Issue #610 — the subset of `signature_paths:` a bundled PLUGIN contributed. Loaded last and
1660
1662
  # allowed to stand down against a colliding generic arity ({.add_deferred_signatures}). Every entry
1661
1663
  # is also in `@signature_paths`, so the env cache descriptor already digests these files.
@@ -1764,6 +1766,9 @@ module Rigor
1764
1766
  # a partial sidecar as authoritative made `sig-gen --write` on redmine report 5.9x more
1765
1767
  # `call.undefined-method` than the same project with no `sig/` at all.
1766
1768
  #
1769
+ # A class declared only by RBS synthesized from the project's own source joins the set too
1770
+ # ({#inline_only_entry?}, issue #1934).
1771
+ #
1767
1772
  # Memoised per loader: one pass over `class_decls` (~1-2k entries), and every consumer is a check
1768
1773
  # rule on the analysis hot path. Attribution is by buffer NAME, which survives the ADR-54 environment
1769
1774
  # cache since #725 — an environment blob written before that carries the `<cached>` sentinel instead,
@@ -1777,10 +1782,14 @@ module Rigor
1777
1782
  return Set.new.freeze if environment.nil?
1778
1783
 
1779
1784
  project_files = self.class.project_sig_files(@signature_paths)
1780
- return Set.new.freeze if project_files.empty?
1785
+ virtual_names = @virtual_rbs.to_set(&:first)
1786
+ return Set.new.freeze if project_files.empty? && virtual_names.empty?
1781
1787
 
1782
1788
  names = environment.class_decls.each_with_object(Set.new) do |(rbs_name, entry), acc|
1783
- acc << rbs_name.to_s.delete_prefix("::") if self.class.project_entry?(entry, project_files)
1789
+ next unless self.class.project_entry?(entry, project_files) ||
1790
+ inline_only_entry?(entry, virtual_names, project_files)
1791
+
1792
+ acc << rbs_name.to_s.delete_prefix("::")
1784
1793
  end
1785
1794
  names.freeze
1786
1795
  rescue ::RBS::BaseError
@@ -1788,6 +1797,92 @@ module Rigor
1788
1797
  end
1789
1798
  private :build_project_declared_classes
1790
1799
 
1800
+ # Issue #1934 — a class whose every declaration a source RBS synthesizer (`rigor-rbs-inline`) wrote from
1801
+ # the project's own `.rb` files is the project's as much as a `sig/` sidecar is: the declaration is the
1802
+ # inline comments of the source under analysis, so a method another project file adds to the class (a
1803
+ # plain `def`, or an `alias_method` whose target the inline declaration names) is the second file of an
1804
+ # ordinary class, not an ADR-17 monkey-patch. Reading the synthesized RBS as bundled reported
1805
+ # `alias_method :title, :label` from another file while the `alias` keyword, which upstream translates,
1806
+ # read clean.
1807
+ #
1808
+ # EVERY declaration, not only the primary one, and only virtual or `sig/` buffers: a core, stdlib or gem
1809
+ # class an inline-annotated file reopens (`class String` with a `#:` method) keeps its bundled
1810
+ # declaration, and a project `def` on it stays the monkey-patch ADR-17 reports. A declaration whose
1811
+ # buffer cannot be attributed (the pre-#725 `<cached>` sentinel) fails the test, which keeps the
1812
+ # pre-#1934 reading.
1813
+ def inline_only_entry?(entry, virtual_names, project_files)
1814
+ return false if virtual_names.empty?
1815
+
1816
+ project_authored_entry?(entry, virtual_names, project_files)
1817
+ end
1818
+ private :inline_only_entry?
1819
+
1820
+ # Every declaration of `entry` sits in a project `sig/` file or in RBS synthesized from the project's
1821
+ # source; a declaration whose buffer cannot be attributed fails the test.
1822
+ def project_authored_entry?(entry, virtual_names, project_files)
1823
+ buffers = self.class.entry_declarations(entry).map { |decl| self.class.declaration_buffer_name(decl) }
1824
+ return false if buffers.empty? || buffers.any?(&:nil?)
1825
+
1826
+ buffers.all? { |name| virtual_names.include?(name) || project_files.include?(File.absolute_path(name)) }
1827
+ end
1828
+ private :project_authored_entry?
1829
+
1830
+ # Issue #2174 — true when every RBS declaration of `class_name` is the project's own (rbs-inline `#:`
1831
+ # annotations, or a file under the configured `signature_paths:`) and its root constant belongs to a library
1832
+ # Ruby ships ({RubyLibraryRoots}) that the project's source requires ({RequiredFeatures.features_of}). Such
1833
+ # a declaration reopens a library class whose real signatures did not load (`class Matrix` with one
1834
+ # annotated method beside `require "matrix"`, and no `matrix` RBS), so it lists only the methods the
1835
+ # project added: the class's surface is not enumerable from it, and a call it omits is not evidence of an
1836
+ # undefined method. A library whose RBS did load contributes a declaration of its own (core, stdlib, a gem's
1837
+ # `sig/`, an rbs collection, a plugin's `sig/`), which takes the class out of this predicate. A project
1838
+ # class that shares a required library's root name declines too; that is the accepted cost.
1839
+ def ruby_library_reopen?(class_name)
1840
+ name = class_name.to_s.delete_prefix("::")
1841
+ return false if @required_features.empty? || !project_authored_classes.include?(name)
1842
+
1843
+ segments = ruby_library_roots[name.split("::").first]
1844
+ !segments.nil? && @required_features.any? { |feature| segments.include?(feature.split("/").first) }
1845
+ end
1846
+
1847
+ def ruby_library_roots
1848
+ @state[:ruby_library_roots] ||= RubyLibraryRoots.shared
1849
+ end
1850
+
1851
+ # Issue #2174 — the subset of `signature_paths:` the user configured (every entry is also in
1852
+ # `@signature_paths`), and the features the project's source requires: read only by
1853
+ # {#ruby_library_reopen?}.
1854
+ def assign_reopen_inputs(project_signature_paths, required_features)
1855
+ @project_signature_paths = project_signature_paths.map { |p| Pathname(p) }.freeze
1856
+ @required_features = required_features.map { |feature| feature.to_s.dup.freeze }.freeze
1857
+ end
1858
+ private :assign_reopen_inputs
1859
+ private :ruby_library_roots
1860
+
1861
+ # The class / module names every one of whose declarations is the project's own
1862
+ # ({#project_authored_entry?}, which {#inline_only_entry?} asks behind its inline-only gate).
1863
+ def project_authored_classes
1864
+ @state[:project_authored_classes] ||= build_project_authored_classes
1865
+ end
1866
+ private :project_authored_classes
1867
+
1868
+ def build_project_authored_classes
1869
+ environment = env
1870
+ return Set.new.freeze if environment.nil?
1871
+
1872
+ # Only the user's configured `signature_paths:`: `@signature_paths` also holds plugin, gem-shipped,
1873
+ # rbs-collection and overlay directories, whose declarations are a library's own.
1874
+ project_files = self.class.project_sig_files(@project_signature_paths)
1875
+ virtual_names = @virtual_rbs.to_set(&:first)
1876
+ return Set.new.freeze if project_files.empty? && virtual_names.empty?
1877
+
1878
+ environment.class_decls.each_with_object(Set.new) do |(rbs_name, entry), acc|
1879
+ acc << rbs_name.to_s.delete_prefix("::") if project_authored_entry?(entry, virtual_names, project_files)
1880
+ end.freeze
1881
+ rescue ::RBS::BaseError
1882
+ Set.new.freeze
1883
+ end
1884
+ private :build_project_authored_classes
1885
+
1791
1886
  # Issue #527 — the class / module names whose PRIMARY declaration lives in the `rbs` gem's own
1792
1887
  # `core/` or `stdlib/` tree, top-level prefix stripped.
1793
1888
  #
@@ -1808,9 +1903,23 @@ module Rigor
1808
1903
  @state[:core_stdlib_classes] ||= build_core_stdlib_classes
1809
1904
  end
1810
1905
 
1811
- def build_core_stdlib_classes
1906
+ # Issue #1154 — the subset of {#core_stdlib_classes} whose primary declaration is in `core/` alone: the classes
1907
+ # the interpreter implements, as opposed to a stdlib library written in Ruby (`Logger` rewrites `@level` in
1908
+ # its `initialize`). Same attribution, same cache behaviour.
1909
+ def core_class?(class_name)
1910
+ (@state[:core_classes] ||= build_core_stdlib_classes(CORE_ROOTS)).include?(class_name.to_s.delete_prefix("::"))
1911
+ end
1912
+
1913
+ CORE_ROOTS = begin
1914
+ [::RBS::EnvironmentLoader::DEFAULT_CORE_ROOT].compact.map { |root| "#{root}/".freeze }.freeze
1915
+ rescue StandardError
1916
+ [].freeze
1917
+ end
1918
+ private_constant :CORE_ROOTS
1919
+
1920
+ def build_core_stdlib_classes(roots = CORE_STDLIB_ROOTS)
1812
1921
  environment = env
1813
- return Set.new.freeze if environment.nil? || CORE_STDLIB_ROOTS.empty?
1922
+ return Set.new.freeze if environment.nil? || roots.empty?
1814
1923
 
1815
1924
  names = environment.class_decls.each_with_object(Set.new) do |(rbs_name, entry), acc|
1816
1925
  decl = self.class.primary_decl_for(entry)
@@ -1819,7 +1928,7 @@ module Rigor
1819
1928
  path = self.class.declaration_buffer_name(decl)
1820
1929
  next if path.nil?
1821
1930
 
1822
- acc << rbs_name.to_s.delete_prefix("::") if CORE_STDLIB_ROOTS.any? { |root| path.start_with?(root) }
1931
+ acc << rbs_name.to_s.delete_prefix("::") if roots.any? { |root| path.start_with?(root) }
1823
1932
  end
1824
1933
  names.freeze
1825
1934
  rescue ::RBS::BaseError