rigortype 0.4.5 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/docs/handbook/11-sig-gen.md +11 -2
  4. data/docs/manual/02-cli-reference.md +46 -7
  5. data/docs/manual/03-configuration.md +2 -1
  6. data/docs/manual/04-diagnostics.md +1 -1
  7. data/docs/manual/10-mcp-server.md +9 -1
  8. data/docs/manual/12-caching.md +8 -1
  9. data/docs/manual/plugins/rigor-actionmailer.md +30 -0
  10. data/docs/manual/plugins/rigor-actionpack.md +30 -0
  11. data/docs/manual/plugins/rigor-devise.md +15 -0
  12. data/docs/manual/plugins/rigor-rspec.md +2 -1
  13. data/lib/rigor/analysis/buffer_binding.rb +14 -0
  14. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +30 -9
  15. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +142 -15
  16. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +4 -0
  17. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +66 -3
  18. data/lib/rigor/analysis/check_rules/rebinding_block_callees/dispatch.rb +112 -0
  19. data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +394 -0
  20. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +7 -0
  21. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +51 -0
  22. data/lib/rigor/analysis/check_rules.rb +93 -8
  23. data/lib/rigor/analysis/dependency_recorder.rb +11 -0
  24. data/lib/rigor/analysis/edit_replay.rb +67 -0
  25. data/lib/rigor/analysis/incremental.rb +37 -0
  26. data/lib/rigor/analysis/incremental_session.rb +175 -27
  27. data/lib/rigor/analysis/path_expansion.rb +41 -0
  28. data/lib/rigor/analysis/require_relative_closure.rb +100 -0
  29. data/lib/rigor/analysis/rule_catalog.rb +5 -1
  30. data/lib/rigor/analysis/run_disclosure_credit.rb +80 -0
  31. data/lib/rigor/analysis/run_disclosure_ledger.rb +155 -0
  32. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +6 -2
  33. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +11 -3
  34. data/lib/rigor/analysis/runner/pool_coordinator.rb +17 -4
  35. data/lib/rigor/analysis/runner/project_pre_passes.rb +45 -3
  36. data/lib/rigor/analysis/runner/scan_environment.rb +51 -0
  37. data/lib/rigor/analysis/runner.rb +156 -49
  38. data/lib/rigor/analysis/worker_session.rb +14 -2
  39. data/lib/rigor/cache/incremental_snapshot.rb +30 -7
  40. data/lib/rigor/cli/check_command.rb +93 -4
  41. data/lib/rigor/cli/diff_command.rb +2 -1
  42. data/lib/rigor/cli/lens_command.rb +5 -5
  43. data/lib/rigor/cli/mcp_command.rb +2 -1
  44. data/lib/rigor/cli/options.rb +20 -3
  45. data/lib/rigor/cli/plugins_command.rb +5 -0
  46. data/lib/rigor/cli/plugins_renderer.rb +24 -2
  47. data/lib/rigor/cli/show_bleedingedge_command.rb +2 -1
  48. data/lib/rigor/cli/sig_gen_command.rb +46 -7
  49. data/lib/rigor/cli/type_of_command.rb +2 -1
  50. data/lib/rigor/cli/type_of_project_scope.rb +27 -0
  51. data/lib/rigor/config_audit.rb +16 -5
  52. data/lib/rigor/configuration.rb +14 -4
  53. data/lib/rigor/environment/rbs_loader.rb +115 -6
  54. data/lib/rigor/environment/required_features.rb +136 -29
  55. data/lib/rigor/environment/ruby_library_roots.rb +122 -0
  56. data/lib/rigor/environment.rb +4 -2
  57. data/lib/rigor/inference/callee_mutation_chain.rb +115 -0
  58. data/lib/rigor/inference/deep_erasure.rb +138 -0
  59. data/lib/rigor/inference/def_return_typer.rb +159 -9
  60. data/lib/rigor/inference/definer_resolution.rb +17 -3
  61. data/lib/rigor/inference/error_info.rb +17 -3
  62. data/lib/rigor/inference/expression_typer.rb +62 -13
  63. data/lib/rigor/inference/global_write_census.rb +36 -1
  64. data/lib/rigor/inference/ivar_writer_census.rb +195 -0
  65. data/lib/rigor/inference/loop_value_record.rb +53 -0
  66. data/lib/rigor/inference/match_rebinding/frame.rb +19 -0
  67. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +110 -9
  68. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +6 -2
  69. data/lib/rigor/inference/method_dispatcher/set_folding.rb +53 -20
  70. data/lib/rigor/inference/method_dispatcher.rb +46 -16
  71. data/lib/rigor/inference/module_function_state/spellings.rb +64 -0
  72. data/lib/rigor/inference/module_function_state.rb +12 -0
  73. data/lib/rigor/inference/mutation_widening.rb +5 -2
  74. data/lib/rigor/inference/narrowing.rb +66 -5
  75. data/lib/rigor/inference/object_mixins.rb +13 -1
  76. data/lib/rigor/inference/project_seed_tables.rb +2 -1
  77. data/lib/rigor/inference/project_yielder.rb +128 -0
  78. data/lib/rigor/inference/scope_indexer/certainty.rb +9 -0
  79. data/lib/rigor/inference/scope_indexer/declared_ivar_types.rb +64 -0
  80. data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +162 -0
  81. data/lib/rigor/inference/scope_indexer/unseen_ivar_writers.rb +133 -0
  82. data/lib/rigor/inference/scope_indexer.rb +480 -103
  83. data/lib/rigor/inference/self_call_ivar_writes.rb +18 -6
  84. data/lib/rigor/inference/statement_evaluator.rb +658 -90
  85. data/lib/rigor/inference/synthetic_method_index.rb +23 -0
  86. data/lib/rigor/inference/synthetic_method_scanner/project_members.rb +130 -0
  87. data/lib/rigor/inference/synthetic_method_scanner.rb +72 -12
  88. data/lib/rigor/language_server/buffer_table.rb +6 -0
  89. data/lib/rigor/language_server/diagnostic_publisher.rb +20 -6
  90. data/lib/rigor/language_server/project_context.rb +59 -3
  91. data/lib/rigor/language_server/server.rb +5 -3
  92. data/lib/rigor/mcp/server.rb +9 -1
  93. data/lib/rigor/plugin/base.rb +26 -11
  94. data/lib/rigor/plugin/manifest.rb +29 -3
  95. data/lib/rigor/plugin/registry.rb +38 -0
  96. data/lib/rigor/protection/discovery_seed.rb +57 -2
  97. data/lib/rigor/rooted_glob.rb +10 -1
  98. data/lib/rigor/scope.rb +14 -5
  99. data/lib/rigor/sig_gen/generator.rb +308 -37
  100. data/lib/rigor/sig_gen/observation_collector.rb +4 -1
  101. data/lib/rigor/sig_gen/rbs_validity.rb +1 -1
  102. data/lib/rigor/sig_gen/renderer.rb +6 -3
  103. data/lib/rigor/sig_gen/type_elaborator.rb +21 -5
  104. data/lib/rigor/type/combinator.rb +4 -3
  105. data/lib/rigor/type/constant.rb +45 -3
  106. data/lib/rigor/type/hash_shape.rb +14 -9
  107. data/lib/rigor/type/struct_instance.rb +3 -2
  108. data/lib/rigor/version.rb +1 -1
  109. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +60 -43
  110. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +20 -1
  111. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +13 -0
  112. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +4 -2
  113. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +5 -3
  114. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +6 -5
  115. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +102 -11
  116. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +4 -2
  117. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +1 -1
  118. data/sig/rigor/analysis/baseline.rbs +3 -2
  119. data/sig/rigor/effects/file_collection.rbs +5 -6
  120. data/sig/rigor/effects/registry.rbs +8 -6
  121. data/sig/rigor/effects/summary.rbs +6 -5
  122. data/sig/rigor/environment.rbs +5 -3
  123. data/sig/rigor/inference/optimistic_origin.rbs +11 -0
  124. data/sig/rigor/inference.rbs +6 -0
  125. data/sig/rigor/plugin/additional_initializer.rbs +5 -5
  126. data/sig/rigor/plugin/effect_ancestry.rbs +6 -4
  127. data/sig/rigor/plugin/effect_attribution.rbs +25 -10
  128. data/sig/rigor/plugin/effect_edge.rbs +12 -5
  129. data/sig/rigor/plugin/effect_entry_points.rbs +8 -3
  130. data/sig/rigor/plugin/io_boundary.rbs +4 -4
  131. data/sig/rigor/plugin/manifest.rbs +16 -18
  132. data/sig/rigor/plugin/protocol_contract.rbs +10 -9
  133. data/sig/rigor/plugin/registry.rbs +1 -1
  134. data/sig/rigor/scope.rbs +31 -50
  135. data/sig/rigor/type.rbs +0 -6
  136. metadata +20 -1
@@ -5,6 +5,7 @@ require_relative "incremental"
5
5
  require_relative "incremental_run_slot"
6
6
  require_relative "incremental_run_slot/write_guard"
7
7
  require_relative "plugin_fact_fingerprint"
8
+ require_relative "run_disclosure_ledger"
8
9
  require_relative "source_rbs_gate"
9
10
  require_relative "../cache/file_digest"
10
11
  require_relative "../cache/incremental_snapshot"
@@ -139,6 +140,9 @@ module Rigor
139
140
  # the frozen `Cache::Descriptor::FileEntry` / `GlobEntry` rows, as each read observed its input. A recheck
140
141
  # re-analyses every file one of whose rows no longer validates ({#plugin_read_affected}).
141
142
  @plugin_reads = {}
143
+ # Issue #2020 — the run-scoped plugin rows each analysed file registered, and the reads that make one stale.
144
+ @plugin_disclosures = RunDisclosureLedger.new
145
+ @stale_disclosures = Set.new
142
146
  reset_carried_state
143
147
  # ADR-88 WD1 — the plugin fact-surface digest computed for THIS invocation (nil until a
144
148
  # `#run_incremental` pass runs / a plugin-free project) and the reporting flags a caller (the CLI
@@ -269,8 +273,10 @@ module Rigor
269
273
  @effect_collections = runner.effect_collections_by_path
270
274
  @effects_identity = current_effects_identity
271
275
  @effect_plugin_digest = effect_plugin_digest_of(runner)
276
+ @synthetic_surface_digest = synthetic_surface_digest_of(runner)
272
277
  @cache = per_file(runner.per_file_diagnostics)
273
278
  @plugin_reads = plugin_reads_of(runner, @analyzed)
279
+ @plugin_disclosures = RunDisclosureLedger.from(runner.analysis_disclosures.slice(*@analyzed))
274
280
  record_baseline_digests
275
281
  @run_level_rows = runner.run_level_rows
276
282
  diagnostics
@@ -294,16 +300,11 @@ module Rigor
294
300
  fresh_params = fresh_param_table(current, changed, added, removed)
295
301
  param_files, param_pairs = param_seed_invalidation(fresh_params)
296
302
  affected = affected_closure(changed, added, removed, param_files, param_pairs)
297
- forced, analyze_set = forced_analyze_set(affected, current)
298
- reused = (current & previous) - analyze_set.to_a
299
- # The freshly collected table is handed to the runner so the run seeds from the SAME table the diff
300
- # was decided on (and the collector runs once per recheck, not twice). The reused files' cached rows
301
- # go in too, so the runner lists them in the full run's file order (#1524).
302
- runner = build_runner(analyze_only: analyze_set, record_dependencies: true,
303
- param_inferred_types: fresh_params, **forced_target_options(forced),
304
- restored_run_level_rows: @run_level_rows, **served_state(reused))
305
- merged = run_runner(runner).diagnostics
306
- return rebaseline_recheck(changed, added, removed) if served_effects_stale?(runner, reused)
303
+ affected, analyze_set, runner, merged, reused =
304
+ run_settled_closure(affected, [current, previous, removed], fresh_params)
305
+ if served_effects_stale?(runner, reused) || synthetic_surface_moved?(runner, reused)
306
+ return rebaseline_recheck(changed, added, removed)
307
+ end
307
308
 
308
309
  absorb(runner, current, analyze_set, removed)
309
310
  @param_table = fresh_params
@@ -311,6 +312,36 @@ module Rigor
311
312
  stale_readers: @stale_plugin_readers)
312
313
  end
313
314
 
315
+ # {#recheck}'s narrowed run. Issue #2020 — a stale run-scoped key whose chosen refresher registered nothing (a
316
+ # parse error, a plugin that raised, an edit that took the notice out of its text) is decided by its other
317
+ # holders, so they join the closure and it runs again. Returns `[affected, analyze_set, runner, merged, reused]`.
318
+ def run_settled_closure(affected, (current, previous, removed), fresh_params)
319
+ forced, analyze_set = forced_analyze_set(affected, current)
320
+ runner, merged, reused = run_closure(analyze_set, current, previous, forced, fresh_params)
321
+ fallback = @plugin_disclosures.unresolved_holders(runner.analysis_disclosures, analyze_set, @stale_disclosures,
322
+ removed.to_set)
323
+ return [affected, analyze_set, runner, merged, reused] if fallback.empty?
324
+
325
+ affected = (affected | fallback).freeze
326
+ @stale_plugin_readers |= fallback
327
+ forced, analyze_set = forced_analyze_set(affected, current)
328
+ [affected, analyze_set, *run_closure(analyze_set, current, previous, forced, fresh_params)]
329
+ end
330
+ private :run_settled_closure
331
+
332
+ # One narrowed run of {#recheck}'s closure: the runner, its merged rows, and the files it served. The freshly
333
+ # collected table is handed to the runner so the run seeds from the SAME table the diff was decided on (and the
334
+ # collector runs once per recheck, not twice). The reused files' cached rows go in too, so the runner lists them
335
+ # in the full run's file order (#1524).
336
+ def run_closure(analyze_set, current, previous, forced, fresh_params)
337
+ reused = (current & previous) - analyze_set.to_a
338
+ runner = build_runner(analyze_only: analyze_set, record_dependencies: true,
339
+ param_inferred_types: fresh_params, **forced_target_options(forced),
340
+ restored_run_level_rows: @run_level_rows, **served_state(reused))
341
+ [runner, run_runner(runner).diagnostics, reused]
342
+ end
343
+ private :run_closure
344
+
314
345
  # Editor mode option B (#146) — a whole-project recheck with the editor's buffer substituted for one
315
346
  # file, for the CLI's `--incremental --tmp-file=X --instead-of=Y`. Returns the {Recheck} when the
316
347
  # snapshot could be reused, or nil when it could not — the caller then falls back to option A
@@ -361,7 +392,7 @@ module Rigor
361
392
  # records none at all), so no dependent set can bound who read it. An edit that moved it re-analyses the
362
393
  # whole project — what a `sig/` edit already costs through the snapshot fingerprint.
363
394
  def affected_closure(changed, added, removed, param_files = Set.new, param_pairs = Set.new)
364
- @stale_plugin_readers = plugin_read_affected
395
+ @stale_plugin_readers = plugin_read_affected | disclosure_refreshers(removed)
365
396
  return whole_project_closure(changed, added) if source_rbs_gate.moved?(@seed_bundles, changed, added, removed)
366
397
 
367
398
  (gated_closure(changed, added, removed, param_files, param_pairs) | @stale_plugin_readers).freeze
@@ -471,8 +502,8 @@ module Rigor
471
502
  # exempts such a write is a fact of the whole program — an alias, or a `write` / `to_str` / `to_int` / hatch
472
503
  # defined in any spelling on any receiver, a rewritten ancestor, a mixin — which no name-keyed edge can
473
504
  # follow, so a stale report would outlive the edit that exempts it. Such files are rare, so this costs little.
474
- # A file the census silenced is not re-checked when the exemption goes away: that stale answer misses a
475
- # report, it never makes one.
505
+ # A file the census silenced is re-checked through the `global_census:` keys its rule recorded
506
+ # ({#global_census_affected}, issue #2158).
476
507
  def global_write_reporters
477
508
  @cache.each_with_object(Set.new) do |(path, diagnostics), reporters|
478
509
  reporters << path if diagnostics.any? { |diagnostic| diagnostic.rule.to_s.start_with?("global.") }
@@ -620,6 +651,32 @@ module Rigor
620
651
  run_runner(runner).diagnostics
621
652
  end
622
653
 
654
+ # Issue #1967 — the edit replay of `--verify-incremental-edits`: a warm recheck of this session's baseline with
655
+ # `buffer` standing in for one file, the way editor mode option B (#146) rechecks an unsaved edit. The baseline is
656
+ # handed over as a Marshal copy of the snapshot a `--incremental` run would save, through a fresh session that
657
+ # never persists, so this session's own state is untouched and each replay starts from the same baseline. Unlike
658
+ # {#reanalyze_subset}, whose tree is the baseline's, the edit moves what the served files' rows were computed
659
+ # from, so a dependent the recheck fails to re-analyse is served stale and the comparison sees it. Returns the
660
+ # {Recheck}, or nil when the recheck declined as option B does (the plugin fact surface moved, an unverified
661
+ # source-RBS gate, a re-baseline), where the answer would be a full analysis and there is nothing to compare.
662
+ def replay_edit(buffer)
663
+ adopt_plugin_fact_fingerprint if @plugin_fact_reusable.nil?
664
+ replay = IncrementalSession.new(
665
+ configuration: @configuration, paths: @paths, environment: @environment, cache_store: @cache_store,
666
+ plugin_requirer: @plugin_requirer, buffer: buffer, explain: @explain
667
+ )
668
+ replay.run_buffer_recheck(snapshot: ReplaySnapshot.new(blob: Marshal.dump(to_payload)), fingerprint: :replay)
669
+ end
670
+
671
+ # The in-memory stand-in for {Cache::IncrementalSnapshot} {#replay_edit} hands its replay session: every load is a
672
+ # fresh copy, as a load from disk is.
673
+ ReplaySnapshot = Data.define(:blob) do
674
+ def load(fingerprint:) # rubocop:disable Lint/UnusedMethodArgument
675
+ Marshal.load(blob) # rubocop:disable Security/MarshalLoad
676
+ end
677
+ end
678
+ private_constant :ReplaySnapshot
679
+
623
680
  # Cross-process incremental run (the `--incremental` flag's engine). With a disk `snapshot` whose
624
681
  # `fingerprint` matches, restore the prior per-file state and `#recheck` (re-analyze only the changed
625
682
  # closure, serve the rest from the restored cache); otherwise run a full `#baseline`. Either way,
@@ -991,7 +1048,7 @@ module Rigor
991
1048
  restore_structural_declarations(payload)
992
1049
  @return_summaries = payload.return_summaries || {}
993
1050
  @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.
1051
+ restore_plugin_records(payload)
995
1052
  # Issues #796 / #794 — absent (nil) in a snapshot written before schema 22 is impossible (the SCHEMA
996
1053
  # gate rejects one), so nil here means the writing run genuinely had nothing to say.
997
1054
  @run_level_rows = payload.run_level_rows
@@ -1007,6 +1064,14 @@ module Rigor
1007
1064
  @negative_dependents = Incremental.invert(@missing)
1008
1065
  end
1009
1066
 
1067
+ # Issue #1553 — the plugin reads each file's last analysis made, and (#2020) the run-scoped plugin rows it
1068
+ # registered; `{}` in a payload built without them. Extracted to keep {#restore} under its ABC budget.
1069
+ def restore_plugin_records(payload)
1070
+ @plugin_reads = payload.plugin_reads
1071
+ @plugin_disclosures = RunDisclosureLedger.load(payload.plugin_disclosures)
1072
+ @synthetic_surface_digest = payload.synthetic_surface_digest # the plugin-built ADR-16 index (PR #2286 M1)
1073
+ end
1074
+
1010
1075
  # ADR-46 slice 3 / issue #644 — the structural tier's negative-edge state: the per-consumer missed
1011
1076
  # lookups plus the two per-file DECLARATION sets (`class_decls`, `constant_decls`) whose diff says what
1012
1077
  # APPEARED in an edit. Absent in an older snapshot → empty, which only loses the appeared-symbol
@@ -1032,7 +1097,9 @@ module Rigor
1032
1097
  effects_identity: @effects_identity,
1033
1098
  run_level_rows: @run_level_rows,
1034
1099
  effect_plugin_digest: @effect_plugin_digest,
1100
+ synthetic_surface_digest: @synthetic_surface_digest,
1035
1101
  plugin_reads: @plugin_reads,
1102
+ plugin_disclosures: @plugin_disclosures.to_h,
1036
1103
  discovery_digests: @discovery_digests
1037
1104
  )
1038
1105
  end
@@ -1152,11 +1219,13 @@ module Rigor
1152
1219
  @digests[path] = pack_digest(path)
1153
1220
  fresh_reads.key?(path) ? @plugin_reads[path] = fresh_reads[path] : @plugin_reads.delete(path)
1154
1221
  end
1222
+ absorb_disclosures(runner, analyze_set)
1155
1223
  forget_unbound_digests
1156
1224
  absorb_dependency_graph(runner)
1157
1225
  # Issues #796 / #794 — the recheck's own snapshots already carry the replayed rows folded together
1158
1226
  # with anything its closure demanded for itself, so this is the same union the next run replays.
1159
1227
  @run_level_rows = runner.run_level_rows
1228
+ @synthetic_surface_digest = synthetic_surface_digest_of(runner)
1160
1229
  refresh_return_summaries(runner, analyze_set)
1161
1230
  refresh_effect_collections(runner, analyze_set)
1162
1231
  end
@@ -1200,6 +1269,7 @@ module Rigor
1200
1269
  @digests.delete(path)
1201
1270
  @sources.delete(path)
1202
1271
  @plugin_reads.delete(path)
1272
+ @plugin_disclosures.forget(path) # Unpinned hygiene: a removed file is never served, so never replayed.
1203
1273
  @symbol_sources.delete(path)
1204
1274
  @ancestry_sources.delete(path)
1205
1275
  @missing.delete(path)
@@ -1271,7 +1341,8 @@ module Rigor
1271
1341
  # top-level def, `method:C#m` otherwise), then unions the recorded negative-dependents of those keys.
1272
1342
  # Issue #1120 — plus the consumers of any refinement name the edit moved ({#refinement_affected}). Issue #1367 —
1273
1343
  # 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}).
1344
+ # the consumers of any method name a file started or stopped defining ({#defined_name_affected}). Issue #2134 —
1345
+ # and the consumers of any visibility slot a file moved ({#visibility_name_affected}).
1275
1346
  def negative_affected(changed, removed, new_fingerprints, new_class_decls, new_constant_decls, summary = nil)
1276
1347
  appeared_methods = Incremental.appeared_symbols(changed, @symbol_fingerprints, new_fingerprints)
1277
1348
  # Issue #639 — a class DISAPPEARING satisfies the `class:` kind too, now that a resolved bare
@@ -1288,11 +1359,18 @@ module Rigor
1288
1359
  keys = appeared_methods.map { |symbol| negative_key_for(symbol) }
1289
1360
  keys.concat(class_move_keys(moved_classes))
1290
1361
  keys.concat(moved_constants.map { |name| "constant:#{name.split('::').last}" })
1291
- Incremental.negative_closure(keys, @negative_dependents) | refinement_affected(changed + removed, summary) |
1292
- defined_name_affected(changed + removed, summary) | mark_affected(changed + removed, summary) |
1362
+ Incremental.negative_closure(keys, @negative_dependents) | table_diff_affected(changed + removed, summary) |
1293
1363
  global_write_reporters
1294
1364
  end
1295
1365
 
1366
+ # The consumers of the name-keyed edges whose producers diff a per-file table between each file's seed bundle
1367
+ # and the scan.
1368
+ def table_diff_affected(paths, summary)
1369
+ refinement_affected(paths, summary) | defined_name_affected(paths, summary) |
1370
+ visibility_name_affected(paths, summary) | mark_affected(paths, summary) |
1371
+ global_census_affected(paths, summary) | ivar_set_affected(paths, summary)
1372
+ end
1373
+
1296
1374
  # Issue #1833 — the consumers whose answer read a class mark that moved in this edit. A reader of a class's
1297
1375
  # envelope bucket records `mark:<last segment>` (`Scope#parameter_envelopes_of`), whichever way it answered, so
1298
1376
  # a file that adds or drops only a mark (`x.singleton_class.prepend(Comparable)`, `o.extend(Helper)` in a
@@ -1379,6 +1457,53 @@ module Rigor
1379
1457
  Incremental.negative_closure(names.map { |name| "defines:#{name}" }, @negative_dependents)
1380
1458
  end
1381
1459
 
1460
+ # Issue #2158 — the consumers whose `global.*` write rule read a census entry that a file in `paths` started or
1461
+ # stopped writing. The before-state is each file's seed bundle, the after-state the scan's per-file census. The
1462
+ # write the census silenced reports nothing, so {#global_write_reporters} does not reach it.
1463
+ def global_census_affected(paths, summary)
1464
+ after = (summary && summary[:global_write_census]) || {}
1465
+ before = paths.to_h { |path| [path, @seed_bundles.dig(path, :global_write_census)] }
1466
+ keys = Incremental.changed_global_census_keys(paths, before, after)
1467
+ return Set.new if keys.empty?
1468
+
1469
+ Incremental.negative_closure(keys, @negative_dependents)
1470
+ end
1471
+
1472
+ # Issue #1154 — the consumers whose declared-parameter ivar credit read an ivar writer entry a file in `paths`
1473
+ # started or stopped recording ({GlobalWriteCensus.ivar_key}: `ivar_set:@x`, `ivar_set_class:K`,
1474
+ # `own_write:K#@x`). The before-state is each file's seed bundle, the after-state the scan's per-file census. The
1475
+ # write sits in a method body, so neither the declaration signature nor the symbol fingerprints move with it.
1476
+ def ivar_set_affected(paths, summary)
1477
+ after = (summary && summary[:global_write_census]) || {}
1478
+ keys = Set.new
1479
+ paths.each do |path|
1480
+ census = @seed_bundles.dig(path, :global_write_census) || Inference::GlobalWriteCensus::EMPTY
1481
+ before = Inference::GlobalWriteCensus.ivar_set_entries(census)
1482
+ now = Inference::GlobalWriteCensus.ivar_set_entries(after[path] || Inference::GlobalWriteCensus::EMPTY)
1483
+ (before ^ now).each { |entry| keys << Inference::GlobalWriteCensus.ivar_key(entry) }
1484
+ end
1485
+ return Set.new if keys.empty?
1486
+
1487
+ Incremental.negative_closure(keys.to_a, @negative_dependents)
1488
+ end
1489
+
1490
+ # Issue #2134 — the consumers whose answer read a class's visibility slot for a method, or its contest, for each
1491
+ # slot whose entries a file in `paths` added, dropped or changed (`Scope#discovered_method_visibility`). The
1492
+ # before-state is each file's seed bundle, the after-state the scan's per-file entries. A `private :foo` in a
1493
+ # reopening or a `class_eval` block moves no `def`, so neither the symbol fingerprints nor the defined-name diff
1494
+ # sees it.
1495
+ def visibility_name_affected(paths, summary)
1496
+ after = (summary && summary[:visibility_entries]) || {}
1497
+ before = paths.to_h do |path|
1498
+ bundle = @seed_bundles[path]
1499
+ [path, bundle && Inference::ScopeIndexer.visibility_entries(bundle)]
1500
+ end
1501
+ slots = Incremental.changed_visibility_slots(paths, before, after)
1502
+ return Set.new if slots.empty?
1503
+
1504
+ Incremental.negative_closure(slots.map { |slot| "visibility:#{slot}" }, @negative_dependents)
1505
+ end
1506
+
1382
1507
  # The qualified class/module names declared in the pre-parsed `index` (shared with
1383
1508
  # {#symbol_fingerprints_from_index}, so the changed set is parsed once). `{ path => Set<class name> }`.
1384
1509
  def class_declarations_from_index(index)
@@ -1541,7 +1666,22 @@ module Rigor
1541
1666
  # and (#1898) their effect collections, so the run closes its effect graph over the whole project.
1542
1667
  def served_state(reused)
1543
1668
  { served_per_file_diagnostics: @cache.slice(*reused),
1544
- served_effect_collections: @effect_collections.slice(*reused) }
1669
+ served_effect_collections: @effect_collections.slice(*reused),
1670
+ served_run_disclosures: @plugin_disclosures.served(reused, @stale_disclosures) }
1671
+ end
1672
+
1673
+ # Issue #2020 — folds the run's run-scoped registrations into the ledger, refreshing the keys this recheck found
1674
+ # stale. Extracted to keep {#absorb} under its ABC budget.
1675
+ def absorb_disclosures(runner, analyze_set)
1676
+ @plugin_disclosures.absorb(runner.analysis_disclosures, analyze_set, @stale_disclosures)
1677
+ @stale_disclosures = Set.new
1678
+ end
1679
+
1680
+ # Issue #2020 — the run-scoped plugin rows whose reads went stale ({RunDisclosureLedger}): one registering file
1681
+ # each joins the closure, and their replays are withheld until {#absorb} refreshes them from that file.
1682
+ def disclosure_refreshers(removed)
1683
+ @stale_disclosures = @plugin_disclosures.stale_identities { |row| plugin_read_fresh?(row) }
1684
+ @plugin_disclosures.refreshers(@stale_disclosures, removed.to_set)
1545
1685
  end
1546
1686
 
1547
1687
  # Issue #1898 — the plugin effect-fact digest a run's collections were stamped under. A plugin writes its
@@ -1560,6 +1700,21 @@ module Rigor
1560
1700
  effect_plugin_digest_of(runner) != @effect_plugin_digest
1561
1701
  end
1562
1702
 
1703
+ # PR #2286 review M1 — whether the ADR-16 synthetic-method index `runner` built differs from the one the
1704
+ # served files were analysed under. The index comes from class-body DSL calls (`devise :confirmable`,
1705
+ # a Tier C `attribute`), which no declaration signature records and no dependency edge names, so a file
1706
+ # that dispatched through a synthesised member is not in the closure of the edit that removed it. The
1707
+ # comparison waits for the run because the index is built by its pre-pass; a move re-analyses the project.
1708
+ def synthetic_surface_moved?(runner, reused)
1709
+ return false if reused.empty?
1710
+
1711
+ synthetic_surface_digest_of(runner) != @synthetic_surface_digest
1712
+ end
1713
+
1714
+ def synthetic_surface_digest_of(runner)
1715
+ runner.synthetic_method_index&.surface_digest
1716
+ end
1717
+
1563
1718
  # The answer {#recheck} gives when {#served_effects_stale?}: a full baseline, reported as a recheck that
1564
1719
  # re-analysed every file. {#run_incremental} reads `rebaselined` so it neither runs a second baseline
1565
1720
  # nor reports the run as warm.
@@ -1644,19 +1799,12 @@ module Rigor
1644
1799
  def rebind_buffer(files)
1645
1800
  return if @buffer.nil? || files.include?(@buffer.logical_path)
1646
1801
 
1647
- target = real_path(@buffer.logical_path)
1648
- return if target.nil?
1802
+ target = BufferBinding.real_path(@buffer.logical_path)
1649
1803
 
1650
- match = files.find { |path| real_path(path) == target }
1804
+ match = files.find { |path| BufferBinding.real_path(path) == target }
1651
1805
  @buffer = BufferBinding.new(logical_path: match, physical_path: @buffer.physical_path) if match
1652
1806
  end
1653
1807
 
1654
- def real_path(path)
1655
- File.realpath(path)
1656
- rescue StandardError
1657
- nil
1658
- end
1659
-
1660
1808
  # True when `path`'s recorded stat entry proves it unchanged since the last analysis. Any stat / parse
1661
1809
  # failure (missing entry, unreadable / vanished file) reads as NOT fresh (→ re-analyse), preserving the
1662
1810
  # prior `digest(path) != recorded` "changed" semantics for a file that cannot be validated.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../rooted_glob"
4
+ require_relative "diagnostic"
4
5
 
5
6
  module Rigor
6
7
  module Analysis
@@ -10,8 +11,13 @@ module Rigor
10
11
  # is taken verbatim; anything else contributes no files here (the Runner layers its own path-error
11
12
  # diagnostics and buffer / in-memory handling on top — none of which add or remove files for an ordinary
12
13
  # on-disk check, so the two file sets agree).
14
+ #
15
+ # #1991 — also the one place a path that names neither a directory nor a `.rb` file is worded and
16
+ # severity-graded, so `rigor check` and `rigor sig-gen` report the same path error the same way.
13
17
  module PathExpansion
14
18
  RUBY_GLOB = "**/*.rb"
19
+ NO_SUCH_PATH = "no such file or directory"
20
+ NOT_A_RUBY_FILE = "not a Ruby file (expected `.rb` or a directory)"
15
21
 
16
22
  module_function
17
23
 
@@ -28,10 +34,45 @@ module Rigor
28
34
  end
29
35
  end
30
36
 
37
+ # The `[path, message]` pairs for each of `paths` that names neither a directory nor a `.rb` file,
38
+ # worded as `rigor check` words them.
39
+ def unusable(paths)
40
+ Array(paths).each_with_object([]) do |path, bad|
41
+ next if File.directory?(path) || (File.file?(path) && path.end_with?(".rb"))
42
+
43
+ bad << [path, unusable_message(path)]
44
+ end
45
+ end
46
+
47
+ def unusable_message(path)
48
+ File.exist?(path) ? NOT_A_RUBY_FILE : NO_SUCH_PATH
49
+ end
50
+
51
+ # The error diagnostics for `bad` (from {unusable}). A bad path among valid ones is a warning that it was
52
+ # skipped; a bad path that leaves nothing to analyse is an error, so a lone typo is not masked.
53
+ def diagnostics(bad, any_files:)
54
+ severity = any_files ? :warning : :error
55
+ suffix = any_files ? " (skipped)" : ""
56
+ bad.map do |path, message|
57
+ Diagnostic.new(path: path, line: 1, column: 1, message: "#{message}#{suffix}", severity: severity)
58
+ end
59
+ end
60
+
31
61
  def directory_files(dir, exclude_patterns, base: Dir.pwd)
32
62
  reject_excluded(RootedGlob.glob(dir, RUBY_GLOB), exclude_patterns, base: base)
33
63
  end
34
64
 
65
+ # The configured project files, expanded as `rigor check` expands its configured paths: a directory globs,
66
+ # any other entry (a file, or a path that does not exist) is kept verbatim and left to the reader to skip.
67
+ # The file list a cross-file discovery seed is folded over ({Protection::DiscoverySeed}).
68
+ def configured_files(configuration)
69
+ Array(configuration.paths).flat_map do |path|
70
+ next [path] unless File.directory?(path)
71
+
72
+ directory_files(path, configuration.exclude_patterns, base: configuration.exclude_base)
73
+ end
74
+ end
75
+
35
76
  # `exclude_patterns` are matched with `File.fnmatch?` WITHOUT `FNM_PATHNAME`, so `**`/`*` span separators
36
77
  # (the patterns behave like substring globs) — the exact semantics the Runner's directory glob applies.
37
78
  #
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Analysis
7
+ # Issue #2021 — the files a run analyses outside the configured `paths:` load through `require_relative`.
8
+ #
9
+ # A run over path arguments widens its discovery to the configured paths (#684), which is the whole project for
10
+ # a file inside them. A file outside them has no such project: `rigor check plugins/x/lib/rigor/plugin/x.rb`
11
+ # in a repository whose `paths:` is `lib` discovered `x.rb` and `lib` alone. The plugin's nested `Effects`,
12
+ # declared in the sibling `x/effects.rb`, was then invisible, the lexical lookup fell through to the
13
+ # project's `Rigor::Effects`, and every call on it drew a false `call.undefined-method` that checking the
14
+ # plugin's `lib` did not.
15
+ #
16
+ # The file names its siblings itself: Ruby loads what it `require_relative`s before any of its code runs, so
17
+ # those declarations are in scope at runtime. {.closure} follows that graph from the out-of-paths files,
18
+ # through each target that is itself outside the configured paths. A target under a configured path is
19
+ # already discovered and stops the walk there, so the files inside `paths:` are discovered exactly as a run
20
+ # over the configured paths discovers them. Only a string literal counts (`require_relative "a/b"`, with or
21
+ # without the `.rb`); a computed name, `require`, `load` and `autoload` are not followed, so a gem's own
22
+ # files and a Zeitwerk-loaded tree are not reached this way.
23
+ #
24
+ # The walk sees one entry point's load graph, and a file it does not reach may redefine what a walked file
25
+ # defines. The runner therefore discovers the walked files as uncertain: their declarations resolve constants
26
+ # and their methods exist, but no return type, arity or visibility is read from them.
27
+ module RequireRelativeClosure
28
+ # The walk's answer: the existing targets it reached, and the targets it named that are not files (each an
29
+ # input of the answer too, since one appearing would join the discovery).
30
+ Result = Data.define(:found, :missing)
31
+
32
+ module_function
33
+
34
+ # @param seeds — the analysed files outside the configured paths, as spelled by the run.
35
+ # @param covered — `->(real_path) { Boolean }`, whether a target lies under a configured path, where discovery
36
+ # holds it already. The walk neither adds nor follows such a target. Paths are compared as real paths, so a
37
+ # symlinked spelling of a configured file is covered too.
38
+ # @param read — `->(path) { String }`, the source of a file (an editor buffer's bytes for its file).
39
+ # @return a {Result}; `found` holds absolute paths in walk order, each file once, and never a seed.
40
+ def closure(seeds, covered:, read:)
41
+ seen = seeds.to_set { |path| real_path(path) }
42
+ queue = seeds.dup
43
+ found = []
44
+ missing = []
45
+ until queue.empty?
46
+ targets_of(queue.shift, read).each do |target|
47
+ next missing << target unless File.file?(target)
48
+
49
+ real = real_path(target)
50
+ next if covered.call(real) || !seen.add?(real)
51
+
52
+ found << target
53
+ queue << target
54
+ end
55
+ end
56
+ Result.new(found: found, missing: missing.uniq)
57
+ end
58
+
59
+ def real_path(path)
60
+ File.realpath(path)
61
+ rescue SystemCallError
62
+ File.absolute_path(path)
63
+ end
64
+
65
+ # The `.rb` paths `path` names with a literal `require_relative`, absolute, whether or not they exist.
66
+ def targets_of(path, read)
67
+ source = read.call(path)
68
+ return [] if source.nil?
69
+
70
+ base = File.dirname(File.absolute_path(path))
71
+ literal_features(Prism.parse(source).value).map do |feature|
72
+ File.expand_path(feature.end_with?(".rb") ? feature : "#{feature}.rb", base)
73
+ end
74
+ rescue StandardError
75
+ []
76
+ end
77
+
78
+ # In source order, so discovery meets the targets in the order Ruby loads them.
79
+ def literal_features(root)
80
+ calls = []
81
+ stack = [root]
82
+ until stack.empty?
83
+ node = stack.pop
84
+ calls << node if literal_feature(node)
85
+ stack.concat(node.compact_child_nodes)
86
+ end
87
+ calls.sort_by { |call| call.location.start_offset }.filter_map { |call| literal_feature(call) }
88
+ end
89
+
90
+ def literal_feature(node)
91
+ return nil unless node.is_a?(Prism::CallNode) && node.name == :require_relative && node.receiver.nil?
92
+
93
+ arguments = node.arguments&.arguments
94
+ return nil unless arguments&.size == 1 && arguments.first.is_a?(Prism::StringNode)
95
+
96
+ arguments.first.unescaped
97
+ end
98
+ end
99
+ end
100
+ end
@@ -706,7 +706,11 @@ module Rigor
706
706
  "Writes are in a singleton body — `def self.foo` or a `class << self` def — those track " \
707
707
  "separately.",
708
708
  "Writes are in a `def` inside an anonymous-class factory block (`Class.new do … end`, " \
709
- "`Module.new`, `Struct.new`, `Data.define`): that body defines another class."
709
+ "`Module.new`, `Struct.new`, `Data.define`): that body defines another class.",
710
+ "The class descends from a plugin-declared per-invocation class (ADR-125; rigor-actionpack declares " \
711
+ "`ActionController::Base` / `ActionController::API`, rigor-actionmailer `ActionMailer::Base`), and the " \
712
+ "write and every conflicting write sit in different actions: public methods no callback macro or " \
713
+ "`helper_method` names. One action runs per instance."
710
714
  ],
711
715
  suppression: "`# rigor:disable ivar-write-mismatch` on the offending write.",
712
716
  severity_authored: :error,
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Analysis
5
+ # Issue #2020 — what one file's analysis contributed to the run-scoped plugin tables ({Plugin::Base#disclose_once}
6
+ # and {Plugin::Base#emit_once}), kept beside the file's cached answer so an `--incremental` recheck that serves
7
+ # the file without analysing it can still report the rows a cold run reports.
8
+ #
9
+ # A replayed row must go stale with the inputs it is about, and the reads those inputs came from are credited to
10
+ # one file at most: a plugin memoises the input (a locale index, a routes table), so the first file to trigger
11
+ # the read is the only one credited with it, and a read in `#prepare` is credited to none. So each record also
12
+ # carries, under `:reads`, every row the registering plugin's boundary holds once the file is analysed,
13
+ # `#prepare`'s included. {IncrementalSession} keeps those rows per `[plugin id, key]`, not per file: when one goes
14
+ # stale it re-analyses one registering file and drops the key's replays, rather than re-analysing every file that
15
+ # registered it. Only inputs read through the boundary are covered.
16
+ module RunDisclosureCredit
17
+ ATTRIBUTION_KEY = :rigor_run_disclosure_attribution
18
+ private_constant :ATTRIBUTION_KEY
19
+
20
+ module_function
21
+
22
+ # Runs the block with run-scoped registrations attributed and returns the file's records — each a run-disclosure
23
+ # record tagged with its `plugin_id` and carrying the registering plugin's boundary rows as `:reads`, one per
24
+ # `[plugin id, key]` — frozen and Marshal-clean. A thread variable, as {Plugin::IoBoundary.attributing} uses, so a
25
+ # registration from inside an `Enumerator` still lands.
26
+ def collect
27
+ registrations = []
28
+ previous = Thread.current.thread_variable_get(ATTRIBUTION_KEY)
29
+ Thread.current.thread_variable_set(ATTRIBUTION_KEY, registrations)
30
+ begin
31
+ yield
32
+ ensure
33
+ Thread.current.thread_variable_set(ATTRIBUTION_KEY, previous)
34
+ end
35
+ credit(registrations)
36
+ end
37
+
38
+ # Called by {Plugin::Base#disclose_once} and {Plugin::Base#emit_once} for every call, a repeat included.
39
+ def attribute(plugin, record)
40
+ Thread.current.thread_variable_get(ATTRIBUTION_KEY)&.push([plugin, record])
41
+ nil
42
+ end
43
+
44
+ def credit(registrations)
45
+ return [].freeze if registrations.empty?
46
+
47
+ reads = {}.compare_by_identity
48
+ records = registrations.map do |plugin, record|
49
+ rows = reads[plugin] ||= boundary_rows(plugin).freeze
50
+ record.merge(plugin_id: plugin_id(plugin), reads: rows).freeze
51
+ end
52
+ records.uniq { |record| identity(record) }.freeze
53
+ end
54
+
55
+ # The de-duplication identity a record shares with every other registration of its key.
56
+ def identity(record)
57
+ [record[:plugin_id], record[:key]]
58
+ end
59
+
60
+ # The record as the aggregator replays it, without the rows that decide whether it is stale.
61
+ def replayable(record)
62
+ record.except(:reads).freeze
63
+ end
64
+
65
+ # The boundary's file and listing rows, read without triggering its lazy initializer: a plugin that never
66
+ # built one read nothing through it.
67
+ def boundary_rows(plugin)
68
+ boundary = plugin.instance_variable_get(:@io_boundary) or return []
69
+ descriptor = boundary.cache_descriptor
70
+ descriptor.files + descriptor.globs
71
+ end
72
+
73
+ def plugin_id(plugin)
74
+ plugin.manifest.id
75
+ rescue StandardError
76
+ plugin.class.to_s
77
+ end
78
+ end
79
+ end
80
+ end