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
@@ -10,6 +10,7 @@ require_relative "../analysis/shadow_harness"
10
10
  require_relative "../analysis/run_cache_key"
11
11
  require_relative "../environment"
12
12
  require_relative "../analysis/path_expansion"
13
+ require_relative "../rooted_glob"
13
14
 
14
15
  module Rigor
15
16
  module Cache
@@ -172,7 +173,16 @@ module Rigor
172
173
  # 38: issue #1898 — the payload gains `effect_plugin_digest`, the plugin effect-fact digest the collections were
173
174
  # stamped under, which a recheck now checks before its served collections reach the envelope pass. A pre-38
174
175
  # blob carries none, so the gate rejects it and the next run is cold.
175
- SCHEMA = 38
176
+ # 39: issue #1984 — the visibility walk records the singleton slots (`[:singleton, name]`) and contests the ones
177
+ # two files disagree on, which a pre-39 bundle carries none of, so a warm run would answer a singleton call a
178
+ # cold run reports.
179
+ # 40: issue #1553 — the payload gains `plugin_reads`, the plugin reads each analysed file's last analysis made,
180
+ # which a recheck validates to put a reader of a changed input into the closure. A pre-40 blob carries none,
181
+ # so its readers would be served stale; the gate rejects it and the next run is cold.
182
+ # 41: issue #1583 — the payload gains `discovery_digests`, the packed digest of each file a run over path
183
+ # arguments discovered without analysing, which a recheck compares to re-baseline when one moved. A pre-41
184
+ # blob carries none; the gate rejects it and the next run is cold.
185
+ SCHEMA = 41
176
186
 
177
187
  # The persisted per-file state.
178
188
  # `cache` maps an analyzed file to its diagnostics.
@@ -227,6 +237,9 @@ module Rigor
227
237
  # Issues #796 / #794 — the run-level rows a narrowed run replays instead of re-deriving; see
228
238
  # docs/internal-spec/cache.md § "Run-level rows the snapshot carries" for why exactly these two are
229
239
  # fresh under the fingerprint and why no other `.rigor.yml`-level row may join them.
240
+ # Issue #1583 — `discovery_digests` maps each file a run over path arguments discovered but did not analyse
241
+ # (#684's widening to the configured `paths:`) to its ADR-87 packed stat-digest entry; `{}` on a run over
242
+ # the configured paths. A recheck that finds one moved, or the set changed, re-baselines.
230
243
  # Issue #1898 — `effect_plugin_digest` is {Rigor::Effects::PluginFacts#digest} at the run that collected
231
244
  # `effect_collections` (nil when collection was off). A plugin stamps its attributions and edges into a file's
232
245
  # collection, and `effects_identity` is plugin-blind (it is compared before any plugin loads), so the session
@@ -236,10 +249,11 @@ module Rigor
236
249
  :missing, :class_decls, :constant_decls, :seed_bundles, :plugin_fact_digest,
237
250
  :return_summaries, :param_table,
238
251
  :effect_collections, :effects_identity, :run_level_rows,
239
- :effect_plugin_digest) do
240
- # Defaults to nil so a payload built without it (an older caller, a spec fixture) reads as "unknown",
241
- # which the session treats as moved.
242
- def initialize(effect_plugin_digest: nil, **rest)
252
+ :effect_plugin_digest, :plugin_reads, :discovery_digests) do
253
+ # `effect_plugin_digest` defaults to nil so a payload built without it (an older caller, a spec fixture)
254
+ # reads as "unknown", which the session treats as moved. `plugin_reads` defaults to no reads, and
255
+ # `discovery_digests` to no discovery-only files.
256
+ def initialize(effect_plugin_digest: nil, plugin_reads: {}, discovery_digests: {}, **rest)
243
257
  super
244
258
  end
245
259
  end
@@ -367,7 +381,7 @@ module Rigor
367
381
  # checkouts.
368
382
  def self.digest_signature_paths(signature_paths)
369
383
  globbed = Array(signature_paths).flat_map do |entry|
370
- File.directory?(entry) ? Dir.glob(File.join(entry, "**", "*.rbs")) : [entry]
384
+ File.directory?(entry) ? RootedGlob.glob(entry, "**/*.rbs") : [entry]
371
385
  end
372
386
  files = globbed.select { |path| File.file?(path) }.sort
373
387
  digest = Digest::SHA256.new
@@ -449,7 +463,9 @@ module Rigor
449
463
  effect_collections: data[:effect_collections] || {},
450
464
  effects_identity: data[:effects_identity],
451
465
  run_level_rows: data[:run_level_rows],
452
- effect_plugin_digest: data[:effect_plugin_digest]
466
+ effect_plugin_digest: data[:effect_plugin_digest],
467
+ plugin_reads: data[:plugin_reads] || {},
468
+ discovery_digests: data.fetch(:discovery_digests, {})
453
469
  )
454
470
  end
455
471
  private :payload_from
@@ -475,7 +491,9 @@ module Rigor
475
491
  effect_collections: payload.effect_collections,
476
492
  effects_identity: payload.effects_identity,
477
493
  run_level_rows: payload.run_level_rows,
478
- effect_plugin_digest: payload.effect_plugin_digest
494
+ effect_plugin_digest: payload.effect_plugin_digest,
495
+ plugin_reads: payload.plugin_reads,
496
+ discovery_digests: payload.discovery_digests
479
497
  )
480
498
  # #1507 — every `--incremental` run that analysed anything rewrites this whole blob, so its deflate is
481
499
  # on the edit path; see {Store::DEFLATE_LEVEL}.
@@ -5,6 +5,7 @@ require "digest"
5
5
  require_relative "descriptor"
6
6
  require_relative "engine_source"
7
7
  require_relative "file_digest"
8
+ require_relative "../rooted_glob"
8
9
 
9
10
  module Rigor
10
11
  module Cache
@@ -110,7 +111,7 @@ module Rigor
110
111
  roots.flat_map do |root|
111
112
  next [] unless root.directory?
112
113
 
113
- Dir.glob(root.join("**", "*.rbs")).map do |path|
114
+ RootedGlob.glob(root, "**/*.rbs").map do |path|
114
115
  digest = FileDigest.hexdigest(path)
115
116
  if comparator == :stat
116
117
  Descriptor::FileEntry.stat(path: path, digest: digest)
@@ -9,6 +9,7 @@ require "zlib"
9
9
 
10
10
  require_relative "../version"
11
11
  require_relative "descriptor"
12
+ require_relative "../rooted_glob"
12
13
 
13
14
  module Rigor
14
15
  module Cache
@@ -176,7 +177,7 @@ module Rigor
176
177
  subdir = File.join(root, child)
177
178
  next nil unless File.directory?(subdir)
178
179
 
179
- entries = Dir.glob(File.join(subdir, "**", "*.entry"))
180
+ entries = RootedGlob.glob(subdir, "**/*.entry")
180
181
  next nil if entries.empty?
181
182
 
182
183
  { id: child, entries: entries.size, bytes: entries.sum { |e| File.size(e) } }
@@ -709,7 +710,7 @@ module Rigor
709
710
 
710
711
  def cleanup_stale_temp_files
711
712
  cutoff = Time.now - STALE_TEMP_FILE_AGE_SECONDS
712
- Dir.glob(File.join(@root, "**", "*.tmp.*")).each do |path|
713
+ RootedGlob.glob(@root, "**/*.tmp.*").each do |path|
713
714
  next unless File.file?(path)
714
715
  next if File.mtime(path) > cutoff
715
716
 
@@ -771,7 +772,7 @@ module Rigor
771
772
  # Returns an array of `{ path:, producer:, mtime:, bytes: }` hashes for every `.entry` file under the
772
773
  # cache root, skipping unreadable entries.
773
774
  def collect_entry_stats
774
- Dir.glob(File.join(@root, "**", "*.entry")).filter_map do |path|
775
+ RootedGlob.glob(@root, "**/*.entry").filter_map do |path|
775
776
  stat = File.stat(path)
776
777
  producer = producer_id_for_entry(path)
777
778
  next nil if producer.nil?
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../rooted_glob"
4
+
3
5
  module Rigor
4
6
  class CLI
5
7
  # Base class for the `rigor <subcommand>` command objects.
@@ -26,7 +28,7 @@ module Rigor
26
28
  paths = []
27
29
  args.each do |arg|
28
30
  if File.directory?(arg)
29
- paths.concat(Dir.glob(File.join(arg, "**/*.rb")))
31
+ paths.concat(RootedGlob.glob(arg, "**/*.rb"))
30
32
  elsif File.file?(arg)
31
33
  paths << arg
32
34
  else
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "command"
4
4
  require_relative "doc_links"
5
+ require_relative "../rooted_glob"
5
6
 
6
7
  module Rigor
7
8
  class CLI
@@ -186,8 +187,8 @@ module Rigor
186
187
  # `install.md` sits at the docs root (category `guide`); the rest are manual / handbook chapters.
187
188
  def discover_docs
188
189
  paths = [File.join(DOCS_ROOT, "install.md")]
189
- paths += Dir.glob(File.join(MANUAL_ROOT, "**", "*.md")) # Dir.glob is already sorted
190
- paths += Dir.glob(File.join(HANDBOOK_ROOT, "**", "*.md"))
190
+ paths += RootedGlob.glob(MANUAL_ROOT, "**/*.md") # Dir.glob is already sorted
191
+ paths += RootedGlob.glob(HANDBOOK_ROOT, "**/*.md")
191
192
  paths.select { |path| File.file?(path) }.map { |path| doc_entry(path) }
192
193
  end
193
194
 
@@ -364,7 +364,7 @@ module Rigor
364
364
  status: :warn,
365
365
  message: "#{gap.locked_gems.join(', ')} #{gap.locked_gems.one? ? 'is a dependency' : 'are dependencies'} " \
366
366
  "of this project and #{gap.plugin_gem} models it, but it is not enabled",
367
- hint: "Add `#{gap.plugin_gem}` to `.rigor.yml` `plugins:`, or leave it out deliberately."
367
+ hint: "Add `#{gap.plugin_gem}` to `.rigor.yml` `plugins:`, or decline it with an `enabled: false` entry."
368
368
  }
369
369
  end
370
370
 
@@ -10,7 +10,7 @@ require_relative "command"
10
10
  module Rigor
11
11
  class CLI
12
12
  # Executes `rigor explain <rule>`. Prints the catalog entry for one canonical rule id, a legacy alias, or a family
13
- # wildcard (`call`, `flow`, `assert`, `dump`, `def`).
13
+ # wildcard (`call`, `flow`, `def`, `assert`, `dump`, `global`, `suppression`, `static`, `effect`).
14
14
  #
15
15
  # Without arguments lists every rule's id and one-line summary.
16
16
  #
@@ -74,7 +74,8 @@ module Rigor
74
74
  end
75
75
  @out.puts("")
76
76
  @out.puts("Run `rigor explain <rule>` for the full description.")
77
- @out.puts("Family wildcards (`call`, `flow`, `assert`, `dump`, `def`) print every rule under that prefix.")
77
+ @out.puts("Family wildcards (#{Analysis::CheckRules::RULE_FAMILIES.map { |f| "`#{f}`" }.join(', ')}) " \
78
+ "print every rule under that prefix.")
78
79
  @out.puts("")
79
80
  @out.puts("`rigor sig-gen` skip reasons:")
80
81
  @out.puts("")
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "command"
4
+ require_relative "../rooted_glob"
4
5
 
5
6
  module Rigor
6
7
  class CLI
@@ -204,7 +205,7 @@ module Rigor
204
205
  candidate = File.join(dir, "lib", "#{plugin.fetch(:name)}.rb")
205
206
  return candidate if File.file?(candidate)
206
207
 
207
- Dir.glob(File.join(dir, "lib", "*.rb")).min
208
+ RootedGlob.glob(dir, "lib/*.rb").min
208
209
  end
209
210
 
210
211
  def docker_note
@@ -13,6 +13,7 @@ require_relative "../reflection"
13
13
  require_relative "../type/combinator"
14
14
  require_relative "plugins_renderer"
15
15
  require_relative "command"
16
+ require_relative "../rooted_glob"
16
17
 
17
18
  module Rigor
18
19
  class CLI
@@ -297,7 +298,7 @@ module Rigor
297
298
  def rbs_file_count(dir)
298
299
  return 0 unless File.directory?(dir)
299
300
 
300
- Dir.glob(File.join(dir, "**", "*.rbs")).size
301
+ RootedGlob.glob(dir, "**/*.rbs").size
301
302
  end
302
303
 
303
304
  def entry_gem_name(entry)
@@ -55,7 +55,8 @@ module Rigor
55
55
  configuration = Configuration.load(options.fetch(:config))
56
56
  paths = @argv.empty? ? configuration.paths : @argv
57
57
 
58
- observations = collect_observations(configuration, options)
58
+ observation_roots = observation_roots(configuration, options)
59
+ observations = collect_observations(configuration, options, observation_roots)
59
60
  generator = SigGen::Generator.new(configuration: configuration, paths: paths,
60
61
  observations: observations,
61
62
  include_private: options.fetch(:include_private),
@@ -68,7 +69,7 @@ module Rigor
68
69
  when :write then dispatch_write(candidates, configuration, options)
69
70
  when :check then dispatch_check(candidates, configuration, options)
70
71
  else
71
- dispatch_print_or_diff(candidates, mode, options)
72
+ dispatch_print_or_diff(candidates, mode, options, observation_roots)
72
73
  0
73
74
  end
74
75
  report_skipped(candidates, options)
@@ -219,12 +220,13 @@ module Rigor
219
220
  unresolvable.sort.each { |class_name, superclass| @err.puts(" #{class_name} < #{superclass}") }
220
221
  end
221
222
 
222
- def dispatch_print_or_diff(candidates, mode, options)
223
+ def dispatch_print_or_diff(candidates, mode, options, observation_roots)
223
224
  SigGen::Renderer.new(out: @out).render(
224
225
  candidates: candidates,
225
226
  mode: mode,
226
227
  format: options.fetch(:format),
227
- selection: options.fetch(:selection)
228
+ selection: options.fetch(:selection),
229
+ observation: observation_report(observation_roots, options)
228
230
  )
229
231
  end
230
232
 
@@ -289,11 +291,9 @@ module Rigor
289
291
  # specified, observe the project's test roots: `test_paths:`, or whichever of `spec/` and `test/` exist. With no
290
292
  # root at all the run still succeeds, but says so — otherwise every parameter stays `untyped` with nothing
291
293
  # naming the reason.
292
- def collect_observations(configuration, options)
294
+ def collect_observations(configuration, options, observe_paths)
293
295
  return {} if options.fetch(:params) != "observed"
294
296
 
295
- observe_paths = options.fetch(:observe)
296
- observe_paths = configuration.resolved_test_paths if observe_paths.empty?
297
297
  if observe_paths.empty?
298
298
  warn_no_test_roots(configuration)
299
299
  else
@@ -302,15 +302,36 @@ module Rigor
302
302
  SigGen::ObservationCollector.new(configuration: configuration, paths: observe_paths).collect
303
303
  end
304
304
 
305
- # A declared root that does not exist is read as empty, so name it: `sig-gen` is the only command that reads
306
- # the test roots, and without this the run looks exactly like one whose tests pass no typed arguments.
305
+ # The roots `--params=observed` reads: `--observe=PATH...` when given, else the project's test roots.
306
+ def observation_roots(configuration, options)
307
+ return [] if options.fetch(:params) != "observed"
308
+
309
+ observe_paths = options.fetch(:observe)
310
+ observe_paths.empty? ? configuration.resolved_test_paths : observe_paths
311
+ end
312
+
313
+ # #1398 — the same fact the stderr notes carry, as data for `--format=json` (and the MCP tool, whose
314
+ # stderr nobody reads). Absent under `--params=untyped`, where no root is read. Paths are spelled as
315
+ # {#observation_roots} yields them: `--observe=PATH` as typed, `test_paths:` as the configuration
316
+ # resolved them, an auto-detected `spec/` / `test/` absolute.
317
+ def observation_report(observe_paths, options)
318
+ return nil if options.fetch(:params) != "observed"
319
+
320
+ present, missing = observe_paths.uniq.partition { |path| SigGen::ObservationCollector.readable_root?(path) }
321
+ { paths: present, missing: missing }
322
+ end
323
+
324
+ # A declared root that does not exist (or is not a directory or `.rb` file) is read as empty, so name it:
325
+ # `sig-gen` is the only command that reads the test roots, and without this the run looks exactly like one
326
+ # whose tests pass no typed arguments.
307
327
  def warn_missing_test_roots(observe_paths)
308
- missing = observe_paths.reject { |path| File.exist?(path) }
328
+ missing = observe_paths.reject { |path| SigGen::ObservationCollector.readable_root?(path) }
309
329
  return if missing.empty?
310
330
 
311
331
  consequence = missing.size == observe_paths.size ? "; every parameter stays untyped" : ""
312
332
  @err.puts("rigor sig-gen: no call sites are observed from #{missing.map(&:inspect).join(', ')}, " \
313
- "which does not exist#{consequence}. Check `test_paths:` or --observe=PATH.")
333
+ "which does not exist or is not a directory or `.rb` file#{consequence}. " \
334
+ "Check `test_paths:` or --observe=PATH.")
314
335
  end
315
336
 
316
337
  def warn_no_test_roots(configuration)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "command"
4
+ require_relative "../rooted_glob"
4
5
 
5
6
  module Rigor
6
7
  class CLI
@@ -227,7 +228,7 @@ module Rigor
227
228
  return [] unless File.directory?(dir)
228
229
 
229
230
  # `Dir.glob` returns lexicographically sorted paths (Ruby 3.0+), so the `NN-` prefixes already drive read order.
230
- Dir.glob(File.join(dir, "*.md"))
231
+ RootedGlob.glob(dir, "*.md")
231
232
  end
232
233
 
233
234
  def name_error(name)
@@ -3,6 +3,7 @@
3
3
  require "yaml"
4
4
 
5
5
  require_relative "../plugin_gap_advisory"
6
+ require_relative "../rooted_glob"
6
7
 
7
8
  module Rigor
8
9
  class CLI
@@ -78,7 +79,7 @@ module Rigor
78
79
  vscode = File.join(@root, ".vscode")
79
80
  return :none unless File.directory?(vscode)
80
81
 
81
- files = Dir.glob(File.join(vscode, "*.json"))
82
+ files = RootedGlob.glob(vscode, "*.json")
82
83
  return :unwired if files.empty?
83
84
 
84
85
  files.any? { |path| file_mentions_rigor?(path) } ? :wired : :unwired
@@ -103,7 +104,7 @@ module Rigor
103
104
  end
104
105
 
105
106
  def ci_config_files
106
- files = Dir.glob(File.join(@root, ".github", "workflows", "*.{yml,yaml}"))
107
+ files = RootedGlob.glob(@root, ".github/workflows/*.{yml,yaml}")
107
108
  gitlab = File.join(@root, ".gitlab-ci.yml")
108
109
  files << gitlab if File.file?(gitlab)
109
110
  files
@@ -15,6 +15,7 @@ require_relative "../cache/store"
15
15
  require_relative "options"
16
16
  require_relative "command"
17
17
  require_relative "../project_environment"
18
+ require_relative "../rooted_glob"
18
19
 
19
20
  module Rigor
20
21
  class CLI
@@ -51,7 +52,7 @@ module Rigor
51
52
  paths = @argv.empty? ? configuration.paths : @argv
52
53
  establish_root(configuration)
53
54
  scan_cache = open_scan_cache(configuration)
54
- declarations, references, dynamic_uses, shadows = scan(paths, configuration, scan_cache)
55
+ declarations, references, dynamic_uses, shadows, ancestries = scan(paths, configuration, scan_cache)
55
56
  references.concat(signature_references(configuration))
56
57
  dynamic_uses.concat(template_mentions(declarations, scan_cache))
57
58
 
@@ -60,7 +61,7 @@ module Rigor
60
61
  references.concat(plugin_references(contribution.references))
61
62
  graph = build_graph(configuration, options, contribution,
62
63
  declarations: declarations, references: references, dynamic_uses: dynamic_uses,
63
- shadows: shadows)
64
+ shadows: shadows, ancestries: ancestries)
64
65
  emit(graph.report, options, supply: root_supply(contribution.roots, declarations))
65
66
  scan_cache.save
66
67
  0
@@ -109,9 +110,9 @@ module Rigor
109
110
  cwd == @root || inside?(cwd, @root) ? [@root] : [@root, cwd]
110
111
  end
111
112
 
112
- def build_graph(configuration, options, contribution, declarations:, references:, dynamic_uses:, shadows:)
113
+ def build_graph(configuration, options, contribution, declarations:, **scanned)
113
114
  Analysis::Reachability::Graph.new(
114
- declarations: declarations, references: references, dynamic_uses: dynamic_uses, shadows: shadows,
115
+ declarations: declarations, **scanned,
115
116
  root_fqns: root_fqns(declarations, options.fetch(:entry_points)) + contribution.roots,
116
117
  foreign: foreign_predicate(configuration)
117
118
  )
@@ -160,16 +161,18 @@ module Rigor
160
161
  # is the one the report prints; any other spelling's declarations are shadows, as a wider-corpus
161
162
  # file's are (#1732).
162
163
  def scan(paths, configuration, cache)
163
- out = { declarations: [], references: [], dynamic_uses: [], shadows: [] }
164
+ out = { declarations: [], references: [], dynamic_uses: [], shadows: [], ancestries: [] }
164
165
  file_spellings(paths, configuration).sort.each do |real, list|
165
166
  primary, analysed = list.find { |_, declared| declared } || list.first
166
167
  result = cache.serve(:scan, primary) { read_and_scan(primary, real, configuration) }
167
168
  next if result.nil?
168
169
 
169
170
  out[analysed ? :declarations : :shadows].concat(result.declarations)
171
+ # An ancestry edge is a fact about the program, not about the file's role, so one spelling carries it.
172
+ out[:ancestries].concat(result.ancestries)
170
173
  list.map(&:first).each { |file| add_spelling(out, result, file, real, primary) }
171
174
  end
172
- out.values_at(:declarations, :references, :dynamic_uses, :shadows)
175
+ out.values_at(:declarations, :references, :dynamic_uses, :shadows, :ancestries)
173
176
  end
174
177
 
175
178
  def add_spelling(out, result, file, real, primary)
@@ -289,7 +292,7 @@ module Rigor
289
292
  Array(configuration.signature_paths).flat_map do |dir|
290
293
  next [] unless File.directory?(dir)
291
294
 
292
- Dir.glob(File.join(dir, "**/*.rbs")).flat_map { |file| Analysis::Reachability::SignatureScan.call(file) }
295
+ RootedGlob.glob(dir, "**/*.rbs").flat_map { |file| Analysis::Reachability::SignatureScan.call(file) }
293
296
  end
294
297
  end
295
298
 
data/lib/rigor/cli.rb CHANGED
@@ -250,8 +250,9 @@ module Rigor
250
250
  # ships (run `rigor explain <rule>` for what
251
251
  # each one catches):
252
252
  #{rule_ids_comment}
253
- # A bare family token (`call`, `flow`,
254
- # `assert`, `dump`, `def`) wildcards every
253
+ # A bare family token (`call`, `flow`, `def`,
254
+ # `assert`, `dump`, `global`, `suppression`,
255
+ # `static`, `effect`, `pre-eval`) wildcards every
255
256
  # rule under that prefix. Legacy unprefixed
256
257
  # names (`undefined-method`, …) still
257
258
  # resolve. In-source
@@ -3,6 +3,7 @@
3
3
  require "did_you_mean"
4
4
 
5
5
  require_relative "config_path"
6
+ require_relative "flow_contribution/conflict"
6
7
  require_relative "signature_path_audit"
7
8
  # ADR-87 WD4 — only the pure rule-id table (`ALL_RULES` / `RULE_FAMILIES`) is needed, so require the light
8
9
  # `rule_ids.rb` rather than the engine-pulling `check_rules.rb`; keeps `config_audit` (loaded by every check)
@@ -26,12 +27,13 @@ module Rigor
26
27
  #
27
28
  # Every check is held to the same bar that {SignaturePathAudit} set: it mirrors the
28
29
  # loader's own acceptance test, so a warning means the loader really did load nothing, and
29
- # it never fires on a setup that works. In particular the `severity_overrides:` check only
30
- # flags a key under a built-in family (`call`, `flow`, …) — a plugin- or `rbs_extended.*`
31
- # rule id (whose family Rigor cannot statically enumerate, since a `node_rule` block emits
32
- # any `rule:` string it likes) is left alone. The `disable:` check goes further because the
33
- # list's reach is enumerable (issue #1953): a plugin's row answers only to its qualified
34
- # `plugin.<id>.<rule>` id, so a bare plugin rule there is inert (see {.disable_token_warning}).
30
+ # it never fires on a setup that works. The rule-token checks lean on the reach of `disable:`
31
+ # and `severity_overrides:` being enumerable (issues #1953, #1985): a plugin's row answers only
32
+ # to its qualified `plugin.<id>.<rule>` id (or `plugin.<id>`), so a bare plugin rule there is
33
+ # inert (see {.disable_token_warning}, {.override_key_warning}). A `plugin.`-qualified token is
34
+ # checked only against `plugins:`, and a token under a non-check engine family
35
+ # (`rbs_extended.*`, `rbs.*`, `dynamic.*`) is left alone, since the engine emits those ids
36
+ # outside the catalogue.
35
37
  module ConfigAudit
36
38
  # One config-level finding. `kind` discriminates the source key (`:signature_path`,
37
39
  # `:bundled_plugin_signature_path`, `:library`, `:disabled_rule`, `:severity_override`,
@@ -148,25 +150,55 @@ module Rigor
148
150
  end
149
151
 
150
152
  # `disable:` tokens that reach no row ({.disable_token_warning}) and `severity_overrides:` keys
151
- # that name no rule. An override key is flagged only under a built-in family, so a plugin
152
- # rule id is never mis-flagged (see the module docstring).
153
+ # that reach none ({.override_key_warning}).
153
154
  def self.rule_token_warnings(configuration)
154
155
  plugin_ids = configured_plugin_ids(configuration.plugins)
155
156
  disable = Array(configuration.disabled_rules).filter_map do |token|
156
157
  disable_token_warning(token.to_s, plugin_ids)
157
158
  end
158
159
  overrides = configuration.severity_overrides.keys.filter_map do |key|
159
- next unless inert_builtin_token?(key.to_s)
160
-
161
- Warning.new(
162
- kind: :severity_override,
163
- message: "severity_overrides: #{key.to_s.inspect} is not a recognized rule id; the override has no effect",
164
- fields: { "token" => key.to_s }
165
- )
160
+ override_key_warning(key.to_s, plugin_ids)
166
161
  end
167
162
  disable + overrides
168
163
  end
169
164
 
165
+ # Issue #1985 — the plugin loader's own rows, which a `severity_overrides:` key names by their qualified
166
+ # id; they also answer to the bare `load-error` / `runtime-error`.
167
+ PLUGIN_LOADER_ROWS = %w[plugin_loader.load-error plugin_loader.runtime-error].freeze
168
+ private_constant :PLUGIN_LOADER_ROWS
169
+
170
+ # A warning for a `severity_overrides:` key that can reach no row, or nil. Mirrors {.disable_token_warning}
171
+ # (issue #1985): a plugin's row answers only to its qualified `plugin.<id>.<rule>` or `plugin.<id>`, so a
172
+ # `plugin.`-qualified key naming an unconfigured plugin, and any key outside the built-in vocabulary that is
173
+ # not a qualified plugin id (a plugin's bare rule included), is inert. A reserved run-level row is the home of
174
+ # 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)
177
+ return nil if reason.nil?
178
+
179
+ Warning.new(kind: :severity_override, message: "severity_overrides: #{key.inspect} #{reason}",
180
+ fields: { "token" => key })
181
+ end
182
+
183
+ def self.override_key_problem(key, plugin_ids)
184
+ return "is not a recognized rule id; the override has no effect" if key == "all"
185
+ return nil unless Array(Analysis::CheckRules.resolve_rule_token(key)).empty?
186
+
187
+ if key.start_with?(Analysis::CheckRules::PLUGIN_FAMILY_PREFIX)
188
+ return nil if plugin_ids.nil? || plausible_plugin_token?(key, plugin_ids)
189
+
190
+ "names no plugin `plugins:` configures; the override has no effect"
191
+ elsif key.start_with?("plugin_loader.")
192
+ "is not a recognized rule id; the override has no effect" unless PLUGIN_LOADER_ROWS.include?(key)
193
+ elsif inert_builtin_token?(key)
194
+ "is not a recognized rule id; the override has no effect"
195
+ elsif !non_check_engine_token?(key) && !severity_only_id?(key)
196
+ "is not a recognized rule id; the override has no effect " \
197
+ "(if this is a plugin's rule, write it as `plugin.<id>.<rule>`)"
198
+ end
199
+ end
200
+ private_class_method :override_key_problem
201
+
170
202
  # Issue #1953 — the run-level rows the diagnostic policy keeps on `severity_overrides:` and the
171
203
  # baseline. `disable:` does not reach them by any spelling, so naming one there is inert.
172
204
  RESERVED_RUN_LEVEL_FAMILIES = %w[plugin_loader plugin_trust rbs.coverage dynamic.dependency-source].freeze
@@ -224,6 +256,19 @@ module Rigor
224
256
  end
225
257
  private_class_method :reserved_run_level_token?
226
258
 
259
+ # Bare ids that a `severity_overrides:` key re-stamps but a marker or `disable:` cannot suppress: the `--explain`
260
+ # `fallback` row and the `contribution_merge` conflict reasons (`FlowContribution::CONFLICT_VALID_REASONS`,
261
+ # kebab-cased). They are not in `CheckRules::NON_CHECK_DIAGNOSTIC_IDS`, which would silence the warnings for a
262
+ # `disable:` token or marker naming them. `spec/rigor/config_audit_spec.rb` pins this list's completeness.
263
+ CONFLICT_IDS = FlowContribution::CONFLICT_VALID_REASONS.map { |reason| reason.to_s.tr("_", "-").freeze }.freeze
264
+ SEVERITY_ONLY_IDS = (%w[fallback] + CONFLICT_IDS).freeze
265
+ private_constant :CONFLICT_IDS, :SEVERITY_ONLY_IDS
266
+
267
+ def self.severity_only_id?(key)
268
+ SEVERITY_ONLY_IDS.include?(key)
269
+ end
270
+ private_class_method :severity_only_id?
271
+
227
272
  def self.non_check_engine_token?(token)
228
273
  Analysis::CheckRules::NON_CHECK_DIAGNOSTIC_IDS.include?(token) ||
229
274
  Analysis::CheckRules::NON_CHECK_DIAGNOSTIC_FAMILIES.include?(token.split(".").first)
@@ -202,11 +202,17 @@ module Rigor
202
202
  # always wins) and *above* the profile table. Exact rule ids only — the overlay never carries family
203
203
  # wildcards. Empty while the overlay is unpopulated, so the default leaves resolution bit-for-bit
204
204
  # unchanged.
205
+ # @param qualified_rule — the row's `<source_family>.<rule>` id (`plugin_loader.load-error`); equal to `rule`
206
+ # for a built-in row. Issue #1985 — an override key may spell it.
207
+ # @param plugin_family — `plugin.<id>` for a row a plugin emitted, nil for every other row. Such a row answers
208
+ # to an override only by its `qualified_rule` or this whole-plugin key, never by its bare `rule` or a family
209
+ # wildcard: plugins share bare rules (`load-error`), and a bare key would re-stamp them all.
205
210
  # @return the resolved severity. Returns `:off` to mean "drop the diagnostic entirely".
206
- def resolve(rule:, authored_severity:, profile: DEFAULT_PROFILE, overrides: {}, bleeding_edge_overrides: {})
211
+ def resolve(rule:, authored_severity:, profile: DEFAULT_PROFILE, overrides: {}, bleeding_edge_overrides: {},
212
+ qualified_rule: rule, plugin_family: nil)
207
213
  return authored_severity if rule.nil?
208
214
 
209
- override = overrides[rule] || family_override(rule, overrides)
215
+ override = row_override(rule, qualified_rule, plugin_family, overrides)
210
216
  return override.to_sym if override
211
217
 
212
218
  bleeding = bleeding_edge_overrides[rule]
@@ -216,6 +222,18 @@ module Rigor
216
222
  profile_table.fetch(rule, authored_severity)
217
223
  end
218
224
 
225
+ # Issue #1985 — the `severity_overrides:` entry a row answers to, or nil. A plugin's row: its qualified id, then
226
+ # its `plugin.<id>`. Any other row: its qualified id (the plugin loader's
227
+ # `plugin_loader.load-error`), then its bare rule, then a family wildcard. The qualified key is the more specific
228
+ # one, so it wins over the bare key, as it does for a plugin's row.
229
+ def row_override(rule, qualified_rule, plugin_family, overrides)
230
+ return overrides[qualified_rule] || overrides[plugin_family] unless plugin_family.nil?
231
+
232
+ overrides[qualified_rule] || overrides[rule] || family_override(rule, overrides)
233
+ end
234
+
235
+ private_class_method :row_override
236
+
219
237
  # A family name has no `.` in it, so only a dot-free key can match one. `resolve` runs for every
220
238
  # diagnostic in a run's stream, and most configurations carry no such key (usually no overrides at all),
221
239
  # so the split below is skipped for them rather than allocating a name that nothing can match.
@@ -8,6 +8,7 @@ require_relative "configuration/dependencies"
8
8
  require_relative "configuration/severity_profile"
9
9
  require_relative "config_path"
10
10
  require_relative "configuration_error"
11
+ require_relative "rooted_glob"
11
12
  require_relative "effects/entry_points"
12
13
  require_relative "effects/label"
13
14
  require_relative "effects/method_key"
@@ -995,9 +996,34 @@ module Rigor
995
996
  # this entry" so a templated `**` pattern in a fresh project doesn't generate an error per match-less
996
997
  # pattern.
997
998
  def expand_pre_eval_entries(entries)
998
- entries.flat_map do |entry|
999
- glob_pattern?(entry) ? Dir.glob(entry, sort: true) : [entry]
1000
- end.uniq.freeze
999
+ entries.flat_map { |entry| expand_pre_eval_entry(entry) }.uniq.freeze
1000
+ end
1001
+
1002
+ # #1956 — a relative entry arrives joined onto the config file's directory, so the metacharacters the
1003
+ # check above sees may be that directory's own (`ds [x]/`). The leading run of segments that name existing
1004
+ # directories is read literally and only the rest is globbed under it: a literal entry under such a
1005
+ # directory stays a literal path, and a pattern matches what it matches under a plain directory.
1006
+ def expand_pre_eval_entry(entry)
1007
+ return [entry] unless glob_pattern?(entry)
1008
+
1009
+ base, pattern = literal_directory_prefix(entry)
1010
+ return [entry] unless glob_pattern?(pattern)
1011
+
1012
+ base ? RootedGlob.glob(base, pattern) : Dir.glob(entry, sort: true)
1013
+ end
1014
+
1015
+ def literal_directory_prefix(entry)
1016
+ segments = entry.split("/")
1017
+ count = (1...segments.size).take_while { |n| File.directory?(join_segments(segments.take(n))) }.size
1018
+ return [nil, entry] if count.zero?
1019
+
1020
+ [join_segments(segments.take(count)), segments.drop(count).join("/")]
1021
+ end
1022
+
1023
+ # `"/a".split("/")` leads with an empty segment, which spells the filesystem root.
1024
+ def join_segments(segments)
1025
+ path = segments.join("/")
1026
+ path.empty? ? "/" : path
1001
1027
  end
1002
1028
 
1003
1029
  def glob_pattern?(path)