rigortype 0.1.18 → 0.2.0

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 (210) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +159 -224
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -3
  4. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
  5. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +32 -23
  6. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
  7. data/lib/rigor/analysis/check_rules/rule_walk.rb +151 -23
  8. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +24 -15
  9. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +9 -3
  10. data/lib/rigor/analysis/check_rules.rb +756 -132
  11. data/lib/rigor/analysis/dependency_source_inference/index.rb +4 -7
  12. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -18
  13. data/lib/rigor/analysis/dependency_source_inference.rb +3 -12
  14. data/lib/rigor/analysis/diagnostic.rb +8 -0
  15. data/lib/rigor/analysis/fact_store.rb +5 -4
  16. data/lib/rigor/analysis/rule_catalog.rb +153 -6
  17. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +19 -18
  18. data/lib/rigor/analysis/runner/project_pre_passes.rb +13 -9
  19. data/lib/rigor/analysis/runner.rb +75 -27
  20. data/lib/rigor/analysis/self_call_resolution_recorder.rb +3 -4
  21. data/lib/rigor/analysis/worker_session.rb +31 -25
  22. data/lib/rigor/bleeding_edge.rb +123 -0
  23. data/lib/rigor/builtins/predefined_constant_refinements.rb +151 -0
  24. data/lib/rigor/cache/descriptor.rb +86 -8
  25. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  26. data/lib/rigor/cache/store.rb +5 -3
  27. data/lib/rigor/cli/annotate_command.rb +122 -16
  28. data/lib/rigor/cli/baseline_command.rb +4 -3
  29. data/lib/rigor/cli/check_command.rb +118 -16
  30. data/lib/rigor/cli/coverage_command.rb +148 -16
  31. data/lib/rigor/cli/coverage_scan.rb +57 -0
  32. data/lib/rigor/cli/explain_command.rb +2 -0
  33. data/lib/rigor/cli/lsp_command.rb +3 -7
  34. data/lib/rigor/cli/mutation_protection_renderer.rb +63 -0
  35. data/lib/rigor/cli/mutation_protection_report.rb +73 -0
  36. data/lib/rigor/cli/options.rb +9 -0
  37. data/lib/rigor/cli/plugins_command.rb +4 -5
  38. data/lib/rigor/cli/plugins_renderer.rb +0 -2
  39. data/lib/rigor/cli/protection_renderer.rb +63 -0
  40. data/lib/rigor/cli/protection_report.rb +68 -0
  41. data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
  42. data/lib/rigor/cli/sig_gen_command.rb +2 -1
  43. data/lib/rigor/cli/trace_command.rb +2 -1
  44. data/lib/rigor/cli/triage_command.rb +8 -4
  45. data/lib/rigor/cli/triage_renderer.rb +15 -1
  46. data/lib/rigor/cli/type_of_command.rb +1 -1
  47. data/lib/rigor/cli/type_scan_command.rb +2 -1
  48. data/lib/rigor/cli.rb +12 -3
  49. data/lib/rigor/configuration/dependencies.rb +2 -4
  50. data/lib/rigor/configuration/severity_profile.rb +13 -1
  51. data/lib/rigor/configuration.rb +100 -6
  52. data/lib/rigor/environment/bundle_sig_discovery.rb +61 -13
  53. data/lib/rigor/environment/class_registry.rb +4 -3
  54. data/lib/rigor/environment/constant_type_cache_holder.rb +43 -0
  55. data/lib/rigor/environment/lockfile_resolver.rb +1 -1
  56. data/lib/rigor/environment/rbs_collection_discovery.rb +1 -2
  57. data/lib/rigor/environment/rbs_coverage_report.rb +2 -1
  58. data/lib/rigor/environment/rbs_loader.rb +74 -5
  59. data/lib/rigor/environment.rb +17 -7
  60. data/lib/rigor/flow_contribution/fact.rb +1 -1
  61. data/lib/rigor/flow_contribution.rb +3 -5
  62. data/lib/rigor/inference/acceptance.rb +17 -9
  63. data/lib/rigor/inference/block_parameter_binder.rb +2 -3
  64. data/lib/rigor/inference/body_fixpoint.rb +89 -0
  65. data/lib/rigor/inference/budget_trace.rb +29 -2
  66. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -2
  67. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -2
  68. data/lib/rigor/inference/builtins/method_catalog.rb +19 -0
  69. data/lib/rigor/inference/builtins/string_catalog.rb +9 -1
  70. data/lib/rigor/inference/expression_typer.rb +1072 -71
  71. data/lib/rigor/inference/hkt_body.rb +8 -11
  72. data/lib/rigor/inference/hkt_body_parser.rb +10 -12
  73. data/lib/rigor/inference/hkt_registry.rb +10 -11
  74. data/lib/rigor/inference/macro_block_self_type.rb +2 -2
  75. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
  76. data/lib/rigor/inference/method_dispatcher/call_context.rb +1 -4
  77. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +210 -35
  78. data/lib/rigor/inference/method_dispatcher/data_folding.rb +9 -73
  79. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -7
  80. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +10 -16
  81. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +25 -13
  82. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +93 -0
  83. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -3
  84. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +24 -22
  85. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
  86. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
  87. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +237 -24
  88. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +303 -0
  89. data/lib/rigor/inference/method_dispatcher.rb +112 -49
  90. data/lib/rigor/inference/method_parameter_binder.rb +56 -2
  91. data/lib/rigor/inference/multi_target_binder.rb +46 -3
  92. data/lib/rigor/inference/mutation_widening.rb +147 -11
  93. data/lib/rigor/inference/narrowing.rb +284 -53
  94. data/lib/rigor/inference/parameter_inference_collector.rb +367 -0
  95. data/lib/rigor/inference/project_patched_methods.rb +4 -7
  96. data/lib/rigor/inference/project_patched_scanner.rb +2 -13
  97. data/lib/rigor/inference/protection_scanner.rb +86 -0
  98. data/lib/rigor/inference/scope_indexer.rb +821 -76
  99. data/lib/rigor/inference/statement_evaluator.rb +1179 -102
  100. data/lib/rigor/inference/struct_fold_safety.rb +181 -0
  101. data/lib/rigor/inference/synthetic_method.rb +7 -7
  102. data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
  103. data/lib/rigor/language_server/completion_provider.rb +6 -12
  104. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -4
  105. data/lib/rigor/language_server/document_symbol_provider.rb +3 -3
  106. data/lib/rigor/language_server/hover_provider.rb +2 -3
  107. data/lib/rigor/language_server/hover_renderer.rb +2 -11
  108. data/lib/rigor/language_server/server.rb +9 -17
  109. data/lib/rigor/language_server.rb +4 -5
  110. data/lib/rigor/plugin/base.rb +245 -87
  111. data/lib/rigor/plugin/macro/block_as_method.rb +25 -25
  112. data/lib/rigor/plugin/macro/heredoc_template.rb +4 -7
  113. data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
  114. data/lib/rigor/plugin/macro/trait_registry.rb +3 -6
  115. data/lib/rigor/plugin/macro.rb +6 -8
  116. data/lib/rigor/plugin/manifest.rb +49 -90
  117. data/lib/rigor/plugin/node_rule_walk.rb +59 -14
  118. data/lib/rigor/plugin/registry.rb +18 -18
  119. data/lib/rigor/plugin/type_node_resolver.rb +6 -8
  120. data/lib/rigor/protection/mutation_scanner.rb +120 -0
  121. data/lib/rigor/protection/mutator.rb +246 -0
  122. data/lib/rigor/rbs_extended.rb +24 -36
  123. data/lib/rigor/reflection.rb +4 -7
  124. data/lib/rigor/scope/discovery_index.rb +16 -2
  125. data/lib/rigor/scope.rb +185 -16
  126. data/lib/rigor/sig_gen/generator.rb +8 -0
  127. data/lib/rigor/sig_gen/observed_call.rb +3 -3
  128. data/lib/rigor/sig_gen/writer.rb +40 -2
  129. data/lib/rigor/source/constant_path.rb +62 -0
  130. data/lib/rigor/source.rb +1 -0
  131. data/lib/rigor/triage/catalogue.rb +4 -19
  132. data/lib/rigor/triage.rb +69 -1
  133. data/lib/rigor/type/bound_method.rb +2 -11
  134. data/lib/rigor/type/combinator.rb +45 -3
  135. data/lib/rigor/type/constant.rb +2 -11
  136. data/lib/rigor/type/data_class.rb +2 -11
  137. data/lib/rigor/type/data_instance.rb +2 -11
  138. data/lib/rigor/type/hash_shape.rb +2 -11
  139. data/lib/rigor/type/integer_range.rb +2 -11
  140. data/lib/rigor/type/intersection.rb +2 -11
  141. data/lib/rigor/type/nominal.rb +2 -11
  142. data/lib/rigor/type/plain_lattice.rb +37 -0
  143. data/lib/rigor/type/refined.rb +72 -13
  144. data/lib/rigor/type/singleton.rb +2 -11
  145. data/lib/rigor/type/struct_class.rb +75 -0
  146. data/lib/rigor/type/struct_instance.rb +93 -0
  147. data/lib/rigor/type/tuple.rb +5 -15
  148. data/lib/rigor/type.rb +2 -0
  149. data/lib/rigor/version.rb +1 -1
  150. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  151. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +3 -3
  152. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +16 -32
  153. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +5 -13
  154. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
  155. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +11 -17
  156. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +34 -100
  157. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +3 -2
  158. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
  159. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  160. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +26 -27
  161. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +5 -7
  162. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +9 -8
  163. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +9 -11
  164. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +8 -9
  165. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +13 -12
  166. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +3 -4
  167. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +8 -8
  168. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +9 -11
  169. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +7 -8
  170. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +18 -49
  171. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +12 -13
  172. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +15 -23
  173. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +4 -4
  174. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +3 -3
  175. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
  176. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +2 -4
  177. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +27 -11
  178. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +22 -35
  179. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -6
  180. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +12 -18
  181. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +16 -23
  182. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  183. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -4
  184. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +1 -1
  185. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  186. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +21 -27
  187. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +0 -1
  188. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
  189. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +5 -4
  190. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
  191. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
  192. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +2 -3
  193. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +7 -11
  194. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +4 -5
  195. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +6 -9
  196. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +5 -15
  197. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +52 -40
  198. data/sig/rigor/analysis/fact_store.rbs +3 -0
  199. data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
  200. data/sig/rigor/plugin/base.rbs +5 -2
  201. data/sig/rigor/plugin/manifest.rbs +1 -2
  202. data/sig/rigor/scope.rbs +18 -1
  203. data/sig/rigor/type.rbs +37 -1
  204. data/sig/rigor.rbs +1 -1
  205. data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
  206. data/skills/rigor-plugin-author/SKILL.md +6 -4
  207. data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
  208. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
  209. metadata +25 -2
  210. data/lib/rigor/plugin/macro/external_file.rb +0 -143
@@ -13,8 +13,8 @@ module Rigor
13
13
  # ([`Rigor::Cache::Store`](store.rb), v0.0.8 slice 2) consumes
14
14
  # descriptors but does not extend them.
15
15
  #
16
- # The descriptor has four slots (`files`, `gems`, `plugins`,
17
- # `configs`); every slot is an array of typed entries; an empty
16
+ # The descriptor has six slots (`files`, `gems`, `plugins`,
17
+ # `configs`, `dependencies`, `globs`); every slot is an array of typed entries; an empty
18
18
  # array means "no dependency in this slot". Composition unions
19
19
  # by key per slot; conflicts on the comparison fields raise
20
20
  # {Conflict}.
@@ -32,7 +32,12 @@ module Rigor
32
32
  # references but never declares, so the marshalled RBS env
33
33
  # cached by an older Rigor (which would leave those signatures
34
34
  # inert) MUST be rebuilt for the synthesis to take effect.
35
- SCHEMA_VERSION = 3
35
+ # v4: ADR-60 WD3 added the `globs` slot ({GlobEntry}) for the
36
+ # record-and-validate plugin-producer cache; the new slot
37
+ # changes `#to_canonical_hash` (and is Marshal-dumped inside
38
+ # `fetch_or_validate` entry pairs), so entries written by an
39
+ # older Rigor must read as misses.
40
+ SCHEMA_VERSION = 4
36
41
 
37
42
  # Per-slot entry value objects. Constructors validate enums /
38
43
  # required fields and freeze the resulting struct so no caller
@@ -160,6 +165,62 @@ module Rigor
160
165
  end
161
166
  end
162
167
 
168
+ # ADR-60 WD3 — one glob's-worth of watched files, digested as a
169
+ # single value so the entry covers content change, addition,
170
+ # AND removal in one row: the digest is the SHA-256 over the
171
+ # sorted `"<path>\0<sha256-of-content>\n"` rows of every file
172
+ # matching `File.join(root, pattern)`. A new file adds a row, a
173
+ # deleted file drops one, an edit changes one — all three move
174
+ # the digest. {Descriptor#fresh?} re-runs the same computation
175
+ # and compares.
176
+ class GlobEntry
177
+ include Rigor::ValueSemantics
178
+
179
+ attr_reader :root, :pattern, :value
180
+
181
+ value_fields :root, :pattern, :value
182
+
183
+ def initialize(root:, pattern:, value:)
184
+ @root = root.to_s.dup.freeze
185
+ @pattern = pattern.to_s.dup.freeze
186
+ @value = value.to_s.dup.freeze
187
+ freeze
188
+ end
189
+
190
+ # Builds the entry for the glob's CURRENT filesystem state.
191
+ def self.compute(root:, pattern:)
192
+ new(root: root, pattern: pattern, value: digest_for(root: root, pattern: pattern))
193
+ end
194
+
195
+ # The digest the entry's `value` carries. Per-file read
196
+ # failures (a file vanishing between the glob and the
197
+ # digest) are treated as the file being absent — same
198
+ # race posture as {Descriptor#file_entry_fresh?}.
199
+ def self.digest_for(root:, pattern:)
200
+ # Dir.glob returns sorted entries by default (sort: true),
201
+ # so the row order — and therefore the digest — is stable.
202
+ rows = Dir.glob(File.join(root, pattern)).filter_map do |path|
203
+ next nil unless File.file?(path)
204
+
205
+ "#{path}\0#{Digest::SHA256.file(path).hexdigest}\n"
206
+ rescue StandardError
207
+ nil
208
+ end
209
+ Digest::SHA256.hexdigest(rows.join)
210
+ end
211
+
212
+ # Composition key — {.compose} unions per (root, pattern)
213
+ # slot; two contributions for the same slot must agree on
214
+ # the digest or {Conflict} is raised.
215
+ def slot_key
216
+ "#{root}\0#{pattern}"
217
+ end
218
+
219
+ def to_h
220
+ { "root" => root, "pattern" => pattern, "value" => value }
221
+ end
222
+ end
223
+
163
224
  # Raised when {.compose} encounters incompatible entries
164
225
  # under the same key (file digest mismatch, gem-locked
165
226
  # disagreement, …). Callers handle the exception by
@@ -167,14 +228,15 @@ module Rigor
167
228
  # contribution silently.
168
229
  class Conflict < StandardError; end
169
230
 
170
- attr_reader :files, :gems, :plugins, :configs, :dependencies
231
+ attr_reader :files, :gems, :plugins, :configs, :dependencies, :globs
171
232
 
172
- def initialize(files: [], gems: [], plugins: [], configs: [], dependencies: [])
233
+ def initialize(files: [], gems: [], plugins: [], configs: [], dependencies: [], globs: [])
173
234
  @files = files.dup.freeze
174
235
  @gems = gems.dup.freeze
175
236
  @plugins = plugins.dup.freeze
176
237
  @configs = configs.dup.freeze
177
238
  @dependencies = dependencies.dup.freeze
239
+ @globs = globs.dup.freeze
178
240
  freeze
179
241
  end
180
242
 
@@ -185,11 +247,15 @@ module Rigor
185
247
  # `files` are checked — non-file inputs (config / gems / version)
186
248
  # belong in the cache *key*, not the validated dependency set — so
187
249
  # a descriptor carrying any non-file slot is never considered fresh
188
- # (it was built wrong for this use).
250
+ # (it was built wrong for this use). ADR-60 WD3 adds `globs`
251
+ # alongside `files` as a re-validatable slot: a {GlobEntry} is
252
+ # fresh when re-globbing + re-digesting reproduces its recorded
253
+ # value.
189
254
  def fresh?
190
255
  return false unless gems.empty? && plugins.empty? && configs.empty? && dependencies.empty?
191
256
 
192
- files.all? { |entry| file_entry_fresh?(entry) }
257
+ files.all? { |entry| file_entry_fresh?(entry) } &&
258
+ globs.all? { |entry| glob_entry_fresh?(entry) }
193
259
  end
194
260
 
195
261
  # File-comparator strictness ordering. `:digest` is strictest
@@ -212,7 +278,9 @@ module Rigor
212
278
  plugins = compose_by_key(descriptors.flat_map(&:plugins), :id)
213
279
  configs = compose_by_key(descriptors.flat_map(&:configs), :key)
214
280
  dependencies = compose_by_key(descriptors.flat_map(&:dependencies), :gem_name)
215
- new(files: files, gems: gems, plugins: plugins, configs: configs, dependencies: dependencies)
281
+ globs = compose_by_key(descriptors.flat_map(&:globs), :slot_key)
282
+ new(files: files, gems: gems, plugins: plugins, configs: configs,
283
+ dependencies: dependencies, globs: globs)
216
284
  end
217
285
 
218
286
  # @param producer_id [String]
@@ -241,6 +309,7 @@ module Rigor
241
309
  "dependencies" => sort_entries(dependencies, "gem_name").map(&:to_h),
242
310
  "files" => sort_entries(files, "path").map(&:to_h),
243
311
  "gems" => sort_entries(gems, "name").map(&:to_h),
312
+ "globs" => globs.sort_by { |e| [e.root, e.pattern] }.map(&:to_h),
244
313
  "plugins" => sort_entries(plugins, "id").map(&:to_h)
245
314
  }
246
315
  end
@@ -291,6 +360,15 @@ module Rigor
291
360
  false
292
361
  end
293
362
 
363
+ # ADR-60 WD3 — re-runs the entry's glob + digest and compares
364
+ # against the recorded value. Any failure reads as stale
365
+ # (recompute), never a crash.
366
+ def glob_entry_fresh?(entry)
367
+ GlobEntry.digest_for(root: entry.root, pattern: entry.pattern) == entry.value
368
+ rescue StandardError
369
+ false
370
+ end
371
+
294
372
  def sort_entries(entries, key)
295
373
  entries.sort_by { |e| e.to_h.fetch(key).to_s }
296
374
  end
@@ -29,7 +29,8 @@ module Rigor
29
29
 
30
30
  def self.file_entries(loader)
31
31
  roots = loader.signature_paths +
32
- Rigor::Environment::RbsLoader.vendored_gem_sig_paths
32
+ Rigor::Environment::RbsLoader.vendored_gem_sig_paths +
33
+ Rigor::Environment::RbsLoader.core_overlay_sig_paths
33
34
  roots.flat_map do |root|
34
35
  next [] unless root.directory?
35
36
 
@@ -13,9 +13,10 @@ module Rigor
13
13
  module Cache
14
14
  # Filesystem-backed cache store. Schema, layout, file format,
15
15
  # atomicity, and locking are fixed by [ADR-6](../../../docs/adr/6-cache-persistence-backend.md);
16
- # callers see the [`Rigor::Cache::Descriptor`](descriptor.rb)
17
- # value object plus this class' `#fetch_or_compute` entry point
18
- # and nothing else.
16
+ # callers use `#fetch_or_compute` (producer-keyed),
17
+ # `#fetch_or_validate` (record-and-validate for discovered-dep
18
+ # caches, ADR-45), `#stats`, `#evict!`, and `.disk_inventory`,
19
+ # plus the [`Rigor::Cache::Descriptor`](descriptor.rb) value object.
19
20
  #
20
21
  # Read failures (missing file, bad magic, format-version mismatch,
21
22
  # corrupt SHA-256 trailer, un-inflatable or unmarshal-able payload)
@@ -119,6 +120,7 @@ module Rigor
119
120
  # When the root does not exist or has no schema-version
120
121
  # marker, `schema_version` is nil and the producer list is
121
122
  # empty.
123
+ #
122
124
  # The `schema_version.txt` marker content. Covers BOTH
123
125
  # invalidation axes: the descriptor schema and the on-disk byte
124
126
  # layout ({FORMAT_VERSION}, ADR-54). A format bump leaves the
@@ -1,13 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "English"
4
+ require "json"
3
5
  require "optionparser"
4
6
  require "prism"
5
7
 
6
8
  require_relative "../configuration"
9
+ require_relative "options"
7
10
  require_relative "../environment"
8
11
  require_relative "../scope"
9
12
  require_relative "../inference/def_return_typer"
10
13
  require_relative "../inference/scope_indexer"
14
+ require_relative "../inference/statement_evaluator"
11
15
  require_relative "prism_colorizer"
12
16
  require_relative "command"
13
17
 
@@ -20,18 +24,33 @@ module Rigor
20
24
  # (so `1; 2; 3` reports `3`), or, for a line that no statement
21
25
  # closes, the widest expression ending there (so the `if nil`
22
26
  # header reports its condition). It infers that expression's
23
- # type and appends a `#=> dump_type: <type>` comment.
27
+ # type and appends a `#=> <type>` comment (the xmpfilter /
28
+ # seeing_is_believing convention).
24
29
  #
25
30
  # The annotated source is re-parsed with Prism — a sanity gate,
26
31
  # since the appended text is always a comment — and printed to
27
- # stdout with IRB-style syntax highlighting via
32
+ # stdout. When colour is enabled and `bat`
33
+ # (https://github.com/sharkdp/bat) is on PATH it is used for
34
+ # highlighting; otherwise IRB-style highlighting via
28
35
  # {PrismColorizer}.
29
36
  class AnnotateCommand < Command
30
37
  USAGE = "Usage: rigor annotate [options] FILE"
31
38
 
32
- # Appended ` #=> dump_type: <type>` suffix. Matched and
33
- # stripped before re-annotating so re-running is idempotent.
34
- ANNOTATION_PATTERN = /\s*#=>\s*dump_type:.*\z/
39
+ # Trailing `#=> …` annotation comment. Matched and stripped
40
+ # before re-annotating so re-running is idempotent — this
41
+ # follows xmpfilter's convention of owning the `#=>` marker,
42
+ # and also absorbs the older `#=> dump_type: <type>` spelling
43
+ # (idempotency across re-runs). The leading `\s` requirement
44
+ # keeps a `#=>` inside a string literal (no preceding
45
+ # whitespace ambiguity aside) from matching mid-expression.
46
+ ANNOTATION_PATTERN = /\s+#=>(?:\s.*)?\z/
47
+
48
+ # Arguments for highlighting through `bat`: the annotated
49
+ # text arrives on stdin, so the language must be explicit;
50
+ # `--style=plain` drops the grid/header chrome so the output
51
+ # matches the PrismColorizer fallback line-for-line; paging
52
+ # stays off because the CLI may itself sit in a pipeline.
53
+ BAT_ARGS = %w[--language=ruby --style=plain --paging=never --color=always].freeze
35
54
 
36
55
  # @return [Integer] CLI exit status.
37
56
  def run
@@ -54,15 +73,23 @@ module Rigor
54
73
  def parse_options
55
74
  # Default: colour a tty, unless `NO_COLOR` opts out. An
56
75
  # explicit `--color` / `--no-color` overrides both.
57
- options = { config: nil, color: @out.tty? && !no_color_env? }
76
+ options = { config: nil, color: @out.tty? && !no_color_env?, bat: nil, format: :text }
58
77
 
59
78
  parser = OptionParser.new do |opts|
60
79
  opts.banner = USAGE
61
- opts.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
80
+ Options.add_config(opts, options)
81
+ opts.on("--format=FORMAT", %w[text json],
82
+ "Output format: text (default) or json (a { line => type } map)") do |value|
83
+ options[:format] = value.to_sym
84
+ end
62
85
  opts.on("--[no-]color",
63
86
  "Force or disable ANSI colour (default: auto-detect a tty; honours NO_COLOR)") do |value|
64
87
  options[:color] = value
65
88
  end
89
+ opts.on("--[no-]bat",
90
+ "Force or disable highlighting through bat (default: when colour is on and bat is found)") do |value|
91
+ options[:bat] = value
92
+ end
66
93
  end
67
94
  parser.parse!(@argv)
68
95
 
@@ -91,15 +118,35 @@ module Rigor
91
118
  parse_result = Prism.parse(source, filepath: file, version: configuration.target_ruby)
92
119
  return 1 if parse_errors?(parse_result, file)
93
120
 
121
+ # `converged_loop_recording` re-records fixpoint-tracked loop
122
+ # bodies from their converged (post-writeback) bindings, so a
123
+ # loop-body line annotates the joined widened type (`Integer`)
124
+ # rather than a stale first-iterations constant (`1 | 2`).
94
125
  scope_index = Inference::ScopeIndexer.index(
95
- parse_result.value, default_scope: base_scope(configuration)
126
+ parse_result.value, default_scope: base_scope(configuration),
127
+ converged_loop_recording: true
96
128
  )
97
129
  line_types = LineTypeCollector.new(scope_index).collect(parse_result.value)
98
130
 
99
- @out.puts(render(annotate(source, line_types), color: options.fetch(:color)))
131
+ case options.fetch(:format)
132
+ when :json
133
+ emit_json(line_types)
134
+ else
135
+ @out.puts(render(annotate(source, line_types), color: options.fetch(:color), bat: options.fetch(:bat)))
136
+ end
100
137
  0
101
138
  end
102
139
 
140
+ # `--format json` — emit the { line_number => type } map directly, the
141
+ # same data the text renderer consumes, so clients (the playground,
142
+ # editors) get structured annotations without reparsing the `#=> <type>`
143
+ # comment grammar. Values are the short type descriptions the text form
144
+ # also shows; keys are 1-based line numbers as strings (JSON object keys).
145
+ def emit_json(line_types)
146
+ annotations = line_types.keys.sort.to_h { |line| [line.to_s, line_types[line].describe(:short)] }
147
+ @out.puts(JSON.generate({ "annotations" => annotations }))
148
+ end
149
+
103
150
  def base_scope(configuration)
104
151
  Scope.empty(
105
152
  environment: Environment.for_project(
@@ -118,8 +165,8 @@ module Rigor
118
165
  true
119
166
  end
120
167
 
121
- # Appends ` #=> dump_type: <type>` to every line a type was
122
- # inferred for, aligning the comment column.
168
+ # Appends ` #=> <type>` to every line a type was inferred
169
+ # for, aligning the comment column.
123
170
  def annotate(source, line_types)
124
171
  lines = source.lines
125
172
  column = annotation_column(lines, line_types)
@@ -130,7 +177,7 @@ module Rigor
130
177
  code = line.chomp.sub(ANNOTATION_PATTERN, "")
131
178
  next "#{code}#{eol}" if type.nil?
132
179
 
133
- "#{code.ljust(column)} #=> dump_type: #{type.describe(:short)}#{eol}"
180
+ "#{code.ljust(column)} #=> #{type.describe(:short)}#{eol}"
134
181
  end.join
135
182
  end
136
183
 
@@ -143,11 +190,46 @@ module Rigor
143
190
  widths.max || 0
144
191
  end
145
192
 
146
- def render(annotated, color:)
193
+ def render(annotated, color:, bat: nil)
147
194
  return annotated unless color
148
195
  return annotated unless Prism.parse(annotated).success?
149
196
 
150
- PrismColorizer.colorize(annotated)
197
+ rendered = render_with_bat(annotated, forced: bat) unless bat == false
198
+ rendered || PrismColorizer.colorize(annotated)
199
+ end
200
+
201
+ # Pipes the annotated source through `bat` and returns its
202
+ # highlighted output, or nil when bat is unavailable or
203
+ # fails (broken install, killed mid-write) — the caller
204
+ # falls back to {PrismColorizer}. An explicit `--bat` with
205
+ # no bat on PATH warns instead of failing silently.
206
+ def render_with_bat(annotated, forced: nil)
207
+ executable = bat_executable
208
+ if executable.nil?
209
+ @err.puts("annotate: --bat requested but no `bat` executable found on PATH") if forced
210
+ return nil
211
+ end
212
+
213
+ output = IO.popen([executable, *BAT_ARGS], "r+") do |io|
214
+ io.write(annotated)
215
+ io.close_write
216
+ io.read
217
+ end
218
+ return nil unless $CHILD_STATUS&.success?
219
+
220
+ output.empty? ? nil : output
221
+ rescue SystemCallError, IOError
222
+ nil
223
+ end
224
+
225
+ def bat_executable
226
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |dir|
227
+ next if dir.empty?
228
+
229
+ candidate = File.join(dir, "bat")
230
+ return candidate if File.file?(candidate) && File.executable?(candidate)
231
+ end
232
+ nil
151
233
  end
152
234
  end
153
235
 
@@ -203,11 +285,30 @@ module Rigor
203
285
  widest_per_line(program).each do |line, node|
204
286
  next if by_line.key?(line)
205
287
 
206
- type = type_of(node)
288
+ type = node.is_a?(Prism::BlockParametersNode) ? block_params_type(node) : type_of(node)
207
289
  by_line[line] = type unless type.nil?
208
290
  end
209
291
  end
210
292
 
293
+ # A `do |i|` header line's widest node is its BlockParametersNode —
294
+ # not an expression, so evaluating it would only echo the
295
+ # `Dynamic[top]` fallback. Annotate the line with the parameters'
296
+ # inferred bindings instead (the single param's type, or a tuple
297
+ # for multi-param blocks); decline (nil) when any param has no
298
+ # plain name or no recorded binding, leaving the line bare.
299
+ def block_params_type(params_node)
300
+ inner = params_node.parameters
301
+ return nil if inner.nil? || inner.requireds.empty?
302
+
303
+ scope = @scope_index[params_node]
304
+ types = inner.requireds.map do |param|
305
+ return nil unless param.respond_to?(:name)
306
+
307
+ scope.local(param.name) or return nil
308
+ end
309
+ types.size == 1 ? types.first : Type::Combinator.tuple_of(*types)
310
+ end
311
+
211
312
  def widest_per_line(program)
212
313
  widest = {}
213
314
  walk(program) do |node|
@@ -231,8 +332,13 @@ module Rigor
231
332
  node.compact_child_nodes.each { |child| walk(child, &block) }
232
333
  end
233
334
 
335
+ # Types the node through the flow evaluator (not the bare
336
+ # expression typer) under its recorded entry scope, so flow-only
337
+ # forms type as the engine sees them — `i += 1` dispatches `+` on
338
+ # `i`'s binding (`Integer`, post-fixpoint) instead of echoing the
339
+ # RHS literal's `1`.
234
340
  def type_of(node)
235
- @scope_index[node].type_of(node)
341
+ Inference::StatementEvaluator.new(scope: @scope_index[node]).evaluate(node).first
236
342
  rescue StandardError
237
343
  nil
238
344
  end
@@ -3,6 +3,7 @@
3
3
  require "optparse"
4
4
 
5
5
  require_relative "../analysis/baseline"
6
+ require_relative "options"
6
7
  require_relative "../analysis/runner"
7
8
  require_relative "../cache/store"
8
9
  require_relative "../configuration"
@@ -106,7 +107,7 @@ module Rigor
106
107
  }
107
108
  parser = OptionParser.new do |opts|
108
109
  opts.banner = "Usage: rigor baseline #{subcommand} [options]"
109
- opts.on("--config=PATH", "Path to the Rigor configuration file") { |v| options[:config] = v }
110
+ Options.add_config(opts, options)
110
111
  opts.on("--output=PATH", "Write baseline to PATH (default: #{DEFAULT_BASELINE_PATH})") do |v|
111
112
  options[:output] = v
112
113
  end
@@ -270,7 +271,7 @@ module Rigor
270
271
  }
271
272
  parser = OptionParser.new do |opts|
272
273
  opts.banner = "Usage: rigor baseline drift [options]"
273
- opts.on("--config=PATH", "Path to the Rigor configuration file") { |v| options[:config] = v }
274
+ Options.add_config(opts, options)
274
275
  opts.on("--baseline=PATH", "Path to the baseline file (default: #{DEFAULT_BASELINE_PATH})") do |v|
275
276
  options[:baseline] = v
276
277
  end
@@ -344,7 +345,7 @@ module Rigor
344
345
  }
345
346
  parser = OptionParser.new do |opts|
346
347
  opts.banner = "Usage: rigor baseline prune [options]"
347
- opts.on("--config=PATH", "Path to the Rigor configuration file") { |v| options[:config] = v }
348
+ Options.add_config(opts, options)
348
349
  opts.on("--baseline=PATH", "Path to the baseline file (default: #{DEFAULT_BASELINE_PATH})") do |v|
349
350
  options[:baseline] = v
350
351
  end