rigortype 0.1.15 → 0.1.17

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 (220) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/exe/rigor +19 -0
  4. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +100 -0
  5. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +209 -0
  6. data/lib/rigor/analysis/check_rules.rb +174 -71
  7. data/lib/rigor/analysis/dependency_recorder.rb +122 -0
  8. data/lib/rigor/analysis/diagnostic.rb +58 -0
  9. data/lib/rigor/analysis/incremental.rb +162 -0
  10. data/lib/rigor/analysis/incremental_session.rb +337 -0
  11. data/lib/rigor/analysis/rule_catalog.rb +48 -0
  12. data/lib/rigor/analysis/runner.rb +485 -29
  13. data/lib/rigor/analysis/self_call_resolution_recorder.rb +121 -0
  14. data/lib/rigor/analysis/worker_session.rb +3 -2
  15. data/lib/rigor/builtins/static_return_refinements.rb +7 -1
  16. data/lib/rigor/cache/descriptor.rb +56 -51
  17. data/lib/rigor/cache/incremental_snapshot.rb +147 -0
  18. data/lib/rigor/cache/rbs_cache_producer.rb +30 -0
  19. data/lib/rigor/cache/rbs_class_ancestor_table.rb +2 -8
  20. data/lib/rigor/cache/rbs_class_type_param_names.rb +2 -8
  21. data/lib/rigor/cache/rbs_constant_table.rb +2 -8
  22. data/lib/rigor/cache/rbs_environment.rb +2 -8
  23. data/lib/rigor/cache/rbs_instance_definitions.rb +3 -16
  24. data/lib/rigor/cache/rbs_known_class_names.rb +2 -8
  25. data/lib/rigor/cache/store.rb +99 -1
  26. data/lib/rigor/cli/annotate_command.rb +2 -7
  27. data/lib/rigor/cli/baseline_command.rb +2 -7
  28. data/lib/rigor/cli/command.rb +47 -0
  29. data/lib/rigor/cli/coverage_command.rb +3 -23
  30. data/lib/rigor/cli/coverage_renderer.rb +3 -8
  31. data/lib/rigor/cli/diff_command.rb +3 -7
  32. data/lib/rigor/cli/explain_command.rb +2 -7
  33. data/lib/rigor/cli/lsp_command.rb +3 -7
  34. data/lib/rigor/cli/mcp_command.rb +3 -7
  35. data/lib/rigor/cli/options.rb +57 -0
  36. data/lib/rigor/cli/plugin_command.rb +3 -7
  37. data/lib/rigor/cli/plugins_command.rb +52 -10
  38. data/lib/rigor/cli/plugins_renderer.rb +86 -1
  39. data/lib/rigor/cli/renderable.rb +26 -0
  40. data/lib/rigor/cli/sig_gen_command.rb +2 -7
  41. data/lib/rigor/cli/skill_command.rb +3 -7
  42. data/lib/rigor/cli/triage_command.rb +2 -7
  43. data/lib/rigor/cli/type_of_command.rb +5 -38
  44. data/lib/rigor/cli/type_of_renderer.rb +4 -9
  45. data/lib/rigor/cli/type_scan_command.rb +3 -23
  46. data/lib/rigor/cli/type_scan_renderer.rb +4 -9
  47. data/lib/rigor/cli.rb +260 -48
  48. data/lib/rigor/configuration/dependencies.rb +18 -1
  49. data/lib/rigor/configuration/severity_profile.rb +22 -3
  50. data/lib/rigor/configuration.rb +13 -3
  51. data/lib/rigor/environment/rbs_loader.rb +335 -4
  52. data/lib/rigor/environment.rb +8 -2
  53. data/lib/rigor/inference/block_parameter_binder.rb +1 -2
  54. data/lib/rigor/inference/budget_trace.rb +137 -0
  55. data/lib/rigor/inference/builtins/array_catalog.rb +2 -5
  56. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -5
  57. data/lib/rigor/inference/builtins/complex_catalog.rb +2 -5
  58. data/lib/rigor/inference/builtins/date_catalog.rb +2 -5
  59. data/lib/rigor/inference/builtins/encoding_catalog.rb +2 -5
  60. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -5
  61. data/lib/rigor/inference/builtins/exception_catalog.rb +2 -5
  62. data/lib/rigor/inference/builtins/hash_catalog.rb +2 -5
  63. data/lib/rigor/inference/builtins/method_catalog.rb +15 -0
  64. data/lib/rigor/inference/builtins/numeric_catalog.rb +21 -93
  65. data/lib/rigor/inference/builtins/pathname_catalog.rb +2 -5
  66. data/lib/rigor/inference/builtins/proc_catalog.rb +2 -5
  67. data/lib/rigor/inference/builtins/random_catalog.rb +2 -5
  68. data/lib/rigor/inference/builtins/range_catalog.rb +2 -5
  69. data/lib/rigor/inference/builtins/rational_catalog.rb +2 -5
  70. data/lib/rigor/inference/builtins/re_catalog.rb +2 -5
  71. data/lib/rigor/inference/builtins/set_catalog.rb +2 -5
  72. data/lib/rigor/inference/builtins/string_catalog.rb +2 -5
  73. data/lib/rigor/inference/builtins/struct_catalog.rb +2 -5
  74. data/lib/rigor/inference/builtins/time_catalog.rb +2 -5
  75. data/lib/rigor/inference/expression_typer.rb +149 -22
  76. data/lib/rigor/inference/hkt_reducer.rb +2 -0
  77. data/lib/rigor/inference/method_dispatcher/block_folding.rb +5 -1
  78. data/lib/rigor/inference/method_dispatcher/call_context.rb +65 -0
  79. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +11 -10
  80. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +12 -6
  81. data/lib/rigor/inference/method_dispatcher/data_folding.rb +246 -0
  82. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -2
  83. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -2
  84. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -1
  85. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +4 -1
  86. data/lib/rigor/inference/method_dispatcher/math_folding.rb +6 -6
  87. data/lib/rigor/inference/method_dispatcher/method_folding.rb +12 -7
  88. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +23 -6
  89. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +100 -23
  90. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +9 -9
  91. data/lib/rigor/inference/method_dispatcher/set_folding.rb +6 -6
  92. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +120 -9
  93. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +12 -12
  94. data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +49 -0
  95. data/lib/rigor/inference/method_dispatcher/time_folding.rb +6 -6
  96. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +9 -9
  97. data/lib/rigor/inference/method_dispatcher.rb +147 -60
  98. data/lib/rigor/inference/narrowing.rb +202 -5
  99. data/lib/rigor/inference/precision_scanner.rb +60 -1
  100. data/lib/rigor/inference/scope_indexer.rb +257 -11
  101. data/lib/rigor/inference/statement_evaluator.rb +110 -26
  102. data/lib/rigor/inference/synthetic_method_index.rb +23 -4
  103. data/lib/rigor/inference/synthetic_method_scanner.rb +148 -14
  104. data/lib/rigor/language_server/buffer_resolution.rb +33 -0
  105. data/lib/rigor/language_server/completion_provider.rb +4 -4
  106. data/lib/rigor/language_server/document_symbol_provider.rb +4 -4
  107. data/lib/rigor/language_server/folding_range_provider.rb +4 -4
  108. data/lib/rigor/language_server/hover_provider.rb +4 -4
  109. data/lib/rigor/language_server/selection_range_provider.rb +4 -4
  110. data/lib/rigor/language_server/signature_help_provider.rb +4 -4
  111. data/lib/rigor/plugin/additional_initializer.rb +108 -0
  112. data/lib/rigor/plugin/base.rb +337 -2
  113. data/lib/rigor/plugin/box.rb +64 -0
  114. data/lib/rigor/plugin/inflector.rb +121 -0
  115. data/lib/rigor/plugin/isolation.rb +191 -0
  116. data/lib/rigor/plugin/macro/nested_class_template.rb +140 -0
  117. data/lib/rigor/plugin/macro.rb +1 -0
  118. data/lib/rigor/plugin/manifest.rb +120 -23
  119. data/lib/rigor/plugin/node_context.rb +62 -0
  120. data/lib/rigor/plugin/registry.rb +49 -1
  121. data/lib/rigor/plugin.rb +3 -0
  122. data/lib/rigor/rbs_extended/conformance_checker.rb +208 -0
  123. data/lib/rigor/rbs_extended.rb +39 -0
  124. data/lib/rigor/scope.rb +123 -9
  125. data/lib/rigor/sig_gen/generator.rb +2 -3
  126. data/lib/rigor/sig_gen/observation_collector.rb +2 -2
  127. data/lib/rigor/source/literals.rb +118 -0
  128. data/lib/rigor/source/node_walker.rb +26 -0
  129. data/lib/rigor/source.rb +1 -0
  130. data/lib/rigor/type/acceptance_router.rb +19 -0
  131. data/lib/rigor/type/accepts_result.rb +3 -10
  132. data/lib/rigor/type/app.rb +3 -7
  133. data/lib/rigor/type/bot.rb +2 -3
  134. data/lib/rigor/type/bound_method.rb +5 -12
  135. data/lib/rigor/type/combinator.rb +23 -1
  136. data/lib/rigor/type/constant.rb +2 -3
  137. data/lib/rigor/type/data_class.rb +80 -0
  138. data/lib/rigor/type/data_instance.rb +100 -0
  139. data/lib/rigor/type/difference.rb +5 -10
  140. data/lib/rigor/type/dynamic.rb +5 -10
  141. data/lib/rigor/type/hash_shape.rb +5 -15
  142. data/lib/rigor/type/integer_range.rb +5 -10
  143. data/lib/rigor/type/intersection.rb +5 -10
  144. data/lib/rigor/type/nominal.rb +5 -10
  145. data/lib/rigor/type/refined.rb +5 -10
  146. data/lib/rigor/type/singleton.rb +5 -10
  147. data/lib/rigor/type/top.rb +2 -3
  148. data/lib/rigor/type/tuple.rb +5 -10
  149. data/lib/rigor/type/union.rb +69 -10
  150. data/lib/rigor/type.rb +2 -0
  151. data/lib/rigor/value_semantics.rb +77 -0
  152. data/lib/rigor/version.rb +1 -1
  153. data/lib/rigor.rb +2 -0
  154. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +31 -53
  155. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +21 -23
  156. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +38 -59
  157. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +7 -13
  158. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +22 -33
  159. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +298 -413
  160. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +69 -71
  161. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +24 -34
  162. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +18 -16
  163. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +4 -46
  164. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  165. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +1 -1
  166. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +17 -12
  167. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -8
  168. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +2 -7
  169. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +2 -6
  170. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +4 -3
  171. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  172. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +40 -45
  173. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +7 -17
  174. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +20 -42
  175. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +7 -4
  176. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +4 -8
  177. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +188 -0
  178. data/plugins/rigor-mangrove/lib/rigor-mangrove.rb +3 -0
  179. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +4 -0
  180. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +24 -8
  181. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +31 -48
  182. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +21 -23
  183. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +54 -82
  184. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +25 -25
  185. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +63 -147
  186. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -17
  187. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +23 -114
  188. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +48 -33
  189. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  190. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +6 -3
  191. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +4 -2
  192. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +13 -12
  193. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +28 -40
  194. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +44 -47
  195. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +11 -10
  196. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +45 -87
  197. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +11 -12
  198. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +29 -42
  199. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +20 -19
  200. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +73 -0
  201. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +43 -1
  202. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +21 -29
  203. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +36 -96
  204. data/sig/rigor/cache.rbs +19 -0
  205. data/sig/rigor/inference.rbs +22 -0
  206. data/sig/rigor/plugin/access_denied_error.rbs +3 -1
  207. data/sig/rigor/plugin/base.rbs +58 -3
  208. data/sig/rigor/plugin/io_boundary.rbs +3 -0
  209. data/sig/rigor/plugin/manifest.rbs +31 -1
  210. data/sig/rigor/rbs_extended.rbs +2 -0
  211. data/sig/rigor/scope.rbs +5 -0
  212. data/sig/rigor/source.rbs +12 -0
  213. data/sig/rigor/type.rbs +58 -1
  214. data/sig/rigor.rbs +11 -1
  215. data/skills/rigor-plugin-author/SKILL.md +13 -9
  216. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +6 -5
  217. data/skills/rigor-plugin-author/references/02-walker-and-types.md +159 -75
  218. data/skills/rigor-plugin-author/references/03-test-and-ship.md +3 -3
  219. metadata +73 -2
  220. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +0 -114
@@ -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
@@ -284,8 +284,9 @@ module Rigor
284
284
  end
285
285
 
286
286
  def collect_plugin_diagnostics(plugin, path, root, scope)
287
- raw = plugin.diagnostics_for_file(path: path, scope: scope, root: root)
288
- Array(raw).map { |diagnostic| stamp_plugin_diagnostic(diagnostic, plugin.manifest.id) }
287
+ raw = Array(plugin.diagnostics_for_file(path: path, scope: scope, root: root))
288
+ raw += plugin.node_rule_diagnostics(path: path, scope: scope, root: root)
289
+ raw.map { |diagnostic| stamp_plugin_diagnostic(diagnostic, plugin.manifest.id) }
289
290
  rescue StandardError => e
290
291
  [plugin_runtime_error_diagnostic(path, plugin, e)]
291
292
  end
@@ -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
@@ -26,18 +27,26 @@ module Rigor
26
27
  # mixes this into the cache key, so a bump implicitly
27
28
  # invalidates every cached value. v2 added the
28
29
  # `dependencies` slot for ADR-10 per-gem-version cache slice
29
- # invalidation.
30
- SCHEMA_VERSION = 2
30
+ # invalidation. v3: `RbsLoader.build_env_for` now synthesizes
31
+ # `module`s for namespaces a project's `signature_paths:` RBS
32
+ # references but never declares, so the marshalled RBS env
33
+ # cached by an older Rigor (which would leave those signatures
34
+ # inert) MUST be rebuilt for the synthesis to take effect.
35
+ SCHEMA_VERSION = 3
31
36
 
32
37
  # Per-slot entry value objects. Constructors validate enums /
33
38
  # required fields and freeze the resulting struct so no caller
34
39
  # can mutate after the entry is in a Descriptor.
35
40
 
36
41
  class FileEntry
42
+ include Rigor::ValueSemantics
43
+
37
44
  VALID_COMPARATORS = %i[digest mtime exists].freeze
38
45
 
39
46
  attr_reader :path, :comparator, :value
40
47
 
48
+ value_fields :path, :comparator, :value
49
+
41
50
  def initialize(path:, comparator:, value:)
42
51
  unless VALID_COMPARATORS.include?(comparator)
43
52
  raise ArgumentError,
@@ -53,20 +62,15 @@ module Rigor
53
62
  def to_h
54
63
  { "path" => path, "comparator" => comparator.to_s, "value" => value }
55
64
  end
56
-
57
- def ==(other)
58
- other.is_a?(FileEntry) && other.path == path && other.comparator == comparator && other.value == value
59
- end
60
- alias eql? ==
61
-
62
- def hash
63
- [self.class, path, comparator, value].hash
64
- end
65
65
  end
66
66
 
67
67
  class GemEntry
68
+ include Rigor::ValueSemantics
69
+
68
70
  attr_reader :name, :requirement, :locked
69
71
 
72
+ value_fields :name, :requirement, :locked
73
+
70
74
  def initialize(name:, requirement:, locked: nil)
71
75
  @name = name.to_s.dup.freeze
72
76
  @requirement = requirement.to_s.dup.freeze
@@ -77,20 +81,15 @@ module Rigor
77
81
  def to_h
78
82
  { "name" => name, "requirement" => requirement, "locked" => locked }
79
83
  end
80
-
81
- def ==(other)
82
- other.is_a?(GemEntry) && other.name == name && other.requirement == requirement && other.locked == locked
83
- end
84
- alias eql? ==
85
-
86
- def hash
87
- [self.class, name, requirement, locked].hash
88
- end
89
84
  end
90
85
 
91
86
  class PluginEntry
87
+ include Rigor::ValueSemantics
88
+
92
89
  attr_reader :id, :version, :config_hash
93
90
 
91
+ value_fields :id, :version, :config_hash
92
+
94
93
  def initialize(id:, version:, config_hash: nil)
95
94
  @id = id.to_s.dup.freeze
96
95
  @version = version.to_s.dup.freeze
@@ -101,21 +100,15 @@ module Rigor
101
100
  def to_h
102
101
  { "id" => id, "version" => version, "config_hash" => config_hash }
103
102
  end
104
-
105
- def ==(other)
106
- other.is_a?(PluginEntry) &&
107
- other.id == id && other.version == version && other.config_hash == config_hash
108
- end
109
- alias eql? ==
110
-
111
- def hash
112
- [self.class, id, version, config_hash].hash
113
- end
114
103
  end
115
104
 
116
105
  class ConfigEntry
106
+ include Rigor::ValueSemantics
107
+
117
108
  attr_reader :key, :value_hash
118
109
 
110
+ value_fields :key, :value_hash
111
+
119
112
  def initialize(key:, value_hash:)
120
113
  @key = key.to_s.dup.freeze
121
114
  @value_hash = value_hash.to_s.dup.freeze
@@ -125,15 +118,6 @@ module Rigor
125
118
  def to_h
126
119
  { "key" => key, "value_hash" => value_hash }
127
120
  end
128
-
129
- def ==(other)
130
- other.is_a?(ConfigEntry) && other.key == key && other.value_hash == value_hash
131
- end
132
- alias eql? ==
133
-
134
- def hash
135
- [self.class, key, value_hash].hash
136
- end
137
121
  end
138
122
 
139
123
  # Per-(gem, version, mode) row carrying the cache slice
@@ -151,10 +135,14 @@ module Rigor
151
135
  # the inferred shapes depend on whether RBS overrides the
152
136
  # walk.
153
137
  class DependencyEntry
138
+ include Rigor::ValueSemantics
139
+
154
140
  VALID_MODES = %i[disabled when_missing full].freeze
155
141
 
156
142
  attr_reader :gem_name, :gem_version, :mode
157
143
 
144
+ value_fields :gem_name, :gem_version, :mode
145
+
158
146
  def initialize(gem_name:, gem_version:, mode:)
159
147
  unless VALID_MODES.include?(mode)
160
148
  raise ArgumentError,
@@ -170,18 +158,6 @@ module Rigor
170
158
  def to_h
171
159
  { "gem_name" => gem_name, "gem_version" => gem_version, "mode" => mode.to_s }
172
160
  end
173
-
174
- def ==(other)
175
- other.is_a?(DependencyEntry) &&
176
- other.gem_name == gem_name &&
177
- other.gem_version == gem_version &&
178
- other.mode == mode
179
- end
180
- alias eql? ==
181
-
182
- def hash
183
- [self.class, gem_name, gem_version, mode].hash
184
- end
185
161
  end
186
162
 
187
163
  # Raised when {.compose} encounters incompatible entries
@@ -202,6 +178,20 @@ module Rigor
202
178
  freeze
203
179
  end
204
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
+
205
195
  # File-comparator strictness ordering. `:digest` is strictest
206
196
  # (deterministic across machines); `:mtime` is cheaper but
207
197
  # local; `:exists` is the weakest signal. When two
@@ -286,6 +276,21 @@ module Rigor
286
276
 
287
277
  private
288
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
+
289
294
  def sort_entries(entries, key)
290
295
  entries.sort_by { |e| e.to_h.fetch(key).to_s }
291
296
  end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "digest"
5
+
6
+ module Rigor
7
+ module Cache
8
+ # ADR-46 — disk persistence for the incremental analyzer's per-file
9
+ # state, so a `--incremental` session survives across processes (one
10
+ # `rigor check` invocation reads the prior run's per-file diagnostics +
11
+ # dependency graph, re-analyzes only the changed closure, and serves the
12
+ # rest from disk).
13
+ #
14
+ # Unlike ADR-45's whole-run cache (record-and-validate ONE entry,
15
+ # invalidated by any analyzed-file change), this snapshot is loaded
16
+ # UNCONDITIONALLY when the global fingerprint matches — the per-file
17
+ # digests *inside* it drive the incremental re-analysis decision; they
18
+ # do not gate the load. The fingerprint captures the inputs whose change
19
+ # requires a full rebuild — the resolved configuration, the RBS
20
+ # environment, the engine version — but NOT the analyzed source
21
+ # contents. A fingerprint mismatch (config / gem / version change) drops
22
+ # the snapshot and forces a full re-analysis, the conservative
23
+ # direction.
24
+ #
25
+ # Every operation is fault-tolerant: a missing, unreadable, schema-
26
+ # mismatched, fingerprint-mismatched, or corrupt snapshot loads as nil
27
+ # (→ a cold full run), and a write failure is swallowed (→ the next run
28
+ # is cold). A cache must never break a run (the ADR-45 invariant).
29
+ class IncrementalSnapshot
30
+ # Bump when the on-disk shape changes so stale snapshots are ignored
31
+ # rather than mis-deserialized.
32
+ SCHEMA = 4
33
+
34
+ # The persisted per-file state.
35
+ # `cache` maps an analyzed file to its diagnostics.
36
+ # `sources` maps a consumer to the Set of source files it read from.
37
+ # `digests` maps a file to its content digest at analysis time.
38
+ # `analyzed` is the ordered analyzed-file list.
39
+ # ADR-46 slice 4:
40
+ # `symbol_sources` maps a consumer to { source_path → Set<"ClassName#method"> }.
41
+ # `ancestry_sources` maps a consumer to Set<source_path> (class-ancestry deps).
42
+ # `symbol_fingerprints` maps a path to { "ClassName#method" => sha256_hex }.
43
+ # ADR-46 slice 3:
44
+ # `missing` maps a consumer to Set<"kind:name"> it looked up and missed.
45
+ # `class_decls` maps a path to Set<qualified class name> it declares.
46
+ Payload = Data.define(:cache, :sources, :digests, :analyzed,
47
+ :symbol_sources, :ancestry_sources, :symbol_fingerprints,
48
+ :missing, :class_decls)
49
+
50
+ # The global fingerprint that gates a snapshot load: a digest of the
51
+ # inputs whose change requires a full rebuild — the engine version +
52
+ # schema, the resolved configuration, the analysis **roots** (the path
53
+ # arguments, e.g. `["lib"]`, NOT the expanded file list — so a snapshot
54
+ # is keyed to an invocation's roots but adding / removing a file under
55
+ # them is handled incrementally by the session, not a full rebuild), the
56
+ # resolved gem set (`Gemfile.lock` / `rbs_collection`), and the project's
57
+ # own RBS (`signature_paths` file contents). Built WITHOUT constructing
58
+ # the RBS environment so the warm path can gate the load cheaply, before
59
+ # the costly env build. The `--verify-incremental` gate is the safety net
60
+ # for any under-capture (it would surface as an incremental-vs-full
61
+ # mismatch). Returns nil on any error → the caller falls back to a
62
+ # non-persisted run.
63
+ def self.fingerprint(configuration:, roots:)
64
+ parts = [
65
+ "engine:#{Rigor::VERSION}:#{SCHEMA}",
66
+ "config:#{Digest::SHA256.hexdigest(Marshal.dump(configuration.to_h))}",
67
+ "roots:#{Array(roots).map(&:to_s).sort.join("\n")}",
68
+ "gems:#{digest_file_if_present('Gemfile.lock')}",
69
+ "rbs_collection:#{digest_file_if_present('rbs_collection.lock.yaml')}",
70
+ "sig:#{digest_signature_paths(configuration.signature_paths)}"
71
+ ]
72
+ Digest::SHA256.hexdigest(parts.join("\x00"))
73
+ rescue StandardError
74
+ nil
75
+ end
76
+
77
+ def self.digest_file_if_present(path)
78
+ File.file?(path) ? Digest::SHA256.file(path).hexdigest : "absent"
79
+ end
80
+ private_class_method :digest_file_if_present
81
+
82
+ # Content-digest every `.rbs` under the configured signature paths
83
+ # (sorted for determinism) so a project RBS edit invalidates the
84
+ # snapshot. Sig trees are small; content (not mtime) keeps it stable
85
+ # across checkouts.
86
+ def self.digest_signature_paths(signature_paths)
87
+ globbed = Array(signature_paths).flat_map do |entry|
88
+ File.directory?(entry) ? Dir.glob(File.join(entry, "**", "*.rbs")) : [entry]
89
+ end
90
+ files = globbed.select { |path| File.file?(path) }.sort
91
+ digest = Digest::SHA256.new
92
+ files.each { |path| digest << path << "\0" << Digest::SHA256.file(path).hexdigest << "\0" }
93
+ digest.hexdigest
94
+ end
95
+ private_class_method :digest_signature_paths
96
+
97
+ def initialize(root:)
98
+ @path = File.join(root.to_s, "incremental", "snapshot.bin")
99
+ end
100
+
101
+ attr_reader :path
102
+
103
+ # The stored {Payload}, or nil when absent / unreadable / schema or
104
+ # fingerprint mismatch / corrupt. Never raises.
105
+ def load(fingerprint:)
106
+ data = Marshal.load(File.binread(@path)) # rubocop:disable Security/MarshalLoad
107
+ return nil unless data.is_a?(Hash) && data[:schema] == SCHEMA && data[:fingerprint] == fingerprint
108
+
109
+ Payload.new(
110
+ cache: data[:cache], sources: data[:sources],
111
+ digests: data[:digests], analyzed: data[:analyzed],
112
+ symbol_sources: data[:symbol_sources] || {},
113
+ ancestry_sources: data[:ancestry_sources] || {},
114
+ symbol_fingerprints: data[:symbol_fingerprints] || {},
115
+ missing: data[:missing] || {},
116
+ class_decls: data[:class_decls] || {}
117
+ )
118
+ rescue StandardError
119
+ nil
120
+ end
121
+
122
+ # Persist `payload` under `fingerprint`. Writes via a temp file +
123
+ # atomic rename so a concurrent reader never sees a half-written
124
+ # snapshot. Returns true on success, false on any failure (never
125
+ # raises).
126
+ def save(fingerprint:, payload:)
127
+ FileUtils.mkdir_p(File.dirname(@path))
128
+ blob = Marshal.dump(
129
+ schema: SCHEMA, fingerprint: fingerprint,
130
+ cache: payload.cache, sources: payload.sources,
131
+ digests: payload.digests, analyzed: payload.analyzed,
132
+ symbol_sources: payload.symbol_sources,
133
+ ancestry_sources: payload.ancestry_sources,
134
+ symbol_fingerprints: payload.symbol_fingerprints,
135
+ missing: payload.missing,
136
+ class_decls: payload.class_decls
137
+ )
138
+ tmp = "#{@path}.#{Process.pid}.tmp"
139
+ File.binwrite(tmp, blob)
140
+ File.rename(tmp, @path)
141
+ true
142
+ rescue StandardError
143
+ false
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,30 @@
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
+ descriptor = RbsDescriptor.build(loader)
24
+ store.fetch_or_compute(producer_id: self::PRODUCER_ID, params: {}, descriptor: descriptor) do
25
+ compute(loader)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ 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|
@@ -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
@@ -15,19 +16,12 @@ module Rigor
15
16
  # Cache descriptor shape is shared with every other cache
16
17
  # producer that depends on the RBS environment — see
17
18
  # {RbsDescriptor.build} for the slot definitions.
18
- class RbsConstantTable
19
+ class RbsConstantTable < RbsCacheProducer
19
20
  PRODUCER_ID = "rbs.constant_type_table"
20
21
 
21
22
  # @param loader [Rigor::Environment::RbsLoader]
22
23
  # @param store [Rigor::Cache::Store]
23
24
  # @return [Hash{String => Rigor::Type}]
24
- def self.fetch(loader:, store:)
25
- descriptor = RbsDescriptor.build(loader)
26
- store.fetch_or_compute(producer_id: PRODUCER_ID, params: {}, descriptor: descriptor) do
27
- compute(loader)
28
- end
29
- end
30
-
31
25
  def self.compute(loader)
32
26
  table = {}
33
27
  loader.each_constant_decl do |name, entry|
@@ -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
  require_relative "rbs_environment_marshal_patch"
5
6
 
6
7
  module Rigor
@@ -26,19 +27,12 @@ module Rigor
26
27
  # Cache descriptor shape is shared with every other cache
27
28
  # producer that depends on the RBS environment — see
28
29
  # {RbsDescriptor.build}.
29
- class RbsEnvironment
30
+ class RbsEnvironment < RbsCacheProducer
30
31
  PRODUCER_ID = "rbs.environment"
31
32
 
32
33
  # @param loader [Rigor::Environment::RbsLoader]
33
34
  # @param store [Rigor::Cache::Store]
34
35
  # @return [::RBS::Environment]
35
- def self.fetch(loader:, store:)
36
- descriptor = RbsDescriptor.build(loader)
37
- store.fetch_or_compute(producer_id: PRODUCER_ID, params: {}, descriptor: descriptor) do
38
- compute(loader)
39
- end
40
- end
41
-
42
36
  def self.compute(loader)
43
37
  Rigor::Environment::RbsLoader.build_env_for(
44
38
  libraries: loader.libraries,