rigortype 0.1.16 → 0.1.18

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 (180) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +18 -1
  4. data/lib/rigor/analysis/check_rules/rule_walk.rb +67 -0
  5. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +100 -0
  6. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +226 -0
  7. data/lib/rigor/analysis/check_rules.rb +180 -73
  8. data/lib/rigor/analysis/dependency_recorder.rb +122 -0
  9. data/lib/rigor/analysis/diagnostic.rb +18 -0
  10. data/lib/rigor/analysis/incremental.rb +162 -0
  11. data/lib/rigor/analysis/incremental_session.rb +337 -0
  12. data/lib/rigor/analysis/rule_catalog.rb +48 -0
  13. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +580 -0
  14. data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
  15. data/lib/rigor/analysis/runner/project_pre_passes.rb +318 -0
  16. data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
  17. data/lib/rigor/analysis/runner.rb +477 -1110
  18. data/lib/rigor/analysis/self_call_resolution_recorder.rb +121 -0
  19. data/lib/rigor/analysis/worker_session.rb +47 -8
  20. data/lib/rigor/builtins/static_return_refinements.rb +7 -1
  21. data/lib/rigor/cache/descriptor.rb +50 -49
  22. data/lib/rigor/cache/incremental_snapshot.rb +153 -0
  23. data/lib/rigor/cache/rbs_cache_producer.rb +34 -0
  24. data/lib/rigor/cache/rbs_class_ancestor_table.rb +2 -8
  25. data/lib/rigor/cache/rbs_class_type_param_names.rb +2 -8
  26. data/lib/rigor/cache/rbs_constant_table.rb +2 -8
  27. data/lib/rigor/cache/rbs_environment.rb +2 -8
  28. data/lib/rigor/cache/rbs_known_class_names.rb +2 -8
  29. data/lib/rigor/cache/store.rb +145 -14
  30. data/lib/rigor/cli/annotate_command.rb +2 -7
  31. data/lib/rigor/cli/baseline_command.rb +2 -7
  32. data/lib/rigor/cli/check_command.rb +705 -0
  33. data/lib/rigor/cli/ci_detector.rb +94 -0
  34. data/lib/rigor/cli/command.rb +47 -0
  35. data/lib/rigor/cli/coverage_command.rb +3 -23
  36. data/lib/rigor/cli/coverage_renderer.rb +3 -8
  37. data/lib/rigor/cli/diagnostic_formats.rb +345 -0
  38. data/lib/rigor/cli/diff_command.rb +3 -7
  39. data/lib/rigor/cli/explain_command.rb +2 -7
  40. data/lib/rigor/cli/lsp_command.rb +3 -7
  41. data/lib/rigor/cli/mcp_command.rb +3 -7
  42. data/lib/rigor/cli/options.rb +57 -0
  43. data/lib/rigor/cli/plugin_command.rb +3 -7
  44. data/lib/rigor/cli/plugins_command.rb +2 -7
  45. data/lib/rigor/cli/prism_colorizer.rb +10 -3
  46. data/lib/rigor/cli/renderable.rb +26 -0
  47. data/lib/rigor/cli/sig_gen_command.rb +2 -7
  48. data/lib/rigor/cli/skill_command.rb +3 -7
  49. data/lib/rigor/cli/trace_command.rb +143 -0
  50. data/lib/rigor/cli/trace_renderer.rb +310 -0
  51. data/lib/rigor/cli/triage_command.rb +2 -7
  52. data/lib/rigor/cli/type_of_command.rb +5 -38
  53. data/lib/rigor/cli/type_of_renderer.rb +4 -9
  54. data/lib/rigor/cli/type_scan_command.rb +3 -23
  55. data/lib/rigor/cli/type_scan_renderer.rb +4 -9
  56. data/lib/rigor/cli.rb +15 -532
  57. data/lib/rigor/configuration/dependencies.rb +18 -1
  58. data/lib/rigor/configuration/severity_profile.rb +22 -3
  59. data/lib/rigor/configuration.rb +16 -3
  60. data/lib/rigor/environment/rbs_loader.rb +129 -71
  61. data/lib/rigor/environment.rb +1 -1
  62. data/lib/rigor/inference/acceptance.rb +10 -0
  63. data/lib/rigor/inference/block_parameter_binder.rb +1 -2
  64. data/lib/rigor/inference/builtins/array_catalog.rb +2 -5
  65. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -5
  66. data/lib/rigor/inference/builtins/complex_catalog.rb +2 -5
  67. data/lib/rigor/inference/builtins/date_catalog.rb +2 -5
  68. data/lib/rigor/inference/builtins/encoding_catalog.rb +2 -5
  69. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -5
  70. data/lib/rigor/inference/builtins/exception_catalog.rb +2 -5
  71. data/lib/rigor/inference/builtins/hash_catalog.rb +2 -5
  72. data/lib/rigor/inference/builtins/method_catalog.rb +15 -0
  73. data/lib/rigor/inference/builtins/numeric_catalog.rb +21 -93
  74. data/lib/rigor/inference/builtins/pathname_catalog.rb +2 -5
  75. data/lib/rigor/inference/builtins/proc_catalog.rb +2 -5
  76. data/lib/rigor/inference/builtins/random_catalog.rb +2 -5
  77. data/lib/rigor/inference/builtins/range_catalog.rb +2 -5
  78. data/lib/rigor/inference/builtins/rational_catalog.rb +2 -5
  79. data/lib/rigor/inference/builtins/re_catalog.rb +2 -5
  80. data/lib/rigor/inference/builtins/set_catalog.rb +2 -5
  81. data/lib/rigor/inference/builtins/string_catalog.rb +2 -5
  82. data/lib/rigor/inference/builtins/struct_catalog.rb +2 -5
  83. data/lib/rigor/inference/builtins/time_catalog.rb +2 -5
  84. data/lib/rigor/inference/expression_typer.rb +149 -63
  85. data/lib/rigor/inference/flow_tracer.rb +180 -0
  86. data/lib/rigor/inference/macro_block_self_type.rb +10 -11
  87. data/lib/rigor/inference/method_dispatcher/block_folding.rb +5 -1
  88. data/lib/rigor/inference/method_dispatcher/call_context.rb +65 -0
  89. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +11 -10
  90. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +12 -6
  91. data/lib/rigor/inference/method_dispatcher/data_folding.rb +246 -0
  92. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -2
  93. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -2
  94. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -1
  95. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +4 -1
  96. data/lib/rigor/inference/method_dispatcher/math_folding.rb +6 -6
  97. data/lib/rigor/inference/method_dispatcher/method_folding.rb +12 -7
  98. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
  99. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +23 -13
  100. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +9 -9
  101. data/lib/rigor/inference/method_dispatcher/set_folding.rb +6 -6
  102. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +120 -9
  103. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +12 -12
  104. data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +49 -0
  105. data/lib/rigor/inference/method_dispatcher/time_folding.rb +6 -6
  106. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +9 -9
  107. data/lib/rigor/inference/method_dispatcher.rb +185 -84
  108. data/lib/rigor/inference/narrowing.rb +262 -5
  109. data/lib/rigor/inference/scope_indexer.rb +208 -21
  110. data/lib/rigor/inference/statement_evaluator.rb +110 -48
  111. data/lib/rigor/language_server/buffer_resolution.rb +33 -0
  112. data/lib/rigor/language_server/completion_provider.rb +4 -4
  113. data/lib/rigor/language_server/document_symbol_provider.rb +4 -4
  114. data/lib/rigor/language_server/folding_range_provider.rb +4 -4
  115. data/lib/rigor/language_server/hover_provider.rb +4 -4
  116. data/lib/rigor/language_server/selection_range_provider.rb +4 -4
  117. data/lib/rigor/language_server/signature_help_provider.rb +4 -4
  118. data/lib/rigor/plugin/additional_initializer.rb +61 -38
  119. data/lib/rigor/plugin/base.rb +302 -45
  120. data/lib/rigor/plugin/node_rule_walk.rb +147 -0
  121. data/lib/rigor/plugin/registry.rb +281 -15
  122. data/lib/rigor/plugin.rb +1 -0
  123. data/lib/rigor/rbs_extended/conformance_checker.rb +293 -0
  124. data/lib/rigor/rbs_extended.rb +39 -0
  125. data/lib/rigor/scope/discovery_index.rb +58 -0
  126. data/lib/rigor/scope.rb +150 -167
  127. data/lib/rigor/sig_gen/observation_collector.rb +6 -6
  128. data/lib/rigor/source/literals.rb +14 -0
  129. data/lib/rigor/type/acceptance_router.rb +19 -0
  130. data/lib/rigor/type/accepts_result.rb +3 -10
  131. data/lib/rigor/type/app.rb +3 -7
  132. data/lib/rigor/type/bot.rb +2 -3
  133. data/lib/rigor/type/bound_method.rb +5 -12
  134. data/lib/rigor/type/combinator.rb +22 -0
  135. data/lib/rigor/type/constant.rb +2 -3
  136. data/lib/rigor/type/data_class.rb +80 -0
  137. data/lib/rigor/type/data_instance.rb +100 -0
  138. data/lib/rigor/type/difference.rb +5 -10
  139. data/lib/rigor/type/dynamic.rb +5 -10
  140. data/lib/rigor/type/hash_shape.rb +5 -15
  141. data/lib/rigor/type/integer_range.rb +5 -10
  142. data/lib/rigor/type/intersection.rb +5 -10
  143. data/lib/rigor/type/nominal.rb +5 -10
  144. data/lib/rigor/type/refined.rb +5 -10
  145. data/lib/rigor/type/singleton.rb +5 -10
  146. data/lib/rigor/type/top.rb +2 -3
  147. data/lib/rigor/type/tuple.rb +5 -10
  148. data/lib/rigor/type/union.rb +5 -10
  149. data/lib/rigor/type.rb +2 -0
  150. data/lib/rigor/value_semantics.rb +77 -0
  151. data/lib/rigor/version.rb +1 -1
  152. data/lib/rigor.rb +1 -1
  153. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
  154. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
  155. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +70 -32
  156. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
  157. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +15 -21
  158. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
  159. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
  160. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
  161. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +12 -2
  162. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
  163. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  164. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +35 -18
  165. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
  166. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
  167. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
  168. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +83 -36
  169. data/sig/rigor/cache.rbs +19 -0
  170. data/sig/rigor/environment.rbs +0 -2
  171. data/sig/rigor/inference.rbs +27 -0
  172. data/sig/rigor/plugin/base.rbs +1 -2
  173. data/sig/rigor/rbs_extended.rbs +2 -0
  174. data/sig/rigor/scope.rbs +42 -25
  175. data/sig/rigor/source.rbs +1 -0
  176. data/sig/rigor/type.rbs +58 -1
  177. data/sig/rigor.rbs +6 -1
  178. data/skills/rigor-ci-setup/SKILL.md +319 -0
  179. metadata +36 -2
  180. data/lib/rigor/cache/rbs_instance_definitions.rb +0 -79
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Analysis
5
+ # ADR-24 slice 4a — records every *implicit-self* method call the
6
+ # inference engine could not resolve to any method (RBS tier miss +
7
+ # user-class ancestor-walk miss + not a `Dynamic` receiver), captured
8
+ # at the single engine choke-point where such a call falls through to
9
+ # `Dynamic[top]` (`ExpressionTyper#call_type_for` → `fallback_for`).
10
+ #
11
+ # ## Why a recorder, not a check-rule
12
+ #
13
+ # Slice 4 attempt 1 reimplemented self-call resolution inside
14
+ # `CheckRules` and produced 135 false positives on Rigor's own `lib`
15
+ # ([ADR-24](../../../docs/adr/24-self-method-call-resolution.md)
16
+ # § "Slice 4"): the second, weaker resolution path could not see
17
+ # `module_function` siblings, `Data.define` / `Struct` accessors, or
18
+ # mixin-contributed methods that the *engine's* real resolution
19
+ # handles. Recording at the engine's own miss point reuses that real
20
+ # resolution — those methods resolve before the miss, so they never
21
+ # reach the recorder. This module is the ADR-46 / ADR-47 "collect at
22
+ # evaluation time, never recompute" lesson applied to self-calls.
23
+ #
24
+ # A later slice consumes the recorded misses behind a confidently-
25
+ # closed-class gate to emit `call.self-undefined-method`, behind its
26
+ # own external-corpus false-positive gate. This slice (4a) lands the
27
+ # plumbing OFF by default — {active?} is false on a normal run, so the
28
+ # instrumented choke-point pays a single integer read and records
29
+ # nothing. Recording is purely observational; it never changes a
30
+ # diagnostic.
31
+ #
32
+ # Modelled on {DependencyRecorder}: process-thread-local accumulator,
33
+ # a cheap disabled fast path, and a frozen snapshot for consumers.
34
+ module SelfCallResolutionRecorder
35
+ KEY = :__rigor_self_call_resolution_recorder__
36
+ private_constant :KEY
37
+
38
+ # One unresolved implicit-self call. `class_name` is the receiver's
39
+ # statically known class (the enclosing `self` type); `method_name`
40
+ # the called name; `node` the Prism `CallNode` (held in-memory so the
41
+ # `call.self-undefined-method` collector can resolve its scope from the
42
+ # scope index and apply the closed-class gate); `path` / `line` /
43
+ # `column` locate the call site for the diagnostic.
44
+ UnresolvedSelfCall = Data.define(:class_name, :method_name, :node, :path, :line, :column)
45
+
46
+ # Mutable per-consumer accumulator, frozen into a {Record} snapshot
47
+ # when {record_for} returns. Dedupes by the full call tuple so a
48
+ # method body re-typed under several call-site signatures records the
49
+ # miss once.
50
+ class Accumulator
51
+ attr_reader :consumer, :calls
52
+
53
+ def initialize(consumer)
54
+ @consumer = consumer
55
+ @calls = []
56
+ @seen = Set.new
57
+ end
58
+
59
+ def add(call)
60
+ return unless @seen.add?(call)
61
+
62
+ @calls << call
63
+ end
64
+
65
+ def snapshot
66
+ Record.new(consumer: consumer, calls: calls.dup.freeze)
67
+ end
68
+ end
69
+
70
+ Record = Data.define(:consumer, :calls)
71
+
72
+ # Module-level activation count so the disabled fast path ({active?})
73
+ # is a plain integer read rather than a `Thread.current` hash lookup —
74
+ # the instrumented choke-point is on the dispatch miss path, so a
75
+ # normal (non-recording) run must pay as little as possible.
76
+ @active_count = 0
77
+ @mutex = Mutex.new
78
+
79
+ module_function
80
+
81
+ # Activates recording for `consumer` (the path being analyzed) for the
82
+ # duration of the block and returns the frozen {Record}. Nests safely;
83
+ # restores the previous recorder on exit.
84
+ def record_for(consumer)
85
+ previous = Thread.current[KEY]
86
+ accumulator = Accumulator.new(consumer.to_s)
87
+ Thread.current[KEY] = accumulator
88
+ @mutex.synchronize { @active_count += 1 }
89
+ yield
90
+ accumulator.snapshot
91
+ ensure
92
+ Thread.current[KEY] = previous
93
+ @mutex.synchronize { @active_count -= 1 }
94
+ end
95
+
96
+ # Plain integer read (GVL-atomic) — no `Thread.current` lookup on the
97
+ # disabled fast path.
98
+ def active?
99
+ @active_count.positive?
100
+ end
101
+
102
+ # Records one unresolved implicit-self call. No-op when no consumer is
103
+ # active on this thread (another thread may have flipped {active?}).
104
+ def record(class_name:, method_name:, node:, path:, line:, column:)
105
+ accumulator = Thread.current[KEY]
106
+ return if accumulator.nil?
107
+
108
+ accumulator.add(
109
+ UnresolvedSelfCall.new(
110
+ class_name: class_name.to_s,
111
+ method_name: method_name.to_sym,
112
+ node: node,
113
+ path: path,
114
+ line: line,
115
+ column: column
116
+ )
117
+ )
118
+ end
119
+ end
120
+ end
121
+ end
@@ -39,12 +39,19 @@ module Rigor
39
39
  # - `plugin_blueprints` — Phase 3a
40
40
  # (`Array<Plugin::Blueprint>` is `Ractor.shareable?`).
41
41
  # - `explain` — Boolean.
42
- # - `synthetic_method_index` / `project_patched_methods`
43
- # optional (default `nil`). NOT `Ractor.shareable?`, so the
44
- # Ractor pool path leaves them unset; the fork backend
42
+ # - `synthetic_method_index` / `project_patched_methods` /
43
+ # `project_scope_seed` — optional (default `nil` / `{}`). NOT
44
+ # `Ractor.shareable?` (the seed tables carry Prism def nodes),
45
+ # so the Ractor pool path leaves them unset; the fork backend
45
46
  # (ADR-15 Amendment), which builds the session pre-fork on the
46
47
  # parent, threads the runner's project-scan results through so
47
48
  # per-file inference matches the sequential path exactly.
49
+ # `project_scope_seed` is `Runner#project_scope_seed_tables` —
50
+ # the cross-file discovery tables `seed_project_scope` applies
51
+ # to every per-file scope on the sequential path; without it a
52
+ # worker cannot resolve calls to methods defined in OTHER
53
+ # project files and emits `call.undefined-method` false
54
+ # positives the sequential path does not.
48
55
  #
49
56
  # Internally the session OWNS (and never shares):
50
57
  #
@@ -97,13 +104,14 @@ module Rigor
97
104
  def initialize(configuration:, cache_store: nil, # rubocop:disable Metrics/MethodLength,Metrics/ParameterLists
98
105
  plugin_blueprints: [], explain: false, buffer: nil,
99
106
  synthetic_method_index: nil, project_patched_methods: nil,
100
- source_files: [])
107
+ project_scope_seed: {}, source_files: [])
101
108
  @configuration = configuration
102
109
  @cache_store = cache_store
103
110
  @explain = explain
104
111
  @buffer = buffer
105
112
  @synthetic_method_index = synthetic_method_index
106
113
  @project_patched_methods = project_patched_methods
114
+ @project_scope_seed = project_scope_seed || {}
107
115
  # ADR-32 WD4 — full project file list (frozen
108
116
  # Array<String>) for env-build-time invocation of any
109
117
  # loaded plugin's `source_rbs_synthesizer` callable.
@@ -165,7 +173,7 @@ module Rigor
165
173
  return parse_diagnostics(path, parse_result)
166
174
  end
167
175
 
168
- scope = Scope.empty(environment: @environment, source_path: path)
176
+ scope = seed_project_scope(Scope.empty(environment: @environment, source_path: path))
169
177
  index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
170
178
  diagnostics = CheckRules.diagnose(
171
179
  path: path,
@@ -200,6 +208,17 @@ module Rigor
200
208
 
201
209
  private
202
210
 
211
+ # Mirrors {Runner#seed_project_scope}: applies the cross-file
212
+ # pre-pass discovery tables the constructor received (fork
213
+ # backend only — see the class comment) to a fresh per-file
214
+ # scope, so worker-side inference resolves project-internal
215
+ # cross-file calls exactly like the sequential path.
216
+ def seed_project_scope(scope)
217
+ return scope if @project_scope_seed.empty?
218
+
219
+ scope.with_discovery(scope.discovery.with(**@project_scope_seed))
220
+ end
221
+
203
222
  # See {Runner#parse_source}. Same contract: if `@buffer`
204
223
  # binds `path` to a physical file, read the physical bytes
205
224
  # but stamp the parse buffer's `filepath:` as the LOGICAL
@@ -278,14 +297,34 @@ module Rigor
278
297
  def plugin_emitted_diagnostics(path, root, scope)
279
298
  return [] if @plugin_registry.empty?
280
299
 
300
+ # ADR-52 WD4 — single engine-owned node-rule walk per file; the
301
+ # results are bucketed per plugin (registry order) so emission
302
+ # stays plugin-major and byte-identical with the per-plugin walk.
303
+ node_results = node_rule_results_by_plugin(path, root, scope)
304
+
281
305
  @plugin_registry.plugins.flat_map do |plugin|
282
- collect_plugin_diagnostics(plugin, path, root, scope)
306
+ collect_plugin_diagnostics(plugin, path, root, scope, node_results[plugin])
283
307
  end
284
308
  end
285
309
 
286
- def collect_plugin_diagnostics(plugin, path, root, scope)
310
+ def node_rule_results_by_plugin(path, root, scope)
311
+ walk = @plugin_registry.node_rule_walk
312
+ return {}.compare_by_identity if walk.empty?
313
+
314
+ results = walk.diagnostics_for_file(path: path, scope: scope, root: root)
315
+ results.each_with_object({}.compare_by_identity) do |result, by_plugin|
316
+ by_plugin[result.plugin] = result
317
+ end
318
+ end
319
+
320
+ def collect_plugin_diagnostics(plugin, path, root, scope, node_result)
287
321
  raw = Array(plugin.diagnostics_for_file(path: path, scope: scope, root: root))
288
- raw += plugin.node_rule_diagnostics(path: path, scope: scope, root: root)
322
+ # A node-rule context/rule raise isolates the whole plugin's
323
+ # node-rule contribution, matching the old combined per-plugin
324
+ # rescue (which discarded `diagnostics_for_file` output too).
325
+ raise node_result.error if node_result&.error
326
+
327
+ raw += node_result.diagnostics if node_result
289
328
  raw.map { |diagnostic| stamp_plugin_diagnostic(diagnostic, plugin.manifest.id) }
290
329
  rescue StandardError => e
291
330
  [plugin_runtime_error_diagnostic(path, plugin, e)]
@@ -134,8 +134,14 @@ module Rigor
134
134
  # @return [Array<String>] the candidate owner class names
135
135
  # for a bare method-name lookup. Empty when no override
136
136
  # names this method.
137
+ NO_OWNERS = [].freeze
138
+ private_constant :NO_OWNERS
139
+
140
+ # Consulted on every dispatch (`try_static_refinement`); the miss
141
+ # case is overwhelmingly common, so share one frozen empty array
142
+ # instead of allocating a fresh `[]` per non-refined method.
137
143
  def self.owners_for(method_name)
138
- OWNERS_BY_METHOD[method_name.to_sym] || []
144
+ OWNERS_BY_METHOD[method_name.to_sym] || NO_OWNERS
139
145
  end
140
146
  end
141
147
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "digest"
4
4
  require "json"
5
+ require_relative "../value_semantics"
5
6
 
6
7
  module Rigor
7
8
  module Cache
@@ -38,10 +39,14 @@ module Rigor
38
39
  # can mutate after the entry is in a Descriptor.
39
40
 
40
41
  class FileEntry
42
+ include Rigor::ValueSemantics
43
+
41
44
  VALID_COMPARATORS = %i[digest mtime exists].freeze
42
45
 
43
46
  attr_reader :path, :comparator, :value
44
47
 
48
+ value_fields :path, :comparator, :value
49
+
45
50
  def initialize(path:, comparator:, value:)
46
51
  unless VALID_COMPARATORS.include?(comparator)
47
52
  raise ArgumentError,
@@ -57,20 +62,15 @@ module Rigor
57
62
  def to_h
58
63
  { "path" => path, "comparator" => comparator.to_s, "value" => value }
59
64
  end
60
-
61
- def ==(other)
62
- other.is_a?(FileEntry) && other.path == path && other.comparator == comparator && other.value == value
63
- end
64
- alias eql? ==
65
-
66
- def hash
67
- [self.class, path, comparator, value].hash
68
- end
69
65
  end
70
66
 
71
67
  class GemEntry
68
+ include Rigor::ValueSemantics
69
+
72
70
  attr_reader :name, :requirement, :locked
73
71
 
72
+ value_fields :name, :requirement, :locked
73
+
74
74
  def initialize(name:, requirement:, locked: nil)
75
75
  @name = name.to_s.dup.freeze
76
76
  @requirement = requirement.to_s.dup.freeze
@@ -81,20 +81,15 @@ module Rigor
81
81
  def to_h
82
82
  { "name" => name, "requirement" => requirement, "locked" => locked }
83
83
  end
84
-
85
- def ==(other)
86
- other.is_a?(GemEntry) && other.name == name && other.requirement == requirement && other.locked == locked
87
- end
88
- alias eql? ==
89
-
90
- def hash
91
- [self.class, name, requirement, locked].hash
92
- end
93
84
  end
94
85
 
95
86
  class PluginEntry
87
+ include Rigor::ValueSemantics
88
+
96
89
  attr_reader :id, :version, :config_hash
97
90
 
91
+ value_fields :id, :version, :config_hash
92
+
98
93
  def initialize(id:, version:, config_hash: nil)
99
94
  @id = id.to_s.dup.freeze
100
95
  @version = version.to_s.dup.freeze
@@ -105,21 +100,15 @@ module Rigor
105
100
  def to_h
106
101
  { "id" => id, "version" => version, "config_hash" => config_hash }
107
102
  end
108
-
109
- def ==(other)
110
- other.is_a?(PluginEntry) &&
111
- other.id == id && other.version == version && other.config_hash == config_hash
112
- end
113
- alias eql? ==
114
-
115
- def hash
116
- [self.class, id, version, config_hash].hash
117
- end
118
103
  end
119
104
 
120
105
  class ConfigEntry
106
+ include Rigor::ValueSemantics
107
+
121
108
  attr_reader :key, :value_hash
122
109
 
110
+ value_fields :key, :value_hash
111
+
123
112
  def initialize(key:, value_hash:)
124
113
  @key = key.to_s.dup.freeze
125
114
  @value_hash = value_hash.to_s.dup.freeze
@@ -129,15 +118,6 @@ module Rigor
129
118
  def to_h
130
119
  { "key" => key, "value_hash" => value_hash }
131
120
  end
132
-
133
- def ==(other)
134
- other.is_a?(ConfigEntry) && other.key == key && other.value_hash == value_hash
135
- end
136
- alias eql? ==
137
-
138
- def hash
139
- [self.class, key, value_hash].hash
140
- end
141
121
  end
142
122
 
143
123
  # Per-(gem, version, mode) row carrying the cache slice
@@ -155,10 +135,14 @@ module Rigor
155
135
  # the inferred shapes depend on whether RBS overrides the
156
136
  # walk.
157
137
  class DependencyEntry
138
+ include Rigor::ValueSemantics
139
+
158
140
  VALID_MODES = %i[disabled when_missing full].freeze
159
141
 
160
142
  attr_reader :gem_name, :gem_version, :mode
161
143
 
144
+ value_fields :gem_name, :gem_version, :mode
145
+
162
146
  def initialize(gem_name:, gem_version:, mode:)
163
147
  unless VALID_MODES.include?(mode)
164
148
  raise ArgumentError,
@@ -174,18 +158,6 @@ module Rigor
174
158
  def to_h
175
159
  { "gem_name" => gem_name, "gem_version" => gem_version, "mode" => mode.to_s }
176
160
  end
177
-
178
- def ==(other)
179
- other.is_a?(DependencyEntry) &&
180
- other.gem_name == gem_name &&
181
- other.gem_version == gem_version &&
182
- other.mode == mode
183
- end
184
- alias eql? ==
185
-
186
- def hash
187
- [self.class, gem_name, gem_version, mode].hash
188
- end
189
161
  end
190
162
 
191
163
  # Raised when {.compose} encounters incompatible entries
@@ -206,6 +178,20 @@ module Rigor
206
178
  freeze
207
179
  end
208
180
 
181
+ # ADR-45 — re-validates this descriptor's recorded {FileEntry}s
182
+ # against the current filesystem. Used by the record-and-validate
183
+ # run-result cache: a value cached alongside its dependency
184
+ # descriptor is fresh iff every recorded file still matches. Only
185
+ # `files` are checked — non-file inputs (config / gems / version)
186
+ # belong in the cache *key*, not the validated dependency set — so
187
+ # a descriptor carrying any non-file slot is never considered fresh
188
+ # (it was built wrong for this use).
189
+ def fresh?
190
+ return false unless gems.empty? && plugins.empty? && configs.empty? && dependencies.empty?
191
+
192
+ files.all? { |entry| file_entry_fresh?(entry) }
193
+ end
194
+
209
195
  # File-comparator strictness ordering. `:digest` is strictest
210
196
  # (deterministic across machines); `:mtime` is cheaper but
211
197
  # local; `:exists` is the weakest signal. When two
@@ -290,6 +276,21 @@ module Rigor
290
276
 
291
277
  private
292
278
 
279
+ def file_entry_fresh?(entry)
280
+ case entry.comparator
281
+ when :digest
282
+ File.file?(entry.path) && Digest::SHA256.file(entry.path).hexdigest == entry.value
283
+ when :mtime
284
+ File.exist?(entry.path) && File.mtime(entry.path).to_i.to_s == entry.value
285
+ when :exists
286
+ File.exist?(entry.path).to_s == entry.value
287
+ else
288
+ false
289
+ end
290
+ rescue StandardError
291
+ false
292
+ end
293
+
293
294
  def sort_entries(entries, key)
294
295
  entries.sort_by { |e| e.to_h.fetch(key).to_s }
295
296
  end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "digest"
5
+ require "zlib"
6
+
7
+ module Rigor
8
+ module Cache
9
+ # ADR-46 — disk persistence for the incremental analyzer's per-file
10
+ # state, so a `--incremental` session survives across processes (one
11
+ # `rigor check` invocation reads the prior run's per-file diagnostics +
12
+ # dependency graph, re-analyzes only the changed closure, and serves the
13
+ # rest from disk).
14
+ #
15
+ # Unlike ADR-45's whole-run cache (record-and-validate ONE entry,
16
+ # invalidated by any analyzed-file change), this snapshot is loaded
17
+ # UNCONDITIONALLY when the global fingerprint matches — the per-file
18
+ # digests *inside* it drive the incremental re-analysis decision; they
19
+ # do not gate the load. The fingerprint captures the inputs whose change
20
+ # requires a full rebuild — the resolved configuration, the RBS
21
+ # environment, the engine version — but NOT the analyzed source
22
+ # contents. A fingerprint mismatch (config / gem / version change) drops
23
+ # the snapshot and forces a full re-analysis, the conservative
24
+ # direction.
25
+ #
26
+ # Every operation is fault-tolerant: a missing, unreadable, schema-
27
+ # mismatched, fingerprint-mismatched, or corrupt snapshot loads as nil
28
+ # (→ a cold full run), and a write failure is swallowed (→ the next run
29
+ # is cold). A cache must never break a run (the ADR-45 invariant).
30
+ class IncrementalSnapshot
31
+ # Bump when the on-disk shape changes so stale snapshots are ignored
32
+ # rather than mis-deserialized. 5: the blob is zlib-deflated
33
+ # (ADR-54 WD2 parity with `Store` entries — the snapshot is the
34
+ # one cache artefact that does not go through `Store`); a raw
35
+ # pre-5 blob fails the inflate and loads as nil, the usual
36
+ # fault-tolerant cold-run path.
37
+ SCHEMA = 5
38
+
39
+ # The persisted per-file state.
40
+ # `cache` maps an analyzed file to its diagnostics.
41
+ # `sources` maps a consumer to the Set of source files it read from.
42
+ # `digests` maps a file to its content digest at analysis time.
43
+ # `analyzed` is the ordered analyzed-file list.
44
+ # ADR-46 slice 4:
45
+ # `symbol_sources` maps a consumer to { source_path → Set<"ClassName#method"> }.
46
+ # `ancestry_sources` maps a consumer to Set<source_path> (class-ancestry deps).
47
+ # `symbol_fingerprints` maps a path to { "ClassName#method" => sha256_hex }.
48
+ # ADR-46 slice 3:
49
+ # `missing` maps a consumer to Set<"kind:name"> it looked up and missed.
50
+ # `class_decls` maps a path to Set<qualified class name> it declares.
51
+ Payload = Data.define(:cache, :sources, :digests, :analyzed,
52
+ :symbol_sources, :ancestry_sources, :symbol_fingerprints,
53
+ :missing, :class_decls)
54
+
55
+ # The global fingerprint that gates a snapshot load: a digest of the
56
+ # inputs whose change requires a full rebuild — the engine version +
57
+ # schema, the resolved configuration, the analysis **roots** (the path
58
+ # arguments, e.g. `["lib"]`, NOT the expanded file list — so a snapshot
59
+ # is keyed to an invocation's roots but adding / removing a file under
60
+ # them is handled incrementally by the session, not a full rebuild), the
61
+ # resolved gem set (`Gemfile.lock` / `rbs_collection`), and the project's
62
+ # own RBS (`signature_paths` file contents). Built WITHOUT constructing
63
+ # the RBS environment so the warm path can gate the load cheaply, before
64
+ # the costly env build. The `--verify-incremental` gate is the safety net
65
+ # for any under-capture (it would surface as an incremental-vs-full
66
+ # mismatch). Returns nil on any error → the caller falls back to a
67
+ # non-persisted run.
68
+ def self.fingerprint(configuration:, roots:)
69
+ parts = [
70
+ "engine:#{Rigor::VERSION}:#{SCHEMA}",
71
+ "config:#{Digest::SHA256.hexdigest(Marshal.dump(configuration.to_h))}",
72
+ "roots:#{Array(roots).map(&:to_s).sort.join("\n")}",
73
+ "gems:#{digest_file_if_present('Gemfile.lock')}",
74
+ "rbs_collection:#{digest_file_if_present('rbs_collection.lock.yaml')}",
75
+ "sig:#{digest_signature_paths(configuration.signature_paths)}"
76
+ ]
77
+ Digest::SHA256.hexdigest(parts.join("\x00"))
78
+ rescue StandardError
79
+ nil
80
+ end
81
+
82
+ def self.digest_file_if_present(path)
83
+ File.file?(path) ? Digest::SHA256.file(path).hexdigest : "absent"
84
+ end
85
+ private_class_method :digest_file_if_present
86
+
87
+ # Content-digest every `.rbs` under the configured signature paths
88
+ # (sorted for determinism) so a project RBS edit invalidates the
89
+ # snapshot. Sig trees are small; content (not mtime) keeps it stable
90
+ # across checkouts.
91
+ def self.digest_signature_paths(signature_paths)
92
+ globbed = Array(signature_paths).flat_map do |entry|
93
+ File.directory?(entry) ? Dir.glob(File.join(entry, "**", "*.rbs")) : [entry]
94
+ end
95
+ files = globbed.select { |path| File.file?(path) }.sort
96
+ digest = Digest::SHA256.new
97
+ files.each { |path| digest << path << "\0" << Digest::SHA256.file(path).hexdigest << "\0" }
98
+ digest.hexdigest
99
+ end
100
+ private_class_method :digest_signature_paths
101
+
102
+ def initialize(root:)
103
+ @path = File.join(root.to_s, "incremental", "snapshot.bin")
104
+ end
105
+
106
+ attr_reader :path
107
+
108
+ # The stored {Payload}, or nil when absent / unreadable / schema or
109
+ # fingerprint mismatch / corrupt. Never raises.
110
+ def load(fingerprint:)
111
+ data = Marshal.load(Zlib::Inflate.inflate(File.binread(@path))) # rubocop:disable Security/MarshalLoad
112
+ return nil unless data.is_a?(Hash) && data[:schema] == SCHEMA && data[:fingerprint] == fingerprint
113
+
114
+ Payload.new(
115
+ cache: data[:cache], sources: data[:sources],
116
+ digests: data[:digests], analyzed: data[:analyzed],
117
+ symbol_sources: data[:symbol_sources] || {},
118
+ ancestry_sources: data[:ancestry_sources] || {},
119
+ symbol_fingerprints: data[:symbol_fingerprints] || {},
120
+ missing: data[:missing] || {},
121
+ class_decls: data[:class_decls] || {}
122
+ )
123
+ rescue StandardError
124
+ nil
125
+ end
126
+
127
+ # Persist `payload` under `fingerprint`. Writes via a temp file +
128
+ # atomic rename so a concurrent reader never sees a half-written
129
+ # snapshot. Returns true on success, false on any failure (never
130
+ # raises).
131
+ def save(fingerprint:, payload:)
132
+ FileUtils.mkdir_p(File.dirname(@path))
133
+ raw = Marshal.dump(
134
+ schema: SCHEMA, fingerprint: fingerprint,
135
+ cache: payload.cache, sources: payload.sources,
136
+ digests: payload.digests, analyzed: payload.analyzed,
137
+ symbol_sources: payload.symbol_sources,
138
+ ancestry_sources: payload.ancestry_sources,
139
+ symbol_fingerprints: payload.symbol_fingerprints,
140
+ missing: payload.missing,
141
+ class_decls: payload.class_decls
142
+ )
143
+ blob = Zlib::Deflate.deflate(raw)
144
+ tmp = "#{@path}.#{Process.pid}.tmp"
145
+ File.binwrite(tmp, blob)
146
+ File.rename(tmp, @path)
147
+ true
148
+ rescue StandardError
149
+ false
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rbs_descriptor"
4
+
5
+ module Rigor
6
+ module Cache
7
+ # Base for the RBS-derived cache producers.
8
+ #
9
+ # Every producer (`RbsKnownClassNames`, `RbsConstantTable`,
10
+ # `RbsEnvironment`, the ancestor / type-param / definition tables, …)
11
+ # repeated the identical `fetch` wiring: build the RBS descriptor,
12
+ # then `store.fetch_or_compute` under the producer's id, yielding to
13
+ # the producer's `compute`. Only the `PRODUCER_ID` constant and the
14
+ # `compute(loader)` body actually differ between producers.
15
+ #
16
+ # Subclasses declare `PRODUCER_ID` and a (private) `self.compute`;
17
+ # this base owns `fetch`. `self::PRODUCER_ID` resolves the constant on
18
+ # the concrete subclass, and `compute(loader)` dispatches to its
19
+ # private class method. See the `_CacheProducer` RBS interface for the
20
+ # structural contract.
21
+ class RbsCacheProducer
22
+ def self.fetch(loader:, store:)
23
+ # ADR-54 WD4 — the descriptor is identical for every producer
24
+ # consulting the same loader (same sig files, same libraries),
25
+ # so the loader memoises one build per process instead of
26
+ # re-digesting every .rbs file once per producer.
27
+ descriptor = loader.rbs_cache_descriptor
28
+ store.fetch_or_compute(producer_id: self::PRODUCER_ID, params: {}, descriptor: descriptor) do
29
+ compute(loader)
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "rbs_descriptor"
4
+ require_relative "rbs_cache_producer"
4
5
 
5
6
  module Rigor
6
7
  module Cache
@@ -22,19 +23,12 @@ module Rigor
22
23
  # Cache descriptor shape is shared with every other cache
23
24
  # producer that depends on the RBS environment — see
24
25
  # {RbsDescriptor.build}.
25
- class RbsClassAncestorTable
26
+ class RbsClassAncestorTable < RbsCacheProducer
26
27
  PRODUCER_ID = "rbs.class_ancestor_table"
27
28
 
28
29
  # @param loader [Rigor::Environment::RbsLoader]
29
30
  # @param store [Rigor::Cache::Store]
30
31
  # @return [Hash{String => Array<String>}]
31
- def self.fetch(loader:, store:)
32
- descriptor = RbsDescriptor.build(loader)
33
- store.fetch_or_compute(producer_id: PRODUCER_ID, params: {}, descriptor: descriptor) do
34
- compute(loader)
35
- end
36
- end
37
-
38
32
  def self.compute(loader)
39
33
  table = {}
40
34
  loader.each_known_class_name do |name|
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "rbs_descriptor"
4
+ require_relative "rbs_cache_producer"
4
5
 
5
6
  module Rigor
6
7
  module Cache
@@ -22,19 +23,12 @@ module Rigor
22
23
  # Cache descriptor shape is shared with every other cache
23
24
  # producer that depends on the RBS environment — see
24
25
  # {RbsDescriptor.build}.
25
- class RbsClassTypeParamNames
26
+ class RbsClassTypeParamNames < RbsCacheProducer
26
27
  PRODUCER_ID = "rbs.class_type_param_names"
27
28
 
28
29
  # @param loader [Rigor::Environment::RbsLoader]
29
30
  # @param store [Rigor::Cache::Store]
30
31
  # @return [Hash{String => Array<Symbol>}]
31
- def self.fetch(loader:, store:)
32
- descriptor = RbsDescriptor.build(loader)
33
- store.fetch_or_compute(producer_id: PRODUCER_ID, params: {}, descriptor: descriptor) do
34
- compute(loader)
35
- end
36
- end
37
-
38
32
  def self.compute(loader)
39
33
  table = {}
40
34
  loader.each_known_class_name do |name|