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
@@ -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
@@ -40,15 +40,19 @@ module Rigor
40
40
  # unconditional snapshot rewrite.
41
41
  # `rebaselined` (#1898) is true when the recheck found its served effect collections stale and answered with a
42
42
  # full baseline instead; see {#rebaseline_recheck}.
43
- Recheck = Data.define(:diagnostics, :changed, :added, :removed, :affected, :reused, :templates, :rebaselined) do
44
- def initialize(rebaselined: false, **rest)
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)
45
48
  super
46
49
  end
47
50
 
48
- # A recheck that changed, added, and removed nothing — the session state is byte-equivalent to the
49
- # snapshot that was restored, so persisting it again would only rewrite identical bytes.
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.
50
54
  def no_change?
51
- changed.empty? && added.empty? && removed.empty?
55
+ changed.empty? && added.empty? && removed.empty? && stale_readers.empty?
52
56
  end
53
57
  end
54
58
 
@@ -99,6 +103,9 @@ module Rigor
99
103
  @cache = {} # analyzed path => [Diagnostic]
100
104
  @sources = {} # analyzed path => Set<source path it read from>
101
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 = {}
102
109
  @analyzed = [] # the project files analyzed last round
103
110
  @dependents = {} # inverted @sources (file-level)
104
111
  # ADR-46 slice 4 — symbol-granularity tracking.
@@ -128,6 +135,10 @@ module Rigor
128
135
  # and a missing invalidation edge is impossible by construction — the reason this is a table diff
129
136
  # and not the caller→callee edge recording #204 first sketched.
130
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 = {}
131
142
  reset_carried_state
132
143
  # ADR-88 WD1 — the plugin fact-surface digest computed for THIS invocation (nil until a
133
144
  # `#run_incremental` pass runs / a plugin-free project) and the reporting flags a caller (the CLI
@@ -151,6 +162,49 @@ module Rigor
151
162
  current_files
152
163
  end
153
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
+
154
208
  # The per-file discovery bundles of the last run, keyed by path (ADR-85 WD2).
155
209
  attr_reader :seed_bundles
156
210
 
@@ -216,8 +270,8 @@ module Rigor
216
270
  @effects_identity = current_effects_identity
217
271
  @effect_plugin_digest = effect_plugin_digest_of(runner)
218
272
  @cache = per_file(runner.per_file_diagnostics)
219
- @digests = @analyzed.to_h { |path| [path, pack_digest(path)] }
220
- forget_unbound_digests
273
+ @plugin_reads = plugin_reads_of(runner, @analyzed)
274
+ record_baseline_digests
221
275
  @run_level_rows = runner.run_level_rows
222
276
  diagnostics
223
277
  end
@@ -231,6 +285,8 @@ module Rigor
231
285
  added = current - previous
232
286
  removed = previous - current
233
287
  changed = changed_paths(current & previous)
288
+ return rebaseline_recheck(changed, added, removed) if discovery_moved?
289
+
234
290
  # ADR-67 WD6c lift — recompute the whole-project inferred-param table BEFORE deciding the closure,
235
291
  # and diff it against the snapshot's copy: an entry that moved (because a caller's argument type
236
292
  # changed, a caller appeared, or one vanished) invalidates the CALLEE's file and its symbol
@@ -251,7 +307,8 @@ module Rigor
251
307
 
252
308
  absorb(runner, current, analyze_set, removed)
253
309
  @param_table = fresh_params
254
- recheck_result(merged, [changed, added, removed], affected: affected, reused: reused.to_set, runner: runner)
310
+ recheck_result(merged, [changed, added, removed], affected: affected, reused: reused.to_set, runner: runner,
311
+ stale_readers: @stale_plugin_readers)
255
312
  end
256
313
 
257
314
  # Editor mode option B (#146) — a whole-project recheck with the editor's buffer substituted for one
@@ -304,9 +361,10 @@ module Rigor
304
361
  # records none at all), so no dependent set can bound who read it. An edit that moved it re-analyses the
305
362
  # whole project — what a `sig/` edit already costs through the snapshot fingerprint.
306
363
  def affected_closure(changed, added, removed, param_files = Set.new, param_pairs = Set.new)
364
+ @stale_plugin_readers = plugin_read_affected
307
365
  return whole_project_closure(changed, added) if source_rbs_gate.moved?(@seed_bundles, changed, added, removed)
308
366
 
309
- gated_closure(changed, added, removed, param_files, param_pairs)
367
+ (gated_closure(changed, added, removed, param_files, param_pairs) | @stale_plugin_readers).freeze
310
368
  end
311
369
 
312
370
  # {#affected_closure} for an edit whose synthesized RBS is byte-identical in every file it touched.
@@ -350,17 +408,47 @@ module Rigor
350
408
  closure.freeze
351
409
  end
352
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
+
353
441
  # Issue #1906 — a file that rewrites a constant's singleton names without declaring it
354
- # (`D.singleton_class.alias_method :own, :keep`) contributes opaque singleton envelopes to the constant's bucket,
355
- # but a reader's class edge names only the files that declared the constant when the reader last ran, and the
356
- # class-declaration diff does not move while the constant stays in the file (`:own` becoming `:keep`, or the
357
- # call becoming a `class D` reopening). So a declaration-unstable edit to such a file fires `class:<last
358
- # segment>` for each constant that, before the edit, both sat in the file's singleton-source set and held a
359
- # singleton envelope entry or an unpositioned singleton side the file recorded (a computed name there leaves the
360
- # side unpositioned, and becoming literal reads it again). A file that only includes into a constant
361
- # (`ActiveRecord::Base.include(X)`) records neither and fires nothing. A constant the edit makes the file
362
- # rewrite needs no key: the class-declaration diff fires if it is new to the file, and the reader's class edge
363
- # names the file if the file declared 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.
364
452
  def singleton_rewrite_affected(unstable)
365
453
  names = unstable.flat_map { |path| singleton_rewritten_constants(@seed_bundles[path]) }.uniq
366
454
  return Set.new if names.empty?
@@ -669,7 +757,7 @@ module Rigor
669
757
  # the gate could not see. {#run_incremental} re-analyses the project and {#run_buffer_recheck} declines,
670
758
  # the way each treats a moved plugin fact surface. A run that changed nothing is exempt: under an
671
759
  # untrusted gate every earlier edit already re-analysed the whole project, so the snapshot it serves is
672
- # 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.
673
761
  def source_rbs_unverified?(result)
674
762
  source_rbs_gate.untrusted? && !result.no_change? && !result.reused.empty?
675
763
  end
@@ -889,7 +977,7 @@ module Rigor
889
977
  @analyzed = payload.analyzed
890
978
  @cache = payload.cache
891
979
  @sources = payload.sources
892
- @digests = payload.digests
980
+ restore_digests(payload)
893
981
  # ADR-85 WD2 — restore the per-file discovery bundles if present (absent in a pre-#85 snapshot → empty,
894
982
  # so the recheck's discovery re-walks every file: a cold-quality index, always sound). The SCHEMA bump
895
983
  # makes a genuinely stale-shaped bundle unreadable rather than mis-folded.
@@ -903,6 +991,7 @@ module Rigor
903
991
  restore_structural_declarations(payload)
904
992
  @return_summaries = payload.return_summaries || {}
905
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.
906
995
  # Issues #796 / #794 — absent (nil) in a snapshot written before schema 22 is impossible (the SCHEMA
907
996
  # gate rejects one), so nil here means the writing run genuinely had nothing to say.
908
997
  @run_level_rows = payload.run_level_rows
@@ -942,7 +1031,9 @@ module Rigor
942
1031
  effect_collections: marshal_safe_effect_collections,
943
1032
  effects_identity: @effects_identity,
944
1033
  run_level_rows: @run_level_rows,
945
- effect_plugin_digest: @effect_plugin_digest
1034
+ effect_plugin_digest: @effect_plugin_digest,
1035
+ plugin_reads: @plugin_reads,
1036
+ discovery_digests: @discovery_digests
946
1037
  )
947
1038
  end
948
1039
 
@@ -1054,9 +1145,12 @@ module Rigor
1054
1145
  whole_project = current.all? { |path| analyze_set.include?(path) }
1055
1146
  @seed_bundles = source_rbs_gate.stamp(runner.seed_bundles, whole_project: whole_project)
1056
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)
1057
1150
  analyze_set.each do |path|
1058
1151
  @cache[path] = fresh_by_file[path] || []
1059
1152
  @digests[path] = pack_digest(path)
1153
+ fresh_reads.key?(path) ? @plugin_reads[path] = fresh_reads[path] : @plugin_reads.delete(path)
1060
1154
  end
1061
1155
  forget_unbound_digests
1062
1156
  absorb_dependency_graph(runner)
@@ -1105,6 +1199,7 @@ module Rigor
1105
1199
  @cache.delete(path)
1106
1200
  @digests.delete(path)
1107
1201
  @sources.delete(path)
1202
+ @plugin_reads.delete(path)
1108
1203
  @symbol_sources.delete(path)
1109
1204
  @ancestry_sources.delete(path)
1110
1205
  @missing.delete(path)
@@ -1194,7 +1289,53 @@ module Rigor
1194
1289
  keys.concat(class_move_keys(moved_classes))
1195
1290
  keys.concat(moved_constants.map { |name| "constant:#{name.split('::').last}" })
1196
1291
  Incremental.negative_closure(keys, @negative_dependents) | refinement_affected(changed + removed, summary) |
1197
- defined_name_affected(changed + removed, summary) | global_write_reporters
1292
+ defined_name_affected(changed + removed, summary) | mark_affected(changed + removed, summary) |
1293
+ global_write_reporters
1294
+ end
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
1198
1339
  end
1199
1340
 
1200
1341
  # The negative keys a class-declaration diff fires: `class:<last segment>` for a class name, and (issue #1820)
@@ -1429,9 +1570,10 @@ module Rigor
1429
1570
  end
1430
1571
 
1431
1572
  # A {Recheck} over `deltas` (`[changed, added, removed]`), counting `runner`'s template units.
1432
- def recheck_result(diagnostics, deltas, affected:, reused:, runner:, rebaselined: false)
1573
+ def recheck_result(diagnostics, deltas, affected:, reused:, runner:, rebaselined: false, stale_readers: Set.new)
1433
1574
  changed, added, removed = deltas.map(&:to_set)
1434
1575
  Recheck.new(diagnostics: diagnostics, changed: changed, added: added, removed: removed, affected: affected,
1576
+ stale_readers: stale_readers,
1435
1577
  reused: reused, templates: runner.template_unit_paths.size, rebaselined: rebaselined)
1436
1578
  end
1437
1579
 
@@ -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
@@ -28,7 +28,10 @@ module Rigor
28
28
  next [] if use.name.nil?
29
29
 
30
30
  name = use.name.delete_prefix("::")
31
- targets = use.within ? literal_targets(use, name) : [nil]
31
+ targets = if use.anywhere then anywhere_targets(name)
32
+ elsif use.within then literal_targets(use, name)
33
+ else [nil]
34
+ end
32
35
  targets.map do |target|
33
36
  Scan::Reference.new(as_written: target || name, nesting: [].freeze, from: nil,
34
37
  role: use.role, path: use.path, line: use.line,
@@ -54,29 +57,101 @@ module Rigor
54
57
  own = declared_member(receiver, name, local)
55
58
  return [own, *runtime].uniq if own
56
59
  return runtime unless use.inherit
60
+ return (anywhere_targets(name) + runtime).uniq if unknown_ancestry?(receiver)
57
61
 
58
62
  (plausible_ancestors(receiver).filter_map { |scope| declared_member(scope, name, local) } + runtime)
59
63
  .uniq + [nil]
60
64
  end
61
65
 
66
+ # Every declaration a literal `name` can name from a receiver the walk cannot bound (#1946): each one a
67
+ # scope's `name` (or a declared prefix of it) could be, and the top level.
68
+ def anywhere_targets(name)
69
+ segments = name.split("::")
70
+ found = segments.length.downto(1).flat_map do |count|
71
+ suffix = segments.first(count).join("::")
72
+ by_last_segment.fetch(segments[count - 1], []).select do |fqn|
73
+ fqn == suffix || fqn.end_with?("::#{suffix}")
74
+ end
75
+ end
76
+ found.uniq + [nil]
77
+ end
78
+
79
+ def by_last_segment
80
+ @by_last_segment ||= @by_fqn.keys.group_by { |fqn| fqn.split("::").last }
81
+ end
82
+
62
83
  # The declarations a descendant of `receiver` holding `name` adds (#1878): a `self` standing for the
63
84
  # enclosing declaration is whichever subclass, includer, prepender or extender the method runs on, and
64
85
  # Ruby looks the name up there first. Each descendant is searched with its own ancestors unless the call
65
86
  # passes `inherit = false`. The descendants are over-approximated as {#plausible_ancestors} is.
87
+ #
88
+ # A module some unknown receiver includes or extends (`k.include(Mix)`, an `include` in an `included do`
89
+ # block) has descendants the walk cannot list, so the name may be any declaration's (#1946). A class
90
+ # with an ancestor the walk cannot name (`K.include(mods.first)`) may be a descendant of any module, so
91
+ # it is searched too.
66
92
  def runtime_targets(receiver, name, inherit, local)
67
- descendants_of(receiver).flat_map do |descendant|
93
+ return anywhere_targets(name) if open_descendants?(receiver)
94
+
95
+ (descendants_of(receiver) | unknown_ancestry_classes).flat_map do |descendant|
68
96
  scopes = inherit ? [descendant, *plausible_ancestors(descendant)] : [descendant]
69
97
  scopes.filter_map { |scope| declared_member(scope, name, local) }
70
98
  end.uniq
71
99
  end
72
100
 
73
101
  def descendants_of(fqn)
74
- @plausible_descendants ||= @by_fqn.keys.each_with_object(Hash.new { |h, k| h[k] = [] }) do |declared, out|
102
+ @plausible_descendants ||= (@by_fqn.keys | ancestry.edges.keys)
103
+ .each_with_object(Hash.new { |h, k| h[k] = [] }) do |declared, out|
75
104
  plausible_ancestors(declared).each { |ancestor| out[ancestor] << declared }
76
105
  end
77
106
  @plausible_descendants.fetch(fqn, [])
78
107
  end
79
108
 
109
+ def open_descendants?(fqn)
110
+ ancestry.open.include?(fqn) || ancestry.open.any? { |mod| plausible_ancestors(mod).include?(fqn) }
111
+ end
112
+
113
+ # Whether `fqn` or one of its ancestors has an ancestor the walk cannot name, which may declare anything.
114
+ def unknown_ancestry?(fqn)
115
+ ([fqn] + plausible_ancestors(fqn)).any? { |scope| ancestry.unknown.include?(scope) }
116
+ end
117
+
118
+ def unknown_ancestry_classes
119
+ @unknown_ancestry_classes ||= ancestry.unknown.flat_map { |fqn| [fqn, *descendants_of(fqn)] }.uniq
120
+ end
121
+
122
+ # The {Scan::Ancestry} edges read (#1946): `edges` maps a class to the modules and superclass it gains
123
+ # outside its declaration; `open` holds the modules an unknown receiver gains, and `unknown` the classes
124
+ # that gain an ancestor the walk cannot name. A target is every scope its written name could be, declared
125
+ # or not (a gem class reopened as `class Foo::X` declares `Foo::X` alone); an ancestor, every scope that
126
+ # declares it.
127
+ AncestryIndex = Data.define(:edges, :open, :unknown)
128
+ private_constant :AncestryIndex
129
+
130
+ def ancestry
131
+ @ancestry ||= begin
132
+ edges = Hash.new { |h, k| h[k] = [] }
133
+ open = Set.new
134
+ unknown = Set.new
135
+ @ancestries.each do |edge|
136
+ targets = edge.target && written_scopes(edge.target)
137
+ ancestors = edge.ancestor && written_scopes(edge.ancestor).select { |fqn| @by_fqn.key?(fqn) }
138
+ if targets.nil? then open.merge(ancestors)
139
+ elsif ancestors.nil? then unknown.merge(targets)
140
+ else targets.each { |target| edges[target].concat(ancestors - [target]) }
141
+ end
142
+ end
143
+ AncestryIndex.new(edges: edges, open: open, unknown: unknown)
144
+ end
145
+ end
146
+
147
+ # Every scope a written reference could name: rooted, itself; otherwise its name under each enclosing scope.
148
+ def written_scopes(ref)
149
+ name = ref.as_written.delete_prefix("::")
150
+ return [name] if ref.rooted
151
+
152
+ ref.nesting.length.downto(0).map { |i| (ref.nesting.first(i) + [name]).join("::") }
153
+ end
154
+
80
155
  # `scope::name`, or the longest declared prefix of it, when one is declared.
81
156
  def declared_member(scope, name, local)
82
157
  segments = name.split("::")
@@ -99,16 +174,15 @@ module Rigor
99
174
  out = []
100
175
  until queue.empty?
101
176
  current = queue.shift
102
- @by_fqn.fetch(current, []).each do |decl|
103
- (decl.includes + [decl.superclass]).compact.each do |raw|
104
- plausible_resolutions(current, raw).each do |resolved|
105
- next if seen.include?(resolved)
177
+ resolved = @by_fqn.fetch(current, []).flat_map do |decl|
178
+ (decl.includes + [decl.superclass]).compact.flat_map { |raw| plausible_resolutions(current, raw) }
179
+ end
180
+ (resolved + ancestry.edges.fetch(current, [])).each do |ancestor|
181
+ next if seen.include?(ancestor)
106
182
 
107
- seen << resolved
108
- out << resolved
109
- queue << resolved
110
- end
111
- end
183
+ seen << ancestor
184
+ out << ancestor
185
+ queue << ancestor
112
186
  end
113
187
  end
114
188
  out.freeze
@@ -174,9 +248,12 @@ module Rigor
174
248
  # A literal-argument site contributes a real reference; a dynamic one taints a namespace (WD4).
175
249
  # @param shadows — declarations made in files outside the analysed paths. They are never nodes, but a
176
250
  # reference written in the SAME file resolves to them first, as Ruby would (#1732, see {#resolve_ref}).
177
- def initialize(declarations:, references:, root_fqns: [], dynamic_uses: [], shadows: [],
251
+ # @param ancestries — {Scan::Ancestry} edges written outside a declaration, read only by the dynamic
252
+ # lookup (#1946).
253
+ def initialize(declarations:, references:, root_fqns: [], dynamic_uses: [], shadows: [], ancestries: [],
178
254
  foreign: ->(_fqn) { false })
179
255
  @declarations = declarations
256
+ @ancestries = ancestries
180
257
  @root_fqns = root_fqns.to_set
181
258
  @foreign = foreign
182
259
  @by_fqn = declarations.group_by(&:fqn)