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
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "run_disclosure_credit"
4
+
5
+ module Rigor
6
+ module Analysis
7
+ # Issue #2020 — the `--incremental` session's record of the run-scoped plugin rows ({Plugin::Base#disclose_once},
8
+ # {Plugin::Base#emit_once}) each analysed file's hooks registered, so a recheck can replay them for the files it
9
+ # serves from cache.
10
+ #
11
+ # - `files` maps a path to its records as the aggregator replays them, one per `[plugin id, key]` identity.
12
+ # - `reads` maps an identity to the registering plugin's boundary rows when it was last registered
13
+ # ({RunDisclosureCredit}), `#prepare`'s reads included.
14
+ #
15
+ # Staleness is per identity, not per file. A plugin memoises the input a notice is about and re-registers the
16
+ # notice from every file's hook, so crediting the rows to every registering file would re-analyse all of them on
17
+ # any edit to an input the plugin read (one view edit re-analysed 400 of 400 files in an app with one i18n view
18
+ # row). Instead a recheck finds the identities whose rows went stale, re-analyses ONE live registering file for
19
+ # each (the first by path), and withholds those identities' replays. When that file registers the identity, every
20
+ # other holder takes its record. When it registers nothing — the edit took the notice out of its text, it no
21
+ # longer parses, the plugin raised — the recheck cannot tell whether the identity is gone, so it runs again with
22
+ # every other live holder of the identity analysed ({#unresolved_holders}), and the identity is dropped only if
23
+ # none of them registers it either.
24
+ #
25
+ # One shape stays unsound: a key whose registration or record depends both on an input the plugin read and on the
26
+ # registering file's own text. When the input changes and the chosen file still registers the key, the other
27
+ # holders take its record even where, re-analysed, they would register another record or none, until each is
28
+ # re-analysed itself. {Plugin::Base#disclose_once} asks a key not to vary between workers, which rules it out for a
29
+ # plugin that keeps the contract. Only inputs read through the plugin's boundary are tracked; state a plugin carries
30
+ # across files, or an input it reads around the boundary (`ENV`, a direct `File.read`), can leave a replayed row
31
+ # that never clears.
32
+ class RunDisclosureLedger
33
+ attr_reader :files, :reads
34
+
35
+ def initialize(files: {}, reads: {})
36
+ @files = files
37
+ @reads = reads
38
+ end
39
+
40
+ # The ledger a baseline builds from `fresh` (`{path => [record with :reads]}`, `Runner#analysis_disclosures`).
41
+ def self.from(fresh)
42
+ new.tap { |ledger| ledger.absorb(fresh, fresh.keys, Set.new) }
43
+ end
44
+
45
+ def self.load(data)
46
+ data.is_a?(Hash) ? new(files: data.fetch(:files, {}), reads: data.fetch(:reads, {})) : new
47
+ end
48
+
49
+ def to_h
50
+ { files: @files, reads: @reads }
51
+ end
52
+
53
+ # The identities with a boundary row that no longer validates under `fresh_row` (a row → Boolean predicate).
54
+ def stale_identities(&fresh_row)
55
+ verdicts = {}
56
+ @reads.each_with_object(Set.new) do |(identity, rows), stale|
57
+ stale << identity if rows.any? { |row| !verdicts.fetch(row) { verdicts[row] = fresh_row.call(row) } }
58
+ end
59
+ end
60
+
61
+ # One live registering file per stale identity, the first by path, skipping `removed`.
62
+ def refreshers(stale, removed)
63
+ return Set.new if stale.empty?
64
+
65
+ holders = {}
66
+ @files.each do |path, records|
67
+ next if removed.include?(path)
68
+
69
+ records.each do |record|
70
+ identity = RunDisclosureCredit.identity(record)
71
+ holders[identity] = [holders[identity], path].compact.min if stale.include?(identity)
72
+ end
73
+ end
74
+ holders.values.to_set
75
+ end
76
+
77
+ # The live holders, outside `analyzed`, of every `stale` identity no file in `analyzed` registered in `fresh`:
78
+ # the files that must be analysed when the chosen refresher registered nothing. Analysing every one of them
79
+ # settles the identity, since every holder has then decided it afresh.
80
+ def unresolved_holders(fresh, analyzed, stale, removed)
81
+ return Set.new if stale.empty?
82
+
83
+ registered = analyzed.flat_map do |path|
84
+ Array(fresh[path]).map { |record| RunDisclosureCredit.identity(record) }
85
+ end
86
+ unresolved = stale - registered
87
+ return Set.new if unresolved.empty?
88
+
89
+ @files.each_with_object(Set.new) do |(path, records), holders|
90
+ next if removed.include?(path) || analyzed.include?(path)
91
+
92
+ holders << path if records.any? { |record| unresolved.include?(RunDisclosureCredit.identity(record)) }
93
+ end
94
+ end
95
+
96
+ # The records to replay for `paths`, without the `stale` identities. Unpinned defence: a stale identity's replay
97
+ # cannot reach the output today, since a refresher that registers it wins over the replay and one that does not
98
+ # sends every holder into the second pass.
99
+ def served(paths, stale = Set.new)
100
+ @files.values_at(*paths).compact.flatten(1).reject do |record|
101
+ stale.include?(RunDisclosureCredit.identity(record))
102
+ end
103
+ end
104
+
105
+ # Folds a run's registrations in: every path in `analyzed` takes this run's records (or none), every identity
106
+ # this run registered takes this run's rows, and each `stale` identity is refreshed or dropped in the files the
107
+ # run did not analyse. Identities no file holds any more lose their rows.
108
+ def absorb(fresh, analyzed, stale)
109
+ registered = {}
110
+ analyzed.each do |path|
111
+ records = Array(fresh[path])
112
+ records.each { |record| registered[RunDisclosureCredit.identity(record)] ||= record }
113
+ store(path, records.map { |record| RunDisclosureCredit.replayable(record) })
114
+ end
115
+ registered.each { |identity, record| @reads[identity] = record[:reads] }
116
+ refresh_unanalysed(stale, registered, analyzed.to_set)
117
+ prune
118
+ end
119
+
120
+ def forget(path)
121
+ @files.delete(path)
122
+ end
123
+
124
+ private
125
+
126
+ def refresh_unanalysed(stale, registered, analyzed)
127
+ return if stale.empty?
128
+
129
+ @files.each_key.to_a.each do |path|
130
+ next if analyzed.include?(path)
131
+
132
+ store(path, @files[path].filter_map do |record|
133
+ identity = RunDisclosureCredit.identity(record)
134
+ next record unless stale.include?(identity)
135
+
136
+ registered[identity] && RunDisclosureCredit.replayable(registered[identity])
137
+ end)
138
+ end
139
+ end
140
+
141
+ def store(path, records)
142
+ records.empty? ? @files.delete(path) : @files[path] = records.freeze
143
+ end
144
+
145
+ # Unpinned hygiene: an identity no file holds is never replayed, so keeping its rows changes no answer; pruning
146
+ # them keeps a stale row from re-analysing a file for nothing and the snapshot from growing.
147
+ def prune
148
+ held = @files.each_value.flat_map do |records|
149
+ records.map { |record| RunDisclosureCredit.identity(record) }
150
+ end.to_set
151
+ @reads.select! { |identity, _| held.include?(identity) }
152
+ end
153
+ end
154
+ end
155
+ end
@@ -79,6 +79,8 @@ module Rigor
79
79
  # (`ProjectContext#environment`) every job's Runner reuses instead of rebuilding.
80
80
  # @param prebuilt — the warm, shared pre-pass snapshot
81
81
  # (`ProjectContext#project_scan`) every job's Runner adopts instead of re-scanning.
82
+ # @param discovery_seed_for — issue #2147 — a callable returning the cross-file discovery seed for one
83
+ # binding (`ProjectContext#discovery_seed_for`), or nil for none.
82
84
  # @param workers — pool size. `#analyze` degrades to sequential in-process execution — one
83
85
  # job at a time, no `fork` — when fewer than 2 bindings are submitted, fewer than `min_batch_size`
84
86
  # bindings are submitted, `workers` is not positive, `fork` is unavailable on this platform, or
@@ -88,12 +90,13 @@ module Rigor
88
90
  # @param min_batch_size — see {DEFAULT_MIN_BATCH_SIZE}. Exposed as a constructor param
89
91
  # (rather than read from the env internally) purely for spec control; production callers get the
90
92
  # resolved default.
91
- def initialize(configuration:, cache_store:, environment:, prebuilt:, workers:,
93
+ def initialize(configuration:, cache_store:, environment:, prebuilt:, workers:, discovery_seed_for: nil,
92
94
  min_batch_size: self.class.resolve_min_batch_size)
93
95
  @configuration = configuration
94
96
  @cache_store = cache_store
95
97
  @environment = environment
96
98
  @prebuilt = prebuilt
99
+ @discovery_seed_for = discovery_seed_for
97
100
  @workers = workers
98
101
  @min_batch_size = min_batch_size
99
102
  end
@@ -123,7 +126,8 @@ module Rigor
123
126
  def run_one(binding)
124
127
  Runner.new(
125
128
  configuration: @configuration, cache_store: @cache_store, collect_stats: false,
126
- buffer: binding, prebuilt: @prebuilt, environment: @environment
129
+ buffer: binding, prebuilt: @prebuilt, environment: @environment,
130
+ discovery_seed: @discovery_seed_for&.call(binding)
127
131
  ).run([binding.logical_path]).diagnostics
128
132
  end
129
133
 
@@ -58,7 +58,7 @@ module Rigor
58
58
  definition_build_failures_snapshot:, hkt_scan_failure_snapshot:,
59
59
  conformance_results_snapshot:, signature_standdowns_snapshot: -> { [] },
60
60
  vendored_standdowns_snapshot: -> { [] }, pooled_run_disclosures: -> { [] },
61
- pooled_trust_refusals: -> { [] })
61
+ served_run_disclosures: -> { [] }, pooled_trust_refusals: -> { [] })
62
62
  @configuration = configuration
63
63
  @rbs_extended_reporter = rbs_extended_reporter
64
64
  @boundary_cross_reporter = boundary_cross_reporter
@@ -79,6 +79,8 @@ module Rigor
79
79
  # Issue #1051 — reader for the pool coordinator's de-duplicated run-scoped disclosure table.
80
80
  # Empty on a sequential run, where `plugin_registry`'s own instances are the ones that registered.
81
81
  @pooled_run_disclosures_reader = pooled_run_disclosures
82
+ # Issue #2020 — reader for the rows an incremental recheck replays for the files it served unanalysed.
83
+ @served_run_disclosures_reader = served_run_disclosures
82
84
  # Issue #1900 — reader for the pool coordinator's summed trust-refusal table. Empty on a
83
85
  # sequential run, where `plugin_registry`'s own boundaries made every read.
84
86
  @pooled_trust_refusals_reader = pooled_trust_refusals
@@ -1116,12 +1118,14 @@ module Rigor
1116
1118
  end
1117
1119
 
1118
1120
  # Issue #1051 — the run-scoped plugin disclosures ({Plugin::Base#disclose_once}), the run's
1119
- # project-global notices about its own inputs. Two sources feed one table:
1121
+ # project-global notices about its own inputs. Three sources feed one table:
1120
1122
  #
1121
1123
  # - the coordinator-side registry, which is where a SEQUENTIAL run's `#prepare` and per-file hooks
1122
1124
  # ran; and
1123
1125
  # - `pooled_run_disclosures`, the pool coordinator's already-de-duplicated collection from the
1124
- # pre-fork parent session and every worker payload.
1126
+ # pre-fork parent session and every worker payload; and
1127
+ # - `served_run_disclosures` (#2020), the rows an incremental recheck replays for the files it served
1128
+ # from cache, since their per-file hooks did not run.
1125
1129
  #
1126
1130
  # Both are keyed `[plugin id, key]` and the first registration of a pair wins, so a run emits one
1127
1131
  # row per pair no matter how many plugin instances (workers) reached it — the #1051 bug, where a
@@ -1183,6 +1187,10 @@ module Rigor
1183
1187
  rescue StandardError
1184
1188
  next
1185
1189
  end
1190
+ # Issue #2020 — last, so a row this run registered afresh wins over the replay of the same key.
1191
+ Array(@served_run_disclosures_reader.call).each do |record|
1192
+ records[[record[:plugin_id], record[:key]]] ||= record
1193
+ end
1186
1194
  records.values
1187
1195
  end
1188
1196
 
@@ -118,6 +118,12 @@ module Rigor
118
118
  @collected_analysis_reads ||= {}
119
119
  end
120
120
 
121
+ # Issue #2020 — the per-file run-scoped registrations the fork pool's workers recorded, folded into
122
+ # `Runner#analysis_disclosures` beside {#collected_analysis_reads}.
123
+ def collected_analysis_disclosures
124
+ @collected_analysis_disclosures ||= {}
125
+ end
126
+
121
127
  # ADR-103 WD12 — the per-file {Effects::FileCollection}s the fork pool captured this run (empty
122
128
  # unless the configuration carries an `effects:` block and a pool run occurred). The runner merges
123
129
  # these with its own sequential records before the post-pool fixpoint.
@@ -914,7 +920,10 @@ module Rigor
914
920
  merge_run_disclosures(session.drain_run_disclosures)
915
921
  # The degraded slice was re-analysed on the parent session, so its dependency records live there,
916
922
  # not in a child payload — fold them in alongside the successful children's.
917
- collect_dependencies(session.drain_dependencies, session.drain_analysis_reads) if @record_dependencies
923
+ if @record_dependencies
924
+ collect_dependencies(session.drain_dependencies, session.drain_analysis_reads,
925
+ session.drain_analysis_disclosures)
926
+ end
918
927
  @collected_effects.merge!(session.drain_effects) if @record_effects
919
928
  end
920
929
 
@@ -952,6 +961,7 @@ module Rigor
952
961
  payload = { results: results, reporters: session.drain_reporters,
953
962
  dependencies: session.drain_dependencies,
954
963
  analysis_reads: session.drain_analysis_reads,
964
+ analysis_disclosures: session.drain_analysis_disclosures,
955
965
  disclosures: session.drain_run_disclosures,
956
966
  refusals: session.drain_trust_refusals }
957
967
  # ADR-103 WD13 — the effects slot exists only on a collecting run, so a normal `--workers N` run
@@ -991,7 +1001,8 @@ module Rigor
991
1001
  refusals = payload.fetch(:refusals, [])
992
1002
  refusals_by_slice ? refusals_by_slice[child[:index]] = refusals : merge_trust_refusals(refusals)
993
1003
  if @record_dependencies
994
- collect_dependencies(payload.fetch(:dependencies, {}), payload.fetch(:analysis_reads, {}))
1004
+ collect_dependencies(payload.fetch(:dependencies, {}), payload.fetch(:analysis_reads, {}),
1005
+ payload.fetch(:analysis_disclosures, {}))
995
1006
  end
996
1007
  @collected_effects.merge!(payload.fetch(:effects, {})) if @record_effects
997
1008
  else
@@ -1001,10 +1012,12 @@ module Rigor
1001
1012
  degraded
1002
1013
  end
1003
1014
 
1004
- # ADR-46 / issue #1553 — fold one worker's dependency records and per-file plugin reads.
1005
- def collect_dependencies(dependencies, analysis_reads)
1015
+ # ADR-46 / issues #1553, #2020 — fold one worker's dependency records, per-file plugin reads and per-file
1016
+ # run-scoped registrations.
1017
+ def collect_dependencies(dependencies, analysis_reads, analysis_disclosures)
1006
1018
  @collected_dependencies.merge!(dependencies)
1007
1019
  collected_analysis_reads.merge!(analysis_reads)
1020
+ collected_analysis_disclosures.merge!(analysis_disclosures)
1008
1021
  end
1009
1022
 
1010
1023
  # @return the child's `{results:, reporters:}` payload, or nil when the child exited
@@ -11,6 +11,8 @@ require_relative "../project_scan"
11
11
  require_relative "../worker_session"
12
12
  require_relative "../template_units"
13
13
  require_relative "../template_unit_collector"
14
+ require_relative "../../project_environment"
15
+ require_relative "scan_environment"
14
16
 
15
17
  module Rigor
16
18
  module Analysis
@@ -100,7 +102,7 @@ module Rigor
100
102
  synthetic_method_index = Inference::SyntheticMethodScanner.scan(
101
103
  plugin_registry: plugin_registry,
102
104
  paths: expansion.fetch(:files),
103
- environment: nil,
105
+ environment: scan_environment(plugin_registry, dependency_source_index, expansion),
104
106
  fact_store: shared_fact_store(plugin_registry),
105
107
  buffer: @buffer
106
108
  )
@@ -135,7 +137,9 @@ module Rigor
135
137
  # collectors walk the tree, and the AST is dropped before the next file (peak RSS stays flat).
136
138
  def discover(expansion:)
137
139
  build_discovery(
138
- Inference::ScopeIndexer.discovered_project_index_for_paths(expansion.fetch(:files), buffer: @buffer)
140
+ Inference::ScopeIndexer.discovered_project_index_for_paths(
141
+ expansion.fetch(:files), buffer: @buffer, uncertain: uncertain_paths(expansion)
142
+ )
139
143
  )
140
144
  end
141
145
 
@@ -145,11 +149,18 @@ module Rigor
145
149
  # (`seed_bundles` empty) this is `#discover` plus a fresh bundle set — the discovery index is identical.
146
150
  def discover_from_bundles(expansion:, seed_bundles:)
147
151
  index = Inference::ScopeIndexer.discovered_project_index_incremental(
148
- expansion.fetch(:files), seed_bundles: seed_bundles, buffer: @buffer
152
+ expansion.fetch(:files), seed_bundles: seed_bundles, buffer: @buffer, uncertain: uncertain_paths(expansion)
149
153
  )
150
154
  [build_discovery(index), index.fetch(:bundles)]
151
155
  end
152
156
 
157
+ # Issue #2021 — the files only the `require_relative` walk added ({Runner#required_outside_paths}): their
158
+ # declarations resolve constants and their methods exist, but every method contribution is `possible`, so no
159
+ # return type, arity or visibility is read from them.
160
+ def uncertain_paths(expansion)
161
+ expansion.fetch(:uncertain, Inference::ScopeIndexer::EMPTY_PATH_SET)
162
+ end
163
+
153
164
  # Builds the {Discovery} bundle from a `{ classes:, def_index: }` index hash (shared by the plain and
154
165
  # seed-bundle discovery paths).
155
166
  def build_discovery(index)
@@ -294,6 +305,37 @@ module Rigor
294
305
 
295
306
  private
296
307
 
308
+ # Issue #476 — the environment the Tier B (trait registry) half of the synthetic-method scan reads, or
309
+ # nil when no loaded plugin registers a trait registry (the scan then has no use for one, and Tier C's
310
+ # template and nested-class lanes behave exactly as they did). The view is lazy ({ScanEnvironment}): it
311
+ # builds an {Environment} on the first question a matching call site asks. That environment is built
312
+ # WITHOUT `synthetic_method_index:` — the index is this scan's output and only the run's real
313
+ # environment stores it; the loader never reads it — and without the diagnostic reporters, which the
314
+ # run's real environment attaches (a second set here would double-report). Every input that decides
315
+ # what the loader resolves (libraries, signature paths, plugin signatures, the bundle / collection
316
+ # axes, required features) is the one the real build passes, so the module a trait names resolves to
317
+ # the same RBS the dispatcher later promotes against.
318
+ def scan_environment(plugin_registry, dependency_source_index, expansion)
319
+ return nil if plugin_registry.nil? || plugin_registry.empty?
320
+ return nil unless plugin_registry.plugins.any? { |plugin| plugin.manifest.trait_registries.any? }
321
+
322
+ files = expansion.fetch(:files)
323
+ ScanEnvironment.new do
324
+ Environment.for_project(
325
+ libraries: @configuration.libraries,
326
+ signature_paths: @configuration.signature_paths,
327
+ cache_store: @cache_store,
328
+ plugin_registry: plugin_registry,
329
+ dependency_source_index: dependency_source_index,
330
+ source_files: files,
331
+ required_features: ProjectEnvironment.required_features(
332
+ @configuration, files, sources: ProjectEnvironment.buffer_sources(@buffer)
333
+ ),
334
+ **ProjectEnvironment.dependency_discovery_options(@configuration)
335
+ )
336
+ end
337
+ end
338
+
297
339
  def trust_refusals(registry, baseline)
298
340
  registry.plugins.filter_map do |plugin|
299
341
  summary = plugin.io_boundary.refusal_summary
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Analysis
5
+ class Runner
6
+ # Issue #476 — the read-only, lazily built view of the project's RBS universe that the ADR-16 Tier B
7
+ # (trait registry) half of {Inference::SyntheticMethodScanner} reads.
8
+ #
9
+ # The scanner needs exactly two answers from an environment: `#rbs_loader` (to explode a trait module such
10
+ # as `Devise::Models::Recoverable` into its instance-method names) and `#class_ordering` (to decide
11
+ # whether a project class descends from an RBS-known `receiver_constraint` such as
12
+ # `ActiveRecord::Base`). Neither reads the synthetic-method index — `Environment.for_project` only stores
13
+ # that index, the RBS loader never consults it — so the chicken-and-egg the issue describes (the env
14
+ # consumes the scan's output) does not reach the loader: the view builds an environment WITHOUT the
15
+ # index, the scan runs against it, and the run's real environment is built afterwards WITH the index.
16
+ #
17
+ # The build is deferred to the first question. A scan whose trait registries never match a call site
18
+ # (no `devise` call in any class body) never asks, so it never pays the environment build. The build is
19
+ # fail-soft: a raise leaves the view answering "unknown", which is what the nil environment answered
20
+ # before — Tier B emits nothing rather than aborting the pre-pass.
21
+ class ScanEnvironment
22
+ # @param builder — called at most once, returns the {Rigor::Environment} (without a synthetic index).
23
+ def initialize(&builder)
24
+ @builder = builder
25
+ @built = false
26
+ @environment = nil
27
+ end
28
+
29
+ def rbs_loader
30
+ environment&.rbs_loader
31
+ end
32
+
33
+ def class_ordering(lhs, rhs)
34
+ env = environment
35
+ env.nil? ? :unknown : env.class_ordering(lhs, rhs)
36
+ end
37
+
38
+ private
39
+
40
+ def environment
41
+ return @environment if @built
42
+
43
+ @built = true
44
+ @environment = @builder.call
45
+ rescue StandardError
46
+ @environment = nil
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end