rigortype 0.4.1 → 0.4.3

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 (230) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/io.yml +1 -0
  4. data/data/builtins/ruby_core/proc.yml +4 -0
  5. data/data/builtins/ruby_core/random.yml +4 -0
  6. data/data/builtins/ruby_core/struct.yml +3 -0
  7. data/data/core_overlay/env.rbs +34 -0
  8. data/data/core_overlay/gc.rbs +50 -0
  9. data/data/core_overlay/hash.rbs +45 -0
  10. data/data/core_overlay/integer.rbs +31 -0
  11. data/data/core_overlay/io_buffer.rbs +27 -0
  12. data/data/core_overlay/kernel.rbs +40 -0
  13. data/data/core_overlay/match_data.rbs +28 -0
  14. data/data/core_overlay/module.rbs +51 -0
  15. data/data/core_overlay/object_space.rbs +29 -0
  16. data/data/core_overlay/proc.rbs +45 -0
  17. data/data/core_overlay/range.rbs +40 -0
  18. data/data/core_overlay/source_range.rbs +78 -0
  19. data/data/core_overlay/string.rbs +86 -0
  20. data/data/vendored_gem_sigs/prime/integer-extension.rbs +41 -0
  21. data/data/vendored_gem_sigs/prime/prime.rbs +372 -0
  22. data/docs/handbook/01-getting-started.md +3 -3
  23. data/docs/handbook/06-classes.md +3 -1
  24. data/docs/handbook/11-sig-gen.md +18 -5
  25. data/docs/handbook/appendix-liskov.md +5 -0
  26. data/docs/manual/02-cli-reference.md +19 -1
  27. data/docs/manual/03-configuration.md +7 -5
  28. data/docs/manual/04-diagnostics.md +2 -1
  29. data/docs/manual/05-inspecting-types.md +50 -0
  30. data/docs/manual/06-baseline.md +2 -0
  31. data/docs/manual/07-plugins.md +1 -1
  32. data/docs/manual/12-caching.md +18 -1
  33. data/docs/manual/14-rails-quickstart.md +2 -2
  34. data/docs/manual/18-removing-dead-code.md +41 -4
  35. data/docs/manual/plugins/README.md +6 -1
  36. data/docs/manual/plugins/rigor-activerecord.md +21 -0
  37. data/docs/manual/plugins/rigor-alba.md +60 -0
  38. data/docs/manual/plugins/rigor-minitest.md +22 -4
  39. data/docs/manual/plugins/rigor-typelizer.md +71 -0
  40. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +27 -3
  41. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +21 -92
  42. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +275 -0
  43. data/lib/rigor/analysis/check_rules/rule_ids.rb +11 -0
  44. data/lib/rigor/analysis/check_rules/source_arity.rb +21 -2
  45. data/lib/rigor/analysis/check_rules.rb +292 -59
  46. data/lib/rigor/analysis/dependency_recorder.rb +15 -0
  47. data/lib/rigor/analysis/dependency_source_inference/builder.rb +8 -1
  48. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +21 -0
  49. data/lib/rigor/analysis/dependency_source_inference/index.rb +36 -11
  50. data/lib/rigor/analysis/dependency_source_inference/walker.rb +196 -27
  51. data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
  52. data/lib/rigor/analysis/erb_template_detector.rb +2 -1
  53. data/lib/rigor/analysis/incremental.rb +29 -9
  54. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +30 -5
  55. data/lib/rigor/analysis/incremental_run_slot.rb +6 -4
  56. data/lib/rigor/analysis/incremental_session.rb +103 -23
  57. data/lib/rigor/analysis/path_expansion.rb +62 -6
  58. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +124 -37
  59. data/lib/rigor/analysis/reachability/graph.rb +241 -37
  60. data/lib/rigor/analysis/reachability/scan.rb +490 -77
  61. data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
  62. data/lib/rigor/analysis/rule_catalog.rb +43 -0
  63. data/lib/rigor/analysis/run_cache_key.rb +16 -1
  64. data/lib/rigor/analysis/run_cache_probe.rb +1 -1
  65. data/lib/rigor/analysis/run_stats.rb +28 -6
  66. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +113 -6
  67. data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -19
  68. data/lib/rigor/analysis/runner/project_pre_passes.rb +21 -1
  69. data/lib/rigor/analysis/runner/run_snapshots.rb +6 -1
  70. data/lib/rigor/analysis/runner.rb +62 -13
  71. data/lib/rigor/analysis/worker_session.rb +8 -5
  72. data/lib/rigor/cache/engine_source.rb +21 -7
  73. data/lib/rigor/cache/incremental_snapshot.rb +55 -5
  74. data/lib/rigor/cli/annotate_command.rb +5 -0
  75. data/lib/rigor/cli/baseline_command.rb +7 -18
  76. data/lib/rigor/cli/check_command.rb +140 -40
  77. data/lib/rigor/cli/docs_command.rb +5 -4
  78. data/lib/rigor/cli/lens_command.rb +144 -0
  79. data/lib/rigor/cli/lsp_command.rb +5 -2
  80. data/lib/rigor/cli/skill_command.rb +3 -3
  81. data/lib/rigor/cli/skill_describe.rb +1 -1
  82. data/lib/rigor/cli/trace_command.rb +5 -0
  83. data/lib/rigor/cli/type_of_command.rb +6 -3
  84. data/lib/rigor/cli/unused_command.rb +166 -27
  85. data/lib/rigor/cli.rb +39 -5
  86. data/lib/rigor/configuration/severity_profile.rb +6 -0
  87. data/lib/rigor/configuration.rb +61 -2
  88. data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
  89. data/lib/rigor/environment/gated_signature_guard.rb +43 -0
  90. data/lib/rigor/environment/gated_signature_plan.rb +339 -0
  91. data/lib/rigor/environment/installed_gem_set.rb +38 -11
  92. data/lib/rigor/environment/lockfile_resolver.rb +37 -6
  93. data/lib/rigor/environment/missing_gem_constant_index.rb +22 -7
  94. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  95. data/lib/rigor/environment/rbs_loader.rb +316 -14
  96. data/lib/rigor/environment/required_features.rb +208 -0
  97. data/lib/rigor/environment.rb +33 -8
  98. data/lib/rigor/inference/acceptance.rb +28 -3
  99. data/lib/rigor/inference/builtins/cruby_definers.rb +78 -0
  100. data/lib/rigor/inference/builtins/method_catalog.rb +3 -0
  101. data/lib/rigor/inference/captured_locals.rb +43 -3
  102. data/lib/rigor/inference/def_node_resolver.rb +23 -1
  103. data/lib/rigor/inference/expression_typer.rb +261 -29
  104. data/lib/rigor/inference/global_write_census.rb +7 -4
  105. data/lib/rigor/inference/guard_rebinding.rb +1 -1
  106. data/lib/rigor/inference/in_effect_refinements/proc_literals.rb +83 -0
  107. data/lib/rigor/inference/in_effect_refinements/refine_defs.rb +54 -0
  108. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +286 -0
  109. data/lib/rigor/inference/in_effect_refinements/walk.rb +276 -0
  110. data/lib/rigor/inference/in_effect_refinements.rb +256 -0
  111. data/lib/rigor/inference/key_presence_guard/scan.rb +281 -0
  112. data/lib/rigor/inference/key_presence_guard.rb +303 -0
  113. data/lib/rigor/inference/macro_block_self_type.rb +59 -12
  114. data/lib/rigor/inference/match_rebinding.rb +1 -1
  115. data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +36 -0
  116. data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
  117. data/lib/rigor/inference/method_dispatcher/arithmetic_sequence_elements.rb +73 -0
  118. data/lib/rigor/inference/method_dispatcher/imprecise_argument.rb +68 -0
  119. data/lib/rigor/inference/method_dispatcher/integer_step_block_params.rb +81 -0
  120. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -0
  121. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
  122. data/lib/rigor/inference/method_dispatcher/keyword_arguments.rb +267 -0
  123. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +2 -2
  124. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +3 -3
  125. data/lib/rigor/inference/method_dispatcher/method_folding.rb +2 -1
  126. data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
  127. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +122 -105
  128. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +247 -28
  129. data/lib/rigor/inference/method_dispatcher/splat_arity.rb +71 -0
  130. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -7
  131. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +8 -26
  132. data/lib/rigor/inference/method_dispatcher/unit_denominator_rational.rb +67 -0
  133. data/lib/rigor/inference/method_dispatcher.rb +96 -14
  134. data/lib/rigor/inference/module_function_state.rb +2 -1
  135. data/lib/rigor/inference/narrowing.rb +20 -6
  136. data/lib/rigor/inference/object_mixins.rb +180 -0
  137. data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
  138. data/lib/rigor/inference/project_patched_methods.rb +12 -7
  139. data/lib/rigor/inference/project_patched_scanner.rb +108 -26
  140. data/lib/rigor/inference/refine_census.rb +239 -0
  141. data/lib/rigor/inference/refine_self.rb +317 -0
  142. data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
  143. data/lib/rigor/inference/refined_dispatch/own_method.rb +475 -0
  144. data/lib/rigor/inference/refined_dispatch.rb +320 -0
  145. data/lib/rigor/inference/repeated_or_writes.rb +2 -0
  146. data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +13 -0
  147. data/lib/rigor/inference/scope_indexer.rb +827 -65
  148. data/lib/rigor/inference/statement_evaluator.rb +294 -51
  149. data/lib/rigor/inference/string_mutation.rb +9 -3
  150. data/lib/rigor/inference/synthetic_method_scanner.rb +2 -1
  151. data/lib/rigor/inference/untyped_rejoin.rb +75 -0
  152. data/lib/rigor/inference/version_guard.rb +19 -10
  153. data/lib/rigor/language_server/project_context.rb +87 -5
  154. data/lib/rigor/language_server/server.rb +18 -0
  155. data/lib/rigor/lens/anchor.rb +115 -0
  156. data/lib/rigor/lens/contract_reader.rb +118 -0
  157. data/lib/rigor/lens/document.rb +113 -0
  158. data/lib/rigor/lens/file_reader.rb +433 -0
  159. data/lib/rigor/lens/members.rb +153 -0
  160. data/lib/rigor/lens/parameters.rb +91 -0
  161. data/lib/rigor/lens/plugins.rb +40 -0
  162. data/lib/rigor/lens/run.rb +280 -0
  163. data/lib/rigor/lens/slot.rb +79 -0
  164. data/lib/rigor/lens.rb +40 -0
  165. data/lib/rigor/plugin/inflector.rb +1 -1
  166. data/lib/rigor/plugin/io_boundary.rb +42 -3
  167. data/lib/rigor/plugin/macro/block_as_method.rb +51 -8
  168. data/lib/rigor/project_environment.rb +48 -6
  169. data/lib/rigor/protection/mutation_cache.rb +3 -2
  170. data/lib/rigor/protection/mutator.rb +1 -1
  171. data/lib/rigor/reflection/constant_ancestors.rb +127 -11
  172. data/lib/rigor/reflection/constant_path.rb +13 -2
  173. data/lib/rigor/reflection.rb +40 -8
  174. data/lib/rigor/scope/discovery_index.rb +40 -1
  175. data/lib/rigor/scope/resolution_chain.rb +159 -11
  176. data/lib/rigor/scope.rb +211 -21
  177. data/lib/rigor/sig_gen/classification.rb +4 -1
  178. data/lib/rigor/sig_gen/generator.rb +100 -10
  179. data/lib/rigor/sig_gen/method_candidate.rb +2 -2
  180. data/lib/rigor/sig_gen/skip_reason_catalog.rb +12 -0
  181. data/lib/rigor/source/alias_names.rb +52 -0
  182. data/lib/rigor/source/literals.rb +3 -2
  183. data/lib/rigor/source/symbol_name.rb +20 -0
  184. data/lib/rigor/type/combinator.rb +34 -2
  185. data/lib/rigor/type/float_range.rb +4 -1
  186. data/lib/rigor/type/nominal.rb +6 -1
  187. data/lib/rigor/version.rb +1 -1
  188. data/plugins/rigor-ac-library-rb/lib/rigor/plugin/ac_library_rb.rb +28 -0
  189. data/plugins/rigor-ac-library-rb/lib/rigor-ac-library-rb.rb +3 -0
  190. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/convolution.rbs +7 -0
  191. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/data_structures.rbs +160 -0
  192. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/graph.rbs +94 -0
  193. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/math.rbs +42 -0
  194. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/modint.rbs +71 -0
  195. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/object.rbs +37 -0
  196. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/segtree.rbs +83 -0
  197. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +2 -1
  198. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +29 -3
  199. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +5 -1
  200. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/association_roots.rb +100 -0
  201. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +94 -10
  202. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +43 -2
  203. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +3 -1
  204. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_collector.rb +149 -0
  205. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +74 -0
  206. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_index.rb +104 -0
  207. data/plugins/rigor-alba/lib/rigor/plugin/alba.rb +85 -0
  208. data/plugins/rigor-alba/lib/rigor-alba.rb +3 -0
  209. data/plugins/rigor-alba/sig/alba.rbs +4 -0
  210. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +2 -2
  211. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +29 -12
  212. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +7 -6
  213. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +130 -21
  214. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +6 -4
  215. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +4 -4
  216. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +1 -1
  217. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1 -1
  218. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +31 -0
  219. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_collector.rb +102 -0
  220. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +87 -0
  221. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_index.rb +76 -0
  222. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +73 -0
  223. data/plugins/rigor-typelizer/lib/rigor-typelizer.rb +3 -0
  224. data/sig/rigor/analysis/dependency_source_inference/index.rbs +1 -1
  225. data/sig/rigor/plugin/io_boundary.rbs +3 -0
  226. data/sig/rigor/scope.rbs +30 -4
  227. data/sig/rigor/source.rbs +4 -0
  228. data/sig/rigor.rbs +9 -4
  229. data/skills/rigor-project-init/references/02-configure.md +2 -2
  230. metadata +83 -1
@@ -4,6 +4,7 @@ require "fileutils"
4
4
  require "securerandom"
5
5
 
6
6
  require_relative "../incremental_run_slot"
7
+ require_relative "../dependency_source_inference/gem_resolver"
7
8
  require_relative "../../cache/file_digest"
8
9
  require_relative "../../cache/incremental_snapshot"
9
10
 
@@ -61,7 +62,9 @@ module Rigor
61
62
  # signature file under the configured or auto-detected `sig/` roots, or a `pre_eval:` file, changed in
62
63
  # between would leave the snapshot the run restores keyed by one tree
63
64
  # and the run reading another. From the stamp on, the lockfiles are the guard's to watch ({#admits?}), which
64
- # is also what lets the slot's key, whose only file inputs they are, be computed when the run ends.
65
+ # is also what lets the slot's key, whose only file inputs they are, be computed when the run ends. The
66
+ # opted-in gems' resolved versions, the key's one input that is neither a file nor the configuration (issue
67
+ # #1814), are read at the mark and watched the same way.
65
68
  #
66
69
  # The mark also records which of the paths an existence row can name are present: the lockfiles, the
67
70
  # analysis roots, the `pre_eval:` entries and the signature roots.
@@ -78,10 +81,22 @@ module Rigor
78
81
  presence = (lockfiles + named.map { |path| File.absolute_path(path.to_s) }).to_h do |path|
79
82
  [path, File.exist?(path)]
80
83
  end
84
+ rows = dependency_rows(configuration)
85
+ return nil if rows.nil?
86
+
81
87
  new(started_ns: started_ns, device: device, lockfiles: lockfiles, presence: presence,
82
- stamp_dir: stamp_dir(cache_root))
88
+ stamp_dir: stamp_dir(cache_root), configuration: configuration, dependency_rows: rows)
83
89
  end
84
90
 
91
+ # Issue #1814 — the opted-in gems' `(name, version, mode)` rows as the key carries them; nil when they cannot
92
+ # be resolved, which takes no mark (the key could not be computed either).
93
+ def self.dependency_rows(configuration)
94
+ DependencySourceInference::GemResolver.dependency_entries(configuration.dependencies)
95
+ rescue StandardError
96
+ nil
97
+ end
98
+ private_class_method :dependency_rows
99
+
85
100
  # The mark: the change time and device of the first stamp whose change time is later than that of a
86
101
  # stamp taken before it, on the same device; nil when none is written within {TICK_WAIT_LIMIT}.
87
102
  def self.stamp(cache_root)
@@ -143,17 +158,21 @@ module Rigor
143
158
  end
144
159
  private_class_method :lockfile_paths
145
160
 
146
- def initialize(started_ns:, device:, lockfiles:, presence: {}, stamp_dir: nil)
161
+ def initialize(started_ns:, device:, lockfiles:, presence: {}, stamp_dir: nil, configuration: nil,
162
+ dependency_rows: [])
147
163
  @stamp_dir = stamp_dir
148
164
  @started_ns = started_ns
149
165
  @device = device
150
166
  @lockfiles = lockfiles
151
167
  @presence = presence
168
+ @configuration = configuration
169
+ @dependency_rows = dependency_rows
152
170
  end
153
171
 
154
172
  # True when nothing the guard watches moved since the mark:
155
173
  #
156
- # - no lockfile the key or the fingerprint digests was written, created or removed;
174
+ # - no lockfile the key or the fingerprint digests was written, created or removed, and no opted-in gem
175
+ # resolves to another version than at the mark (issue #1814);
157
176
  # - no file `rows` or `pinned` names changed. A content row's file changed if its change time moved. An
158
177
  # existence row asks only whether its path is there, so a path the mark recorded changed if it came or
159
178
  # went, and an editor's lock file created and removed beside it, which moves its directory's change
@@ -174,7 +193,7 @@ module Rigor
174
193
  # @param rows — a {Cache::Descriptor} of the rows the key does not pin
175
194
  # @param pinned — a {Cache::Descriptor} of the rows it does
176
195
  def admits?(rows, pinned: Cache::Descriptor.new)
177
- !clock_stepped_back? && @lockfiles.none? { |path| lockfile_changed?(path) } &&
196
+ !clock_stepped_back? && @lockfiles.none? { |path| lockfile_changed?(path) } && dependency_rows_unchanged? &&
178
197
  files_unchanged?(rows.files, strict: true) && globs_unchanged?(rows.globs, strict: true) &&
179
198
  files_unchanged?(pinned.files, strict: false) && globs_unchanged?(pinned.globs, strict: false)
180
199
  rescue StandardError
@@ -196,6 +215,12 @@ module Rigor
196
215
 
197
216
  private
198
217
 
218
+ # Issue #1814 — the opted-in gems resolve to the versions they did at the mark. A guard built without a
219
+ # configuration (the specs' bare constructor) watches none.
220
+ def dependency_rows_unchanged?
221
+ @configuration.nil? || self.class.send(:dependency_rows, @configuration) == @dependency_rows
222
+ end
223
+
199
224
  # One more stamp: a change time earlier than the mark means the clock stepped back since it was taken, so
200
225
  # a save since then may carry a change time the guard reads as before it. A stamp that cannot be written,
201
226
  # or lands on another device, cannot vouch for the clock either.
@@ -42,8 +42,8 @@ module Rigor
42
42
  # plugin's identity and the engine's (key slots), so a project whose plugins claim template globs is served
43
43
  # too. The ADR-87 probe misses on one: the plain slot's key carries the `template-units` slot.
44
44
  #
45
- # `explain:` is always false: the session's runners never set it (#1533), and the CLI declines the probe under
46
- # `--explain`.
45
+ # `explain:` is always false: the session writes no slot for an `--explain` run (#1533), and the CLI declines
46
+ # the probe under `--explain`.
47
47
  module IncrementalRunSlot
48
48
  module_function
49
49
 
@@ -84,7 +84,9 @@ module Rigor
84
84
  return nil if base.nil?
85
85
 
86
86
  roots = RunCacheKey.config_entry("incremental.roots", normalize_roots(target.roots).sort.join("\n"))
87
- Cache::Descriptor.new(gems: base.gems, configs: base.configs + [roots])
87
+ # Issue #1814 — the opted-in gems' `(name, version, mode)` rows travel with the key, as they do in the plain
88
+ # probe's: the gem's walked source reaches every file's answer and no analysed file records it.
89
+ Cache::Descriptor.new(gems: base.gems, dependencies: base.dependencies, configs: base.configs + [roots])
88
90
  end
89
91
 
90
92
  # The roots as absolute paths, which the KEY holds sorted: `lib`, `./lib` and `lib/` are one root there. A
@@ -113,7 +115,7 @@ module Rigor
113
115
  def serve(configuration:, cache_root:, paths:)
114
116
  return nil if configuration.effects_enabled?
115
117
 
116
- files = PathExpansion.ruby_files(paths, configuration.exclude_patterns)
118
+ files = PathExpansion.ruby_files(paths, configuration.exclude_patterns, base: configuration.exclude_base)
117
119
  slot_key = key(configuration: configuration, target: Target.new(files: files, roots: paths))
118
120
  return nil if slot_key.nil?
119
121
 
@@ -38,7 +38,7 @@ 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) do
41
+ Recheck = Data.define(:diagnostics, :changed, :added, :removed, :affected, :reused, :templates) do
42
42
  # A recheck that changed, added, and removed nothing — the session state is byte-equivalent to the
43
43
  # snapshot that was restored, so persisting it again would only rewrite identical bytes.
44
44
  def no_change?
@@ -68,9 +68,14 @@ module Rigor
68
68
  # `--verify-incremental` gate use) keeps the sequential recording path bit-for-bit unchanged. The fork
69
69
  # pool records each worker's cross-file reads and marshals them back (PoolCoordinator), so the
70
70
  # dependency graph a pooled recheck rebuilds equals the sequential one.
71
- def initialize(configuration:, paths: nil, environment: nil, cache_store: nil, plugin_requirer: nil,
72
- workers: 0, buffer: nil)
71
+ def initialize(configuration:, paths: nil, environment: nil, cache_store: nil, plugin_requirer: nil, # rubocop:disable Metrics/ParameterLists,Metrics/AbcSize,Metrics/MethodLength
72
+ workers: 0, buffer: nil, explain: false, on_file_indexed: nil)
73
73
  @configuration = configuration
74
+ # ADR-113 WD2 — `rigor lens`: {#force_targets=} names files a {#recheck} analyses whether or not they changed,
75
+ # in this process, so `on_file_indexed` (see {Runner}) sees their seeded index. Neither moves a diagnostic:
76
+ # a forced file re-analyses under the same seeds its cached rows were computed with.
77
+ @force_targets = Set.new
78
+ @on_file_indexed = on_file_indexed
74
79
  @paths = paths
75
80
  # Editor mode option B (#146) — the in-flight buffer, threaded into every internal Runner so the
76
81
  # pre-passes and the closure re-analysis read the editor's bytes at the logical path. A session
@@ -81,6 +86,7 @@ module Rigor
81
86
  @cache_store = cache_store
82
87
  @plugin_requirer = plugin_requirer
83
88
  @workers = workers
89
+ @explain = explain # #1533: threaded into every Runner; keys the snapshot, and no slot is written
84
90
  # ADR-85 WD2 — per-file discovery seed bundles keyed by logical path. A cold baseline builds them; a
85
91
  # warm recheck folds them (re-walking only changed files) and refreshes the set. Ride the snapshot.
86
92
  @seed_bundles = {}
@@ -122,12 +128,31 @@ module Rigor
122
128
  # banner + `--cache-stats`) reads after `#run_incremental`. `@last_runner` is the analysis runner the
123
129
  # post-hoc fingerprint reads from; `@plugin_fact_reusable` is the computed decision object.
124
130
  @plugin_fact_digest = nil
125
- @opaque_plugin_ids = [].freeze
131
+ @opaque_reasons = {}.freeze
132
+ @fact_store_unreadable = false
126
133
  @fact_surface_invalidated = false
127
134
  @last_runner = nil
128
135
  @plugin_fact_reusable = nil
129
136
  end
130
137
 
138
+ # ADR-113 WD2 — the paths (as {#project_files} spells them) every later {#recheck} analyses in this process.
139
+ def force_targets=(paths)
140
+ @force_targets = paths.to_set
141
+ end
142
+
143
+ # The project files a run of this session analyses, as the runner expands the configured paths.
144
+ def project_files
145
+ current_files
146
+ end
147
+
148
+ # The per-file discovery bundles of the last run, keyed by path (ADR-85 WD2).
149
+ attr_reader :seed_bundles
150
+
151
+ # The project files `path`'s analysis read from at the last run that analysed it (ADR-46 slice 1).
152
+ def dependency_sources(path)
153
+ @sources.fetch(path, Set.new)
154
+ end
155
+
131
156
  # The project files analyzed at the last baseline / recheck — the set a verify pass partitions and the
132
157
  # merge subtracts the affected closure from.
133
158
  def analyzed_files
@@ -206,13 +231,13 @@ module Rigor
206
231
  fresh_params = fresh_param_table(current, changed, added, removed)
207
232
  param_files, param_pairs = param_seed_invalidation(fresh_params)
208
233
  affected = affected_closure(changed, added, removed, param_files, param_pairs)
209
- analyze_set = affected & current
210
- reused = (current & previous) - affected.to_a
234
+ forced, analyze_set = forced_analyze_set(affected, current)
235
+ reused = (current & previous) - analyze_set.to_a
211
236
  # The freshly collected table is handed to the runner so the run seeds from the SAME table the diff
212
237
  # was decided on (and the collector runs once per recheck, not twice). The reused files' cached rows
213
238
  # go in too, so the runner lists them in the full run's file order (#1524).
214
239
  runner = build_runner(analyze_only: analyze_set, record_dependencies: true,
215
- param_inferred_types: fresh_params,
240
+ param_inferred_types: fresh_params, **forced_target_options(forced),
216
241
  restored_run_level_rows: @run_level_rows,
217
242
  served_per_file_diagnostics: @cache.slice(*reused))
218
243
  merged = run_runner(runner).diagnostics
@@ -220,7 +245,8 @@ module Rigor
220
245
  absorb(runner, current, analyze_set, removed)
221
246
  @param_table = fresh_params
222
247
  Recheck.new(diagnostics: merged, changed: changed.to_set, added: added.to_set,
223
- removed: removed.to_set, affected: affected, reused: reused.to_set)
248
+ removed: removed.to_set, affected: affected, reused: reused.to_set,
249
+ templates: runner.template_unit_paths.size)
224
250
  end
225
251
 
226
252
  # Editor mode option B (#146) — a whole-project recheck with the editor's buffer substituted for one
@@ -480,6 +506,7 @@ module Rigor
480
506
  # to. Every soundness gate below — the fingerprint, the ADR-88 fact surface — is unchanged, so the
481
507
  # decision to reuse is made identically whether or not the result is saved.
482
508
  def run_incremental(snapshot:, fingerprint:, persist: true)
509
+ @last_recheck = nil # #1526 — set only by a warm recheck, see {#last_recheck}
483
510
  # ADR-87 WD1 — install the per-run digest table + recording instant + strict flag for the whole
484
511
  # invocation so change-detection's stat-then-digest freshness (`#pack_digest` / `#stat_fresh?`) honours
485
512
  # `cache.validation: digest` (and `RIGOR_STRICT_VALIDATION`, which the env-only path already sees) and
@@ -508,7 +535,7 @@ module Rigor
508
535
  adopt_plugin_fact_fingerprint
509
536
  fact_reusable = @plugin_fact_reusable.reusable_against?(restored.plugin_fact_digest)
510
537
  if fact_reusable && effects_reusable && !source_rbs_unverified?(result)
511
- diagnostics = result.diagnostics
538
+ diagnostics = keep_recheck(result)
512
539
  warm = true
513
540
  # ADR-87 WD3 — a warm recheck that changed nothing leaves the session state byte-equivalent to the
514
541
  # snapshot it restored, so skip the unconditional rewrite (209ms + 2 MB on gitlab per null recheck).
@@ -539,9 +566,36 @@ module Rigor
539
566
 
540
567
  # ADR-88 WD1 — reporting hooks the CLI reads after {#run_incremental}. `fact_surface_invalidated?` is true
541
568
  # when a valid snapshot was dropped for a fact-surface reason (a plugin sig/catalog edit, or an opaque
542
- # plugin) rather than a cold miss. `opaque_plugin_ids` names the contributing-but-surfaceless plugins that
543
- # force a full run every invocation.
544
- attr_reader :opaque_plugin_ids
569
+ # plugin) rather than a cold miss. `opaque_reasons` maps each plugin that forces a full run every invocation
570
+ # to its {PluginFactFingerprint::OpaqueReason}.
571
+ attr_reader :opaque_reasons
572
+
573
+ # #1526 — the {Recheck} of the last {#run_incremental}, or nil unless that run answered warm. A baseline
574
+ # (cold, or a re-baseline after a fact-surface, effects or source-RBS reason) has no recheck to report.
575
+ # The diagnostics are not kept: a long-lived session (the LSP) would otherwise retain every merged list.
576
+ attr_reader :last_recheck
577
+
578
+ # Records the warm recheck's sets for {#last_recheck} and hands back its diagnostics.
579
+ def keep_recheck(result)
580
+ @last_recheck = result.with(diagnostics: [])
581
+ result.diagnostics
582
+ end
583
+ private :keep_recheck
584
+
585
+ # The opaque plugin ids, sorted (the keys of {#opaque_reasons}).
586
+ def opaque_plugin_ids
587
+ @opaque_reasons.keys
588
+ end
589
+
590
+ # True when the fact store could not be read, so no single plugin is named (see {PluginFactFingerprint::Result}).
591
+ def fact_store_unreadable?
592
+ @fact_store_unreadable
593
+ end
594
+
595
+ # True when the fact surface forces a full run: some plugin is opaque, or the fact store is unreadable.
596
+ def opaque_fact_surface?
597
+ !@opaque_reasons.empty? || @fact_store_unreadable
598
+ end
545
599
 
546
600
  def fact_surface_invalidated?
547
601
  @fact_surface_invalidated
@@ -566,7 +620,8 @@ module Rigor
566
620
  registry = prepared_plugin_registry
567
621
  fact = compute_plugin_fact_fingerprint(registry)
568
622
  @plugin_fact_digest = fact.digest
569
- @opaque_plugin_ids = fact.opaque_plugin_ids
623
+ @opaque_reasons = fact.opaque_reasons
624
+ @fact_store_unreadable = fact.fact_store_unreadable
570
625
  @plugin_fact_reusable = fact
571
626
  @seed_bundles = source_rbs_gate.verify(registry, @seed_bundles)
572
627
  end
@@ -596,10 +651,10 @@ module Rigor
596
651
 
597
652
  # ADR-88 WD1 — the plugin fact-surface fingerprint for this invocation. For a SEQUENTIAL run it is read
598
653
  # post-hoc from the analysis runner's already-prepared registry (no second `#prepare`, producers already
599
- # validated during analysis). A POOLED run's main process skips `#prepare`, so its registry carries no
600
- # published facts — there it falls back to the always-sequential probe. Both paths compute the identical
601
- # digest for a given fact surface, so the reuse decision is worker-count-independent (the parity spec
602
- # asserts this).
654
+ # validated during analysis). A POOLED run's main process does not run `#prepare` on it (its workers do),
655
+ # so its registry carries no published facts — there it falls back to the always-sequential probe. Both
656
+ # paths compute the identical digest for a given fact surface, so the reuse decision is worker-count-independent
657
+ # (the parity spec asserts this).
603
658
  def compute_plugin_fact_fingerprint(registry = prepared_plugin_registry)
604
659
  PluginFactFingerprint.from_registry(registry)
605
660
  end
@@ -710,7 +765,8 @@ module Rigor
710
765
  return nil if fingerprint.nil? || @cache_store.nil? || @cache_store.read_only? || !@buffer.nil?
711
766
  # Known before the run: none of these can be written afterwards, so the stamp and the recomputed
712
767
  # fingerprint would be spent for nothing.
713
- return nil unless @workers.zero? && !@configuration.effects_enabled?
768
+ # `WriteGuard.start` would also refuse (it recomputes the fingerprint without `explain:`); say so here.
769
+ return nil unless @workers.zero? && !@configuration.effects_enabled? && !@explain
714
770
 
715
771
  IncrementalRunSlot::WriteGuard.start(
716
772
  configuration: @configuration, roots: @paths || @configuration.paths, cache_root: @cache_store.root,
@@ -724,7 +780,7 @@ module Rigor
724
780
  # cold, not the warm run the probe stands in for.
725
781
  def run_slot_writable?
726
782
  !@cache_store.nil? && !@cache_store.read_only? && @buffer.nil? && @workers.zero? &&
727
- !@configuration.effects_enabled? && @opaque_plugin_ids.empty? && !@last_runner.nil?
783
+ !@configuration.effects_enabled? && !@explain && !opaque_fact_surface? && !@last_runner.nil?
728
784
  end
729
785
 
730
786
  def start_chain(rows)
@@ -1090,12 +1146,24 @@ module Rigor
1090
1146
  changed + removed, @constant_decls, new_constant_decls
1091
1147
  )
1092
1148
  keys = appeared_methods.map { |symbol| negative_key_for(symbol) }
1093
- keys.concat(moved_classes.map { |klass| "class:#{klass.split('::').last}" })
1149
+ keys.concat(class_move_keys(moved_classes))
1094
1150
  keys.concat(moved_constants.map { |name| "constant:#{name.split('::').last}" })
1095
1151
  Incremental.negative_closure(keys, @negative_dependents) | refinement_affected(changed + removed, summary) |
1096
1152
  global_write_reporters
1097
1153
  end
1098
1154
 
1155
+ # The negative keys a class-declaration diff fires: `class:<last segment>` for a class name, and (issue #1820)
1156
+ # `subclass_of:<last segment of the superclass>` for a `[class, superclass]` pair appearing or vanishing. Any
1157
+ # class declaration moving may move the set of classes whose superclass the tables do not know, which
1158
+ # `subclass_of:*` stands for.
1159
+ def class_move_keys(moved)
1160
+ names, edges = moved.partition { |entry| entry.is_a?(String) }
1161
+ keys = names.map { |klass| "class:#{klass.split('::').last}" }
1162
+ keys.concat(edges.map { |(_child, parent)| "subclass_of:#{parent.split('::').last}" })
1163
+ keys << "subclass_of:*" unless moved.empty?
1164
+ keys
1165
+ end
1166
+
1099
1167
  # Issue #1120 — the consumers whose `call.undefined-method` answer read the refinement table for a method
1100
1168
  # name whose refinements moved in this edit. The before-state is each file's seed bundle (the per-file
1101
1169
  # table the last run recorded), the after-state the scan's per-file tables. A refine body is not a class's
@@ -1115,9 +1183,10 @@ module Rigor
1115
1183
  return {} if index.nil?
1116
1184
 
1117
1185
  result = Hash.new { |hash, key| hash[key] = Set.new }
1118
- index[:class_sources].each do |class_name, files|
1119
- files.each { |file| result[file] << class_name }
1186
+ [index[:class_sources], index[:singleton_class_sources] || {}].each do |sources|
1187
+ sources.each { |class_name, files| files.each { |file| result[file] << class_name } }
1120
1188
  end
1189
+ Incremental.add_superclass_edges(result, index[:class_sources], index[:superclasses])
1121
1190
  result.transform_values(&:freeze).freeze
1122
1191
  end
1123
1192
 
@@ -1256,10 +1325,21 @@ module Rigor
1256
1325
  Runner.new(
1257
1326
  configuration: @configuration, cache_store: @cache_store, environment: @environment,
1258
1327
  plugin_requirer: @plugin_requirer, seed_bundles: @seed_bundles, collect_seed_bundles: true,
1259
- workers: @workers, buffer: @buffer, **
1328
+ workers: @workers, buffer: @buffer, explain: @explain, on_file_indexed: @on_file_indexed, **
1260
1329
  )
1261
1330
  end
1262
1331
 
1332
+ # The forced targets present in `current`, and the recheck's analyse set with them joined to the closure.
1333
+ def forced_analyze_set(affected, current)
1334
+ forced = @force_targets & current.to_set
1335
+ [forced, (affected | forced) & current]
1336
+ end
1337
+
1338
+ # A recheck that forces targets analyses in this process, so the index callback reaches them.
1339
+ def forced_target_options(forced)
1340
+ forced.empty? ? {} : { workers: 0 }
1341
+ end
1342
+
1263
1343
  # Run the runner over the session's explicit paths (or, when none were given, the configuration's
1264
1344
  # `paths:` via `Runner#run`'s default).
1265
1345
  def run_runner(runner)
@@ -14,10 +14,10 @@ module Rigor
14
14
  module_function
15
15
 
16
16
  # The `.rb` files an ordinary (non-buffer) check over `paths` would analyze.
17
- def ruby_files(paths, exclude_patterns)
17
+ def ruby_files(paths, exclude_patterns, base: Dir.pwd)
18
18
  Array(paths).flat_map do |path|
19
19
  if File.directory?(path)
20
- directory_files(path, exclude_patterns)
20
+ directory_files(path, exclude_patterns, base: base)
21
21
  elsif File.file?(path) && path.end_with?(".rb")
22
22
  [path]
23
23
  else
@@ -26,16 +26,72 @@ module Rigor
26
26
  end
27
27
  end
28
28
 
29
- def directory_files(dir, exclude_patterns)
30
- reject_excluded(Dir.glob(File.join(dir, RUBY_GLOB)), exclude_patterns)
29
+ def directory_files(dir, exclude_patterns, base: Dir.pwd)
30
+ reject_excluded(Dir.glob(File.join(dir, RUBY_GLOB)), exclude_patterns, base: base)
31
31
  end
32
32
 
33
33
  # `exclude_patterns` are matched with `File.fnmatch?` WITHOUT `FNM_PATHNAME`, so `**`/`*` span separators
34
34
  # (the patterns behave like substring globs) — the exact semantics the Runner's directory glob applies.
35
- def reject_excluded(file_list, exclude_patterns)
35
+ #
36
+ # #1576 — a RELATIVE pattern (a leading `./` is ignored) is matched against the file's path relative to
37
+ # `base` (the config file's directory, or the working directory with no config), so the same file is
38
+ # excluded whether `paths:` (absolute) or an argument (relative) named it.
39
+ #
40
+ # Two spellings are tried. The LOGICAL one (the path as given, against the logical base) counts only when
41
+ # the file lies inside the base; it is what lets a symlinked `lib` match `lib/gen/**`. The REALPATH one
42
+ # (realpath of the file against realpath of the base) is the only one used for a file outside the base, and
43
+ # it also catches a base or file spelled through a different symlink root (macOS `/tmp` vs `/private/tmp`).
44
+ # Neither shows a directory name above the base: a file outside it comes out as `../…`, so `**/tmp/**`
45
+ # cannot exclude a project merely located under a `tmp/`, while `**/vendor/bundle/**` still reaches
46
+ # `../other/vendor/bundle/x.rb`. The pattern is also tried against `/<relative>`, because without
47
+ # `FNM_PATHNAME` the unanchored `**/tmp/**` needs a leading `/` to match a top-level `tmp/x.rb` (and `*`
48
+ # never matches a leading `.`, as in `../other`). A pattern starting with `.` therefore also matches files
49
+ # outside the base. An ABSOLUTE pattern is matched only against the absolute path (logical, then real).
50
+ def reject_excluded(file_list, exclude_patterns, base: Dir.pwd)
36
51
  return file_list if exclude_patterns.empty?
37
52
 
38
- file_list.reject { |path| exclude_patterns.any? { |pattern| File.fnmatch?(pattern, path) } }
53
+ absolute_patterns, relative_patterns = exclude_patterns.partition { |pattern| pattern.start_with?("/") }
54
+ relative_patterns = relative_patterns.map { |pattern| pattern.sub(%r{\A(?:\./+)+}, "") }
55
+ logical_base = File.absolute_path(base)
56
+ context = { cwd: Dir.pwd, base: logical_base, real_base: real_directory(logical_base), real_dirs: {} }
57
+ file_list.reject { |path| excluded?(path, absolute_patterns, relative_patterns, context) }
58
+ end
59
+
60
+ def excluded?(path, absolute_patterns, relative_patterns, context)
61
+ logical = File.absolute_path(path, context.fetch(:cwd))
62
+ base = context.fetch(:base)
63
+ inside = logical.start_with?("#{base}/")
64
+ return true if inside && matches_relative?(relative_patterns, logical.byteslice((base.bytesize + 1)..))
65
+ return true if absolute_patterns.any? { |pattern| File.fnmatch?(pattern, logical) }
66
+
67
+ real = real_file(logical, context.fetch(:real_dirs))
68
+ return false if real == logical && inside && context.fetch(:real_base) == base
69
+
70
+ matches_relative?(relative_patterns, relative_path(real, context.fetch(:real_base))) ||
71
+ (real != logical && absolute_patterns.any? { |pattern| File.fnmatch?(pattern, real) })
72
+ end
73
+
74
+ def relative_path(absolute, base)
75
+ return absolute.byteslice((base.bytesize + 1)..) if absolute.start_with?("#{base}/")
76
+
77
+ Pathname.new(absolute).relative_path_from(Pathname.new(base)).to_s
78
+ end
79
+
80
+ def matches_relative?(patterns, relative)
81
+ candidates = [relative, "/#{relative}"]
82
+ patterns.any? { |pattern| candidates.any? { |candidate| File.fnmatch?(pattern, candidate) } }
83
+ end
84
+
85
+ def real_directory(dir)
86
+ File.realpath(dir)
87
+ rescue SystemCallError
88
+ File.absolute_path(dir)
89
+ end
90
+
91
+ def real_file(absolute, real_dirs)
92
+ dir = File.dirname(absolute)
93
+ real_dir = real_dirs[dir] ||= real_directory(dir)
94
+ File.join(real_dir, File.basename(absolute))
39
95
  end
40
96
  end
41
97
  end