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
@@ -111,6 +111,13 @@ module Rigor
111
111
  # so the incremental session's dependency graph is refreshed whether analysis ran sequential or pooled.
112
112
  attr_reader :collected_dependencies
113
113
 
114
+ # Issue #1553 — the per-file plugin reads the fork pool's workers recorded this run (empty unless
115
+ # `record_dependencies:` and a pool run occurred), folded into `Runner#analysis_reads`. Each worker's are
116
+ # marshalled back beside its dependency records.
117
+ def collected_analysis_reads
118
+ @collected_analysis_reads ||= {}
119
+ end
120
+
114
121
  # ADR-103 WD12 — the per-file {Effects::FileCollection}s the fork pool captured this run (empty
115
122
  # unless the configuration carries an `effects:` block and a pool run occurred). The runner merges
116
123
  # these with its own sequential records before the post-pool fixpoint.
@@ -197,13 +204,17 @@ module Rigor
197
204
  if pool_mode?
198
205
  dispatch_pool(files, source_files: source_files)
199
206
  else
200
- analyze_files_sequentially(
201
- files, environment || resolve_sequential_environment(source_files: source_files)
202
- )
207
+ environment ||= resolve_sequential_environment(source_files: source_files)
208
+ analyze_files_sequentially(files, environment)
203
209
  end
204
210
  # Issues #796 / #794 — a narrowed run's own demand set is a SUBSET of the full run's, so the two
205
- # rows below are folded in after it, whichever backend produced the per-file stream.
206
- replayed_restored_run_level_rows?(closure: files)
211
+ # rows below are folded in after it, whichever backend produced the per-file stream. Issue #1929 —
212
+ # the sequential run's environment re-derives a stale replayed failure; a pool run has none on
213
+ # this side and resolves one only when a stale failure needs it.
214
+ redemand_env = lambda do
215
+ environment ||= resolve_sequential_environment(source_files: source_files)
216
+ end
217
+ replayed_restored_run_level_rows?(closure: files, redemand_env: redemand_env)
207
218
  result
208
219
  end
209
220
 
@@ -261,7 +272,9 @@ module Rigor
261
272
  # `env` rather than `resolve.call`: read the demand the snapshots above just made when they built
262
273
  # an environment, and force nothing when they did not.
263
274
  record_definition_build_failures(env&.rbs_loader&.definition_build_failures)
264
- record_hkt_scan_failure(hkt_scan_outcome(resolve.call)) unless replayed_restored_run_level_rows?
275
+ # An empty closure analyses no file, so only a deleted file's buffer can make a tuple stale here.
276
+ replayed = replayed_restored_run_level_rows?(closure: [], redemand_env: resolve)
277
+ record_hkt_scan_failure(hkt_scan_outcome(resolve.call)) unless replayed
265
278
  prepare_rows
266
279
  end
267
280
 
@@ -319,18 +332,46 @@ module Rigor
319
332
  # of its own — and so no environment.
320
333
  #
321
334
  # @param closure — the files this run analyses; see {#stale_replayed_failure?}.
322
- def replayed_restored_run_level_rows?(closure: nil)
335
+ # @param redemand_env — resolves the environment {#redemand_stale_failures} re-derives a stale
336
+ # failure in; nil (the empty closure, which has no stale failure) re-derives nothing.
337
+ def replayed_restored_run_level_rows?(closure: nil, redemand_env: nil)
323
338
  rows = @restored_run_level_rows
324
339
  return false if rows.nil?
325
340
 
326
- replayable = Array(rows.definition_build_failures).reject do |failure|
341
+ stale, replayable = Array(rows.definition_build_failures).partition do |failure|
327
342
  stale_replayed_failure?(failure, closure)
328
343
  end
329
344
  record_definition_build_failures(replayable)
345
+ redemand_stale_failures(stale, redemand_env)
330
346
  record_hkt_scan_failure(rows.hkt_scan_failure)
331
347
  true
332
348
  end
333
349
 
350
+ # Issue #1929 — a stale replayed failure is RE-DERIVED, not dropped. Dropping it was right for the
351
+ # class whose collision the edit removed, and wrong for every class the edit left failing: the file
352
+ # that demanded such a class usually sits outside the recheck's closure (the edited file only
353
+ # contributes a conflicting buffer, such as a missing superclass every subclass inherits), so
354
+ # nothing in the recheck asks for it again, and the row lost classes a cold run reports. The class
355
+ # is asked for again in this run's environment ({Environment::RbsLoader#redemand_definition}), so
356
+ # it is reported exactly when it still fails, with the tuple the current signatures give it.
357
+ #
358
+ # A class this run's own analysis already reported is skipped; its tuple is current. The residue:
359
+ # a class whose only demand was in an edited file that no longer makes it, and which still fails,
360
+ # stays reported until a full run. The failure itself is real, unlike the dropped-collision case
361
+ # this replaced.
362
+ def redemand_stale_failures(stale, redemand_env)
363
+ return if stale.empty? || redemand_env.nil?
364
+
365
+ reported = @snapshots.definition_build_failures.to_set(&:first)
366
+ pending = stale.map(&:first).reject { |name| reported.include?(name) }
367
+ return if pending.empty?
368
+
369
+ loader = redemand_env.call&.rbs_loader
370
+ return if loader.nil?
371
+
372
+ record_definition_build_failures(pending.filter_map { |name| loader.redemand_definition(name) })
373
+ end
374
+
334
375
  # A replayed definition-build failure the snapshot's own fingerprint cannot vouch for. The
335
376
  # fingerprint digests the three signature sources it can read WITHOUT an environment — `sig:`,
336
377
  # `Gemfile.lock` / `rbs_collection.lock.yaml`, and the configuration — but NOT the virtual RBS a
@@ -340,11 +381,11 @@ module Rigor
340
381
  # closure, its analysis demands nothing that would re-derive the row, and the run reports a
341
382
  # collision between files that no longer collide.
342
383
  #
343
- # The narrowed run cannot re-derive the row either (#696 forbids Rigor demanding a definition the
344
- # analysis did not), so the honest answer is to DROP it and under-report rather than assert a
345
- # diagnostic on a program that may now be correct — false positives outrank worst-case reading. The
346
- # cost is bounded to the row's own conflicting buffers: a collision between two files neither of
347
- # which this run touches still replays.
384
+ # Such a failure is not replayed as it stands; {#redemand_stale_failures} re-derives it instead
385
+ # (#1929), so the row asserts no collision the current signatures do not have. A failure whose
386
+ # buffers name no file this run touches still replays. A buffer whose file no longer exists is stale
387
+ # too: deleting an inline source a tuple names would otherwise replay the deleted declaration's
388
+ # failure, where a full run reports what the remaining signatures give.
348
389
  def stale_replayed_failure?(failure, closure)
349
390
  return false if closure.nil?
350
391
 
@@ -354,7 +395,10 @@ module Rigor
354
395
  closure_paths = closure.to_set { |path| File.absolute_path(path.to_s) }
355
396
  Array(buffers).any? do |name|
356
397
  path = virtual_buffer_path(name)
357
- !path.nil? && closure_paths.include?(File.absolute_path(path))
398
+ next false if path.nil?
399
+
400
+ absolute = File.absolute_path(path)
401
+ closure_paths.include?(absolute) || !File.exist?(absolute)
358
402
  end
359
403
  end
360
404
 
@@ -870,7 +914,7 @@ module Rigor
870
914
  merge_run_disclosures(session.drain_run_disclosures)
871
915
  # The degraded slice was re-analysed on the parent session, so its dependency records live there,
872
916
  # not in a child payload — fold them in alongside the successful children's.
873
- @collected_dependencies.merge!(session.drain_dependencies) if @record_dependencies
917
+ collect_dependencies(session.drain_dependencies, session.drain_analysis_reads) if @record_dependencies
874
918
  @collected_effects.merge!(session.drain_effects) if @record_effects
875
919
  end
876
920
 
@@ -907,6 +951,7 @@ module Rigor
907
951
  results = slice.to_h { |path| [path, session.analyze(path)] }
908
952
  payload = { results: results, reporters: session.drain_reporters,
909
953
  dependencies: session.drain_dependencies,
954
+ analysis_reads: session.drain_analysis_reads,
910
955
  disclosures: session.drain_run_disclosures,
911
956
  refusals: session.drain_trust_refusals }
912
957
  # ADR-103 WD13 — the effects slot exists only on a collecting run, so a normal `--workers N` run
@@ -945,7 +990,9 @@ module Rigor
945
990
  merge_run_disclosures(payload.fetch(:disclosures, []))
946
991
  refusals = payload.fetch(:refusals, [])
947
992
  refusals_by_slice ? refusals_by_slice[child[:index]] = refusals : merge_trust_refusals(refusals)
948
- @collected_dependencies.merge!(payload.fetch(:dependencies, {})) if @record_dependencies
993
+ if @record_dependencies
994
+ collect_dependencies(payload.fetch(:dependencies, {}), payload.fetch(:analysis_reads, {}))
995
+ end
949
996
  @collected_effects.merge!(payload.fetch(:effects, {})) if @record_effects
950
997
  else
951
998
  degraded.concat(child[:slice])
@@ -954,6 +1001,12 @@ module Rigor
954
1001
  degraded
955
1002
  end
956
1003
 
1004
+ # ADR-46 / issue #1553 — fold one worker's dependency records and per-file plugin reads.
1005
+ def collect_dependencies(dependencies, analysis_reads)
1006
+ @collected_dependencies.merge!(dependencies)
1007
+ collected_analysis_reads.merge!(analysis_reads)
1008
+ end
1009
+
957
1010
  # @return the child's `{results:, reporters:}` payload, or nil when the child exited
958
1011
  # abnormally or wrote no readable payload. `Marshal.load` is safe here: the blob was written by
959
1012
  # our own forked child to a temp file we created.
@@ -26,6 +26,7 @@ require_relative "../effects/identity"
26
26
  require_relative "../effects/propagator"
27
27
  require_relative "../inference/parameter_inference_collector"
28
28
  require_relative "../inference/pre_eval_constants"
29
+ require_relative "../inference/project_seed_tables"
29
30
  require_relative "../inference/scope_indexer"
30
31
  require_relative "../inference/synthetic_method_scanner"
31
32
  require_relative "../inference/project_patched_scanner"
@@ -98,6 +99,16 @@ module Rigor
98
99
  pooled.empty? ? @file_dependencies : @file_dependencies.merge(pooled)
99
100
  end
100
101
 
102
+ # Issue #1553 — the plugin reads ({Plugin::IoBoundary.attributing}) each file's analysis made this run,
103
+ # `{path => [row]}`, reconciled from the sequential and pooled halves exactly as {#file_dependencies} is.
104
+ # Empty unless `record_dependencies: true`; a file whose analysis read nothing has no key. The incremental
105
+ # session keeps them beside each file's cached rows so a later recheck re-analyses the file once one of
106
+ # them goes stale.
107
+ def analysis_reads
108
+ pooled = @pool_coordinator.collected_analysis_reads
109
+ pooled.empty? ? @analysis_reads : @analysis_reads.merge(pooled)
110
+ end
111
+
101
112
  # ADR-103 — the run's propagated effect graph, or {Effects::EffectTable.empty} when collection did
102
113
  # not run. Reconciled from the sequential and pooled halves exactly as {#file_dependencies} is, then
103
114
  # closed by the post-pool fixpoint in {#assemble_run_diagnostics}.
@@ -464,6 +475,9 @@ module Rigor
464
475
  @project_struct_member_layouts = {}.freeze
465
476
  # Issue #684 — set per run by {#project_discovery_expansion}; nil on every path that does not widen.
466
477
  @project_discovery_expansion = nil
478
+ # Issue #1583 — whether this run widened its discovery to the configured paths, even when the widening
479
+ # added no file; {#discovery_glob_entries} lists the configured roots for every such run.
480
+ @discovery_widened = false
467
481
  # ADR-67 WD6a — the call-site parameter-inference table, populated by the opt-in pre-pass in
468
482
  # `assemble_run_diagnostics` when `parameter_inference:` is enabled, then seeded onto every per-file
469
483
  # scope (sequential + fork-worker) through `project_scope_seed_tables`. Empty by default, so the gate-off
@@ -518,6 +532,7 @@ module Rigor
518
532
 
519
533
  expansion = expand_paths(paths)
520
534
  @project_discovery_expansion = project_discovery_expansion(paths, expansion)
535
+ @discovery_widened = widen_discovery_to_project?(paths)
521
536
  @snapshots.reset_for_run
522
537
  # Per-run reset of the deferred-discovery memo (see `#ensure_project_discovery`).
523
538
  @project_discovery_done = false
@@ -599,6 +614,19 @@ module Rigor
599
614
  expand_paths(paths).fetch(:files)
600
615
  end
601
616
 
617
+ # Issue #1583 — the project files a run over `paths` would DISCOVER without analysing them (#684's
618
+ # widening), by globbing only. Empty whenever the run would not widen, which includes every run over
619
+ # the configured `paths:`. {IncrementalSession} digests them, because an edit to one moves the
620
+ # diagnostics of the files it does analyse.
621
+ def discovery_only_file_set(paths = @configuration.paths)
622
+ expansion = expand_paths(paths)
623
+ widened = project_discovery_expansion(paths, expansion)
624
+ return [] if widened.nil?
625
+
626
+ analysed = expansion.fetch(:files).to_set
627
+ widened.fetch(:files).reject { |path| analysed.include?(path) }
628
+ end
629
+
602
630
  # ADR-46 §2 — inverts {#file_dependencies} into the reverse edge the incremental step walks:
603
631
  # `dependents[X] = { A : A read a declaration / body from X }`. On an edit to X, the body tier
604
632
  # (slice 2) re-analyses `{X} ∪ dependents[X]` and serves every other file from the per-file cache.
@@ -791,6 +819,7 @@ module Rigor
791
819
  @project_discovery_done = false
792
820
  # This entry point supplies its own expansion; it never widens (see {#project_discovery_expansion}).
793
821
  @project_discovery_expansion = nil
822
+ @discovery_widened = false
794
823
  @run_generation = Object.new.freeze
795
824
  run_project_pre_passes(expansion: expansion)
796
825
  ensure_project_discovery(expansion)
@@ -1391,7 +1420,7 @@ module Rigor
1391
1420
  # #979 — the signature ROOTS' listings, one glob row each. `rbs_descriptor.files` covers only the
1392
1421
  # `.rbs` files that existed while the run read them, so without these a signature file written after
1393
1422
  # the run left the slot validating fresh and the warm run answered without it.
1394
- globs = rbs_descriptor.globs + template_unit_glob_entries
1423
+ globs = rbs_descriptor.globs + template_unit_glob_entries + discovery_glob_entries
1395
1424
  boundary = plugin_boundary_rows
1396
1425
  entries.concat(boundary.files)
1397
1426
  globs.concat(boundary.globs).concat(producer_watch_glob_entries)
@@ -1406,7 +1435,7 @@ module Rigor
1406
1435
  # auto-detected `sig`: the loader reads a directory and a file differently, and #979's listing rows cover a
1407
1436
  # file appearing under a root that existed, not the root appearing. A kind row rather than an existence row,
1408
1437
  # because a directory replaced by a file of the same name leaves an existence row fresh. The `--incremental`
1409
- # slot records existence rows for the same paths ({#incremental_slot_rows}, {#baseline_dependency_rows}).
1438
+ # slot records kind rows for the same paths ({#incremental_slot_rows}, {#baseline_dependency_rows}, #1977).
1410
1439
  def root_presence_entries(expansion)
1411
1440
  (expansion.fetch(:roots) + @configuration.pre_eval + signature_root_candidates)
1412
1441
  .uniq.map { |path| Cache::Descriptor::FileEntry.kind(path: path) }
@@ -1452,11 +1481,11 @@ module Rigor
1452
1481
  # globs, which a producer replays into its boundary whether it computed or was served (#1558). A save
1453
1482
  # after the read leaves the row stale, so no guard is needed.
1454
1483
  # - `derived` — rows taken when the run ENDS: the template files and their claimed globs (#392, re-analysed
1455
- # every run), and one existence row per analysis root and per `pre_eval:` entry. A run's path errors (`no
1456
- # such file or directory`, `pre-eval.file-not-found`) answer to those last rows; the plain slot records
1457
- # neither, and the incremental path regenerates the errors every run, so a slot that served one past the
1458
- # edit that retracts it would print what no run of that tree prints. Existence rows carry ADR-45 WD1b's
1459
- # bound: a directory replaced by a file of the same name stays fresh.
1484
+ # every run), and one `:kind` row per analysis root and per `pre_eval:` entry. A run's path errors (`no
1485
+ # such file or directory`, `not a Ruby file`, `pre-eval.file-not-found`) answer to those last rows, and the
1486
+ # incremental path regenerates the errors every run, so a slot that served one past the edit that retracts
1487
+ # it would print what no run of that tree prints. A kind row rather than an existence row (#1977): an
1488
+ # existence row reads a directory replaced by a file of the same name as fresh, ADR-45 WD1b's bound.
1460
1489
  # - `by_file` — `{path => Descriptor}`, the plugin reads each file's analysis made
1461
1490
  # ({Plugin::IoBoundary.attributing}). A narrowed run analyses only its closure, so the session carries the
1462
1491
  # other files' reads forward from the previous slot.
@@ -1480,7 +1509,7 @@ module Rigor
1480
1509
  IncrementalSlotRows.new(
1481
1510
  observed: plugin_boundary_rows,
1482
1511
  derived: Cache::Descriptor.new(
1483
- files: template_unit_file_entries + existence_entries(Array(roots) + @configuration.pre_eval),
1512
+ files: template_unit_file_entries + kind_entries(Array(roots) + @configuration.pre_eval),
1484
1513
  globs: template_unit_glob_entries
1485
1514
  ),
1486
1515
  by_file: @analysis_reads.transform_values do |reads|
@@ -1507,13 +1536,14 @@ module Rigor
1507
1536
  # which the session therefore carries unchanged from the full run that recorded them to every later slot:
1508
1537
  #
1509
1538
  # - the signature tree the plain slot records (`RbsDescriptor.file_entries` / `.glob_entries` over the loader
1510
- # roots this run's analysis resolved), and an existence row per signature root, configured or the
1511
- # auto-detected `sig` — a recheck rebuilds no environment unless its closure is non-empty, and the snapshot
1512
- # fingerprint digests the same roots (configured, or the auto-detected `sig`), so a change to one drops the
1513
- # snapshot and forces a full run rather than reaching a recheck;
1539
+ # roots this run's analysis resolved), and a `:kind` row per signature root, configured or the
1540
+ # auto-detected `sig` — a recheck rebuilds no environment unless its closure is non-empty. The snapshot
1541
+ # fingerprint digests the same roots, so a change to one makes the next full-path run a full run rather
1542
+ # than a recheck, but the engine-free slot probe serves before any fingerprint is consulted: a root that
1543
+ # turns from a file into a directory (or back) is declined there by this `:kind` row alone (#1977);
1514
1544
  # - the discovered-not-analysed files (#684) and a listing row per discovery root — a narrowed recheck does
1515
1545
  # not widen discovery at all;
1516
- # - every `pre_eval:` file outside `files` (a stat row, or an absence row) — the session tracks only analysed
1546
+ # - every `pre_eval:` file outside `files` (a stat row, or a kind row) — the session tracks only analysed
1517
1547
  # files, and one that is analysed is guarded by its own row.
1518
1548
  #
1519
1549
  # Recomputing any of these on a recheck would vouch for the tree the recheck saw while its answer still
@@ -1537,7 +1567,7 @@ module Rigor
1537
1567
  own, pinned = signature_rows
1538
1568
  BaselineRows.new(
1539
1569
  owned: Cache::Descriptor.new(
1540
- files: own.files + existence_entries(signature_root_candidates) +
1570
+ files: own.files + kind_entries(signature_root_candidates) +
1541
1571
  discovery_file_entries({ files: files }) + unanalysed_pre_eval_entries(files),
1542
1572
  globs: (own.globs + discovery_glob_entries).uniq
1543
1573
  ),
@@ -1563,16 +1593,18 @@ module Rigor
1563
1593
 
1564
1594
  # The project's own signature roots, whether or not they exist: the configured `signature_paths:`, or the
1565
1595
  # `sig` directory `Environment.default_signature_paths` auto-detects when none are configured. The
1566
- # signature rows' listing rows cover a file appearing under a root that existed; only an existence row
1567
- # covers the root itself appearing.
1596
+ # signature rows' listing rows cover a file appearing under a root that existed; only a row for the root
1597
+ # itself covers it appearing.
1568
1598
  def signature_root_candidates
1569
1599
  @configuration.signature_paths || ["sig"]
1570
1600
  end
1571
1601
 
1572
1602
  # One names-mode listing row per directory the discovery widening globbed, so a file appearing there is seen
1573
- # as well as an edit to one that existed.
1603
+ # as well as an edit to one that existed. Issue #1583 — keyed on the widening decision, not on what it
1604
+ # added: a run over `lib/b.rb` alone in `lib` widens to nothing, and a `lib/a.rb` created afterwards must
1605
+ # still move the answer of the run-result cache and the `--incremental` slot.
1574
1606
  def discovery_glob_entries
1575
- return [] if @project_discovery_expansion.nil?
1607
+ return [] unless @discovery_widened
1576
1608
 
1577
1609
  @configuration.paths.select { |root| File.directory?(root) }.map do |root|
1578
1610
  Cache::Descriptor::GlobEntry.compute(root: root, pattern: PathExpansion::RUBY_GLOB, mode: :names)
@@ -1586,22 +1618,18 @@ module Rigor
1586
1618
  if File.file?(path)
1587
1619
  Cache::Descriptor::FileEntry.stat(path: path, digest: Cache::FileDigest.hexdigest(path))
1588
1620
  else
1589
- existence_entries([path]).first
1621
+ kind_entries([path]).first
1590
1622
  end
1591
1623
  end
1592
1624
  end
1593
1625
 
1594
- def existence_entries(paths)
1626
+ # #1977 — one `:kind` row per path, the row {#root_presence_entries} records in the plain slot.
1627
+ def kind_entries(paths)
1595
1628
  paths.uniq.map do |path|
1596
- physical = @buffer ? @buffer.resolve(path) : path
1597
- if File.exist?(physical)
1598
- Cache::Descriptor::FileEntry.present(path: physical)
1599
- else
1600
- Cache::Descriptor::FileEntry.absent(path: physical)
1601
- end
1629
+ Cache::Descriptor::FileEntry.kind(path: @buffer ? @buffer.resolve(path) : path)
1602
1630
  end
1603
1631
  end
1604
- private :plugin_boundary_rows, :producer_watch_glob_entries, :existence_entries, :root_presence_entries,
1632
+ private :plugin_boundary_rows, :producer_watch_glob_entries, :kind_entries, :root_presence_entries,
1605
1633
  :analysis_reads_attributed?, :signature_root_candidates, :discovery_glob_entries,
1606
1634
  :unanalysed_pre_eval_entries, :signature_rows, :within?
1607
1635
 
@@ -1825,8 +1853,14 @@ module Rigor
1825
1853
  # on what else was on the command line.
1826
1854
  #
1827
1855
  # Excluded, each because its expansion is already the one it means: the LSP `prebuilt:` path (which
1828
- # seeds discovery from a snapshot), editor `buffer:` mode, `run_source`'s in-memory single file, and
1829
- # ADR-46's `analyze_only` subset mode (which already passes the full expansion and filters later).
1856
+ # seeds discovery from a snapshot), editor `buffer:` mode, and `run_source`'s in-memory single file.
1857
+ #
1858
+ # Issue #1583 — ADR-46's `analyze_only` subset mode widens like a full run. It was excluded on the
1859
+ # reading that it "already passes the full expansion", which holds only when the incremental session
1860
+ # runs over the configured `paths:`. `rigor check --incremental lib/b.rb` passes the argument's
1861
+ # expansion, so its rechecks (and `--verify-incremental`'s partitions) discovered the argument alone
1862
+ # while its baseline discovered the project, and `lib/b.rb`'s report on a sibling's constant came and
1863
+ # went with the run kind.
1830
1864
  #
1831
1865
  # Issue #1556 — the widened set holds each FILE once. `.rigor.yml`'s `paths:` load as absolute paths and a
1832
1866
  # path argument usually does not, so `rigor check lib` widened discovery over both spellings of every file:
@@ -1864,8 +1898,7 @@ module Rigor
1864
1898
  end
1865
1899
 
1866
1900
  def widen_discovery_to_project?(paths)
1867
- @prebuilt.nil? && @buffer.nil? && @in_memory_sources.nil? && @analyze_only.nil? &&
1868
- paths != @configuration.paths
1901
+ @prebuilt.nil? && @buffer.nil? && @in_memory_sources.nil? && paths != @configuration.paths
1869
1902
  end
1870
1903
 
1871
1904
  # ADR-46 — the dependency-recording and subset-analysis modes read the discovery tables OUTSIDE the
@@ -2425,50 +2458,21 @@ module Rigor
2425
2458
  tables[:published_constant_names] = names unless names.empty?
2426
2459
  end
2427
2460
 
2428
- # Issue #617 — the censused names that bind, grouped by last segment (`Scope#bound_constant_names`). A
2429
- # name some write other than a memo `||=` touches always binds. A memo-only name binds once memos of its
2430
- # segment span two files: either may load first and set the constant the other then reads, so only a
2431
- # memo no other file shares keeps the idiom's reading. Files are counted expanded, because discovery can
2432
- # walk one file under two spellings (`lib/b.rb`, `./lib/b.rb`).
2461
+ # Issue #617 / #1290 — the binding and shadowing constant names, by last segment; derived by
2462
+ # {Inference::ProjectSeedTables}, which the seed paths that run no `check` share.
2433
2463
  def constant_writer_index
2434
- @constant_writer_index ||= begin
2435
- memo_files = memo_files_by_segment
2436
- @project_constant_writes.each_with_object({}) do |(name, by_path), index|
2437
- segment = name.split("::").last
2438
- next if memo_only?(by_path) && memo_files.fetch(segment).size < 2
2439
-
2440
- (index[segment] ||= []) << name
2441
- end.each_value(&:freeze).freeze
2442
- end
2464
+ @constant_writer_index ||= Inference::ProjectSeedTables.constant_writers(@project_constant_writes)
2443
2465
  end
2444
2466
 
2445
- # Issue #1290 — the censused names some write other than a memo `||=` assigns, grouped by last segment
2446
- # (`Scope#shadowing_constant_names`): the ones that exist whether or not an outer constant of the name
2447
- # does, and so stop the lexical ladder above it. A memo-only name is left out however many files memoize
2448
- # it, unlike in {#constant_writer_index}: `X ||= v` resolves `X` through the very lookup whose lower rung
2449
- # the stop would replace, so wherever that rung answers, the memo finds it and never creates the name.
2450
2467
  def constant_shadower_index
2451
- @constant_shadower_index ||= @project_constant_writes.each_with_object({}) do |(name, by_path), index|
2452
- (index[name.split("::").last] ||= []) << name unless memo_only?(by_path)
2453
- end.each_value(&:freeze).freeze
2468
+ @constant_shadower_index ||= Inference::ProjectSeedTables.constant_shadowers(@project_constant_writes)
2454
2469
  end
2455
2470
 
2456
- def memo_files_by_segment
2457
- @project_constant_writes.each_with_object({}) do |(name, by_path), files|
2458
- set = (files[name.split("::").last] ||= Set.new)
2459
- by_path.each { |path, descriptor| set << File.absolute_path(path) if memo_descriptor?(descriptor) }
2460
- end
2461
- end
2462
-
2463
- def memo_only?(by_path) = by_path.each_value.all? { |descriptor| memo_descriptor?(descriptor) }
2464
- def memo_descriptor?(descriptor) = descriptor == Inference::ScopeIndexer::CONSTANT_MEMO
2465
-
2466
2471
  # Issue #644 — the LAST SEGMENTS of the published table, the run-wide half of
2467
2472
  # `Scope#published_constant?`. Seeded on EVERY run (unlike `constant_sources`, which only a recording
2468
2473
  # run needs) because {CheckRules::PublishedConstantGuard} asks the question on every `if` / `unless`.
2469
2474
  def published_constant_name_set
2470
- @published_constant_name_set ||=
2471
- @project_constant_values.keys.to_set { |name| name.split("::").last }.freeze
2475
+ @published_constant_name_set ||= Inference::ProjectSeedTables.published_constant_names(@project_constant_values)
2472
2476
  end
2473
2477
 
2474
2478
  # Issue #644 — the seeded `in_source_constants` table, joining the two cross-file constant producers.
@@ -19,6 +19,9 @@ module Rigor
19
19
  # `plugin.<id>`, never to a bare id or `all`. The run-level rows outside the check-rule catalogue
20
20
  # (`rbs.coverage.*`, `dynamic.dependency-source.*`, the plugin loader, `plugin_trust.*`, `pool-degraded`) keep
21
21
  # answering to `severity_overrides:` only, by any spelling.
22
+ # Issue #1985 — `severity_overrides:` follows the same rule: a plugin's row answers to its qualified id or
23
+ # `plugin.<id>`, never to its bare rule or a family wildcard; the plugin loader's rows answer to
24
+ # `plugin_loader.<rule>` first and to their bare `load-error` / `runtime-error` after it.
22
25
  #
23
26
  # Extracted (ADR-87 WD4) so the miss path ({Runner::DiagnosticAggregator}) and the boot-slimming hit path
24
27
  # ({RunCacheProbe}, which never loads the aggregator) apply the IDENTICAL final filter — a warm HIT's
@@ -52,11 +55,19 @@ module Rigor
52
55
  diagnostic.source_family == Diagnostic::DEFAULT_SOURCE_FAMILY && CHECK_RULES.include?(diagnostic.rule)
53
56
  end
54
57
 
58
+ # `plugin.<id>` for a row a plugin emitted, nil for every other row.
59
+ def plugin_family(diagnostic)
60
+ family = diagnostic.source_family.to_s
61
+ family if family.start_with?(CheckRules::PLUGIN_FAMILY_PREFIX)
62
+ end
63
+
55
64
  def stamp(diagnostic, configuration)
56
65
  return diagnostic if diagnostic.rule.nil?
57
66
 
58
67
  resolved = Configuration::SeverityProfile.resolve(
59
68
  rule: diagnostic.rule,
69
+ qualified_rule: diagnostic.qualified_rule,
70
+ plugin_family: plugin_family(diagnostic),
60
71
  authored_severity: diagnostic.severity,
61
72
  profile: configuration.severity_profile,
62
73
  overrides: configuration.severity_overrides,
@@ -170,8 +170,13 @@ module Rigor
170
170
  return analyze_with_effects(path) unless @record_dependencies
171
171
 
172
172
  diagnostics = nil
173
- record = DependencyRecorder.record_for(path) { diagnostics = analyze_with_effects(path) }
173
+ reads = []
174
+ record = Plugin::IoBoundary.attributing(reads) do
175
+ DependencyRecorder.record_for(path) { diagnostics = analyze_with_effects(path) }
176
+ end
174
177
  @file_dependencies[path] = record
178
+ # Issue #1553 — the plugin reads this file's analysis made, as `Runner#analyze_file` keeps them.
179
+ (@analysis_reads ||= {})[path] = reads unless reads.empty?
175
180
  diagnostics
176
181
  end
177
182
 
@@ -309,6 +314,12 @@ module Rigor
309
314
  @file_dependencies
310
315
  end
311
316
 
317
+ # Issue #1553 — the plugin reads ({Plugin::IoBoundary.attributing}) each `#analyze` call's file made (empty
318
+ # unless `record_dependencies:` was set). The rows are value objects of frozen Strings, so they marshal back.
319
+ def drain_analysis_reads
320
+ @analysis_reads || {}
321
+ end
322
+
312
323
  # ADR-103 WD12 — the per-file {Effects::FileCollection}s this session collected (empty unless the
313
324
  # configuration carries an `effects:` block). Marshal-clean by construction — frozen Hashes of
314
325
  # Strings, `LabelSet`s of frozen Arrays, and `Data` edges — so the fork pool ships them back to the
@@ -4,6 +4,7 @@ require "digest"
4
4
  require "json"
5
5
  require_relative "../value_semantics"
6
6
  require_relative "file_digest"
7
+ require_relative "../rooted_glob"
7
8
 
8
9
  module Rigor
9
10
  module Cache
@@ -87,7 +88,13 @@ module Rigor
87
88
  # appears, vanishes or changes kind invalidates the warm entry. A
88
89
  # pre-19 entry carries none of them and would validate fresh across exactly those edits, so cached
89
90
  # entries must read as misses once and rebuild carrying the rows.
90
- SCHEMA_VERSION = 19
91
+ # v20: #1977 — the `--incremental` run-result slot records a `:kind` row, not an existence row, per analysis
92
+ # root, `pre_eval:` entry and project signature root. A pre-20 slot's existence row validates fresh across a
93
+ # directory replaced by a file of the same name, so it must read as a miss once and rebuild.
94
+ # v21: #1984 — the visibility walk records each class's singleton slots (`[:singleton, name]`) in the seed
95
+ # bundles' `method_visibilities`. A pre-21 bundle carries none for an unchanged file, so cached bundles must read
96
+ # as misses once and rebuild carrying them.
97
+ SCHEMA_VERSION = 21
91
98
 
92
99
  # Per-slot entry value objects. Constructors validate enums / required fields and freeze the resulting
93
100
  # struct so no caller can mutate after the entry is in a Descriptor.
@@ -325,7 +332,12 @@ module Rigor
325
332
  # tuple moves, so a `git checkout`, a `bundle install` or a CI cache restored over a fresh checkout
326
333
  # keeps the file rows fresh while it would move every glob signature, turning every such run into a
327
334
  # full re-analysis. `:names` also reads no stat at all, so it costs a `Dir.glob` and nothing else.
328
- VALID_MODES = %i[stat names].freeze
335
+ #
336
+ # `:kinds` (#1959) is `:names` plus each match's `File.lstat` file type: the set moves it, and so does a
337
+ # path that keeps its name and changes kind (a file `gen` replaced by a directory `gen/`, a symlinked
338
+ # directory replaced by a real one). It reads one `lstat` per match and no content, so a `touch` or an
339
+ # edit leaves it fresh.
340
+ VALID_MODES = %i[stat names kinds].freeze
329
341
 
330
342
  attr_reader :root, :pattern, :value
331
343
 
@@ -357,13 +369,15 @@ module Rigor
357
369
  # The aggregate signature the entry's `value` carries: SHA-256 over the sorted per-file rows. In the
358
370
  # default (`:stat`) mode a row is the file's `(path, size, mtime_ns, ctime_ns, inode)` tuple — NO
359
371
  # content read; in strict mode a row is `(path, content-sha256)`, restoring the pre-ADR-87 authority.
360
- # In `:names` mode a row is the path alone, stat-free, so only the matching SET moves the signature.
372
+ # In `:names` mode a row is the path alone, stat-free, so only the matching SET moves the signature; in
373
+ # `:kinds` mode it is the path and its {.lstat_kind}.
361
374
  # Per-file stat failures (a file vanishing between the glob and the stat) drop the row — same race
362
375
  # posture as {Descriptor#file_entry_fresh?}. `Dir.glob` returns sorted entries by default so the row
363
376
  # order — and therefore the signature — is stable.
364
377
  def self.signature_for(root:, pattern:, mode: :stat)
365
- paths = Dir.glob(File.join(root, pattern))
378
+ paths = RootedGlob.glob(root, pattern)
366
379
  return Digest::SHA256.hexdigest(paths.map { |path| "#{path}#{ROW_SEPARATOR}" }.join) if mode == :names
380
+ return kinds_signature(paths) if mode == :kinds
367
381
 
368
382
  strict = FileDigest.strict_validation?
369
383
  rows = paths.filter_map do |path|
@@ -382,10 +396,29 @@ module Rigor
382
396
  Digest::SHA256.hexdigest(rows.join)
383
397
  end
384
398
 
399
+ # #1959 — what `path` itself is, a symlink not followed: `:file`, `:directory`, `:symlink` or `:other`, and
400
+ # `:absent` for a path that vanished between the glob and the `lstat`.
401
+ def self.lstat_kind(path)
402
+ case File.lstat(path).ftype
403
+ when "file" then :file
404
+ when "directory" then :directory
405
+ when "link" then :symlink
406
+ else :other
407
+ end
408
+ rescue SystemCallError
409
+ :absent
410
+ end
411
+
412
+ def self.kinds_signature(paths)
413
+ rows = paths.map { |path| "#{path}#{FIELD_SEPARATOR}#{lstat_kind(path)}#{ROW_SEPARATOR}" }
414
+ Digest::SHA256.hexdigest(rows.join)
415
+ end
416
+ private_class_method :kinds_signature
417
+
385
418
  # ADR-87 WD2 — fresh iff re-globbing + re-stat-ing reproduces the recorded signature. Zero file-content
386
- # bytes are read in the default mode, and no stat at all in `:names`. Any failure reads as stale
387
- # (recompute), never a crash. A stored signature recorded under the opposite validation mode simply
388
- # mismatches and recomputes.
419
+ # bytes are read in the default mode, no stat at all in `:names`, and one `lstat` per match in `:kinds`.
420
+ # Any failure reads as stale (recompute), never a crash. A stored signature recorded under the opposite
421
+ # validation mode simply mismatches and recomputes.
389
422
  def self.fresh?(entry)
390
423
  signature_for(root: entry.root, pattern: entry.pattern, mode: entry.mode) == entry.value
391
424
  rescue StandardError
@@ -4,6 +4,7 @@ require "digest"
4
4
 
5
5
  require_relative "../version"
6
6
  require_relative "descriptor"
7
+ require_relative "../rooted_glob"
7
8
 
8
9
  module Rigor
9
10
  module Cache
@@ -195,8 +196,8 @@ module Rigor
195
196
  # Sorted, de-duplicated absolute paths of every engine `.rb` file and {DATA_GLOBS} data file, across
196
197
  # the directories that exist. `plugins/*/sig/**/*.rbs` overlaps nothing else but is de-duplicated anyway.
197
198
  def source_files(root)
198
- sources = SOURCE_DIRECTORIES.flat_map { |relative| Dir.glob(File.join(root, relative, "**", "*.rb")) }
199
- data = DATA_GLOBS.flat_map { |pattern| Dir.glob(File.join(root, pattern)) }
199
+ sources = SOURCE_DIRECTORIES.flat_map { |relative| RootedGlob.glob(root, File.join(relative, "**", "*.rb")) }
200
+ data = DATA_GLOBS.flat_map { |pattern| RootedGlob.glob(root, pattern) }
200
201
  (sources + data).uniq.sort
201
202
  end
202
203
  end