rigortype 0.4.4 → 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 (233) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/docs/handbook/11-sig-gen.md +100 -18
  4. data/docs/manual/02-cli-reference.md +85 -16
  5. data/docs/manual/03-configuration.md +12 -9
  6. data/docs/manual/04-diagnostics.md +17 -6
  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 +17 -2
  12. data/docs/manual/12-caching.md +8 -1
  13. data/docs/manual/18-removing-dead-code.md +18 -7
  14. data/docs/manual/plugins/rigor-actionmailer.md +30 -0
  15. data/docs/manual/plugins/rigor-actionpack.md +30 -0
  16. data/docs/manual/plugins/rigor-devise.md +15 -0
  17. data/docs/manual/plugins/rigor-rspec.md +102 -1
  18. data/lib/rigor/analysis/buffer_binding.rb +14 -0
  19. data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
  20. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +30 -9
  21. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +142 -15
  22. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +115 -7
  23. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +253 -0
  24. data/lib/rigor/analysis/check_rules/rebinding_block_callees/dispatch.rb +112 -0
  25. data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +394 -0
  26. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +7 -0
  27. data/lib/rigor/analysis/check_rules/rule_ids.rb +3 -1
  28. data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
  29. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +64 -4
  30. data/lib/rigor/analysis/check_rules.rb +319 -21
  31. data/lib/rigor/analysis/dependency_recorder.rb +11 -0
  32. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  33. data/lib/rigor/analysis/edit_replay.rb +67 -0
  34. data/lib/rigor/analysis/incremental.rb +86 -0
  35. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
  36. data/lib/rigor/analysis/incremental_session.rb +336 -46
  37. data/lib/rigor/analysis/path_expansion.rb +44 -1
  38. data/lib/rigor/analysis/reachability/graph.rb +90 -13
  39. data/lib/rigor/analysis/reachability/scan.rb +279 -39
  40. data/lib/rigor/analysis/require_relative_closure.rb +100 -0
  41. data/lib/rigor/analysis/rule_catalog.rb +22 -5
  42. data/lib/rigor/analysis/run_disclosure_credit.rb +80 -0
  43. data/lib/rigor/analysis/run_disclosure_ledger.rb +155 -0
  44. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +6 -2
  45. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +11 -3
  46. data/lib/rigor/analysis/runner/pool_coordinator.rb +82 -16
  47. data/lib/rigor/analysis/runner/project_pre_passes.rb +45 -3
  48. data/lib/rigor/analysis/runner/scan_environment.rb +51 -0
  49. data/lib/rigor/analysis/runner.rb +222 -111
  50. data/lib/rigor/analysis/severity_stamp.rb +11 -0
  51. data/lib/rigor/analysis/worker_session.rb +24 -1
  52. data/lib/rigor/cache/descriptor.rb +40 -7
  53. data/lib/rigor/cache/engine_source.rb +3 -2
  54. data/lib/rigor/cache/incremental_snapshot.rb +49 -8
  55. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  56. data/lib/rigor/cache/store.rb +4 -3
  57. data/lib/rigor/cli/check_command.rb +93 -4
  58. data/lib/rigor/cli/command.rb +3 -1
  59. data/lib/rigor/cli/diff_command.rb +2 -1
  60. data/lib/rigor/cli/docs_command.rb +3 -2
  61. data/lib/rigor/cli/doctor_command.rb +1 -1
  62. data/lib/rigor/cli/explain_command.rb +3 -2
  63. data/lib/rigor/cli/lens_command.rb +5 -5
  64. data/lib/rigor/cli/mcp_command.rb +2 -1
  65. data/lib/rigor/cli/options.rb +20 -3
  66. data/lib/rigor/cli/plugin_command.rb +2 -1
  67. data/lib/rigor/cli/plugins_command.rb +7 -1
  68. data/lib/rigor/cli/plugins_renderer.rb +24 -2
  69. data/lib/rigor/cli/show_bleedingedge_command.rb +2 -1
  70. data/lib/rigor/cli/sig_gen_command.rb +73 -13
  71. data/lib/rigor/cli/skill_command.rb +2 -1
  72. data/lib/rigor/cli/skill_describe.rb +3 -2
  73. data/lib/rigor/cli/type_of_command.rb +2 -1
  74. data/lib/rigor/cli/type_of_project_scope.rb +27 -0
  75. data/lib/rigor/cli/unused_command.rb +10 -7
  76. data/lib/rigor/cli.rb +3 -2
  77. data/lib/rigor/config_audit.rb +72 -16
  78. data/lib/rigor/configuration/severity_profile.rb +20 -2
  79. data/lib/rigor/configuration.rb +43 -7
  80. data/lib/rigor/effects/signature_sources.rb +3 -1
  81. data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
  82. data/lib/rigor/environment/gated_signature_guard.rb +2 -1
  83. data/lib/rigor/environment/gated_signature_plan.rb +2 -1
  84. data/lib/rigor/environment/installed_gem_set.rb +2 -1
  85. data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
  86. data/lib/rigor/environment/rbs_loader.rb +136 -7
  87. data/lib/rigor/environment/required_features.rb +136 -29
  88. data/lib/rigor/environment/ruby_library_roots.rb +122 -0
  89. data/lib/rigor/environment.rb +4 -2
  90. data/lib/rigor/inference/block_call_timing.rb +7 -1
  91. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  92. data/lib/rigor/inference/callee_mutation_chain.rb +115 -0
  93. data/lib/rigor/inference/captured_locals.rb +44 -13
  94. data/lib/rigor/inference/clause_widening.rb +90 -0
  95. data/lib/rigor/inference/deep_erasure.rb +138 -0
  96. data/lib/rigor/inference/def_return_typer.rb +159 -9
  97. data/lib/rigor/inference/definer_resolution.rb +101 -20
  98. data/lib/rigor/inference/error_info.rb +17 -3
  99. data/lib/rigor/inference/expression_typer.rb +97 -18
  100. data/lib/rigor/inference/global_write_census.rb +36 -1
  101. data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
  102. data/lib/rigor/inference/ivar_writer_census.rb +195 -0
  103. data/lib/rigor/inference/loop_value_record.rb +53 -0
  104. data/lib/rigor/inference/macro_block_self_type.rb +20 -6
  105. data/lib/rigor/inference/match_rebinding/frame.rb +19 -0
  106. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +278 -42
  107. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +6 -2
  108. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
  109. data/lib/rigor/inference/method_dispatcher/set_folding.rb +53 -20
  110. data/lib/rigor/inference/method_dispatcher.rb +113 -19
  111. data/lib/rigor/inference/module_function_state/spellings.rb +64 -0
  112. data/lib/rigor/inference/module_function_state.rb +149 -42
  113. data/lib/rigor/inference/mutation_widening.rb +5 -2
  114. data/lib/rigor/inference/narrowing.rb +116 -11
  115. data/lib/rigor/inference/object_mixins.rb +13 -1
  116. data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
  117. data/lib/rigor/inference/project_patched_methods.rb +168 -4
  118. data/lib/rigor/inference/project_patched_mixins.rb +162 -0
  119. data/lib/rigor/inference/project_patched_scanner.rb +51 -12
  120. data/lib/rigor/inference/project_patched_visibility.rb +214 -0
  121. data/lib/rigor/inference/project_seed_tables.rb +121 -0
  122. data/lib/rigor/inference/project_yielder.rb +128 -0
  123. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
  124. data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
  125. data/lib/rigor/inference/reflective_ivar_write.rb +6 -6
  126. data/lib/rigor/inference/return_barrier.rb +16 -0
  127. data/lib/rigor/inference/scope_indexer/certainty.rb +9 -0
  128. data/lib/rigor/inference/scope_indexer/declared_ivar_types.rb +64 -0
  129. data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +162 -0
  130. data/lib/rigor/inference/scope_indexer/unseen_ivar_writers.rb +133 -0
  131. data/lib/rigor/inference/scope_indexer.rb +1144 -197
  132. data/lib/rigor/inference/self_call_ivar_writes.rb +29 -12
  133. data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
  134. data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
  135. data/lib/rigor/inference/statement_evaluator.rb +1088 -213
  136. data/lib/rigor/inference/synthetic_method_index.rb +23 -0
  137. data/lib/rigor/inference/synthetic_method_scanner/project_members.rb +130 -0
  138. data/lib/rigor/inference/synthetic_method_scanner.rb +72 -12
  139. data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
  140. data/lib/rigor/language_server/buffer_table.rb +6 -0
  141. data/lib/rigor/language_server/diagnostic_publisher.rb +20 -6
  142. data/lib/rigor/language_server/project_context.rb +59 -3
  143. data/lib/rigor/language_server/server.rb +5 -3
  144. data/lib/rigor/lens/contract_reader.rb +45 -2
  145. data/lib/rigor/lens/document.rb +2 -1
  146. data/lib/rigor/lens/file_reader.rb +12 -2
  147. data/lib/rigor/lens/members.rb +16 -2
  148. data/lib/rigor/lens/run.rb +34 -7
  149. data/lib/rigor/mcp/server.rb +11 -3
  150. data/lib/rigor/plugin/base.rb +29 -13
  151. data/lib/rigor/plugin/io_boundary.rb +42 -6
  152. data/lib/rigor/plugin/isolation.rb +4 -3
  153. data/lib/rigor/plugin/manifest.rb +29 -3
  154. data/lib/rigor/plugin/registry.rb +38 -0
  155. data/lib/rigor/protection/discovery_seed.rb +64 -36
  156. data/lib/rigor/protection/mutation_cache.rb +8 -2
  157. data/lib/rigor/rooted_glob.rb +36 -0
  158. data/lib/rigor/scope/discovery_index.rb +5 -0
  159. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
  160. data/lib/rigor/scope/resolution_chain.rb +40 -5
  161. data/lib/rigor/scope.rb +59 -7
  162. data/lib/rigor/sig_gen/alias_index.rb +2 -1
  163. data/lib/rigor/sig_gen/classification.rb +4 -1
  164. data/lib/rigor/sig_gen/generator.rb +465 -49
  165. data/lib/rigor/sig_gen/layout_index.rb +5 -2
  166. data/lib/rigor/sig_gen/observation_collector.rb +141 -12
  167. data/lib/rigor/sig_gen/rbs_validity.rb +1 -1
  168. data/lib/rigor/sig_gen/renderer.rb +16 -155
  169. data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
  170. data/lib/rigor/sig_gen/type_elaborator.rb +21 -5
  171. data/lib/rigor/sig_gen/write_reporter.rb +203 -0
  172. data/lib/rigor/sig_gen/write_result.rb +3 -1
  173. data/lib/rigor/sig_gen/writer.rb +5 -3
  174. data/lib/rigor/signature_path_audit.rb +4 -2
  175. data/lib/rigor/type/combinator.rb +4 -3
  176. data/lib/rigor/type/constant.rb +54 -3
  177. data/lib/rigor/type/hash_shape.rb +14 -9
  178. data/lib/rigor/type/struct_instance.rb +3 -2
  179. data/lib/rigor/version.rb +1 -1
  180. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  181. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +61 -44
  182. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +20 -1
  183. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
  184. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
  185. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
  186. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +13 -0
  187. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
  188. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
  189. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
  190. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +4 -2
  191. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
  192. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
  193. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
  194. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +5 -3
  195. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +7 -6
  196. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  197. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +38 -9
  198. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
  199. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
  200. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
  201. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
  202. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
  203. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
  204. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
  205. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +102 -11
  206. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
  207. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +4 -2
  208. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
  209. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
  210. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
  211. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +1 -1
  212. data/sig/rigor/analysis/baseline.rbs +3 -2
  213. data/sig/rigor/effects/file_collection.rbs +5 -6
  214. data/sig/rigor/effects/registry.rbs +8 -6
  215. data/sig/rigor/effects/summary.rbs +6 -5
  216. data/sig/rigor/environment.rbs +5 -3
  217. data/sig/rigor/inference/optimistic_origin.rbs +11 -0
  218. data/sig/rigor/inference.rbs +6 -0
  219. data/sig/rigor/plugin/additional_initializer.rbs +5 -5
  220. data/sig/rigor/plugin/effect_ancestry.rbs +6 -4
  221. data/sig/rigor/plugin/effect_attribution.rbs +25 -10
  222. data/sig/rigor/plugin/effect_edge.rbs +12 -5
  223. data/sig/rigor/plugin/effect_entry_points.rbs +8 -3
  224. data/sig/rigor/plugin/io_boundary.rbs +5 -2
  225. data/sig/rigor/plugin/manifest.rbs +16 -18
  226. data/sig/rigor/plugin/protocol_contract.rbs +10 -9
  227. data/sig/rigor/plugin/registry.rbs +1 -1
  228. data/sig/rigor/rooted_glob.rbs +7 -0
  229. data/sig/rigor/scope.rbs +34 -52
  230. data/sig/rigor/type.rbs +0 -6
  231. data/skills/rigor-doctor/references/01-checks.md +23 -11
  232. data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
  233. metadata +32 -1
@@ -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
@@ -99,7 +99,12 @@ module Rigor
99
99
  "Rigor's bundled overlays name (`OpenStruct`, whose fields are per-instance singleton methods; " \
100
100
  "`ActiveSupport::Duration` and `ActiveSupport::Concern` when their gem overlay loads). Both sides " \
101
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."
102
+ "`call.wrong-arity` or `call.argument-type-mismatch` on it.",
103
+ "The program may answer the call another way (#1722): a `method_missing` on the receiver's class or an " \
104
+ "ancestor; a module mixed into either that defines the method (`Integer.include(Ext)` counts wherever " \
105
+ "it is written); singleton methods given to the receiver local (`s.extend(M)`, " \
106
+ "`define_singleton_method`, `class << s`); or, for a receiver typed by its class's name rather than a " \
107
+ "literal, a project subclass that defines the method."
103
108
  ],
104
109
  suppression: "`# rigor:disable call.undefined-method` on the call line, " \
105
110
  "or `disable: [\"call.undefined-method\"]` in `.rigor.yml`.",
@@ -561,14 +566,22 @@ module Rigor
561
566
  summary: "Explicit-receiver call to a method declared `private` in source.",
562
567
  fires_when: [
563
568
  "Call is `receiver.method(...)` with explicit non-self receiver.",
564
- "Receiver type resolves to `Type::Nominal[X]`.",
565
- "X is a user-defined class whose source carries the method under `private`."
569
+ "Receiver type resolves to `Type::Nominal[X]`, or to the class object `singleton(X)`.",
570
+ "X is a user-defined class whose source carries the method under `private`; for a class object, " \
571
+ "the singleton method the call reaches is private (`private_class_method`, `class << self` with " \
572
+ "`private`, or a private method of a module X extends)."
566
573
  ],
567
574
  does_not_fire_when: [
568
575
  "Implicit-self call (no receiver) — always allowed for private.",
569
576
  "Receiver is `self` (Ruby 2.7+ permits `self.private_method`).",
570
577
  "Receiver class is RBS-known but not user-source-defined (RBS-side visibility is deferred).",
571
- "Method is `:protected` (subclass tracking is deferred)."
578
+ "Method is `:protected` (subclass tracking is deferred).",
579
+ "A `method_missing` or `respond_to_missing?` on the receiver's chain, on the side the call reaches, " \
580
+ "may answer the call (Ruby sends a private call to `method_missing`).",
581
+ "The visibility rests on a statement that may not run, or on files that disagree about it.",
582
+ "On a class object: the receiver is not a constant (`self.class.x` may be a subclass that " \
583
+ "re-publicises the name), or the call is not inside a `def` (load-time code may run before the " \
584
+ "method is made private)."
572
585
  ],
573
586
  suppression: "`# rigor:disable method-visibility-mismatch`.",
574
587
  severity_authored: :error,
@@ -693,7 +706,11 @@ module Rigor
693
706
  "Writes are in a singleton body — `def self.foo` or a `class << self` def — those track " \
694
707
  "separately.",
695
708
  "Writes are in a `def` inside an anonymous-class factory block (`Class.new do … end`, " \
696
- "`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."
697
714
  ],
698
715
  suppression: "`# rigor:disable ivar-write-mismatch` on the offending write.",
699
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
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "run_disclosure_credit"
4
+
5
+ module Rigor
6
+ module Analysis
7
+ # Issue #2020 — the `--incremental` session's record of the run-scoped plugin rows ({Plugin::Base#disclose_once},
8
+ # {Plugin::Base#emit_once}) each analysed file's hooks registered, so a recheck can replay them for the files it
9
+ # serves from cache.
10
+ #
11
+ # - `files` maps a path to its records as the aggregator replays them, one per `[plugin id, key]` identity.
12
+ # - `reads` maps an identity to the registering plugin's boundary rows when it was last registered
13
+ # ({RunDisclosureCredit}), `#prepare`'s reads included.
14
+ #
15
+ # Staleness is per identity, not per file. A plugin memoises the input a notice is about and re-registers the
16
+ # notice from every file's hook, so crediting the rows to every registering file would re-analyse all of them on
17
+ # any edit to an input the plugin read (one view edit re-analysed 400 of 400 files in an app with one i18n view
18
+ # row). Instead a recheck finds the identities whose rows went stale, re-analyses ONE live registering file for
19
+ # each (the first by path), and withholds those identities' replays. When that file registers the identity, every
20
+ # other holder takes its record. When it registers nothing — the edit took the notice out of its text, it no
21
+ # longer parses, the plugin raised — the recheck cannot tell whether the identity is gone, so it runs again with
22
+ # every other live holder of the identity analysed ({#unresolved_holders}), and the identity is dropped only if
23
+ # none of them registers it either.
24
+ #
25
+ # One shape stays unsound: a key whose registration or record depends both on an input the plugin read and on the
26
+ # registering file's own text. When the input changes and the chosen file still registers the key, the other
27
+ # holders take its record even where, re-analysed, they would register another record or none, until each is
28
+ # re-analysed itself. {Plugin::Base#disclose_once} asks a key not to vary between workers, which rules it out for a
29
+ # plugin that keeps the contract. Only inputs read through the plugin's boundary are tracked; state a plugin carries
30
+ # across files, or an input it reads around the boundary (`ENV`, a direct `File.read`), can leave a replayed row
31
+ # that never clears.
32
+ class RunDisclosureLedger
33
+ attr_reader :files, :reads
34
+
35
+ def initialize(files: {}, reads: {})
36
+ @files = files
37
+ @reads = reads
38
+ end
39
+
40
+ # The ledger a baseline builds from `fresh` (`{path => [record with :reads]}`, `Runner#analysis_disclosures`).
41
+ def self.from(fresh)
42
+ new.tap { |ledger| ledger.absorb(fresh, fresh.keys, Set.new) }
43
+ end
44
+
45
+ def self.load(data)
46
+ data.is_a?(Hash) ? new(files: data.fetch(:files, {}), reads: data.fetch(:reads, {})) : new
47
+ end
48
+
49
+ def to_h
50
+ { files: @files, reads: @reads }
51
+ end
52
+
53
+ # The identities with a boundary row that no longer validates under `fresh_row` (a row → Boolean predicate).
54
+ def stale_identities(&fresh_row)
55
+ verdicts = {}
56
+ @reads.each_with_object(Set.new) do |(identity, rows), stale|
57
+ stale << identity if rows.any? { |row| !verdicts.fetch(row) { verdicts[row] = fresh_row.call(row) } }
58
+ end
59
+ end
60
+
61
+ # One live registering file per stale identity, the first by path, skipping `removed`.
62
+ def refreshers(stale, removed)
63
+ return Set.new if stale.empty?
64
+
65
+ holders = {}
66
+ @files.each do |path, records|
67
+ next if removed.include?(path)
68
+
69
+ records.each do |record|
70
+ identity = RunDisclosureCredit.identity(record)
71
+ holders[identity] = [holders[identity], path].compact.min if stale.include?(identity)
72
+ end
73
+ end
74
+ holders.values.to_set
75
+ end
76
+
77
+ # The live holders, outside `analyzed`, of every `stale` identity no file in `analyzed` registered in `fresh`:
78
+ # the files that must be analysed when the chosen refresher registered nothing. Analysing every one of them
79
+ # settles the identity, since every holder has then decided it afresh.
80
+ def unresolved_holders(fresh, analyzed, stale, removed)
81
+ return Set.new if stale.empty?
82
+
83
+ registered = analyzed.flat_map do |path|
84
+ Array(fresh[path]).map { |record| RunDisclosureCredit.identity(record) }
85
+ end
86
+ unresolved = stale - registered
87
+ return Set.new if unresolved.empty?
88
+
89
+ @files.each_with_object(Set.new) do |(path, records), holders|
90
+ next if removed.include?(path) || analyzed.include?(path)
91
+
92
+ holders << path if records.any? { |record| unresolved.include?(RunDisclosureCredit.identity(record)) }
93
+ end
94
+ end
95
+
96
+ # The records to replay for `paths`, without the `stale` identities. Unpinned defence: a stale identity's replay
97
+ # cannot reach the output today, since a refresher that registers it wins over the replay and one that does not
98
+ # sends every holder into the second pass.
99
+ def served(paths, stale = Set.new)
100
+ @files.values_at(*paths).compact.flatten(1).reject do |record|
101
+ stale.include?(RunDisclosureCredit.identity(record))
102
+ end
103
+ end
104
+
105
+ # Folds a run's registrations in: every path in `analyzed` takes this run's records (or none), every identity
106
+ # this run registered takes this run's rows, and each `stale` identity is refreshed or dropped in the files the
107
+ # run did not analyse. Identities no file holds any more lose their rows.
108
+ def absorb(fresh, analyzed, stale)
109
+ registered = {}
110
+ analyzed.each do |path|
111
+ records = Array(fresh[path])
112
+ records.each { |record| registered[RunDisclosureCredit.identity(record)] ||= record }
113
+ store(path, records.map { |record| RunDisclosureCredit.replayable(record) })
114
+ end
115
+ registered.each { |identity, record| @reads[identity] = record[:reads] }
116
+ refresh_unanalysed(stale, registered, analyzed.to_set)
117
+ prune
118
+ end
119
+
120
+ def forget(path)
121
+ @files.delete(path)
122
+ end
123
+
124
+ private
125
+
126
+ def refresh_unanalysed(stale, registered, analyzed)
127
+ return if stale.empty?
128
+
129
+ @files.each_key.to_a.each do |path|
130
+ next if analyzed.include?(path)
131
+
132
+ store(path, @files[path].filter_map do |record|
133
+ identity = RunDisclosureCredit.identity(record)
134
+ next record unless stale.include?(identity)
135
+
136
+ registered[identity] && RunDisclosureCredit.replayable(registered[identity])
137
+ end)
138
+ end
139
+ end
140
+
141
+ def store(path, records)
142
+ records.empty? ? @files.delete(path) : @files[path] = records.freeze
143
+ end
144
+
145
+ # Unpinned hygiene: an identity no file holds is never replayed, so keeping its rows changes no answer; pruning
146
+ # them keeps a stale row from re-analysing a file for nothing and the snapshot from growing.
147
+ def prune
148
+ held = @files.each_value.flat_map do |records|
149
+ records.map { |record| RunDisclosureCredit.identity(record) }
150
+ end.to_set
151
+ @reads.select! { |identity, _| held.include?(identity) }
152
+ end
153
+ end
154
+ end
155
+ end
@@ -79,6 +79,8 @@ module Rigor
79
79
  # (`ProjectContext#environment`) every job's Runner reuses instead of rebuilding.
80
80
  # @param prebuilt — the warm, shared pre-pass snapshot
81
81
  # (`ProjectContext#project_scan`) every job's Runner adopts instead of re-scanning.
82
+ # @param discovery_seed_for — issue #2147 — a callable returning the cross-file discovery seed for one
83
+ # binding (`ProjectContext#discovery_seed_for`), or nil for none.
82
84
  # @param workers — pool size. `#analyze` degrades to sequential in-process execution — one
83
85
  # job at a time, no `fork` — when fewer than 2 bindings are submitted, fewer than `min_batch_size`
84
86
  # bindings are submitted, `workers` is not positive, `fork` is unavailable on this platform, or
@@ -88,12 +90,13 @@ module Rigor
88
90
  # @param min_batch_size — see {DEFAULT_MIN_BATCH_SIZE}. Exposed as a constructor param
89
91
  # (rather than read from the env internally) purely for spec control; production callers get the
90
92
  # resolved default.
91
- def initialize(configuration:, cache_store:, environment:, prebuilt:, workers:,
93
+ def initialize(configuration:, cache_store:, environment:, prebuilt:, workers:, discovery_seed_for: nil,
92
94
  min_batch_size: self.class.resolve_min_batch_size)
93
95
  @configuration = configuration
94
96
  @cache_store = cache_store
95
97
  @environment = environment
96
98
  @prebuilt = prebuilt
99
+ @discovery_seed_for = discovery_seed_for
97
100
  @workers = workers
98
101
  @min_batch_size = min_batch_size
99
102
  end
@@ -123,7 +126,8 @@ module Rigor
123
126
  def run_one(binding)
124
127
  Runner.new(
125
128
  configuration: @configuration, cache_store: @cache_store, collect_stats: false,
126
- buffer: binding, prebuilt: @prebuilt, environment: @environment
129
+ buffer: binding, prebuilt: @prebuilt, environment: @environment,
130
+ discovery_seed: @discovery_seed_for&.call(binding)
127
131
  ).run([binding.logical_path]).diagnostics
128
132
  end
129
133
 
@@ -58,7 +58,7 @@ module Rigor
58
58
  definition_build_failures_snapshot:, hkt_scan_failure_snapshot:,
59
59
  conformance_results_snapshot:, signature_standdowns_snapshot: -> { [] },
60
60
  vendored_standdowns_snapshot: -> { [] }, pooled_run_disclosures: -> { [] },
61
- pooled_trust_refusals: -> { [] })
61
+ served_run_disclosures: -> { [] }, pooled_trust_refusals: -> { [] })
62
62
  @configuration = configuration
63
63
  @rbs_extended_reporter = rbs_extended_reporter
64
64
  @boundary_cross_reporter = boundary_cross_reporter
@@ -79,6 +79,8 @@ module Rigor
79
79
  # Issue #1051 — reader for the pool coordinator's de-duplicated run-scoped disclosure table.
80
80
  # Empty on a sequential run, where `plugin_registry`'s own instances are the ones that registered.
81
81
  @pooled_run_disclosures_reader = pooled_run_disclosures
82
+ # Issue #2020 — reader for the rows an incremental recheck replays for the files it served unanalysed.
83
+ @served_run_disclosures_reader = served_run_disclosures
82
84
  # Issue #1900 — reader for the pool coordinator's summed trust-refusal table. Empty on a
83
85
  # sequential run, where `plugin_registry`'s own boundaries made every read.
84
86
  @pooled_trust_refusals_reader = pooled_trust_refusals
@@ -1116,12 +1118,14 @@ module Rigor
1116
1118
  end
1117
1119
 
1118
1120
  # Issue #1051 — the run-scoped plugin disclosures ({Plugin::Base#disclose_once}), the run's
1119
- # project-global notices about its own inputs. Two sources feed one table:
1121
+ # project-global notices about its own inputs. Three sources feed one table:
1120
1122
  #
1121
1123
  # - the coordinator-side registry, which is where a SEQUENTIAL run's `#prepare` and per-file hooks
1122
1124
  # ran; and
1123
1125
  # - `pooled_run_disclosures`, the pool coordinator's already-de-duplicated collection from the
1124
- # pre-fork parent session and every worker payload.
1126
+ # pre-fork parent session and every worker payload; and
1127
+ # - `served_run_disclosures` (#2020), the rows an incremental recheck replays for the files it served
1128
+ # from cache, since their per-file hooks did not run.
1125
1129
  #
1126
1130
  # Both are keyed `[plugin id, key]` and the first registration of a pair wins, so a run emits one
1127
1131
  # row per pair no matter how many plugin instances (workers) reached it — the #1051 bug, where a
@@ -1183,6 +1187,10 @@ module Rigor
1183
1187
  rescue StandardError
1184
1188
  next
1185
1189
  end
1190
+ # Issue #2020 — last, so a row this run registered afresh wins over the replay of the same key.
1191
+ Array(@served_run_disclosures_reader.call).each do |record|
1192
+ records[[record[:plugin_id], record[:key]]] ||= record
1193
+ end
1186
1194
  records.values
1187
1195
  end
1188
1196
 
@@ -111,6 +111,19 @@ module Rigor
111
111
  # so the incremental session's dependency graph is refreshed whether analysis ran sequential or pooled.
112
112
  attr_reader :collected_dependencies
113
113
 
114
+ # Issue #1553 — the per-file plugin reads the fork pool's workers recorded this run (empty unless
115
+ # `record_dependencies:` and a pool run occurred), folded into `Runner#analysis_reads`. Each worker's are
116
+ # marshalled back beside its dependency records.
117
+ def collected_analysis_reads
118
+ @collected_analysis_reads ||= {}
119
+ end
120
+
121
+ # Issue #2020 — the per-file run-scoped registrations the fork pool's workers recorded, folded into
122
+ # `Runner#analysis_disclosures` beside {#collected_analysis_reads}.
123
+ def collected_analysis_disclosures
124
+ @collected_analysis_disclosures ||= {}
125
+ end
126
+
114
127
  # ADR-103 WD12 — the per-file {Effects::FileCollection}s the fork pool captured this run (empty
115
128
  # unless the configuration carries an `effects:` block and a pool run occurred). The runner merges
116
129
  # these with its own sequential records before the post-pool fixpoint.
@@ -197,13 +210,17 @@ module Rigor
197
210
  if pool_mode?
198
211
  dispatch_pool(files, source_files: source_files)
199
212
  else
200
- analyze_files_sequentially(
201
- files, environment || resolve_sequential_environment(source_files: source_files)
202
- )
213
+ environment ||= resolve_sequential_environment(source_files: source_files)
214
+ analyze_files_sequentially(files, environment)
203
215
  end
204
216
  # Issues #796 / #794 — a narrowed run's own demand set is a SUBSET of the full run's, so the two
205
- # rows below are folded in after it, whichever backend produced the per-file stream.
206
- replayed_restored_run_level_rows?(closure: files)
217
+ # rows below are folded in after it, whichever backend produced the per-file stream. Issue #1929 —
218
+ # the sequential run's environment re-derives a stale replayed failure; a pool run has none on
219
+ # this side and resolves one only when a stale failure needs it.
220
+ redemand_env = lambda do
221
+ environment ||= resolve_sequential_environment(source_files: source_files)
222
+ end
223
+ replayed_restored_run_level_rows?(closure: files, redemand_env: redemand_env)
207
224
  result
208
225
  end
209
226
 
@@ -261,7 +278,9 @@ module Rigor
261
278
  # `env` rather than `resolve.call`: read the demand the snapshots above just made when they built
262
279
  # an environment, and force nothing when they did not.
263
280
  record_definition_build_failures(env&.rbs_loader&.definition_build_failures)
264
- record_hkt_scan_failure(hkt_scan_outcome(resolve.call)) unless replayed_restored_run_level_rows?
281
+ # An empty closure analyses no file, so only a deleted file's buffer can make a tuple stale here.
282
+ replayed = replayed_restored_run_level_rows?(closure: [], redemand_env: resolve)
283
+ record_hkt_scan_failure(hkt_scan_outcome(resolve.call)) unless replayed
265
284
  prepare_rows
266
285
  end
267
286
 
@@ -319,18 +338,46 @@ module Rigor
319
338
  # of its own — and so no environment.
320
339
  #
321
340
  # @param closure — the files this run analyses; see {#stale_replayed_failure?}.
322
- def replayed_restored_run_level_rows?(closure: nil)
341
+ # @param redemand_env — resolves the environment {#redemand_stale_failures} re-derives a stale
342
+ # failure in; nil (the empty closure, which has no stale failure) re-derives nothing.
343
+ def replayed_restored_run_level_rows?(closure: nil, redemand_env: nil)
323
344
  rows = @restored_run_level_rows
324
345
  return false if rows.nil?
325
346
 
326
- replayable = Array(rows.definition_build_failures).reject do |failure|
347
+ stale, replayable = Array(rows.definition_build_failures).partition do |failure|
327
348
  stale_replayed_failure?(failure, closure)
328
349
  end
329
350
  record_definition_build_failures(replayable)
351
+ redemand_stale_failures(stale, redemand_env)
330
352
  record_hkt_scan_failure(rows.hkt_scan_failure)
331
353
  true
332
354
  end
333
355
 
356
+ # Issue #1929 — a stale replayed failure is RE-DERIVED, not dropped. Dropping it was right for the
357
+ # class whose collision the edit removed, and wrong for every class the edit left failing: the file
358
+ # that demanded such a class usually sits outside the recheck's closure (the edited file only
359
+ # contributes a conflicting buffer, such as a missing superclass every subclass inherits), so
360
+ # nothing in the recheck asks for it again, and the row lost classes a cold run reports. The class
361
+ # is asked for again in this run's environment ({Environment::RbsLoader#redemand_definition}), so
362
+ # it is reported exactly when it still fails, with the tuple the current signatures give it.
363
+ #
364
+ # A class this run's own analysis already reported is skipped; its tuple is current. The residue:
365
+ # a class whose only demand was in an edited file that no longer makes it, and which still fails,
366
+ # stays reported until a full run. The failure itself is real, unlike the dropped-collision case
367
+ # this replaced.
368
+ def redemand_stale_failures(stale, redemand_env)
369
+ return if stale.empty? || redemand_env.nil?
370
+
371
+ reported = @snapshots.definition_build_failures.to_set(&:first)
372
+ pending = stale.map(&:first).reject { |name| reported.include?(name) }
373
+ return if pending.empty?
374
+
375
+ loader = redemand_env.call&.rbs_loader
376
+ return if loader.nil?
377
+
378
+ record_definition_build_failures(pending.filter_map { |name| loader.redemand_definition(name) })
379
+ end
380
+
334
381
  # A replayed definition-build failure the snapshot's own fingerprint cannot vouch for. The
335
382
  # fingerprint digests the three signature sources it can read WITHOUT an environment — `sig:`,
336
383
  # `Gemfile.lock` / `rbs_collection.lock.yaml`, and the configuration — but NOT the virtual RBS a
@@ -340,11 +387,11 @@ module Rigor
340
387
  # closure, its analysis demands nothing that would re-derive the row, and the run reports a
341
388
  # collision between files that no longer collide.
342
389
  #
343
- # The narrowed run cannot re-derive the row either (#696 forbids Rigor demanding a definition the
344
- # analysis did not), so the honest answer is to DROP it and under-report rather than assert a
345
- # diagnostic on a program that may now be correct — false positives outrank worst-case reading. The
346
- # cost is bounded to the row's own conflicting buffers: a collision between two files neither of
347
- # which this run touches still replays.
390
+ # Such a failure is not replayed as it stands; {#redemand_stale_failures} re-derives it instead
391
+ # (#1929), so the row asserts no collision the current signatures do not have. A failure whose
392
+ # buffers name no file this run touches still replays. A buffer whose file no longer exists is stale
393
+ # too: deleting an inline source a tuple names would otherwise replay the deleted declaration's
394
+ # failure, where a full run reports what the remaining signatures give.
348
395
  def stale_replayed_failure?(failure, closure)
349
396
  return false if closure.nil?
350
397
 
@@ -354,7 +401,10 @@ module Rigor
354
401
  closure_paths = closure.to_set { |path| File.absolute_path(path.to_s) }
355
402
  Array(buffers).any? do |name|
356
403
  path = virtual_buffer_path(name)
357
- !path.nil? && closure_paths.include?(File.absolute_path(path))
404
+ next false if path.nil?
405
+
406
+ absolute = File.absolute_path(path)
407
+ closure_paths.include?(absolute) || !File.exist?(absolute)
358
408
  end
359
409
  end
360
410
 
@@ -870,7 +920,10 @@ module Rigor
870
920
  merge_run_disclosures(session.drain_run_disclosures)
871
921
  # The degraded slice was re-analysed on the parent session, so its dependency records live there,
872
922
  # not in a child payload — fold them in alongside the successful children's.
873
- @collected_dependencies.merge!(session.drain_dependencies) if @record_dependencies
923
+ if @record_dependencies
924
+ collect_dependencies(session.drain_dependencies, session.drain_analysis_reads,
925
+ session.drain_analysis_disclosures)
926
+ end
874
927
  @collected_effects.merge!(session.drain_effects) if @record_effects
875
928
  end
876
929
 
@@ -907,6 +960,8 @@ module Rigor
907
960
  results = slice.to_h { |path| [path, session.analyze(path)] }
908
961
  payload = { results: results, reporters: session.drain_reporters,
909
962
  dependencies: session.drain_dependencies,
963
+ analysis_reads: session.drain_analysis_reads,
964
+ analysis_disclosures: session.drain_analysis_disclosures,
910
965
  disclosures: session.drain_run_disclosures,
911
966
  refusals: session.drain_trust_refusals }
912
967
  # ADR-103 WD13 — the effects slot exists only on a collecting run, so a normal `--workers N` run
@@ -945,7 +1000,10 @@ module Rigor
945
1000
  merge_run_disclosures(payload.fetch(:disclosures, []))
946
1001
  refusals = payload.fetch(:refusals, [])
947
1002
  refusals_by_slice ? refusals_by_slice[child[:index]] = refusals : merge_trust_refusals(refusals)
948
- @collected_dependencies.merge!(payload.fetch(:dependencies, {})) if @record_dependencies
1003
+ if @record_dependencies
1004
+ collect_dependencies(payload.fetch(:dependencies, {}), payload.fetch(:analysis_reads, {}),
1005
+ payload.fetch(:analysis_disclosures, {}))
1006
+ end
949
1007
  @collected_effects.merge!(payload.fetch(:effects, {})) if @record_effects
950
1008
  else
951
1009
  degraded.concat(child[:slice])
@@ -954,6 +1012,14 @@ module Rigor
954
1012
  degraded
955
1013
  end
956
1014
 
1015
+ # ADR-46 / issues #1553, #2020 — fold one worker's dependency records, per-file plugin reads and per-file
1016
+ # run-scoped registrations.
1017
+ def collect_dependencies(dependencies, analysis_reads, analysis_disclosures)
1018
+ @collected_dependencies.merge!(dependencies)
1019
+ collected_analysis_reads.merge!(analysis_reads)
1020
+ collected_analysis_disclosures.merge!(analysis_disclosures)
1021
+ end
1022
+
957
1023
  # @return the child's `{results:, reporters:}` payload, or nil when the child exited
958
1024
  # abnormally or wrote no readable payload. `Marshal.load` is safe here: the blob was written by
959
1025
  # our own forked child to a temp file we created.