rigortype 0.4.3 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/data/core_overlay/ostruct.rbs +40 -0
  4. data/docs/handbook/11-sig-gen.md +104 -15
  5. data/docs/manual/02-cli-reference.md +43 -12
  6. data/docs/manual/03-configuration.md +20 -7
  7. data/docs/manual/04-diagnostics.md +56 -3
  8. data/docs/manual/05-inspecting-types.md +2 -2
  9. data/docs/manual/06-baseline.md +1 -1
  10. data/docs/manual/07-plugins.md +3 -2
  11. data/docs/manual/08-skills.md +7 -0
  12. data/docs/manual/10-mcp-server.md +40 -4
  13. data/docs/manual/18-removing-dead-code.md +23 -2
  14. data/docs/manual/plugins/rigor-rails-routes.md +2 -2
  15. data/docs/manual/plugins/rigor-rspec.md +101 -1
  16. data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
  17. data/exe/rigor +12 -17
  18. data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
  19. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +111 -7
  20. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +190 -0
  21. data/lib/rigor/analysis/check_rules/rule_ids.rb +60 -1
  22. data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
  23. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +13 -4
  24. data/lib/rigor/analysis/check_rules.rb +274 -51
  25. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  26. data/lib/rigor/analysis/incremental.rb +61 -0
  27. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
  28. data/lib/rigor/analysis/incremental_session.rb +270 -28
  29. data/lib/rigor/analysis/path_expansion.rb +3 -1
  30. data/lib/rigor/analysis/reachability/graph.rb +111 -14
  31. data/lib/rigor/analysis/reachability/scan.rb +305 -23
  32. data/lib/rigor/analysis/rule_catalog.rb +22 -4
  33. data/lib/rigor/analysis/run_cache_key.rb +31 -7
  34. data/lib/rigor/analysis/run_cache_probe.rb +5 -3
  35. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +48 -12
  36. data/lib/rigor/analysis/runner/pool_coordinator.rb +179 -31
  37. data/lib/rigor/analysis/runner/project_pre_passes.rb +29 -4
  38. data/lib/rigor/analysis/runner.rb +102 -72
  39. data/lib/rigor/analysis/severity_stamp.rb +45 -1
  40. data/lib/rigor/analysis/worker_session.rb +69 -2
  41. data/lib/rigor/cache/descriptor.rb +79 -12
  42. data/lib/rigor/cache/engine_source.rb +3 -2
  43. data/lib/rigor/cache/incremental_snapshot.rb +39 -5
  44. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  45. data/lib/rigor/cache/store.rb +4 -3
  46. data/lib/rigor/cli/command.rb +3 -1
  47. data/lib/rigor/cli/docs_command.rb +3 -2
  48. data/lib/rigor/cli/doctor_command.rb +1 -1
  49. data/lib/rigor/cli/explain_command.rb +3 -2
  50. data/lib/rigor/cli/mcp_command.rb +1 -1
  51. data/lib/rigor/cli/plugin_command.rb +2 -1
  52. data/lib/rigor/cli/plugins_command.rb +2 -1
  53. data/lib/rigor/cli/sig_gen_command.rb +32 -11
  54. data/lib/rigor/cli/skill_command.rb +2 -1
  55. data/lib/rigor/cli/skill_describe.rb +3 -2
  56. data/lib/rigor/cli/unused_command.rb +10 -7
  57. data/lib/rigor/cli.rb +3 -2
  58. data/lib/rigor/config_audit.rb +163 -22
  59. data/lib/rigor/configuration/dependencies.rb +19 -11
  60. data/lib/rigor/configuration/severity_profile.rb +20 -2
  61. data/lib/rigor/configuration.rb +29 -3
  62. data/lib/rigor/effects/signature_sources.rb +3 -1
  63. data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
  64. data/lib/rigor/environment/gated_signature_guard.rb +2 -1
  65. data/lib/rigor/environment/gated_signature_plan.rb +2 -1
  66. data/lib/rigor/environment/installed_gem_set.rb +2 -1
  67. data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
  68. data/lib/rigor/environment/rbs_loader.rb +67 -11
  69. data/lib/rigor/inference/block_call_timing.rb +12 -1
  70. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  71. data/lib/rigor/inference/captured_locals.rb +67 -13
  72. data/lib/rigor/inference/clause_widening.rb +90 -0
  73. data/lib/rigor/inference/definer_resolution.rb +101 -25
  74. data/lib/rigor/inference/expression_typer.rb +94 -9
  75. data/lib/rigor/inference/guard_rebinding.rb +7 -2
  76. data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
  77. data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
  78. data/lib/rigor/inference/macro_block_self_type.rb +20 -6
  79. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +170 -35
  80. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
  81. data/lib/rigor/inference/method_dispatcher.rb +67 -3
  82. data/lib/rigor/inference/module_function_state.rb +213 -51
  83. data/lib/rigor/inference/narrowing.rb +235 -75
  84. data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
  85. data/lib/rigor/inference/project_patched_methods.rb +168 -4
  86. data/lib/rigor/inference/project_patched_mixins.rb +162 -0
  87. data/lib/rigor/inference/project_patched_scanner.rb +51 -12
  88. data/lib/rigor/inference/project_patched_visibility.rb +214 -0
  89. data/lib/rigor/inference/project_seed_tables.rb +120 -0
  90. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
  91. data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
  92. data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
  93. data/lib/rigor/inference/repeated_or_writes.rb +414 -1
  94. data/lib/rigor/inference/return_barrier.rb +16 -0
  95. data/lib/rigor/inference/scope_indexer.rb +1061 -122
  96. data/lib/rigor/inference/self_call_ivar_writes.rb +346 -0
  97. data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
  98. data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
  99. data/lib/rigor/inference/statement_evaluator.rb +512 -150
  100. data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
  101. data/lib/rigor/lens/anchor.rb +3 -3
  102. data/lib/rigor/lens/contract_reader.rb +60 -2
  103. data/lib/rigor/lens/document.rb +4 -3
  104. data/lib/rigor/lens/file_reader.rb +452 -73
  105. data/lib/rigor/lens/members.rb +81 -17
  106. data/lib/rigor/lens/run.rb +73 -4
  107. data/lib/rigor/lens.rb +2 -1
  108. data/lib/rigor/mcp/server.rb +63 -22
  109. data/lib/rigor/plugin/base.rb +3 -2
  110. data/lib/rigor/plugin/box_probe.rb +154 -7
  111. data/lib/rigor/plugin/io_boundary.rb +42 -6
  112. data/lib/rigor/plugin/isolation.rb +4 -3
  113. data/lib/rigor/project_environment.rb +6 -3
  114. data/lib/rigor/protection/discovery_seed.rb +9 -36
  115. data/lib/rigor/protection/mutation_cache.rb +8 -2
  116. data/lib/rigor/rooted_glob.rb +27 -0
  117. data/lib/rigor/scope/discovery_index.rb +5 -0
  118. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
  119. data/lib/rigor/scope/resolution_chain.rb +40 -5
  120. data/lib/rigor/scope.rb +56 -8
  121. data/lib/rigor/sig_gen/alias_index.rb +2 -1
  122. data/lib/rigor/sig_gen/classification.rb +4 -1
  123. data/lib/rigor/sig_gen/generator.rb +167 -22
  124. data/lib/rigor/sig_gen/layout_index.rb +40 -1
  125. data/lib/rigor/sig_gen/method_candidate.rb +8 -0
  126. data/lib/rigor/sig_gen/observation_collector.rb +137 -11
  127. data/lib/rigor/sig_gen/path_mapper.rb +18 -2
  128. data/lib/rigor/sig_gen/renderer.rb +13 -155
  129. data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
  130. data/lib/rigor/sig_gen/write_reporter.rb +203 -0
  131. data/lib/rigor/sig_gen/write_result.rb +3 -1
  132. data/lib/rigor/sig_gen/writer.rb +50 -35
  133. data/lib/rigor/signature_path_audit.rb +4 -2
  134. data/lib/rigor/type/constant.rb +10 -1
  135. data/lib/rigor/version.rb +1 -1
  136. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  137. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +1 -1
  138. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
  139. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
  140. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
  141. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
  142. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
  143. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
  144. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
  145. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
  146. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
  147. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
  148. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +1 -1
  149. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  150. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  151. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +57 -6
  152. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
  153. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
  154. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
  155. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
  156. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
  157. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
  158. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
  159. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
  160. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
  161. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
  162. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
  163. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
  164. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
  165. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
  166. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
  167. data/sig/rigor/plugin/io_boundary.rbs +5 -2
  168. data/sig/rigor/rooted_glob.rbs +7 -0
  169. data/sig/rigor/scope.rbs +4 -3
  170. data/skills/rigor-doctor/references/01-checks.md +23 -11
  171. data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
  172. metadata +16 -1
@@ -7,6 +7,7 @@ require_relative "../incremental_run_slot"
7
7
  require_relative "../dependency_source_inference/gem_resolver"
8
8
  require_relative "../../cache/file_digest"
9
9
  require_relative "../../cache/incremental_snapshot"
10
+ require_relative "../../rooted_glob"
10
11
 
11
12
  module Rigor
12
13
  module Analysis
@@ -66,8 +67,8 @@ module Rigor
66
67
  # opted-in gems' resolved versions, the key's one input that is neither a file nor the configuration (issue
67
68
  # #1814), are read at the mark and watched the same way.
68
69
  #
69
- # The mark also records which of the paths an existence row can name are present: the lockfiles, the
70
- # analysis roots, the `pre_eval:` entries and the signature roots.
70
+ # The mark also records what each path a presence row can name is ({Cache::Descriptor::FileEntry.kind_of}):
71
+ # the lockfiles, the analysis roots, the `pre_eval:` entries and the signature roots.
71
72
  def self.start(configuration:, roots:, cache_root:, fingerprint:)
72
73
  return nil if Gem.win_platform?
73
74
 
@@ -79,7 +80,7 @@ module Rigor
79
80
  lockfiles = lockfile_paths(configuration)
80
81
  named = Array(roots) + configuration.pre_eval + (configuration.signature_paths || ["sig"])
81
82
  presence = (lockfiles + named.map { |path| File.absolute_path(path.to_s) }).to_h do |path|
82
- [path, File.exist?(path)]
83
+ [path, Cache::Descriptor::FileEntry.kind_of(path)]
83
84
  end
84
85
  rows = dependency_rows(configuration)
85
86
  return nil if rows.nil?
@@ -174,11 +175,12 @@ module Rigor
174
175
  # - no lockfile the key or the fingerprint digests was written, created or removed, and no opted-in gem
175
176
  # resolves to another version than at the mark (issue #1814);
176
177
  # - no file `rows` or `pinned` names changed. A content row's file changed if its change time moved. An
177
- # existence row asks only whether its path is there, so a path the mark recorded changed if it came or
178
- # went, and an editor's lock file created and removed beside it, which moves its directory's change
179
- # time, does not count. That needs no clock, so it holds on any filesystem; it cannot see a path that
180
- # comes and goes again within the run. Any other existence row falls back to change times: its own when
181
- # present, its nearest existing ancestor's when absent;
178
+ # existence row asks only whether its path is there, and a `:kind` row what it is (#1977), so a path the
179
+ # mark recorded changed if it came or went, or for a kind row turned from a directory into a file or back;
180
+ # an editor's lock file created and removed beside it, which moves its directory's change time, does not
181
+ # count. That needs no clock, so it holds on any filesystem; it cannot see a path that comes and goes
182
+ # again within the run. Any other presence row falls back to change times: its own when present, its
183
+ # nearest existing ancestor's when absent;
182
184
  # - no directory a glob row lists gained or lost an entry, nor, for a stat-mode glob, did any file it
183
185
  # matches change.
184
186
  #
@@ -231,15 +233,20 @@ module Rigor
231
233
  ns.nil? || device != @device || ns < @started_ns
232
234
  end
233
235
 
234
- # An existence row and a content row for one path are different questions (the first asks only whether it
235
- # is there, the second whether it changed), so the row that comes first must not hide the other.
236
+ # A presence row and a content row for one path are different questions (the first asks only whether it
237
+ # is there, or what it is, the second whether it changed), so the row that comes first must not hide the
238
+ # other; nor may an existence row hide a kind row.
239
+ PRESENCE_COMPARATORS = %i[exists kind].freeze
240
+ private_constant :PRESENCE_COMPARATORS
241
+
236
242
  def files_unchanged?(entries, strict:)
237
243
  seen = Set.new
238
244
  entries.none? do |entry|
239
- next false unless seen.add?([entry.path, entry.comparator == :exists])
245
+ presence = PRESENCE_COMPARATORS.include?(entry.comparator) ? entry.comparator : nil
246
+ next false unless seen.add?([entry.path, presence])
240
247
 
241
- if entry.comparator == :exists
242
- existence_changed?(entry.path, strict: strict)
248
+ if presence
249
+ presence_changed?(entry.path, presence, strict: strict)
243
250
  else
244
251
  changed?(entry.path, strict: strict)
245
252
  end
@@ -248,8 +255,8 @@ module Rigor
248
255
 
249
256
  def globs_unchanged?(entries, strict:)
250
257
  entries.none? do |entry|
251
- listed = Dir.glob(File.join(entry.root, File.dirname(entry.pattern), ""))
252
- matched = entry.mode == :stat ? Dir.glob(File.join(entry.root, entry.pattern)) : []
258
+ listed = RootedGlob.glob(entry.root, File.join(File.dirname(entry.pattern), ""))
259
+ matched = entry.mode == :stat ? RootedGlob.glob(entry.root, entry.pattern) : []
253
260
  (listed + matched).any? { |path| changed?(path, strict: strict) }
254
261
  end
255
262
  end
@@ -257,14 +264,25 @@ module Rigor
257
264
  # A lockfile is read for its bytes: one absent at the mark must still be absent, one present must still be
258
265
  # there with its change time before the mark.
259
266
  def lockfile_changed?(path)
260
- return File.exist?(path) unless @presence[path]
267
+ return File.exist?(path) unless present_at_mark?(path)
261
268
 
262
269
  changed?(path, strict: true, missing: true)
263
270
  end
264
271
 
265
- def existence_changed?(path, strict:)
272
+ def present_at_mark?(path)
273
+ kind = @presence[path]
274
+ !kind.nil? && kind != Cache::Descriptor::FileEntry::KIND_ABSENT
275
+ end
276
+
277
+ # `comparator` is `:exists` or `:kind`: whether the path came or went, or whether it is no longer the kind
278
+ # of thing the mark recorded.
279
+ def presence_changed?(path, comparator, strict:)
266
280
  absolute = File.absolute_path(path)
267
- return File.exist?(absolute) != @presence[absolute] if @presence.key?(absolute)
281
+ if @presence.key?(absolute)
282
+ return File.exist?(absolute) != present_at_mark?(absolute) if comparator == :exists
283
+
284
+ return Cache::Descriptor::FileEntry.kind_of(absolute) != @presence[absolute]
285
+ end
268
286
 
269
287
  nearest = absolute
270
288
  nearest = File.dirname(nearest) until File.exist?(nearest) || File.dirname(nearest) == nearest
@@ -38,11 +38,21 @@ module Rigor
38
38
  # gate) can report what was re-analyzed versus served from cache. `added` / `removed` carry the
39
39
  # structural delta so {#run_incremental} (ADR-87 WD3) can recognise a zero-change recheck and skip the
40
40
  # unconditional snapshot rewrite.
41
- Recheck = Data.define(:diagnostics, :changed, :added, :removed, :affected, :reused, :templates) do
42
- # A recheck that changed, added, and removed nothing — the session state is byte-equivalent to the
43
- # snapshot that was restored, so persisting it again would only rewrite identical bytes.
41
+ # `rebaselined` (#1898) is true when the recheck found its served effect collections stale and answered with a
42
+ # full baseline instead; see {#rebaseline_recheck}.
43
+ # `stale_readers` (#1553) are the files the recheck re-analysed because a plugin read one of them made went
44
+ # stale ({#plugin_read_affected}).
45
+ Recheck = Data.define(:diagnostics, :changed, :added, :removed, :affected, :reused, :templates, :rebaselined,
46
+ :stale_readers) do
47
+ def initialize(rebaselined: false, stale_readers: Set.new, **rest)
48
+ super
49
+ end
50
+
51
+ # A recheck that changed, added, and removed nothing, and found no stale plugin read — the session state is
52
+ # byte-equivalent to the snapshot that was restored, so persisting it again would only rewrite identical
53
+ # bytes. A stale reader's refreshed rows differ (#1553): unsaved, every later run would re-analyse it.
44
54
  def no_change?
45
- changed.empty? && added.empty? && removed.empty?
55
+ changed.empty? && added.empty? && removed.empty? && stale_readers.empty?
46
56
  end
47
57
  end
48
58
 
@@ -93,6 +103,9 @@ module Rigor
93
103
  @cache = {} # analyzed path => [Diagnostic]
94
104
  @sources = {} # analyzed path => Set<source path it read from>
95
105
  @digests = {} # analyzed path => ADR-87 packed stat-digest entry at last analysis
106
+ # Issue #1583 — discovered-not-analysed path => the same packed entry, for a run over path arguments that
107
+ # #684 widened to the configured `paths:`. Empty on every run over the configured paths.
108
+ @discovery_digests = {}
96
109
  @analyzed = [] # the project files analyzed last round
97
110
  @dependents = {} # inverted @sources (file-level)
98
111
  # ADR-46 slice 4 — symbol-granularity tracking.
@@ -122,6 +135,10 @@ module Rigor
122
135
  # and a missing invalidation edge is impossible by construction — the reason this is a table diff
123
136
  # and not the caller→callee edge recording #204 first sketched.
124
137
  @param_table = {}
138
+ # Issue #1553 — analysed path => the plugin reads ({Plugin::IoBoundary.attributing}) its last analysis made:
139
+ # the frozen `Cache::Descriptor::FileEntry` / `GlobEntry` rows, as each read observed its input. A recheck
140
+ # re-analyses every file one of whose rows no longer validates ({#plugin_read_affected}).
141
+ @plugin_reads = {}
125
142
  reset_carried_state
126
143
  # ADR-88 WD1 — the plugin fact-surface digest computed for THIS invocation (nil until a
127
144
  # `#run_incremental` pass runs / a plugin-free project) and the reporting flags a caller (the CLI
@@ -145,6 +162,49 @@ module Rigor
145
162
  current_files
146
163
  end
147
164
 
165
+ # Issue #1583 (and #1560) — whether a file the run discovers but does not analyse was edited, added or
166
+ # removed since the snapshot. Such a file feeds the cross-file index the analysed files resolve against
167
+ # (a constant, a method, an ancestor), but no recorded edge or digest of the analysed set sees it move.
168
+ # A move re-baselines the session: discovery-only files are not tracked per symbol, so the closure
169
+ # cannot be narrowed, and the full analysis is over the argument set alone. A session over the
170
+ # configured `paths:` discovers nothing beyond what it analyses and pays nothing here.
171
+ def discovery_moved?
172
+ current = current_discovery_only_files
173
+ return true unless current.size == @discovery_digests.size
174
+
175
+ current.any? do |path|
176
+ entry = @discovery_digests[path]
177
+ entry.nil? || !Cache::FileDigest.stat_fresh?(path, entry)
178
+ end
179
+ rescue StandardError
180
+ true
181
+ end
182
+
183
+ # A baseline's freshness entries: each analysed file's, and (issue #1583) each discovered-not-analysed file's.
184
+ def record_baseline_digests
185
+ @digests = @analyzed.to_h { |path| [path, pack_digest(path)] }
186
+ @discovery_digests = current_discovery_only_files.to_h { |path| [path, pack_digest(path)] }
187
+ forget_unbound_digests
188
+ end
189
+
190
+ def restore_digests(payload)
191
+ @digests = payload.digests
192
+ @discovery_digests = payload.discovery_digests # Issue #1583 — `{}` in a payload built without it.
193
+ end
194
+
195
+ # Issue #1583 — the discovered-not-analysed files of a run over this session's paths
196
+ # ({Runner#discovery_only_file_set}). Asked of a runner WITHOUT the editor buffer: a buffered run does not
197
+ # widen, so a buffered session (editor mode option B, #146) would read an empty set against the snapshot's
198
+ # and re-baseline on every keystroke. Globbing without it keeps the comparison the one the snapshot's
199
+ # writer made, so a sibling edit still declines option B and an unchanged project still serves it.
200
+ def current_discovery_only_files
201
+ return [] if @paths.nil?
202
+
203
+ Runner.new(configuration: @configuration, cache_store: nil, environment: @environment)
204
+ .discovery_only_file_set(@paths)
205
+ end
206
+ private :discovery_moved?, :record_baseline_digests, :restore_digests, :current_discovery_only_files
207
+
148
208
  # The per-file discovery bundles of the last run, keyed by path (ADR-85 WD2).
149
209
  attr_reader :seed_bundles
150
210
 
@@ -208,9 +268,10 @@ module Rigor
208
268
  # ADR-103 WD13 / #382 — a baseline collects every file, so its collections are the whole world.
209
269
  @effect_collections = runner.effect_collections_by_path
210
270
  @effects_identity = current_effects_identity
271
+ @effect_plugin_digest = effect_plugin_digest_of(runner)
211
272
  @cache = per_file(runner.per_file_diagnostics)
212
- @digests = @analyzed.to_h { |path| [path, pack_digest(path)] }
213
- forget_unbound_digests
273
+ @plugin_reads = plugin_reads_of(runner, @analyzed)
274
+ record_baseline_digests
214
275
  @run_level_rows = runner.run_level_rows
215
276
  diagnostics
216
277
  end
@@ -224,6 +285,8 @@ module Rigor
224
285
  added = current - previous
225
286
  removed = previous - current
226
287
  changed = changed_paths(current & previous)
288
+ return rebaseline_recheck(changed, added, removed) if discovery_moved?
289
+
227
290
  # ADR-67 WD6c lift — recompute the whole-project inferred-param table BEFORE deciding the closure,
228
291
  # and diff it against the snapshot's copy: an entry that moved (because a caller's argument type
229
292
  # changed, a caller appeared, or one vanished) invalidates the CALLEE's file and its symbol
@@ -238,15 +301,14 @@ module Rigor
238
301
  # go in too, so the runner lists them in the full run's file order (#1524).
239
302
  runner = build_runner(analyze_only: analyze_set, record_dependencies: true,
240
303
  param_inferred_types: fresh_params, **forced_target_options(forced),
241
- restored_run_level_rows: @run_level_rows,
242
- served_per_file_diagnostics: @cache.slice(*reused))
304
+ restored_run_level_rows: @run_level_rows, **served_state(reused))
243
305
  merged = run_runner(runner).diagnostics
244
- @last_runner = runner # ADR-88 WD1 — the post-hoc fact-surface fingerprint reads this prepared registry.
306
+ return rebaseline_recheck(changed, added, removed) if served_effects_stale?(runner, reused)
307
+
245
308
  absorb(runner, current, analyze_set, removed)
246
309
  @param_table = fresh_params
247
- Recheck.new(diagnostics: merged, changed: changed.to_set, added: added.to_set,
248
- removed: removed.to_set, affected: affected, reused: reused.to_set,
249
- templates: runner.template_unit_paths.size)
310
+ recheck_result(merged, [changed, added, removed], affected: affected, reused: reused.to_set, runner: runner,
311
+ stale_readers: @stale_plugin_readers)
250
312
  end
251
313
 
252
314
  # Editor mode option B (#146) — a whole-project recheck with the editor's buffer substituted for one
@@ -271,6 +333,9 @@ module Rigor
271
333
  # latency this mode exists to avoid, so decline and let the caller drop to single-file scope.
272
334
  break nil unless @plugin_fact_reusable.reusable_against?(restored.plugin_fact_digest)
273
335
  break nil if source_rbs_unverified?(result)
336
+ # #1898 — a recheck that re-baselined over stale plugin effect facts ran its baseline with the buffer set,
337
+ # which analyses the buffer alone, so it is not a whole-project answer. Decline, as above.
338
+ break nil if result.rebaselined
274
339
 
275
340
  result
276
341
  end
@@ -296,9 +361,10 @@ module Rigor
296
361
  # records none at all), so no dependent set can bound who read it. An edit that moved it re-analyses the
297
362
  # whole project — what a `sig/` edit already costs through the snapshot fingerprint.
298
363
  def affected_closure(changed, added, removed, param_files = Set.new, param_pairs = Set.new)
364
+ @stale_plugin_readers = plugin_read_affected
299
365
  return whole_project_closure(changed, added) if source_rbs_gate.moved?(@seed_bundles, changed, added, removed)
300
366
 
301
- gated_closure(changed, added, removed, param_files, param_pairs)
367
+ (gated_closure(changed, added, removed, param_files, param_pairs) | @stale_plugin_readers).freeze
302
368
  end
303
369
 
304
370
  # {#affected_closure} for an edit whose synthesized RBS is byte-identical in every file it touched.
@@ -342,6 +408,65 @@ module Rigor
342
408
  closure.freeze
343
409
  end
344
410
 
411
+ # Issue #1553 — every analysed file whose last analysis made a plugin read that no longer validates: a file
412
+ # read through {Plugin::IoBoundary#read_file} or probed through `#file?` / `#directory?` was edited,
413
+ # removed, or created after a read that found it absent, or a `#list_directory` / `#glob` listing moved.
414
+ # The ADR-46 edges record type and declaration reads only, so none of these moves a reader into the
415
+ # closure, and its cached rows were computed from the input as it was. The reader's own text is unchanged,
416
+ # so it joins the closure alone: a plugin read made while analysing a dependent is credited to that
417
+ # dependent. Each distinct row is validated once however many files made it (one `File.stat` for a
418
+ # content row whose stat tuple did not move, one re-glob for a listing row).
419
+ def plugin_read_affected
420
+ verdicts = {}
421
+ @plugin_reads.each_with_object(Set.new) do |(path, rows), stale|
422
+ next unless rows.any? { |row| !verdicts.fetch(row) { verdicts[row] = plugin_read_fresh?(row) } }
423
+
424
+ stale << path
425
+ end
426
+ end
427
+
428
+ def plugin_read_fresh?(row)
429
+ if row.is_a?(Cache::Descriptor::GlobEntry)
430
+ Cache::Descriptor::GlobEntry.fresh?(row)
431
+ else
432
+ Cache::Descriptor.new(files: [row]).fresh?
433
+ end
434
+ end
435
+
436
+ # Issue #1553 — `runner`'s per-file plugin reads for `paths`, each file's rows de-duplicated and frozen.
437
+ def plugin_reads_of(runner, paths)
438
+ runner.analysis_reads.slice(*paths).transform_values { |rows| rows.uniq.freeze }
439
+ end
440
+
441
+ # Issue #1906 — a file that rewrites a constant's singleton names without declaring it
442
+ # (`D.singleton_class.alias_method :own, :keep`, or, since #1941, `def D.own`) contributes opaque singleton
443
+ # envelopes to the constant's bucket, but a reader's class edge names only the files that declared the constant
444
+ # when the reader last ran, and the class-declaration diff does not move while the constant stays in the file
445
+ # (`:own` becoming `:keep`, or the call becoming a `class D` reopening). So a declaration-unstable edit to such a
446
+ # file fires `class:<last segment>` for each constant that, before the edit, both sat in the file's
447
+ # singleton-source set and held a singleton envelope entry or an unpositioned singleton side the file recorded (a
448
+ # computed name there leaves the side unpositioned, and becoming literal reads it again). A file that only
449
+ # includes into a constant (`ActiveRecord::Base.include(X)`) records neither and fires nothing. A constant the
450
+ # edit makes the file rewrite needs no key: the class-declaration diff fires if it is new to the file, and the
451
+ # reader's class edge names the file if the file declared it.
452
+ def singleton_rewrite_affected(unstable)
453
+ names = unstable.flat_map { |path| singleton_rewritten_constants(@seed_bundles[path]) }.uniq
454
+ return Set.new if names.empty?
455
+
456
+ Incremental.negative_closure(names.map { |name| "class:#{name.split('::').last}" }, @negative_dependents)
457
+ end
458
+
459
+ def singleton_rewritten_constants(bundle)
460
+ return [] if bundle.nil?
461
+
462
+ envelopes = bundle[:parameter_envelopes] || {}
463
+ unpositioned = bundle[:unpositioned_mixins] || {}
464
+ (bundle[:singleton_class_source_names] || []).select do |name|
465
+ (envelopes[name] || {}).each_key.any? { |key| key.is_a?(Array) && key.first == :singleton } ||
466
+ unpositioned.dig(name, :extend)
467
+ end
468
+ end
469
+
345
470
  # Issue #1367 — every file whose last result holds a `global.*` diagnostic re-checks on any recheck. What
346
471
  # exempts such a write is a fact of the whole program — an alias, or a `write` / `to_str` / `to_int` / hatch
347
472
  # defined in any spelling on any receiver, a rewritten ancestor, a mixin — which no name-keyed edge can
@@ -365,13 +490,15 @@ module Rigor
365
490
 
366
491
  # The dependents contributed by the declaration-unstable changed files and the behaviourally-unstable
367
492
  # symbol pairs: the ADR-46 slice-4 symbol-granular fan-out when either is present (ancestry deps of the
368
- # unstable files + symbol deps of the changed pairs), else the coarse file-level fan-out.
493
+ # unstable files + symbol deps of the changed pairs), else the coarse file-level fan-out; plus the readers of
494
+ # the constants an unstable file rewrote from outside their bodies ({#singleton_rewrite_affected}).
369
495
  def dependents_base(unstable, symbol_pairs)
370
- if symbol_pairs.any? || unstable.any? { |f| @ancestry_dependents[f] }
371
- Incremental.affected_with_symbols(unstable, symbol_pairs, @symbol_dependents, @ancestry_dependents)
372
- else
373
- Incremental.affected(unstable, @dependents)
374
- end
496
+ base = if symbol_pairs.any? || unstable.any? { |f| @ancestry_dependents[f] }
497
+ Incremental.affected_with_symbols(unstable, symbol_pairs, @symbol_dependents, @ancestry_dependents)
498
+ else
499
+ Incremental.affected(unstable, @dependents)
500
+ end
501
+ base | singleton_rewrite_affected(unstable)
375
502
  end
376
503
 
377
504
  # ADR-67 WD6c lift — the fresh whole-project inferred-param table for this recheck ({} when the gate
@@ -466,7 +593,7 @@ module Rigor
466
593
  def whole_project_closure(changed, added)
467
594
  (@analyzed.to_set | changed | added).freeze
468
595
  end
469
- private :gated_closure, :whole_project_closure
596
+ private :gated_closure, :whole_project_closure, :singleton_rewrite_affected, :singleton_rewritten_constants
470
597
 
471
598
  # The current project file set (cheap directory expansion, no analysis), used to detect files added /
472
599
  # removed since the last run. Also where an editor buffer's binding is re-spelled onto the set, since
@@ -489,8 +616,7 @@ module Rigor
489
616
  # ADR-67 WD6c lift — seed the subset run from the baseline's own table so the verification engine
490
617
  # exercises the exact seeds the served cache entries were computed under (and skips a re-collect).
491
618
  runner = build_runner(analyze_only: affected, param_inferred_types: @param_table,
492
- restored_run_level_rows: @run_level_rows,
493
- served_per_file_diagnostics: @cache.slice(*reused))
619
+ restored_run_level_rows: @run_level_rows, **served_state(reused))
494
620
  run_runner(runner).diagnostics
495
621
  end
496
622
 
@@ -534,7 +660,7 @@ module Rigor
534
660
  result = recheck
535
661
  adopt_plugin_fact_fingerprint
536
662
  fact_reusable = @plugin_fact_reusable.reusable_against?(restored.plugin_fact_digest)
537
- if fact_reusable && effects_reusable && !source_rbs_unverified?(result)
663
+ if fact_reusable && effects_reusable && !result.rebaselined && !source_rbs_unverified?(result)
538
664
  diagnostics = keep_recheck(result)
539
665
  warm = true
540
666
  # ADR-87 WD3 — a warm recheck that changed nothing leaves the session state byte-equivalent to the
@@ -549,7 +675,8 @@ module Rigor
549
675
  # digest (from the recheck runner) is unchanged by the re-analysis, so it is kept for the save.
550
676
  # Only a genuine fact-surface reason sets the reporting flag the CLI banner reads.
551
677
  @fact_surface_invalidated = true unless fact_reusable
552
- diagnostics = baseline
678
+ # #1898 — a recheck that already re-baselined over stale plugin effect facts is that re-analysis.
679
+ diagnostics = result.rebaselined ? result.diagnostics : baseline
553
680
  warm = false
554
681
  skip_save = false
555
682
  end
@@ -630,7 +757,7 @@ module Rigor
630
757
  # the gate could not see. {#run_incremental} re-analyses the project and {#run_buffer_recheck} declines,
631
758
  # the way each treats a moved plugin fact surface. A run that changed nothing is exempt: under an
632
759
  # untrusted gate every earlier edit already re-analysed the whole project, so the snapshot it serves is
633
- # whole, and a null run stays one.
760
+ # whole, and a null run stays one. A recheck that found a stale plugin read (#1553) is a change.
634
761
  def source_rbs_unverified?(result)
635
762
  source_rbs_gate.untrusted? && !result.no_change? && !result.reused.empty?
636
763
  end
@@ -850,7 +977,7 @@ module Rigor
850
977
  @analyzed = payload.analyzed
851
978
  @cache = payload.cache
852
979
  @sources = payload.sources
853
- @digests = payload.digests
980
+ restore_digests(payload)
854
981
  # ADR-85 WD2 — restore the per-file discovery bundles if present (absent in a pre-#85 snapshot → empty,
855
982
  # so the recheck's discovery re-walks every file: a cold-quality index, always sound). The SCHEMA bump
856
983
  # makes a genuinely stale-shaped bundle unreadable rather than mis-folded.
@@ -864,6 +991,7 @@ module Rigor
864
991
  restore_structural_declarations(payload)
865
992
  @return_summaries = payload.return_summaries || {}
866
993
  @param_table = payload.param_table || {} # ADR-67 WD6c lift — the seeds the cache was built under.
994
+ @plugin_reads = payload.plugin_reads # Issue #1553 — `{}` in a payload built without it.
867
995
  # Issues #796 / #794 — absent (nil) in a snapshot written before schema 22 is impossible (the SCHEMA
868
996
  # gate rejects one), so nil here means the writing run genuinely had nothing to say.
869
997
  @run_level_rows = payload.run_level_rows
@@ -873,6 +1001,7 @@ module Rigor
873
1001
  # a partial re-collection cannot be closed into a whole-project fixpoint.
874
1002
  @effects_identity = payload.effects_identity
875
1003
  @effect_collections = effect_collections_reusable?(payload) ? payload.effect_collections.dup : {}
1004
+ @effect_plugin_digest = payload.effect_plugin_digest
876
1005
  @symbol_dependents = Incremental.invert_symbols(@symbol_sources)
877
1006
  @ancestry_dependents = Incremental.invert(@ancestry_sources)
878
1007
  @negative_dependents = Incremental.invert(@missing)
@@ -901,7 +1030,10 @@ module Rigor
901
1030
  param_table: marshal_safe_param_table,
902
1031
  effect_collections: marshal_safe_effect_collections,
903
1032
  effects_identity: @effects_identity,
904
- run_level_rows: @run_level_rows
1033
+ run_level_rows: @run_level_rows,
1034
+ effect_plugin_digest: @effect_plugin_digest,
1035
+ plugin_reads: @plugin_reads,
1036
+ discovery_digests: @discovery_digests
905
1037
  )
906
1038
  end
907
1039
 
@@ -913,6 +1045,7 @@ module Rigor
913
1045
  def reset_effect_state
914
1046
  @effect_collections = {}
915
1047
  @effects_identity = nil
1048
+ @effect_plugin_digest = nil
916
1049
  end
917
1050
 
918
1051
  # The state this session carries ACROSS processes in the snapshot rather than deriving each run: the
@@ -1003,6 +1136,7 @@ module Rigor
1003
1136
  # multiple edits: the analyzed set gets fresh diagnostics + digests + dependency edges, removed files
1004
1137
  # are evicted from every map, and the analyzed-file list advances to `current`.
1005
1138
  def absorb(runner, current, analyze_set, removed)
1139
+ @last_runner = runner # ADR-88 WD1 — the post-hoc fact-surface fingerprint reads this prepared registry.
1006
1140
  removed.each { |path| forget(path) }
1007
1141
  @analyzed = current
1008
1142
  # ADR-85 WD2 — the recheck's discovery folded the restored bundles and refreshed them (changed files
@@ -1011,9 +1145,12 @@ module Rigor
1011
1145
  whole_project = current.all? { |path| analyze_set.include?(path) }
1012
1146
  @seed_bundles = source_rbs_gate.stamp(runner.seed_bundles, whole_project: whole_project)
1013
1147
  fresh_by_file = per_file(runner.per_file_diagnostics)
1148
+ # Issue #1553 — a re-analysed file's reads are this run's: a read it no longer makes stops guarding it.
1149
+ fresh_reads = plugin_reads_of(runner, analyze_set)
1014
1150
  analyze_set.each do |path|
1015
1151
  @cache[path] = fresh_by_file[path] || []
1016
1152
  @digests[path] = pack_digest(path)
1153
+ fresh_reads.key?(path) ? @plugin_reads[path] = fresh_reads[path] : @plugin_reads.delete(path)
1017
1154
  end
1018
1155
  forget_unbound_digests
1019
1156
  absorb_dependency_graph(runner)
@@ -1036,6 +1173,7 @@ module Rigor
1036
1173
  @effect_collections.reject! { |path, _| analyzed.include?(path) }
1037
1174
  @effect_collections.merge!(runner.effect_collections_by_path)
1038
1175
  @effects_identity = current_effects_identity
1176
+ @effect_plugin_digest = effect_plugin_digest_of(runner)
1039
1177
  end
1040
1178
 
1041
1179
  # ADR-89 WD2 — replace the behavioural summaries of every re-analyzed file with THIS run's harvest: drop
@@ -1061,6 +1199,7 @@ module Rigor
1061
1199
  @cache.delete(path)
1062
1200
  @digests.delete(path)
1063
1201
  @sources.delete(path)
1202
+ @plugin_reads.delete(path)
1064
1203
  @symbol_sources.delete(path)
1065
1204
  @ancestry_sources.delete(path)
1066
1205
  @missing.delete(path)
@@ -1131,7 +1270,8 @@ module Rigor
1131
1270
  # appeared `"ClassName#method"` to the negative-dependency key it would satisfy (`toplevel:foo` for a
1132
1271
  # top-level def, `method:C#m` otherwise), then unions the recorded negative-dependents of those keys.
1133
1272
  # Issue #1120 — plus the consumers of any refinement name the edit moved ({#refinement_affected}). Issue #1367 —
1134
- # and every file whose last result holds a `global.*` diagnostic ({#global_write_reporters}).
1273
+ # and every file whose last result holds a `global.*` diagnostic ({#global_write_reporters}). Issue #1459 — and
1274
+ # the consumers of any method name a file started or stopped defining ({#defined_name_affected}).
1135
1275
  def negative_affected(changed, removed, new_fingerprints, new_class_decls, new_constant_decls, summary = nil)
1136
1276
  appeared_methods = Incremental.appeared_symbols(changed, @symbol_fingerprints, new_fingerprints)
1137
1277
  # Issue #639 — a class DISAPPEARING satisfies the `class:` kind too, now that a resolved bare
@@ -1149,9 +1289,55 @@ module Rigor
1149
1289
  keys.concat(class_move_keys(moved_classes))
1150
1290
  keys.concat(moved_constants.map { |name| "constant:#{name.split('::').last}" })
1151
1291
  Incremental.negative_closure(keys, @negative_dependents) | refinement_affected(changed + removed, summary) |
1292
+ defined_name_affected(changed + removed, summary) | mark_affected(changed + removed, summary) |
1152
1293
  global_write_reporters
1153
1294
  end
1154
1295
 
1296
+ # Issue #1833 — the consumers whose answer read a class mark that moved in this edit. A reader of a class's
1297
+ # envelope bucket records `mark:<last segment>` (`Scope#parameter_envelopes_of`), whichever way it answered, so
1298
+ # a file that adds or drops only a mark (`x.singleton_class.prepend(Comparable)`, `o.extend(Helper)` in a
1299
+ # method body, a `refine` block's refined mark) re-checks it although the file declares nothing it read. A
1300
+ # reader that scans every bucket for an object-extended module (`SourceArity#object_extension_may_shadow?`)
1301
+ # records `mark:*`, which fires when an object-extended mark moves. The before-state is each touched file's
1302
+ # seed bundle, the after-state the scan's merged table.
1303
+ #
1304
+ # Issue #1661 — and `mixin:<qualified name>` for a class or module whose unpositioned mixin edges moved
1305
+ # ({Incremental.changed_unpositioned_owners}): an external chain entry records it for each name it can denote
1306
+ # (`ResolutionChain#record_hooks`), so a new file that taints `Comparable` with `include(*MODS)` re-checks the
1307
+ # readers of a chain through it. It is not `mark:`, which a plain reopening of a module moves (the bucket's
1308
+ # module mark) and which would re-check every reader of `Kernel` on any `module Kernel` reopening.
1309
+ def mark_affected(paths, summary)
1310
+ def_index = summary && summary[:def_index]
1311
+ before = paths.map { |path| @seed_bundles.dig(path, :parameter_envelopes) }
1312
+ moved = Incremental.changed_class_marks(before, [def_index && def_index[:parameter_envelopes]])
1313
+ tainted = Incremental.changed_unpositioned_owners(
1314
+ paths.map { |path| @seed_bundles.dig(path, :unpositioned_mixins) },
1315
+ [def_index && def_index[:unpositioned_mixins]]
1316
+ )
1317
+ return Set.new if moved.empty? && tainted.empty?
1318
+
1319
+ keys = moved.map { |(class_name, _mark)| "mark:#{class_name.split('::').last}" }.uniq
1320
+ keys.concat(mixin_keys(tainted))
1321
+ extended = Scope::DiscoveryIndex::ENVELOPE_OBJECT_EXTENDED_MARK
1322
+ keys << "mark:*" if moved.any? { |(_class_name, mark)| mark == extended }
1323
+ Incremental.negative_closure(keys, @negative_dependents)
1324
+ end
1325
+
1326
+ # Issue #1661 — the `mixin:` keys an owner of a moved unpositioned edge fires. The table keys a receiver-form
1327
+ # owner by the spelling written (`class File; Constants.include(*MODS); end` is `"Constants"`), not the constant
1328
+ # Ruby resolves it to, while the chain files the qualified candidate (`mixin:File::Constants`). The table does
1329
+ # not carry the nesting, and every lexical-prefix candidate of the owner ends in `::<owner>`, so the owner fires
1330
+ # its own key and every recorded `mixin:` key ending in `::<owner>`. A relative spelling over-fires to the
1331
+ # readers of every module sharing its trailing segments; a qualified one fires only its suffix matches.
1332
+ def mixin_keys(owners)
1333
+ keys = owners.map { |name| "mixin:#{name}" }
1334
+ suffixes = owners.map { |name| "::#{name}" }
1335
+ @negative_dependents.each_key do |key|
1336
+ keys << key if key.start_with?("mixin:") && suffixes.any? { |suffix| key.end_with?(suffix) }
1337
+ end
1338
+ keys.uniq
1339
+ end
1340
+
1155
1341
  # The negative keys a class-declaration diff fires: `class:<last segment>` for a class name, and (issue #1820)
1156
1342
  # `subclass_of:<last segment of the superclass>` for a `[class, superclass]` pair appearing or vanishing. Any
1157
1343
  # class declaration moving may move the set of classes whose superclass the tables do not know, which
@@ -1177,6 +1363,22 @@ module Rigor
1177
1363
  Incremental.negative_closure(names.map { |name| "refinement:#{name}" }, @negative_dependents)
1178
1364
  end
1179
1365
 
1366
+ # Issue #1459 — the consumers whose answer asked whether the program defines a method name anywhere, for each
1367
+ # name a file in `paths` started or stopped defining. The before-state is each file's seed bundle, the
1368
+ # after-state the scan's per-file name sets. Unlike the appeared-symbol diff, it sees a name an `attr_*`,
1369
+ # `alias` or `define_method` adds, and one that vanishes, which brings a declined reading back.
1370
+ def defined_name_affected(paths, summary)
1371
+ after = (summary && summary[:defined_method_names]) || {}
1372
+ before = paths.to_h do |path|
1373
+ bundle = @seed_bundles[path]
1374
+ [path, bundle && Inference::ScopeIndexer.defined_method_names(bundle)]
1375
+ end
1376
+ names = Incremental.changed_defined_names(paths, before, after)
1377
+ return Set.new if names.empty?
1378
+
1379
+ Incremental.negative_closure(names.map { |name| "defines:#{name}" }, @negative_dependents)
1380
+ end
1381
+
1180
1382
  # The qualified class/module names declared in the pre-parsed `index` (shared with
1181
1383
  # {#symbol_fingerprints_from_index}, so the changed set is parsed once). `{ path => Set<class name> }`.
1182
1384
  def class_declarations_from_index(index)
@@ -1335,6 +1537,46 @@ module Rigor
1335
1537
  [forced, (affected | forced) & current]
1336
1538
  end
1337
1539
 
1540
+ # What the session serves a narrowed run for the files it does not analyse: their cached per-file rows (#1524),
1541
+ # and (#1898) their effect collections, so the run closes its effect graph over the whole project.
1542
+ def served_state(reused)
1543
+ { served_per_file_diagnostics: @cache.slice(*reused),
1544
+ served_effect_collections: @effect_collections.slice(*reused) }
1545
+ end
1546
+
1547
+ # Issue #1898 — the plugin effect-fact digest a run's collections were stamped under. A plugin writes its
1548
+ # attributions and edges into a file's collection when the file is collected, and what it contributes can
1549
+ # follow project state no file digest sees (rigor-activejob reads `config.active_job.queue_adapter`), so a
1550
+ # served collection is current only while this digest is. nil when collection is off.
1551
+ def effect_plugin_digest_of(runner)
1552
+ @configuration.effects_enabled? ? runner.effect_plugin_facts.digest : nil
1553
+ end
1554
+
1555
+ # Whether `runner` judged envelopes over served collections stamped under a different plugin effect-fact
1556
+ # digest, or an unknown one. The comparison waits for the run because the plugins load inside it.
1557
+ def served_effects_stale?(runner, reused)
1558
+ return false if reused.empty? || @effect_collections.empty?
1559
+
1560
+ effect_plugin_digest_of(runner) != @effect_plugin_digest
1561
+ end
1562
+
1563
+ # The answer {#recheck} gives when {#served_effects_stale?}: a full baseline, reported as a recheck that
1564
+ # re-analysed every file. {#run_incremental} reads `rebaselined` so it neither runs a second baseline
1565
+ # nor reports the run as warm.
1566
+ def rebaseline_recheck(changed, added, removed)
1567
+ diagnostics = baseline
1568
+ recheck_result(diagnostics, [changed, added, removed],
1569
+ affected: @analyzed.to_set, reused: Set.new, runner: @last_runner, rebaselined: true)
1570
+ end
1571
+
1572
+ # A {Recheck} over `deltas` (`[changed, added, removed]`), counting `runner`'s template units.
1573
+ def recheck_result(diagnostics, deltas, affected:, reused:, runner:, rebaselined: false, stale_readers: Set.new)
1574
+ changed, added, removed = deltas.map(&:to_set)
1575
+ Recheck.new(diagnostics: diagnostics, changed: changed, added: added, removed: removed, affected: affected,
1576
+ stale_readers: stale_readers,
1577
+ reused: reused, templates: runner.template_unit_paths.size, rebaselined: rebaselined)
1578
+ end
1579
+
1338
1580
  # A recheck that forces targets analyses in this process, so the index callback reaches them.
1339
1581
  def forced_target_options(forced)
1340
1582
  forced.empty? ? {} : { workers: 0 }
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../rooted_glob"
4
+
3
5
  module Rigor
4
6
  module Analysis
5
7
  # ADR-87 WD4 — project file-set expansion shared by the {Analysis::Runner} (miss path) and the
@@ -27,7 +29,7 @@ module Rigor
27
29
  end
28
30
 
29
31
  def directory_files(dir, exclude_patterns, base: Dir.pwd)
30
- reject_excluded(Dir.glob(File.join(dir, RUBY_GLOB)), exclude_patterns, base: base)
32
+ reject_excluded(RootedGlob.glob(dir, RUBY_GLOB), exclude_patterns, base: base)
31
33
  end
32
34
 
33
35
  # `exclude_patterns` are matched with `File.fnmatch?` WITHOUT `FNM_PATHNAME`, so `**`/`*` span separators