rigortype 0.4.3 → 0.4.4

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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/core_overlay/ostruct.rbs +40 -0
  4. data/docs/handbook/11-sig-gen.md +16 -0
  5. data/docs/manual/02-cli-reference.md +4 -3
  6. data/docs/manual/03-configuration.md +16 -5
  7. data/docs/manual/04-diagnostics.md +44 -2
  8. data/docs/manual/07-plugins.md +1 -1
  9. data/docs/manual/10-mcp-server.md +32 -3
  10. data/docs/manual/18-removing-dead-code.md +11 -1
  11. data/docs/manual/plugins/rigor-rails-routes.md +2 -2
  12. data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
  13. data/exe/rigor +12 -17
  14. data/lib/rigor/analysis/check_rules/rule_ids.rb +57 -0
  15. data/lib/rigor/analysis/check_rules.rb +46 -36
  16. data/lib/rigor/analysis/incremental.rb +12 -0
  17. data/lib/rigor/analysis/incremental_session.rb +121 -21
  18. data/lib/rigor/analysis/reachability/graph.rb +23 -3
  19. data/lib/rigor/analysis/reachability/scan.rb +51 -9
  20. data/lib/rigor/analysis/rule_catalog.rb +6 -1
  21. data/lib/rigor/analysis/run_cache_key.rb +31 -7
  22. data/lib/rigor/analysis/run_cache_probe.rb +5 -3
  23. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +48 -12
  24. data/lib/rigor/analysis/runner/pool_coordinator.rb +110 -15
  25. data/lib/rigor/analysis/runner/project_pre_passes.rb +29 -4
  26. data/lib/rigor/analysis/runner.rb +35 -9
  27. data/lib/rigor/analysis/severity_stamp.rb +34 -1
  28. data/lib/rigor/analysis/worker_session.rb +57 -1
  29. data/lib/rigor/cache/descriptor.rb +40 -6
  30. data/lib/rigor/cache/incremental_snapshot.rb +20 -4
  31. data/lib/rigor/cli/mcp_command.rb +1 -1
  32. data/lib/rigor/config_audit.rb +111 -15
  33. data/lib/rigor/configuration/dependencies.rb +19 -11
  34. data/lib/rigor/environment/rbs_loader.rb +46 -10
  35. data/lib/rigor/inference/block_call_timing.rb +5 -0
  36. data/lib/rigor/inference/captured_locals.rb +26 -3
  37. data/lib/rigor/inference/definer_resolution.rb +16 -7
  38. data/lib/rigor/inference/expression_typer.rb +59 -4
  39. data/lib/rigor/inference/guard_rebinding.rb +7 -2
  40. data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
  41. data/lib/rigor/inference/module_function_state.rb +80 -13
  42. data/lib/rigor/inference/narrowing.rb +184 -68
  43. data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
  44. data/lib/rigor/inference/repeated_or_writes.rb +414 -1
  45. data/lib/rigor/inference/scope_indexer.rb +439 -70
  46. data/lib/rigor/inference/self_call_ivar_writes.rb +341 -0
  47. data/lib/rigor/inference/statement_evaluator.rb +84 -29
  48. data/lib/rigor/lens/anchor.rb +3 -3
  49. data/lib/rigor/lens/contract_reader.rb +15 -0
  50. data/lib/rigor/lens/document.rb +2 -2
  51. data/lib/rigor/lens/file_reader.rb +442 -73
  52. data/lib/rigor/lens/members.rb +65 -15
  53. data/lib/rigor/lens/run.rb +44 -2
  54. data/lib/rigor/lens.rb +2 -1
  55. data/lib/rigor/mcp/server.rb +61 -20
  56. data/lib/rigor/plugin/box_probe.rb +154 -7
  57. data/lib/rigor/project_environment.rb +6 -3
  58. data/lib/rigor/scope.rb +11 -6
  59. data/lib/rigor/sig_gen/layout_index.rb +36 -0
  60. data/lib/rigor/sig_gen/method_candidate.rb +8 -0
  61. data/lib/rigor/sig_gen/path_mapper.rb +18 -2
  62. data/lib/rigor/sig_gen/writer.rb +47 -34
  63. data/lib/rigor/version.rb +1 -1
  64. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
  65. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  66. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +28 -6
  67. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
  68. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
  69. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
  70. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
  71. metadata +4 -1
@@ -38,7 +38,13 @@ 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
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
+ Recheck = Data.define(:diagnostics, :changed, :added, :removed, :affected, :reused, :templates, :rebaselined) do
44
+ def initialize(rebaselined: false, **rest)
45
+ super
46
+ end
47
+
42
48
  # A recheck that changed, added, and removed nothing — the session state is byte-equivalent to the
43
49
  # snapshot that was restored, so persisting it again would only rewrite identical bytes.
44
50
  def no_change?
@@ -208,6 +214,7 @@ module Rigor
208
214
  # ADR-103 WD13 / #382 — a baseline collects every file, so its collections are the whole world.
209
215
  @effect_collections = runner.effect_collections_by_path
210
216
  @effects_identity = current_effects_identity
217
+ @effect_plugin_digest = effect_plugin_digest_of(runner)
211
218
  @cache = per_file(runner.per_file_diagnostics)
212
219
  @digests = @analyzed.to_h { |path| [path, pack_digest(path)] }
213
220
  forget_unbound_digests
@@ -238,15 +245,13 @@ module Rigor
238
245
  # go in too, so the runner lists them in the full run's file order (#1524).
239
246
  runner = build_runner(analyze_only: analyze_set, record_dependencies: true,
240
247
  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))
248
+ restored_run_level_rows: @run_level_rows, **served_state(reused))
243
249
  merged = run_runner(runner).diagnostics
244
- @last_runner = runner # ADR-88 WD1 — the post-hoc fact-surface fingerprint reads this prepared registry.
250
+ return rebaseline_recheck(changed, added, removed) if served_effects_stale?(runner, reused)
251
+
245
252
  absorb(runner, current, analyze_set, removed)
246
253
  @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)
254
+ recheck_result(merged, [changed, added, removed], affected: affected, reused: reused.to_set, runner: runner)
250
255
  end
251
256
 
252
257
  # Editor mode option B (#146) — a whole-project recheck with the editor's buffer substituted for one
@@ -271,6 +276,9 @@ module Rigor
271
276
  # latency this mode exists to avoid, so decline and let the caller drop to single-file scope.
272
277
  break nil unless @plugin_fact_reusable.reusable_against?(restored.plugin_fact_digest)
273
278
  break nil if source_rbs_unverified?(result)
279
+ # #1898 — a recheck that re-baselined over stale plugin effect facts ran its baseline with the buffer set,
280
+ # which analyses the buffer alone, so it is not a whole-project answer. Decline, as above.
281
+ break nil if result.rebaselined
274
282
 
275
283
  result
276
284
  end
@@ -342,6 +350,35 @@ module Rigor
342
350
  closure.freeze
343
351
  end
344
352
 
353
+ # 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.
364
+ def singleton_rewrite_affected(unstable)
365
+ names = unstable.flat_map { |path| singleton_rewritten_constants(@seed_bundles[path]) }.uniq
366
+ return Set.new if names.empty?
367
+
368
+ Incremental.negative_closure(names.map { |name| "class:#{name.split('::').last}" }, @negative_dependents)
369
+ end
370
+
371
+ def singleton_rewritten_constants(bundle)
372
+ return [] if bundle.nil?
373
+
374
+ envelopes = bundle[:parameter_envelopes] || {}
375
+ unpositioned = bundle[:unpositioned_mixins] || {}
376
+ (bundle[:singleton_class_source_names] || []).select do |name|
377
+ (envelopes[name] || {}).each_key.any? { |key| key.is_a?(Array) && key.first == :singleton } ||
378
+ unpositioned.dig(name, :extend)
379
+ end
380
+ end
381
+
345
382
  # Issue #1367 — every file whose last result holds a `global.*` diagnostic re-checks on any recheck. What
346
383
  # exempts such a write is a fact of the whole program — an alias, or a `write` / `to_str` / `to_int` / hatch
347
384
  # defined in any spelling on any receiver, a rewritten ancestor, a mixin — which no name-keyed edge can
@@ -365,13 +402,15 @@ module Rigor
365
402
 
366
403
  # The dependents contributed by the declaration-unstable changed files and the behaviourally-unstable
367
404
  # 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.
405
+ # unstable files + symbol deps of the changed pairs), else the coarse file-level fan-out; plus the readers of
406
+ # the constants an unstable file rewrote from outside their bodies ({#singleton_rewrite_affected}).
369
407
  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
408
+ base = if symbol_pairs.any? || unstable.any? { |f| @ancestry_dependents[f] }
409
+ Incremental.affected_with_symbols(unstable, symbol_pairs, @symbol_dependents, @ancestry_dependents)
410
+ else
411
+ Incremental.affected(unstable, @dependents)
412
+ end
413
+ base | singleton_rewrite_affected(unstable)
375
414
  end
376
415
 
377
416
  # ADR-67 WD6c lift — the fresh whole-project inferred-param table for this recheck ({} when the gate
@@ -466,7 +505,7 @@ module Rigor
466
505
  def whole_project_closure(changed, added)
467
506
  (@analyzed.to_set | changed | added).freeze
468
507
  end
469
- private :gated_closure, :whole_project_closure
508
+ private :gated_closure, :whole_project_closure, :singleton_rewrite_affected, :singleton_rewritten_constants
470
509
 
471
510
  # The current project file set (cheap directory expansion, no analysis), used to detect files added /
472
511
  # removed since the last run. Also where an editor buffer's binding is re-spelled onto the set, since
@@ -489,8 +528,7 @@ module Rigor
489
528
  # ADR-67 WD6c lift — seed the subset run from the baseline's own table so the verification engine
490
529
  # exercises the exact seeds the served cache entries were computed under (and skips a re-collect).
491
530
  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))
531
+ restored_run_level_rows: @run_level_rows, **served_state(reused))
494
532
  run_runner(runner).diagnostics
495
533
  end
496
534
 
@@ -534,7 +572,7 @@ module Rigor
534
572
  result = recheck
535
573
  adopt_plugin_fact_fingerprint
536
574
  fact_reusable = @plugin_fact_reusable.reusable_against?(restored.plugin_fact_digest)
537
- if fact_reusable && effects_reusable && !source_rbs_unverified?(result)
575
+ if fact_reusable && effects_reusable && !result.rebaselined && !source_rbs_unverified?(result)
538
576
  diagnostics = keep_recheck(result)
539
577
  warm = true
540
578
  # ADR-87 WD3 — a warm recheck that changed nothing leaves the session state byte-equivalent to the
@@ -549,7 +587,8 @@ module Rigor
549
587
  # digest (from the recheck runner) is unchanged by the re-analysis, so it is kept for the save.
550
588
  # Only a genuine fact-surface reason sets the reporting flag the CLI banner reads.
551
589
  @fact_surface_invalidated = true unless fact_reusable
552
- diagnostics = baseline
590
+ # #1898 — a recheck that already re-baselined over stale plugin effect facts is that re-analysis.
591
+ diagnostics = result.rebaselined ? result.diagnostics : baseline
553
592
  warm = false
554
593
  skip_save = false
555
594
  end
@@ -873,6 +912,7 @@ module Rigor
873
912
  # a partial re-collection cannot be closed into a whole-project fixpoint.
874
913
  @effects_identity = payload.effects_identity
875
914
  @effect_collections = effect_collections_reusable?(payload) ? payload.effect_collections.dup : {}
915
+ @effect_plugin_digest = payload.effect_plugin_digest
876
916
  @symbol_dependents = Incremental.invert_symbols(@symbol_sources)
877
917
  @ancestry_dependents = Incremental.invert(@ancestry_sources)
878
918
  @negative_dependents = Incremental.invert(@missing)
@@ -901,7 +941,8 @@ module Rigor
901
941
  param_table: marshal_safe_param_table,
902
942
  effect_collections: marshal_safe_effect_collections,
903
943
  effects_identity: @effects_identity,
904
- run_level_rows: @run_level_rows
944
+ run_level_rows: @run_level_rows,
945
+ effect_plugin_digest: @effect_plugin_digest
905
946
  )
906
947
  end
907
948
 
@@ -913,6 +954,7 @@ module Rigor
913
954
  def reset_effect_state
914
955
  @effect_collections = {}
915
956
  @effects_identity = nil
957
+ @effect_plugin_digest = nil
916
958
  end
917
959
 
918
960
  # The state this session carries ACROSS processes in the snapshot rather than deriving each run: the
@@ -1003,6 +1045,7 @@ module Rigor
1003
1045
  # multiple edits: the analyzed set gets fresh diagnostics + digests + dependency edges, removed files
1004
1046
  # are evicted from every map, and the analyzed-file list advances to `current`.
1005
1047
  def absorb(runner, current, analyze_set, removed)
1048
+ @last_runner = runner # ADR-88 WD1 — the post-hoc fact-surface fingerprint reads this prepared registry.
1006
1049
  removed.each { |path| forget(path) }
1007
1050
  @analyzed = current
1008
1051
  # ADR-85 WD2 — the recheck's discovery folded the restored bundles and refreshed them (changed files
@@ -1036,6 +1079,7 @@ module Rigor
1036
1079
  @effect_collections.reject! { |path, _| analyzed.include?(path) }
1037
1080
  @effect_collections.merge!(runner.effect_collections_by_path)
1038
1081
  @effects_identity = current_effects_identity
1082
+ @effect_plugin_digest = effect_plugin_digest_of(runner)
1039
1083
  end
1040
1084
 
1041
1085
  # ADR-89 WD2 — replace the behavioural summaries of every re-analyzed file with THIS run's harvest: drop
@@ -1131,7 +1175,8 @@ module Rigor
1131
1175
  # appeared `"ClassName#method"` to the negative-dependency key it would satisfy (`toplevel:foo` for a
1132
1176
  # top-level def, `method:C#m` otherwise), then unions the recorded negative-dependents of those keys.
1133
1177
  # 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}).
1178
+ # and every file whose last result holds a `global.*` diagnostic ({#global_write_reporters}). Issue #1459 — and
1179
+ # the consumers of any method name a file started or stopped defining ({#defined_name_affected}).
1135
1180
  def negative_affected(changed, removed, new_fingerprints, new_class_decls, new_constant_decls, summary = nil)
1136
1181
  appeared_methods = Incremental.appeared_symbols(changed, @symbol_fingerprints, new_fingerprints)
1137
1182
  # Issue #639 — a class DISAPPEARING satisfies the `class:` kind too, now that a resolved bare
@@ -1149,7 +1194,7 @@ module Rigor
1149
1194
  keys.concat(class_move_keys(moved_classes))
1150
1195
  keys.concat(moved_constants.map { |name| "constant:#{name.split('::').last}" })
1151
1196
  Incremental.negative_closure(keys, @negative_dependents) | refinement_affected(changed + removed, summary) |
1152
- global_write_reporters
1197
+ defined_name_affected(changed + removed, summary) | global_write_reporters
1153
1198
  end
1154
1199
 
1155
1200
  # The negative keys a class-declaration diff fires: `class:<last segment>` for a class name, and (issue #1820)
@@ -1177,6 +1222,22 @@ module Rigor
1177
1222
  Incremental.negative_closure(names.map { |name| "refinement:#{name}" }, @negative_dependents)
1178
1223
  end
1179
1224
 
1225
+ # Issue #1459 — the consumers whose answer asked whether the program defines a method name anywhere, for each
1226
+ # name a file in `paths` started or stopped defining. The before-state is each file's seed bundle, the
1227
+ # after-state the scan's per-file name sets. Unlike the appeared-symbol diff, it sees a name an `attr_*`,
1228
+ # `alias` or `define_method` adds, and one that vanishes, which brings a declined reading back.
1229
+ def defined_name_affected(paths, summary)
1230
+ after = (summary && summary[:defined_method_names]) || {}
1231
+ before = paths.to_h do |path|
1232
+ bundle = @seed_bundles[path]
1233
+ [path, bundle && Inference::ScopeIndexer.defined_method_names(bundle)]
1234
+ end
1235
+ names = Incremental.changed_defined_names(paths, before, after)
1236
+ return Set.new if names.empty?
1237
+
1238
+ Incremental.negative_closure(names.map { |name| "defines:#{name}" }, @negative_dependents)
1239
+ end
1240
+
1180
1241
  # The qualified class/module names declared in the pre-parsed `index` (shared with
1181
1242
  # {#symbol_fingerprints_from_index}, so the changed set is parsed once). `{ path => Set<class name> }`.
1182
1243
  def class_declarations_from_index(index)
@@ -1335,6 +1396,45 @@ module Rigor
1335
1396
  [forced, (affected | forced) & current]
1336
1397
  end
1337
1398
 
1399
+ # What the session serves a narrowed run for the files it does not analyse: their cached per-file rows (#1524),
1400
+ # and (#1898) their effect collections, so the run closes its effect graph over the whole project.
1401
+ def served_state(reused)
1402
+ { served_per_file_diagnostics: @cache.slice(*reused),
1403
+ served_effect_collections: @effect_collections.slice(*reused) }
1404
+ end
1405
+
1406
+ # Issue #1898 — the plugin effect-fact digest a run's collections were stamped under. A plugin writes its
1407
+ # attributions and edges into a file's collection when the file is collected, and what it contributes can
1408
+ # follow project state no file digest sees (rigor-activejob reads `config.active_job.queue_adapter`), so a
1409
+ # served collection is current only while this digest is. nil when collection is off.
1410
+ def effect_plugin_digest_of(runner)
1411
+ @configuration.effects_enabled? ? runner.effect_plugin_facts.digest : nil
1412
+ end
1413
+
1414
+ # Whether `runner` judged envelopes over served collections stamped under a different plugin effect-fact
1415
+ # digest, or an unknown one. The comparison waits for the run because the plugins load inside it.
1416
+ def served_effects_stale?(runner, reused)
1417
+ return false if reused.empty? || @effect_collections.empty?
1418
+
1419
+ effect_plugin_digest_of(runner) != @effect_plugin_digest
1420
+ end
1421
+
1422
+ # The answer {#recheck} gives when {#served_effects_stale?}: a full baseline, reported as a recheck that
1423
+ # re-analysed every file. {#run_incremental} reads `rebaselined` so it neither runs a second baseline
1424
+ # nor reports the run as warm.
1425
+ def rebaseline_recheck(changed, added, removed)
1426
+ diagnostics = baseline
1427
+ recheck_result(diagnostics, [changed, added, removed],
1428
+ affected: @analyzed.to_set, reused: Set.new, runner: @last_runner, rebaselined: true)
1429
+ end
1430
+
1431
+ # A {Recheck} over `deltas` (`[changed, added, removed]`), counting `runner`'s template units.
1432
+ def recheck_result(diagnostics, deltas, affected:, reused:, runner:, rebaselined: false)
1433
+ changed, added, removed = deltas.map(&:to_set)
1434
+ Recheck.new(diagnostics: diagnostics, changed: changed, added: added, removed: removed, affected: affected,
1435
+ reused: reused, templates: runner.template_unit_paths.size, rebaselined: rebaselined)
1436
+ end
1437
+
1338
1438
  # A recheck that forces targets analyses in this process, so the index callback reaches them.
1339
1439
  def forced_target_options(forced)
1340
1440
  forced.empty? ? {} : { workers: 0 }
@@ -50,11 +50,31 @@ module Rigor
50
50
  return [nil] if receiver.nil?
51
51
 
52
52
  local = @shadows[use.path]
53
+ runtime = use.receiver_open ? runtime_targets(receiver, name, use.inherit, local) : []
53
54
  own = declared_member(receiver, name, local)
54
- return [own] if own
55
- return [] unless use.inherit
55
+ return [own, *runtime].uniq if own
56
+ return runtime unless use.inherit
56
57
 
57
- plausible_ancestors(receiver).filter_map { |scope| declared_member(scope, name, local) }.uniq + [nil]
58
+ (plausible_ancestors(receiver).filter_map { |scope| declared_member(scope, name, local) } + runtime)
59
+ .uniq + [nil]
60
+ end
61
+
62
+ # The declarations a descendant of `receiver` holding `name` adds (#1878): a `self` standing for the
63
+ # enclosing declaration is whichever subclass, includer, prepender or extender the method runs on, and
64
+ # Ruby looks the name up there first. Each descendant is searched with its own ancestors unless the call
65
+ # passes `inherit = false`. The descendants are over-approximated as {#plausible_ancestors} is.
66
+ def runtime_targets(receiver, name, inherit, local)
67
+ descendants_of(receiver).flat_map do |descendant|
68
+ scopes = inherit ? [descendant, *plausible_ancestors(descendant)] : [descendant]
69
+ scopes.filter_map { |scope| declared_member(scope, name, local) }
70
+ end.uniq
71
+ end
72
+
73
+ def descendants_of(fqn)
74
+ @plausible_descendants ||= @by_fqn.keys.each_with_object(Hash.new { |h, k| h[k] = [] }) do |declared, out|
75
+ plausible_ancestors(declared).each { |ancestor| out[ancestor] << declared }
76
+ end
77
+ @plausible_descendants.fetch(fqn, [])
58
78
  end
59
79
 
60
80
  # `scope::name`, or the longest declared prefix of it, when one is declared.
@@ -57,8 +57,16 @@ module Rigor
57
57
  # matching. A literal `name` keeps `within` too, and {Graph} looks it up from there (#1761). `inherit`
58
58
  # is false when the call passes `inherit = false`, which confines the lookup to the receiver itself;
59
59
  # otherwise the receiver's ancestors are searched as well. `role` is the referring file's (WD8).
60
- DynamicUse = Data.define(:name, :prefix, :reason, :site, :path, :line, :scope, :within, :inherit, :role) do
61
- def initialize(scope: :namespace, within: nil, inherit: true, role: :production, **) = super
60
+ #
61
+ # `receiver_open` is true when `within` is the enclosing declaration standing for a runtime `self` that
62
+ # may be one of its descendants (#1878): a `def self.x = const_get(:X)` runs on whichever subclass it
63
+ # is called on, and a module's method on whichever class includes, prepends or extends it. {Graph}
64
+ # then looks the literal name up in those descendants as well.
65
+ DynamicUse = Data.define(:name, :prefix, :reason, :site, :path, :line, :scope, :within, :inherit, :role,
66
+ :receiver_open) do
67
+ def initialize(scope: :namespace, within: nil, inherit: true, role: :production, receiver_open: false, **)
68
+ super
69
+ end
62
70
 
63
71
  # Whether this site's evidence reaches `fqn`.
64
72
  def taints?(fqn)
@@ -251,10 +259,21 @@ module Rigor
251
259
  module_exec instance_eval instance_exec].freeze
252
260
  private_constant :SELF_REBINDING
253
261
 
262
+ # The `*_eval` / `*_exec` family: on a constant receiver, the block's `self` IS that constant (#1878).
263
+ EVAL_FAMILY = %i[class_eval module_eval class_exec module_exec instance_eval instance_exec].freeze
264
+ private_constant :EVAL_FAMILY
265
+
254
266
  # A call is a dynamic-resolution site, and its block may run with another `self` ({#rebinds_self?}).
255
267
  def record_call(node, nesting)
256
268
  record_dynamic_use(node, nesting)
257
- @rebinding_blocks << node.block if node.block.is_a?(Prism::BlockNode) && rebinds_self?(node)
269
+ return unless node.block.is_a?(Prism::BlockNode) && rebinds_self?(node)
270
+
271
+ @rebinding_blocks << node.block
272
+ @eval_receivers[node.block] = receiver_reference(node.receiver, nesting) if eval_on_constant?(node)
273
+ end
274
+
275
+ def eval_on_constant?(call)
276
+ EVAL_FAMILY.include?(call.name) && constant_receiver?(call.receiver)
258
277
  end
259
278
 
260
279
  # Rigor knows the argument's shape, which is the whole reason this can be tiered rather than treated
@@ -290,6 +309,7 @@ module Rigor
290
309
  bounds.each do |prefix, scope, within|
291
310
  @dynamic_uses << DynamicUse.new(name: name, prefix: prefix, scope: scope, within: within,
292
311
  inherit: anchors.include?(TOP_LEVEL), role: @role,
312
+ receiver_open: !within.nil? && runtime_self?(node),
293
313
  site: site(node), path: @path, line: node.location.start_line,
294
314
  reason: reason)
295
315
  end
@@ -318,12 +338,21 @@ module Rigor
318
338
  end
319
339
  end
320
340
 
341
+ # Whether a `const_get` on `self` can run on a descendant of the enclosing declaration (#1878): in a
342
+ # method, or a block that rebinds `self`, `self` is whichever subclass or includer the method was
343
+ # called on. Directly in a class body, or inside `Other.class_eval { }`, it is the declaration itself.
344
+ def runtime_self?(node)
345
+ node.name == :const_get && self_receiver?(node.receiver) && @eval_receiver.nil? &&
346
+ !(@self_kind == :declaration && !@in_block && !@self_rebound)
347
+ end
348
+
321
349
  # Where a `const_get` on `self` (implicit, `self`, or `self.class`) looks its name up: `:declaration`
322
350
  # when `self` is the enclosing class or module, `:top_level` when it is `Class` or a singleton class,
323
351
  # `:unknown` otherwise. A block's `self` is unknown — `included do`, `class_eval` and an instance-
324
352
  # evaluated DSL block (`configure { }`, an RSpec `describe` body) all rebind it, and the walk cannot
325
353
  # tell those from an `each` block. So is the `self` of a `def Other.x` or of a `class << Other` body.
326
354
  def self_lookup(receiver)
355
+ return :declaration if @eval_receiver && !receiver.is_a?(Prism::CallNode)
327
356
  return :unknown if @in_block || @self_rebound
328
357
 
329
358
  class_of_self = receiver.is_a?(Prism::CallNode)
@@ -345,7 +374,7 @@ module Rigor
345
374
  receiver = node.receiver
346
375
  namespaces =
347
376
  if self_receiver?(receiver)
348
- self_references(node, nesting)
377
+ eval_self_references(receiver) || self_references(node, nesting)
349
378
  elsif constant_receiver?(receiver)
350
379
  [receiver_reference(receiver, nesting)]
351
380
  else
@@ -357,6 +386,11 @@ module Rigor
357
386
  inherit.is_a?(Prism::FalseNode) ? namespaces : namespaces + [TOP_LEVEL]
358
387
  end
359
388
 
389
+ # Inside `Other.class_eval { }` an implicit or `self` receiver is `Other` (#1878).
390
+ def eval_self_references(receiver)
391
+ [@eval_receiver] if @eval_receiver && !receiver.is_a?(Prism::CallNode)
392
+ end
393
+
360
394
  # The enclosing declaration as a rooted {Reference}, so {Graph} anchors it as it anchors a constant
361
395
  # receiver and can search its ancestors. Inside a meta-new rvalue `self` is the declared class in its
362
396
  # block but the outer scope in its arguments; the walk does not tell the two apart, so both are taken.
@@ -478,6 +512,10 @@ module Rigor
478
512
  # `class_eval` or anonymous `Class.new` block. See {#rebinds_self?}.
479
513
  @self_rebound = false
480
514
  @rebinding_blocks = Set.new.compare_by_identity
515
+ # The constant receiver of each `*_eval` block, and the one the walk is inside (nil when `self` is not
516
+ # such a constant). See {#eval_on_constant?}.
517
+ @eval_receivers = {}.compare_by_identity
518
+ @eval_receiver = nil
481
519
  # What `self` is here, for {#self_lookup}: `:declaration` in a class or module body (and at the top
482
520
  # level), `:singleton_method` in a `def self.x` or a def in a `class << self` body,
483
521
  # `:instance_method` in any other def, `:singleton_class` in a `class << self` body outside its
@@ -602,13 +640,14 @@ module Rigor
602
640
  end
603
641
 
604
642
  def in_local_scope(scope, self_kind = self_kind_in(scope))
605
- saved = [@local_scope, @block_params, @self_kind]
643
+ saved = [@local_scope, @block_params, @self_kind, @eval_receiver]
644
+ @eval_receiver = nil
606
645
  @local_scope = scope
607
646
  @block_params = Set.new.freeze
608
647
  @self_kind = self_kind
609
648
  yield
610
649
  ensure
611
- @local_scope, @block_params, @self_kind = saved
650
+ @local_scope, @block_params, @self_kind, @eval_receiver = saved
612
651
  end
613
652
 
614
653
  # The {#self_lookup} kind inside `scope`, from the kind around it. Only a receiver-less def or a `def
@@ -642,14 +681,17 @@ module Rigor
642
681
  end
643
682
 
644
683
  def in_block(block)
645
- saved = [@block_params, @self_rebound, @in_block]
684
+ saved = [@block_params, @self_rebound, @in_block, @eval_receiver]
646
685
  names = block.parameters ? LocalValues.bindings(block.parameters) : []
647
686
  @block_params = (@block_params | names).freeze unless names.empty?
648
- @self_rebound ||= @rebinding_blocks.include?(block)
687
+ if @rebinding_blocks.include?(block)
688
+ @self_rebound = true
689
+ @eval_receiver = @eval_receivers[block]
690
+ end
649
691
  @in_block = true
650
692
  yield
651
693
  ensure
652
- @block_params, @self_rebound, @in_block = saved
694
+ @block_params, @self_rebound, @in_block, @eval_receiver = saved
653
695
  end
654
696
 
655
697
  def site(node)
@@ -94,7 +94,12 @@ module Rigor
94
94
  does_not_fire_when: [
95
95
  "Implicit-self calls (no receiver) — too noisy without per-method RBS for every helper.",
96
96
  "Receiver is `Dynamic[T]` / `Top` / `Union` — by definition the method set isn't enumerable.",
97
- "Receiver class is in the loader but its RBS definition cannot be built (constant aliases)."
97
+ "Receiver class is in the loader but its RBS definition cannot be built (constant aliases).",
98
+ "Receiver class answers beyond any signature: a plugin-declared open receiver (ADR-26), or one " \
99
+ "Rigor's bundled overlays name (`OpenStruct`, whose fields are per-instance singleton methods; " \
100
+ "`ActiveSupport::Duration` and `ActiveSupport::Concern` when their gem overlay loads). Both sides " \
101
+ "of the class are open, and an inherited Object / Kernel signature no longer decides " \
102
+ "`call.wrong-arity` or `call.argument-type-mismatch` on it."
98
103
  ],
99
104
  suppression: "`# rigor:disable call.undefined-method` on the call line, " \
100
105
  "or `disable: [\"call.undefined-method\"]` in `.rigor.yml`.",
@@ -70,13 +70,14 @@ module Rigor
70
70
  RUN_EFFECTS_TABLE_PRODUCER_ID = "analysis.run-effects-table"
71
71
 
72
72
  # The run-result producer's declared compaction budget (`Cache::Store#evict!` pass 2). Whole-project,
73
- # but unlike the `rbs.*` producers several generations can be live at once: the `paths` key slot means
74
- # one entry per analyzed-path SET, so `rigor check` over the whole project, over `lib`, and over a
75
- # single file are three separate live generations. 16 is a judgement call sized for that churn; it is
76
- # a cap on GENERATIONS, not on correctness — over-evicting here costs a recompute, never a wrong
77
- # answer. Measured 2026-07-25 against a real project's `.rigor/cache`: the cap does bind (60 distinct
78
- # path-set generations observed, differing in the `paths` slot alone), with no evidence yet on how
79
- # often an evicted generation is asked for again — see issue #151.
73
+ # but unlike the `rbs.*` producers several generations can be live at once: the `paths` and `roots` key
74
+ # slots mean one entry per analyzed-path SET x root list as written (#1559), so `rigor check` over the
75
+ # whole project, over `lib`, and over a single file are three separate live generations, and `rigor
76
+ # check` and `rigor check lib` are two even when `lib` is the configured `paths:`. 16 is a judgement call
77
+ # sized for that churn; it is a cap on GENERATIONS, not on correctness — over-evicting here costs a
78
+ # recompute, never a wrong answer. Measured 2026-07-25 against a real project's `.rigor/cache`: the cap
79
+ # does bind (60 distinct path-set generations observed, differing in the `paths` slot alone), with no
80
+ # evidence yet on how often an evicted generation is asked for again — see issue #151.
80
81
  GENERATION_CAP = 16
81
82
 
82
83
  # The effects sidecar's own compaction budget. One generation per (path set × effects identity), and
@@ -109,6 +110,29 @@ module Rigor
109
110
  nil
110
111
  end
111
112
 
113
+ # #1559 — the run-result key's analysis ROOTS, as the run was given them. The `paths` slot holds only the
114
+ # files the roots expand to, so `check lib extra` with `extra` missing and `check lib` shared a key while
115
+ # only the first prints `extra:1:1: warning: no such file or directory (skipped)`, which changes the exit
116
+ # code under `--fail-on=warning`. The roots are kept in the order and spelling written, not normalised: a
117
+ # missing root is reported as written (`./extra` is not `extra`), and the order of the roots is the order
118
+ # the diagnostics come out in, so two runs that differ only in either are two slots (a forgone hit, never a
119
+ # wrong one).
120
+ #
121
+ # Added to the DIAGNOSTICS key only. The ADR-103 effects sidecar derives its identity from {#descriptor}
122
+ # without it: the effect table is a function of the analysed files, and no path-error row reaches it.
123
+ # The `--incremental` slot builds its own, normalised, roots entry ({IncrementalRunSlot}).
124
+ #
125
+ # @param descriptor — a {#descriptor} result (nil passes through, so a disabled cache stays disabled).
126
+ def with_roots(descriptor, roots)
127
+ return nil if descriptor.nil?
128
+
129
+ entry = config_entry("roots", Marshal.dump(Array(roots).map(&:to_s)))
130
+ Cache::Descriptor.new(
131
+ gems: descriptor.gems, dependencies: descriptor.dependencies, plugins: descriptor.plugins,
132
+ configs: descriptor.configs + [entry]
133
+ )
134
+ end
135
+
112
136
  # Issue #285 — the `engine` slot above pins the engine by VERSION, which identifies the source only
113
137
  # for a released gem. A checkout (a contributor's, or a `bundle add rigor, github:` clone) gets one
114
138
  # extra slot carrying a digest of the engine's own source, so editing `lib/rigor/inference/*.rb` no
@@ -65,9 +65,11 @@ module Rigor
65
65
  # probe must never turn a servable run into a crash.
66
66
  def serve(paths)
67
67
  files = PathExpansion.ruby_files(paths, @configuration.exclude_patterns, base: @configuration.exclude_base)
68
- key = RunCacheKey.descriptor(
69
- configuration: @configuration, files: files, explain: @explain,
70
- rbs_config_entries: RunCacheKey.libraries_config_entries(@configuration)
68
+ key = RunCacheKey.with_roots(
69
+ RunCacheKey.descriptor(
70
+ configuration: @configuration, files: files, explain: @explain,
71
+ rbs_config_entries: RunCacheKey.libraries_config_entries(@configuration)
72
+ ), paths
71
73
  )
72
74
  return nil if key.nil?
73
75
 
@@ -57,7 +57,8 @@ module Rigor
57
57
  quarantined_signatures_snapshot:, env_build_failure_snapshot:,
58
58
  definition_build_failures_snapshot:, hkt_scan_failure_snapshot:,
59
59
  conformance_results_snapshot:, signature_standdowns_snapshot: -> { [] },
60
- vendored_standdowns_snapshot: -> { [] }, pooled_run_disclosures: -> { [] })
60
+ vendored_standdowns_snapshot: -> { [] }, pooled_run_disclosures: -> { [] },
61
+ pooled_trust_refusals: -> { [] })
61
62
  @configuration = configuration
62
63
  @rbs_extended_reporter = rbs_extended_reporter
63
64
  @boundary_cross_reporter = boundary_cross_reporter
@@ -78,6 +79,9 @@ module Rigor
78
79
  # Issue #1051 — reader for the pool coordinator's de-duplicated run-scoped disclosure table.
79
80
  # Empty on a sequential run, where `plugin_registry`'s own instances are the ones that registered.
80
81
  @pooled_run_disclosures_reader = pooled_run_disclosures
82
+ # Issue #1900 — reader for the pool coordinator's summed trust-refusal table. Empty on a
83
+ # sequential run, where `plugin_registry`'s own boundaries made every read.
84
+ @pooled_trust_refusals_reader = pooled_trust_refusals
81
85
  end
82
86
 
83
87
  # Pre-file diagnostic streams that fire once per run rather than per analyzed file: plugin load /
@@ -613,9 +617,18 @@ module Rigor
613
617
  # culprit and reads the same on a cache hit, where every `RBS::Location` has been dumped to a
614
618
  # sentinel. The error class is attributed to that first failure explicitly rather than to all of
615
619
  # them: the per-class memo keeps only the first reason, and one run can mix error classes.
620
+ #
621
+ # Issue #1902 — "first" is first by CLASS NAME, not first recorded. The snapshot arrives in demand
622
+ # order, and that order is a property of the invocation, not of the project: an `--incremental`
623
+ # recheck records the classes its closure demanded before it unions in the replayed set, and a worker
624
+ # pool unions per-worker lists in drain order. So the classes are sorted by name and the files
625
+ # sorted and deduplicated here. Which tuple each class carries is fixed upstream
626
+ # ({Environment::RbsLoader.definition_build_detail_order}), not here.
616
627
  def build_rbs_definition_build_failed_diagnostic(failures)
628
+ failures = failures.sort_by { |failure| failure.first.to_s }
617
629
  sample_size = 5
618
- files = failures.flat_map { |failure| Array(failure[3]) }.uniq.map { |name| relative_signature_path(name) }
630
+ files = failures.flat_map { |failure| Array(failure[3]) }
631
+ .map { |name| relative_signature_path(name) }.uniq.sort
619
632
  Diagnostic.new(
620
633
  path: ".rigor.yml",
621
634
  line: 1,
@@ -1084,16 +1097,22 @@ module Rigor
1084
1097
  # cache"). It is also never routed through `IncrementalSession`'s per-file cache (`#788`): that cache
1085
1098
  # holds only `Runner#per_file_diagnostics`, and this row is not part of that stream.
1086
1099
  #
1087
- # Scope: `plugin_registry` here is the coordinator-side registry, which only actually RUNS `#prepare`
1088
- # / per-file analysis in sequential mode (`--workers` unset, the default) — a pooled run (`--workers
1089
- # N`) prepares and analyses on per-worker `WorkerSession` registries this reader never sees, so a
1090
- # refusal confined to a pooled worker's slice is not reported. Left out deliberately: draining it
1091
- # needs a fourth marshalled channel beside `drain_reporters` / `drain_dependencies` / `drain_effects`
1092
- # in `Runner::PoolCoordinator`, and the default (unpooled) path already covers the reported bug.
1100
+ # Two sources (issue #1900), summed per plugin: the coordinator-side registry's own boundaries — where
1101
+ # a sequential run, and the pool's no-worker fallback, made every read — and `pooled_trust_refusals`,
1102
+ # what a pooled run drained from its pre-fork parent session and every worker
1103
+ # ({Runner::PoolCoordinator#merge_trust_refusals}). The count is per refused ATTEMPT, as
1104
+ # `--workers 0` always counted it; the pool drains the parent's `#prepare` refusals before forking and
1105
+ # each worker reports only what it added, so the workers, which inherit the parent's boundaries, do
1106
+ # not count them again. Rows follow registry
1107
+ # (plugin load) order, which every mode shares.
1093
1108
  def plugin_trust_refusal_diagnostics
1094
1109
  return [] if plugin_registry.empty?
1095
1110
 
1096
- plugin_registry.plugins.filter_map { |plugin| plugin_trust_refusal_diagnostic(plugin) }
1111
+ pooled = Array(pooled_trust_refusals).to_h { |record| [record[:plugin_id], record] }
1112
+ plugin_registry.plugins.filter_map do |plugin|
1113
+ summary = combined_refusal_summary(pooled[disclosure_plugin_id(plugin)], plugin)
1114
+ plugin_trust_refusal_diagnostic(plugin, summary) if summary
1115
+ end
1097
1116
  end
1098
1117
 
1099
1118
  # Issue #1051 — the run-scoped plugin disclosures ({Plugin::Base#disclose_once}), the run's
@@ -1193,10 +1212,23 @@ module Rigor
1193
1212
  plugin.class.to_s
1194
1213
  end
1195
1214
 
1196
- def plugin_trust_refusal_diagnostic(plugin)
1197
- summary = plugin.io_boundary.refusal_summary
1198
- return nil if summary.nil?
1215
+ # The coordinator's own record first when it has one: on a pooled run it holds the `#init` reads
1216
+ # (every registry runs `#init`; the workers' drains start after it), which `--workers 0` makes before
1217
+ # `#prepare` and before any file, so its first path is the one a sequential run names. Not so after a
1218
+ # Ractor-backend degrade, where this record also holds the re-analysed files' refusals and is named
1219
+ # ahead of a worker's `#prepare` path; the count is right either way.
1220
+ def combined_refusal_summary(pooled, plugin)
1221
+ local = begin
1222
+ plugin.io_boundary.refusal_summary
1223
+ rescue StandardError
1224
+ nil
1225
+ end
1226
+ return local || pooled if pooled.nil? || local.nil?
1227
+
1228
+ local.merge(count: local[:count] + pooled[:count])
1229
+ end
1199
1230
 
1231
+ def plugin_trust_refusal_diagnostic(plugin, summary)
1200
1232
  plugin_id = plugin.manifest.id
1201
1233
  Diagnostic.new(
1202
1234
  path: ".rigor.yml", line: 1, column: 1,
@@ -1283,6 +1315,10 @@ module Rigor
1283
1315
  @pooled_run_disclosures_reader.call
1284
1316
  end
1285
1317
 
1318
+ def pooled_trust_refusals
1319
+ @pooled_trust_refusals_reader.call
1320
+ end
1321
+
1286
1322
  def pre_eval_diagnostics_from_scanner
1287
1323
  @pre_eval_diagnostics_from_scanner_reader.call
1288
1324
  end