rigortype 0.1.9 → 0.1.11

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 (158) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/rigor/analysis/baseline.rb +51 -15
  4. data/lib/rigor/analysis/runner.rb +67 -9
  5. data/lib/rigor/analysis/worker_session.rb +13 -4
  6. data/lib/rigor/cache/rbs_descriptor.rb +21 -2
  7. data/lib/rigor/cache/rbs_environment.rb +2 -1
  8. data/lib/rigor/cli/annotate_command.rb +57 -7
  9. data/lib/rigor/cli/baseline_command.rb +4 -3
  10. data/lib/rigor/cli/coverage_command.rb +126 -0
  11. data/lib/rigor/cli/coverage_renderer.rb +162 -0
  12. data/lib/rigor/cli/coverage_report.rb +75 -0
  13. data/lib/rigor/cli/mcp_command.rb +70 -0
  14. data/lib/rigor/cli.rb +88 -5
  15. data/lib/rigor/environment/rbs_loader.rb +46 -5
  16. data/lib/rigor/environment/reporters.rb +3 -2
  17. data/lib/rigor/environment.rb +159 -4
  18. data/lib/rigor/inference/def_return_typer.rb +98 -0
  19. data/lib/rigor/inference/expression_typer.rb +143 -12
  20. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +5 -0
  21. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
  22. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +115 -7
  23. data/lib/rigor/inference/precision_scanner.rb +131 -0
  24. data/lib/rigor/inference/statement_evaluator.rb +26 -2
  25. data/lib/rigor/mcp/loop.rb +43 -0
  26. data/lib/rigor/mcp/server.rb +263 -0
  27. data/lib/rigor/mcp.rb +16 -0
  28. data/lib/rigor/plugin/base.rb +28 -5
  29. data/lib/rigor/plugin/manifest.rb +33 -5
  30. data/lib/rigor/plugin/registry.rb +21 -0
  31. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
  32. data/lib/rigor/sig_gen/generator.rb +150 -75
  33. data/lib/rigor/type/combinator.rb +57 -0
  34. data/lib/rigor/version.rb +1 -1
  35. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
  36. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
  37. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
  38. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
  39. data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
  40. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +178 -0
  41. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +310 -0
  42. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +76 -0
  43. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +177 -0
  44. data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
  45. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +589 -0
  46. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +150 -0
  47. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +123 -0
  48. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +247 -0
  49. data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
  50. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
  51. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
  52. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
  53. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
  54. data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
  55. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +273 -0
  56. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
  57. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
  58. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
  59. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +240 -0
  60. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +94 -0
  61. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +514 -0
  62. data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
  63. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
  64. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
  65. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
  66. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
  67. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
  68. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
  69. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +34 -0
  70. data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
  71. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +463 -0
  72. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
  73. data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
  74. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
  75. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
  76. data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
  77. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
  78. data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
  79. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
  80. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
  81. data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
  82. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
  83. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
  84. data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
  85. data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
  86. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
  87. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
  88. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
  89. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
  90. data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
  91. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
  92. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
  93. data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
  94. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
  95. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
  96. data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
  97. data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
  98. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
  99. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
  100. data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
  101. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
  102. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
  103. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
  104. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
  105. data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
  106. data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
  107. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +277 -0
  108. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
  109. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
  110. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +167 -0
  111. data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
  112. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +161 -0
  113. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +103 -0
  114. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +490 -0
  115. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +158 -0
  116. data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
  117. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
  118. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
  119. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
  120. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
  121. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
  122. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
  123. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
  124. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
  125. data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
  126. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
  127. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
  128. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
  129. data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
  130. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
  131. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
  132. data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
  133. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
  134. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
  135. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
  136. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
  137. data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
  138. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
  139. data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
  140. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
  141. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
  142. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
  143. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
  144. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
  145. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
  146. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
  147. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
  148. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
  149. data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
  150. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
  151. data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
  152. data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
  153. data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
  154. data/sig/rigor/analysis/baseline.rbs +39 -0
  155. data/sig/rigor/environment.rbs +3 -2
  156. data/sig/rigor/type.rbs +4 -0
  157. data/sig/rigor.rbs +2 -0
  158. metadata +180 -1
@@ -217,6 +217,16 @@ module Rigor
217
217
  end
218
218
 
219
219
  def dispatch_nominal_size(nominal, method_name, args)
220
+ if nominal.class_name == "String" && args.size == 1
221
+ string_binary = dispatch_string_binary_from_arg(method_name, args.first)
222
+ return string_binary if string_binary
223
+ end
224
+
225
+ if nominal.class_name == "Integer" && args.size == 1
226
+ integer_binary = dispatch_integer_binary_from_arg(method_name, args.first)
227
+ return integer_binary if integer_binary
228
+ end
229
+
220
230
  return nil unless args.empty?
221
231
 
222
232
  selectors = SIZE_RETURNING_NOMINALS[nominal.class_name]
@@ -225,6 +235,38 @@ module Rigor
225
235
  Type::Combinator.non_negative_int
226
236
  end
227
237
 
238
+ # Arg-type-driven String binary projections for any String-typed
239
+ # receiver (including Nominal, Refined, and Difference fallbacks).
240
+ # Called before the no-arg size guard so binary operators are seen.
241
+ #
242
+ # - `String + non-empty-string` → `non-empty-string`
243
+ # (arg guarantees the concatenation is non-empty)
244
+ # - `String * Constant[0]` → `Constant[""]`
245
+ # (every string repeated 0 times is the empty string)
246
+ def dispatch_string_binary_from_arg(method_name, arg)
247
+ case method_name
248
+ when :+
249
+ return Type::Combinator.non_empty_string if Type::Combinator.non_empty_string_compatible?(arg)
250
+ when :*
251
+ if arg.is_a?(Type::Constant) && arg.value.is_a?(Integer) && arg.value.zero?
252
+ return Type::Combinator.constant_of("")
253
+ end
254
+ end
255
+ nil
256
+ end
257
+
258
+ # Arg-type-driven Integer binary projections for any Integer-typed
259
+ # receiver (including Nominal, Refined, and Difference fallbacks).
260
+ #
261
+ # - `Integer * Constant[0]` → `Constant[0]`
262
+ # (any integer multiplied by 0 is 0)
263
+ def dispatch_integer_binary_from_arg(method_name, arg)
264
+ return nil unless method_name == :*
265
+ return nil unless arg.is_a?(Type::Constant) && arg.value.is_a?(Integer) && arg.value.zero?
266
+
267
+ Type::Combinator.constant_of(0)
268
+ end
269
+
228
270
  # `IntegerRange#to_s` precision (v0.1.1 Track 1 slice 5b).
229
271
  # When the range's lower bound is `>= 0`, every member is
230
272
  # a non-negative integer and `to_s(base)` returns a
@@ -281,7 +323,7 @@ module Rigor
281
323
  return nil unless base.is_a?(Type::Nominal)
282
324
 
283
325
  if removes_empty_witness?(difference)
284
- precise = empty_removal_projection(base, method_name, args)
326
+ precise = empty_removal_projection(difference, method_name, args)
285
327
  return precise if precise
286
328
  end
287
329
 
@@ -305,14 +347,62 @@ module Rigor
305
347
  !!(predicate && predicate.call(difference.removed))
306
348
  end
307
349
 
308
- def empty_removal_projection(base, method_name, args)
309
- return nil unless args.empty?
350
+ # Methods on a non-empty String that preserve non-emptiness
351
+ # (they transform characters but never reduce the string to "").
352
+ NON_EMPTY_STRING_PRESERVING_UNARY = Set[:upcase, :downcase, :capitalize, :swapcase, :reverse].freeze
353
+ # Methods on non-zero-int that return a non-zero-int (identity ops).
354
+ # Negation of a non-zero integer is non-zero; `to_i`/`to_int` are
355
+ # identity operations on Integer.
356
+ NON_ZERO_INT_PRESERVING_UNARY = Set[:-@, :+@, :to_i, :to_int].freeze
357
+ private_constant :NON_EMPTY_STRING_PRESERVING_UNARY, :NON_ZERO_INT_PRESERVING_UNARY
310
358
 
311
- if %i[size length count bytesize].include?(method_name)
312
- return size_returning_for_empty_removal(base, method_name)
313
- end
359
+ def empty_removal_projection(difference, method_name, args)
360
+ base = difference.base
361
+ return empty_removal_unary(difference, base, method_name) if args.empty?
362
+
363
+ empty_removal_binary(difference, base, method_name, args)
364
+ end
365
+
366
+ def empty_removal_unary(difference, base, method_name)
367
+ return size_returning_for_empty_removal(base, method_name) if
368
+ %i[size length count bytesize].include?(method_name)
369
+
370
+ predicate_result = empty_predicate_projection(base, method_name)
371
+ return predicate_result if predicate_result
372
+
373
+ return difference if base.class_name == "String" &&
374
+ NON_EMPTY_STRING_PRESERVING_UNARY.include?(method_name)
314
375
 
315
- empty_predicate_projection(base, method_name)
376
+ non_zero_int_unary_projection(difference, base, method_name)
377
+ end
378
+
379
+ def non_zero_int_unary_projection(difference, base, method_name)
380
+ return nil unless base.class_name == "Integer"
381
+ return Type::Combinator.positive_int if %i[abs magnitude].include?(method_name)
382
+ return difference if NON_ZERO_INT_PRESERVING_UNARY.include?(method_name)
383
+
384
+ nil
385
+ end
386
+
387
+ def empty_removal_binary(difference, base, method_name, args)
388
+ return empty_string_binary(difference, method_name, args) if base.class_name == "String"
389
+ return empty_integer_binary(difference, method_name, args) if base.class_name == "Integer"
390
+
391
+ nil
392
+ end
393
+
394
+ def empty_string_binary(difference, method_name, args)
395
+ return difference if method_name == :+ && args.size == 1
396
+ return non_empty_string_repeat(difference, args.first) if method_name == :* && args.size == 1
397
+
398
+ nil
399
+ end
400
+
401
+ def empty_integer_binary(difference, method_name, args)
402
+ return nil unless method_name == :* && args.size == 1
403
+ return nil unless Type::Combinator.non_zero_int_compatible?(args.first)
404
+
405
+ difference
316
406
  end
317
407
 
318
408
  def empty_predicate_projection(base, method_name)
@@ -324,6 +414,24 @@ module Rigor
324
414
  end
325
415
  end
326
416
 
417
+ # `non-empty-string * n` result:
418
+ # - `n == 0` → `Constant[""]` (any string repeated 0 times is empty)
419
+ # - `n >= 1` → `difference` (non-empty-string stays non-empty)
420
+ # - otherwise → nil (fall through, e.g. unknown n or non-negative-int)
421
+ def non_empty_string_repeat(difference, arg)
422
+ case arg
423
+ when Type::Constant
424
+ return nil unless arg.value.is_a?(Integer)
425
+
426
+ return Type::Combinator.constant_of("") if arg.value.zero?
427
+ return difference if arg.value.positive?
428
+ when Type::IntegerRange
429
+ return Type::Combinator.constant_of("") if arg.lower.zero? && arg.upper.zero?
430
+ return difference if arg.lower >= 1
431
+ end
432
+ nil
433
+ end
434
+
327
435
  def size_returning_for_empty_removal(base, method_name)
328
436
  return nil if base.class_name == "Integer" # Integer has no size method on Difference
329
437
 
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../source/node_walker"
4
+ require_relative "../scope"
5
+ require_relative "scope_indexer"
6
+
7
+ module Rigor
8
+ module Inference
9
+ # Measures the *type quality* of inferred expressions — not whether the
10
+ # engine recognises an AST node class (that is `CoverageScanner`'s job),
11
+ # but whether the type it produces carries useful static information.
12
+ #
13
+ # Each visited node is classified into one of eight precision tiers:
14
+ #
15
+ # :constant — Constant[T]: literal value known exactly
16
+ # :nominal — Nominal/Singleton: class identity known
17
+ # :shaped — Tuple/HashShape/IntegerRange/App: structure known
18
+ # :refined — Refined: narrowed by a predicate/assertion
19
+ # :bot — Bot: unreachable branch (definitively precise)
20
+ # :dynamic_specific — Dynamic[X] where X is not Top: origin partial
21
+ # :dynamic_top — Dynamic[Top]: completely opaque (the "untyped" hole)
22
+ # :top — Top: universal supertype (no information)
23
+ #
24
+ # The summary exposes `precision_ratio` (constant+nominal+shaped+refined+bot
25
+ # over total) and `opaque_ratio` (dynamic_top+top over total).
26
+ #
27
+ # For Union types the *worst* member tier is used, since the union is only
28
+ # as precise as its least-precise constituent. Intersection uses the *best*
29
+ # member (the most specific side wins). Difference follows its base type.
30
+ class PrecisionScanner
31
+ TIERS = %i[
32
+ constant nominal shaped refined bot
33
+ dynamic_specific dynamic_top top
34
+ ].freeze
35
+
36
+ TIER_RANK = TIERS.each_with_index.to_h.freeze
37
+ private_constant :TIER_RANK
38
+
39
+ PRECISE_TIERS = %i[constant nominal shaped refined bot].to_set.freeze
40
+ private_constant :PRECISE_TIERS
41
+
42
+ # Per-file result. Immutable value object.
43
+ class FileResult < Data.define(:total, :tier_counts)
44
+ def precise_count
45
+ PRECISE_TIERS.sum { |t| tier_counts.fetch(t, 0) }
46
+ end
47
+
48
+ def dynamic_top_count
49
+ tier_counts.fetch(:dynamic_top, 0)
50
+ end
51
+
52
+ def dynamic_specific_count
53
+ tier_counts.fetch(:dynamic_specific, 0)
54
+ end
55
+
56
+ def dynamic_count
57
+ dynamic_top_count + dynamic_specific_count
58
+ end
59
+
60
+ def opaque_count
61
+ tier_counts.fetch(:dynamic_top, 0) + tier_counts.fetch(:top, 0)
62
+ end
63
+
64
+ def precision_ratio
65
+ return 1.0 if total.zero?
66
+
67
+ precise_count.fdiv(total)
68
+ end
69
+
70
+ def opaque_ratio
71
+ return 0.0 if total.zero?
72
+
73
+ opaque_count.fdiv(total)
74
+ end
75
+ end
76
+
77
+ # @param scope [Rigor::Scope] base scope for type inference.
78
+ def initialize(scope: nil)
79
+ @scope = scope || Scope.empty
80
+ end
81
+
82
+ # @param root [Prism::Node] the parsed AST
83
+ # @return [FileResult]
84
+ def scan(root)
85
+ scope_index = ScopeIndexer.index(root, default_scope: @scope)
86
+ tier_counts = TIERS.to_h { |t| [t, 0] }
87
+ total = 0
88
+
89
+ Source::NodeWalker.each(root) do |node|
90
+ type = scope_index[node].type_of(node)
91
+ tier = classify(type)
92
+ tier_counts[tier] += 1
93
+ total += 1
94
+ end
95
+
96
+ FileResult.new(total: total, tier_counts: tier_counts)
97
+ end
98
+
99
+ private
100
+
101
+ def classify(type)
102
+ case type
103
+ when Type::Bot then :bot
104
+ when Type::Top then :top
105
+ when Type::Constant then :constant
106
+ when Type::Nominal, Type::Singleton then :nominal
107
+ when Type::Tuple, Type::HashShape,
108
+ Type::IntegerRange, Type::App then :shaped
109
+ when Type::Refined then :refined
110
+ when Type::Dynamic then classify_dynamic(type)
111
+ when Type::Union then worst_of(type.members)
112
+ when Type::Intersection then best_of(type.members)
113
+ when Type::Difference then classify(type.base)
114
+ else :dynamic_top
115
+ end
116
+ end
117
+
118
+ def classify_dynamic(type)
119
+ type.static_facet.is_a?(Type::Top) ? :dynamic_top : :dynamic_specific
120
+ end
121
+
122
+ def worst_of(members)
123
+ members.map { |m| classify(m) }.max_by { |t| TIER_RANK[t] } || :dynamic_top
124
+ end
125
+
126
+ def best_of(members)
127
+ members.map { |m| classify(m) }.min_by { |t| TIER_RANK[t] } || :dynamic_top
128
+ end
129
+ end
130
+ end
131
+ end
@@ -347,7 +347,20 @@ module Rigor
347
347
  # result type is precise (`Constant[:even]` instead of the
348
348
  # joined `Constant[:even] | Constant[:odd]`).
349
349
  live = live_branch_for_if(node, pred_type, post_pred)
350
- return live if live
350
+ if live
351
+ live_type, _live_scope = live
352
+ # When the provably-live then-branch terminates and there is no
353
+ # else, apply the same falsey-scope narrowing as the standard
354
+ # early-return path below. Without this, `return if @ivar.nil?`
355
+ # with an ivar seeded as Constant[nil] (making nil? = Constant[true]
356
+ # and the then-branch "provably live") propagates the un-narrowed
357
+ # nil scope past the guard instead of Bot.
358
+ if branch_terminates?(node.statements, live_type) && node.subsequent.nil?
359
+ _, falsey_scope = Narrowing.predicate_scopes(node.predicate, post_pred)
360
+ return [live_type, falsey_scope]
361
+ end
362
+ return live
363
+ end
351
364
 
352
365
  truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, post_pred)
353
366
  then_type, then_scope = eval_branch_or_nil(node.statements, truthy_scope)
@@ -376,7 +389,18 @@ module Rigor
376
389
  pred_type, post_pred = sub_eval(node.predicate, scope)
377
390
 
378
391
  live = live_branch_for_unless(node, pred_type, post_pred)
379
- return live if live
392
+ if live
393
+ live_type, _live_scope = live
394
+ # Mirror of the eval_if fix: when the provably-live unless-body
395
+ # terminates and there is no else, apply the truthy-scope narrowing
396
+ # so `return unless @ivar` with a nil-seeded ivar doesn't propagate
397
+ # the nil scope past the guard.
398
+ if branch_terminates?(node.statements, live_type) && node.else_clause.nil?
399
+ truthy_scope, = Narrowing.predicate_scopes(node.predicate, post_pred)
400
+ return [live_type, truthy_scope]
401
+ end
402
+ return live
403
+ end
380
404
 
381
405
  truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, post_pred)
382
406
  then_type, then_scope = eval_branch_or_nil(node.statements, falsey_scope)
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Rigor
6
+ module MCP
7
+ # Reads newline-delimited JSON-RPC 2.0 messages from `input`,
8
+ # dispatches each to `server`, and writes responses to `output`.
9
+ # Runs until input reaches EOF (the client closes the connection).
10
+ class Loop
11
+ def initialize(input:, output:, server:)
12
+ @input = input
13
+ @output = output
14
+ @server = server
15
+ end
16
+
17
+ def run
18
+ @input.each_line do |raw|
19
+ line = raw.chomp
20
+ next if line.empty?
21
+
22
+ begin
23
+ request = JSON.parse(line)
24
+ rescue JSON::ParserError => e
25
+ write_response({ "jsonrpc" => "2.0", "id" => nil,
26
+ "error" => { "code" => -32_700, "message" => "Parse error: #{e.message}" } })
27
+ next
28
+ end
29
+
30
+ response = @server.handle(request)
31
+ write_response(response) if response
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def write_response(response)
38
+ @output.puts(JSON.generate(response))
39
+ @output.flush
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "stringio"
5
+
6
+ module Rigor
7
+ module MCP
8
+ # JSON-RPC 2.0 dispatcher for the MCP server.
9
+ #
10
+ # Each public `handle` call takes a parsed request hash and returns
11
+ # a response hash (or nil for notifications that require no reply).
12
+ # Tool implementations delegate to `CLI.new(argv, out:, err:).run`
13
+ # with StringIO capture — every tool stays in sync with its CLI
14
+ # counterpart automatically (ADR-33 WD4).
15
+ class Server # rubocop:disable Metrics/ClassLength
16
+ PROTOCOL_VERSION = "2024-11-05"
17
+
18
+ TOOLS = [
19
+ {
20
+ name: "rigor_check",
21
+ description: "Analyze Ruby files for type errors, undefined methods, arity mismatches, " \
22
+ "and nil-receiver risks. Returns a JSON diagnostic report.",
23
+ inputSchema: {
24
+ type: "object",
25
+ properties: {
26
+ paths: {
27
+ type: "array",
28
+ items: { type: "string" },
29
+ description: "Files or directories to analyze (required)"
30
+ },
31
+ config: { type: "string", description: "Path to .rigor.yml (optional)" }
32
+ },
33
+ required: ["paths"]
34
+ }
35
+ },
36
+ {
37
+ name: "rigor_type_of",
38
+ description: "Get the inferred type of the expression at a specific location in a Ruby file.",
39
+ inputSchema: {
40
+ type: "object",
41
+ properties: {
42
+ file: { type: "string", description: "Path to the Ruby file" },
43
+ line: { type: "integer", description: "1-based line number" },
44
+ col: { type: "integer", description: "1-based column number" },
45
+ config: { type: "string", description: "Path to .rigor.yml (optional)" }
46
+ },
47
+ required: %w[file line col]
48
+ }
49
+ },
50
+ {
51
+ name: "rigor_triage",
52
+ description: "Summarize a project's diagnostics: rule distribution, per-file hotspots, " \
53
+ "and heuristic hints for the most common error clusters. Returns JSON. " \
54
+ "Useful for understanding the shape of a diagnostic set before deciding what to fix.",
55
+ inputSchema: {
56
+ type: "object",
57
+ properties: {
58
+ paths: {
59
+ type: "array",
60
+ items: { type: "string" },
61
+ description: "Files or directories to analyze (defaults to configured paths)"
62
+ },
63
+ top: { type: "integer", description: "Number of hotspot files to include (default: 10)" },
64
+ config: { type: "string", description: "Path to .rigor.yml (optional)" }
65
+ }
66
+ }
67
+ },
68
+ {
69
+ name: "rigor_annotate",
70
+ description: "Return the given Ruby source file with each line's last-expression type " \
71
+ "appended as a comment. Useful for understanding how Rigor infers types " \
72
+ "through a file.",
73
+ inputSchema: {
74
+ type: "object",
75
+ properties: {
76
+ file: { type: "string", description: "Path to the Ruby file to annotate" },
77
+ config: { type: "string", description: "Path to .rigor.yml (optional)" }
78
+ },
79
+ required: ["file"]
80
+ }
81
+ },
82
+ {
83
+ name: "rigor_sig_gen",
84
+ description: "Generate RBS skeleton signatures inferred from Ruby source files. " \
85
+ "Returns a JSON report of candidates with their classifications " \
86
+ "(new-file, new-method, tighter-return, equivalent, skipped).",
87
+ inputSchema: {
88
+ type: "object",
89
+ properties: {
90
+ paths: {
91
+ type: "array",
92
+ items: { type: "string" },
93
+ description: "Files or directories to generate signatures for (defaults to configured paths)"
94
+ },
95
+ params: {
96
+ type: "string",
97
+ enum: %w[untyped observed],
98
+ description: "Parameter policy: untyped (default) or observed " \
99
+ "(harvests call-site argument types from spec/)"
100
+ },
101
+ config: { type: "string", description: "Path to .rigor.yml (optional)" }
102
+ }
103
+ }
104
+ },
105
+ {
106
+ name: "rigor_explain",
107
+ description: "Look up the description of one or all Rigor diagnostic rules. " \
108
+ "Returns JSON. Without a rule argument, returns the full catalog.",
109
+ inputSchema: {
110
+ type: "object",
111
+ properties: {
112
+ rule: {
113
+ type: "string",
114
+ description: "Rule ID, legacy alias, or family prefix (call, flow, assert, dump, def). " \
115
+ "Omit to list every rule."
116
+ }
117
+ }
118
+ }
119
+ },
120
+ {
121
+ name: "rigor_coverage",
122
+ description: "Report type-precision coverage: the ratio of expressions Rigor types as " \
123
+ "Constant / Nominal / shaped / refined (precise) vs Dynamic or top (opaque). " \
124
+ "Returns JSON. Useful for measuring the impact of adding new fold rules.",
125
+ inputSchema: {
126
+ type: "object",
127
+ properties: {
128
+ paths: {
129
+ type: "array",
130
+ items: { type: "string" },
131
+ description: "Files or directories to scan (required)"
132
+ },
133
+ config: { type: "string", description: "Path to .rigor.yml (optional)" }
134
+ },
135
+ required: ["paths"]
136
+ }
137
+ }
138
+ ].freeze
139
+
140
+ def initialize(config_path: nil, err: $stderr)
141
+ @config_path = config_path
142
+ @err = err
143
+ end
144
+
145
+ # Dispatches a parsed JSON-RPC request hash.
146
+ # Returns nil for notifications (requests without an `id`).
147
+ def handle(request)
148
+ id = request["id"]
149
+ method_name = request["method"]
150
+
151
+ # Notifications carry no `id` and require no response.
152
+ return nil if id.nil?
153
+
154
+ case method_name
155
+ when "initialize" then handle_initialize(id)
156
+ when "ping" then success(id, {})
157
+ when "tools/list" then success(id, { tools: TOOLS })
158
+ when "tools/call"
159
+ call_tool(id,
160
+ request.dig("params", "name"),
161
+ request.dig("params", "arguments") || {})
162
+ else
163
+ error(id, -32_601, "Method not found: #{method_name.inspect}")
164
+ end
165
+ end
166
+
167
+ private
168
+
169
+ def handle_initialize(id)
170
+ require_relative "../version"
171
+ success(id, {
172
+ protocolVersion: PROTOCOL_VERSION,
173
+ capabilities: { tools: { listChanged: false } },
174
+ serverInfo: { name: "rigor", version: Rigor::VERSION }
175
+ })
176
+ end
177
+
178
+ def call_tool(id, name, args)
179
+ argv = build_argv(name, args)
180
+ return error(id, -32_602, "Unknown tool: #{name.inspect}") unless argv
181
+
182
+ out_io = StringIO.new
183
+ err_io = StringIO.new
184
+ require_relative "../cli"
185
+ exit_code = CLI.new(argv, out: out_io, err: err_io).run
186
+
187
+ is_error = exit_code == CLI::EXIT_USAGE
188
+ text = out_io.string
189
+ text = err_io.string if text.empty? && is_error
190
+
191
+ success(id, { content: [{ type: "text", text: text }], isError: is_error })
192
+ rescue StandardError => e
193
+ @err.puts("rigor mcp: #{e.class}: #{e.message}")
194
+ @err.puts(e.backtrace.first(5).join("\n")) if e.backtrace
195
+ success(id, {
196
+ content: [{ type: "text", text: "Internal error: #{e.message}" }],
197
+ isError: true
198
+ })
199
+ end
200
+
201
+ def build_argv(name, args) # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity,Metrics/AbcSize,Metrics/PerceivedComplexity
202
+ effective_config = args["config"] || @config_path
203
+
204
+ case name
205
+ when "rigor_check"
206
+ argv = ["check", "--format=json", "--no-stats"]
207
+ argv << "--config=#{effective_config}" if effective_config
208
+ argv += Array(args["paths"])
209
+ argv
210
+
211
+ when "rigor_type_of"
212
+ return nil unless args["file"] && args["line"] && args["col"]
213
+
214
+ argv = ["type-of", "--format=json"]
215
+ argv << "--config=#{effective_config}" if effective_config
216
+ argv << "#{args['file']}:#{args['line']}:#{args['col']}"
217
+ argv
218
+
219
+ when "rigor_triage"
220
+ argv = ["triage", "--format=json"]
221
+ argv << "--config=#{effective_config}" if effective_config
222
+ argv << "--top=#{args['top']}" if args["top"]
223
+ argv += Array(args["paths"])
224
+ argv
225
+
226
+ when "rigor_annotate"
227
+ return nil unless args["file"]
228
+
229
+ argv = ["annotate", "--no-color"]
230
+ argv << "--config=#{effective_config}" if effective_config
231
+ argv << args["file"]
232
+ argv
233
+
234
+ when "rigor_sig_gen"
235
+ argv = ["sig-gen", "--print", "--format=json"]
236
+ argv << "--config=#{effective_config}" if effective_config
237
+ argv << "--params=#{args['params']}" if args["params"]
238
+ argv += Array(args["paths"])
239
+ argv
240
+
241
+ when "rigor_explain"
242
+ argv = ["explain", "--format=json"]
243
+ argv << args["rule"] if args["rule"]
244
+ argv
245
+
246
+ when "rigor_coverage"
247
+ argv = ["coverage", "--format=json"]
248
+ argv << "--config=#{effective_config}" if effective_config
249
+ argv += Array(args["paths"])
250
+ argv
251
+ end
252
+ end
253
+
254
+ def success(id, result)
255
+ { "jsonrpc" => "2.0", "id" => id, "result" => result }
256
+ end
257
+
258
+ def error(id, code, message)
259
+ { "jsonrpc" => "2.0", "id" => id, "error" => { "code" => code, "message" => message } }
260
+ end
261
+ end
262
+ end
263
+ end
data/lib/rigor/mcp.rb ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ # The MCP (Model Context Protocol) server subsystem.
5
+ # See `docs/adr/33-mcp-server.md` for the design.
6
+ #
7
+ # Entry point: `rigor mcp --transport stdio`.
8
+ # The server exposes Rigor's analysis tools (check, type-of, triage,
9
+ # annotate, sig-gen, explain, coverage) as MCP tool calls over a
10
+ # newline-delimited JSON-RPC 2.0 stdio stream.
11
+ module MCP
12
+ end
13
+ end
14
+
15
+ require_relative "mcp/server"
16
+ require_relative "mcp/loop"