rigortype 0.1.10 → 0.1.12

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 (144) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/baseline.rb +51 -15
  3. data/lib/rigor/analysis/erb_template_detector.rb +38 -0
  4. data/lib/rigor/analysis/runner.rb +6 -1
  5. data/lib/rigor/analysis/worker_session.rb +6 -1
  6. data/lib/rigor/cli/baseline_command.rb +4 -3
  7. data/lib/rigor/cli/plugins_command.rb +308 -0
  8. data/lib/rigor/cli/plugins_renderer.rb +173 -0
  9. data/lib/rigor/cli.rb +44 -3
  10. data/lib/rigor/inference/block_parameter_binder.rb +35 -0
  11. data/lib/rigor/inference/expression_typer.rb +69 -30
  12. data/lib/rigor/inference/indexed_narrowing.rb +187 -0
  13. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +24 -0
  14. data/lib/rigor/inference/method_dispatcher.rb +23 -0
  15. data/lib/rigor/inference/mutation_widening.rb +285 -0
  16. data/lib/rigor/inference/narrowing.rb +72 -4
  17. data/lib/rigor/inference/scope_indexer.rb +409 -12
  18. data/lib/rigor/inference/statement_evaluator.rb +256 -4
  19. data/lib/rigor/scope.rb +181 -4
  20. data/lib/rigor/version.rb +1 -1
  21. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
  22. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
  23. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
  24. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
  25. data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
  26. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +199 -0
  27. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +398 -0
  28. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +86 -0
  29. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +183 -0
  30. data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
  31. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +713 -0
  32. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +201 -0
  33. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +226 -0
  34. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +261 -0
  35. data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
  36. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
  37. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
  38. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
  39. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
  40. data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
  41. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +283 -0
  42. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
  43. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
  44. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
  45. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +250 -0
  46. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +98 -0
  47. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +590 -0
  48. data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
  49. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
  50. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
  51. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
  52. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
  53. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
  54. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
  55. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +37 -0
  56. data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
  57. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +478 -0
  58. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
  59. data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
  60. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
  61. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
  62. data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
  63. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
  64. data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
  65. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
  66. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
  67. data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
  68. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
  69. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
  70. data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
  71. data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
  72. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
  73. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
  74. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
  75. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
  76. data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
  77. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
  78. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
  79. data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
  80. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
  81. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
  82. data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
  83. data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
  84. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
  85. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
  86. data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
  87. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
  88. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
  89. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
  90. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
  91. data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
  92. data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
  93. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +353 -0
  94. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
  95. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
  96. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +175 -0
  97. data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
  98. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +350 -0
  99. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +264 -0
  100. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +100 -0
  101. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +175 -0
  102. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +164 -0
  103. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1538 -0
  104. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +235 -0
  105. data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
  106. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
  107. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
  108. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
  109. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
  110. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
  111. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
  112. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
  113. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
  114. data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
  115. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
  116. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
  117. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
  118. data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
  119. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
  120. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
  121. data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
  122. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
  123. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
  124. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
  125. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
  126. data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
  127. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
  128. data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
  129. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
  130. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
  131. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
  132. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
  133. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
  134. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
  135. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
  136. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
  137. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
  138. data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
  139. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
  140. data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
  141. data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
  142. data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
  143. data/sig/rigor/scope.rbs +22 -0
  144. metadata +157 -1
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Rigor
6
+ class CLI
7
+ # Renderer for `rigor plugins`. Produces a human-readable
8
+ # text table and a JSON representation from the same row
9
+ # shape (the Hash documented on
10
+ # {Rigor::CLI::PluginsCommand#loaded_row}).
11
+ #
12
+ # The two formats carry the same content; JSON is meant for
13
+ # tooling (SKILLs, CI, editor integrations) while text is
14
+ # for interactive inspection. Rows are printed in the order
15
+ # the loader resolved them.
16
+ class PluginsRenderer
17
+ def initialize(rows:, configuration_path:)
18
+ @rows = rows
19
+ @configuration_path = configuration_path
20
+ end
21
+
22
+ def text
23
+ lines = []
24
+ lines << header
25
+ lines << ""
26
+ @rows.each_with_index do |row, index|
27
+ lines.concat(row_lines(row))
28
+ lines << "" unless index == @rows.size - 1
29
+ end
30
+ lines << ""
31
+ lines << footer
32
+ lines.join("\n")
33
+ end
34
+
35
+ def json
36
+ JSON.pretty_generate(
37
+ {
38
+ "configuration" => @configuration_path,
39
+ "plugins" => @rows.map { |row| row_json(row) },
40
+ "summary" => summary
41
+ }
42
+ )
43
+ end
44
+
45
+ private
46
+
47
+ def header
48
+ loaded = @rows.count { |r| r[:status] == :loaded }
49
+ errored = @rows.count { |r| r[:status] == :load_error }
50
+ config = @configuration_path || "(no .rigor.yml found; using defaults)"
51
+ "Plugin activation report\n " \
52
+ "configuration: #{config}\n " \
53
+ "loaded: #{loaded} load-error: #{errored}"
54
+ end
55
+
56
+ def footer
57
+ errored = @rows.select { |r| r[:status] == :load_error }
58
+ if errored.empty?
59
+ "All configured plugins loaded successfully."
60
+ else
61
+ "#{errored.size} plugin(s) failed to load — see above. " \
62
+ "Run with --strict to make this a CI gate."
63
+ end
64
+ end
65
+
66
+ def row_lines(row)
67
+ marker = row[:status] == :loaded ? "OK " : "ERR"
68
+ head = if row[:status] == :loaded
69
+ " [#{marker}] #{row[:id]} v#{row[:version]} (#{row[:gem]})"
70
+ else
71
+ " [#{marker}] #{row[:gem]}"
72
+ end
73
+ lines = [head]
74
+ lines << " #{row[:description]}" if row[:description]
75
+
76
+ if row[:status] == :load_error
77
+ lines << " load error: #{row[:load_error]}"
78
+ lines << " config: #{row[:config].inspect}" if row[:config] && !row[:config].empty?
79
+ return lines
80
+ end
81
+
82
+ lines.concat(loaded_detail_lines(row))
83
+ lines
84
+ end
85
+
86
+ def loaded_detail_lines(row)
87
+ lines = []
88
+ lines.concat(signature_paths_lines(row[:signature_paths])) if row[:signature_paths].any?
89
+ lines.concat(receiver_and_fact_lines(row))
90
+ lines.concat(macro_substrate_lines(row))
91
+ lines.concat(extra_surface_lines(row))
92
+ lines << " config: #{row[:config].inspect}" if row[:config] && !row[:config].empty?
93
+ lines
94
+ end
95
+
96
+ def receiver_and_fact_lines(row)
97
+ lines = []
98
+ lines << " open_receivers: #{row[:open_receivers].join(', ')}" if row[:open_receivers].any?
99
+ lines << " owns_receivers: #{row[:owns_receivers].join(', ')}" if row[:owns_receivers].any?
100
+ lines << " produces: #{row[:produces].join(', ')}" if row[:produces].any?
101
+ lines << " consumes: #{row[:consumes].join(', ')}" if row[:consumes].any?
102
+ lines
103
+ end
104
+
105
+ def extra_surface_lines(row)
106
+ lines = []
107
+ lines << " protocol_contracts: #{row[:protocol_contracts]}" if row[:protocol_contracts].positive?
108
+ lines << " source_rbs_synthesizer: yes" if row[:source_rbs_synthesizer]
109
+ lines << " type_node_resolvers: #{row[:type_node_resolvers]}" if row[:type_node_resolvers].positive?
110
+ if row[:hkt_registrations].positive? || row[:hkt_definitions].positive?
111
+ lines << " hkt: #{row[:hkt_registrations]} registration(s), #{row[:hkt_definitions]} definition(s)"
112
+ end
113
+ lines
114
+ end
115
+
116
+ def signature_paths_lines(paths)
117
+ lines = [" signature_paths:"]
118
+ paths.each do |entry|
119
+ marker = entry[:exists] ? "" : " (MISSING)"
120
+ lines << " #{entry[:path]} (#{entry[:rbs_files]} .rbs file(s))#{marker}"
121
+ end
122
+ lines
123
+ end
124
+
125
+ def macro_substrate_lines(row)
126
+ parts = []
127
+ parts << "block_as_methods=#{row[:block_as_methods]}" if row[:block_as_methods].positive?
128
+ parts << "heredoc_templates=#{row[:heredoc_templates]}" if row[:heredoc_templates].positive?
129
+ parts << "trait_registries=#{row[:trait_registries]}" if row[:trait_registries].positive?
130
+ parts << "external_files=#{row[:external_files]}" if row[:external_files].positive?
131
+ return [] if parts.empty?
132
+
133
+ [" macro substrate: #{parts.join(', ')}"]
134
+ end
135
+
136
+ def row_json(row)
137
+ {
138
+ "gem" => row[:gem],
139
+ "status" => row[:status].to_s,
140
+ "id" => row[:id],
141
+ "version" => row[:version],
142
+ "description" => row[:description],
143
+ "config" => row[:config],
144
+ "signature_paths" => row[:signature_paths].map do |sp|
145
+ { "path" => sp[:path], "exists" => sp[:exists], "rbs_files" => sp[:rbs_files] }
146
+ end,
147
+ "open_receivers" => row[:open_receivers],
148
+ "owns_receivers" => row[:owns_receivers],
149
+ "produces" => row[:produces],
150
+ "consumes" => row[:consumes],
151
+ "block_as_methods" => row[:block_as_methods],
152
+ "heredoc_templates" => row[:heredoc_templates],
153
+ "trait_registries" => row[:trait_registries],
154
+ "external_files" => row[:external_files],
155
+ "type_node_resolvers" => row[:type_node_resolvers],
156
+ "hkt_registrations" => row[:hkt_registrations],
157
+ "hkt_definitions" => row[:hkt_definitions],
158
+ "protocol_contracts" => row[:protocol_contracts],
159
+ "source_rbs_synthesizer" => row[:source_rbs_synthesizer],
160
+ "load_error" => row[:load_error]
161
+ }
162
+ end
163
+
164
+ def summary
165
+ {
166
+ "total" => @rows.size,
167
+ "loaded" => @rows.count { |r| r[:status] == :loaded },
168
+ "load_error" => @rows.count { |r| r[:status] == :load_error }
169
+ }
170
+ end
171
+ end
172
+ end
173
+ end
data/lib/rigor/cli.rb CHANGED
@@ -31,7 +31,9 @@ module Rigor
31
31
  "mcp" => :run_mcp,
32
32
  "baseline" => :run_baseline,
33
33
  "triage" => :run_triage,
34
- "coverage" => :run_coverage
34
+ "coverage" => :run_coverage,
35
+ "plugins" => :run_plugins,
36
+ "playground" => :run_playground
35
37
  }.freeze
36
38
 
37
39
  def self.start(argv = ARGV, out: $stdout, err: $stderr)
@@ -120,7 +122,7 @@ module Rigor
120
122
  return false
121
123
  end
122
124
 
123
- baseline = Analysis::Baseline.load(path)
125
+ baseline = Analysis::Baseline.load(path, project_root: Dir.pwd)
124
126
  return false if baseline.nil? || baseline.empty?
125
127
 
126
128
  drifted = baseline.audit(raw_diagnostics).reject { |row| row.status == :within }
@@ -163,7 +165,7 @@ module Rigor
163
165
  path = resolve_baseline_path(configuration, options)
164
166
  return result if path.nil?
165
167
 
166
- baseline = Analysis::Baseline.load(path)
168
+ baseline = Analysis::Baseline.load(path, project_root: Dir.pwd)
167
169
  return result if baseline.nil?
168
170
 
169
171
  surfaced, silenced_count = baseline.filter(result.diagnostics)
@@ -474,9 +476,29 @@ module Rigor
474
476
 
475
477
  File.write(path, init_template)
476
478
  @out.puts("Created #{path}")
479
+ print_init_next_steps(path)
477
480
  0
478
481
  end
479
482
 
483
+ # `rigor init`'s template ships empty `plugins:` so a fresh
484
+ # init has nothing to validate — but the moment the user adds
485
+ # any plugin entry, the activation-failure surfaces enumerated
486
+ # in `rigor plugins`'s docstring become real. Point them at
487
+ # the verification command + the canonical readiness flow so
488
+ # silent failures (the cwd / Gemfile / signature_paths
489
+ # mismatches that surfaced during the Mastodon trial) get
490
+ # caught the first time the user wires a plugin, not the first
491
+ # time `rigor check` reports false positives that should have
492
+ # been covered.
493
+ def print_init_next_steps(path)
494
+ @out.puts ""
495
+ @out.puts "Next steps:"
496
+ @out.puts " 1. Edit #{path} — add the `plugins:` your project needs."
497
+ @out.puts " 2. Run `rigor plugins` to verify every configured plugin loads."
498
+ @out.puts " (`--strict` exits 1 on failure; ideal CI gate.)"
499
+ @out.puts " 3. Run `rigor check` to analyse your code."
500
+ end
501
+
480
502
  # Renders the starter `.rigor.yml` body. The template
481
503
  # serialises `Configuration::DEFAULTS` (so the on-disk file
482
504
  # round-trips through `Configuration.load`) and prepends a
@@ -596,6 +618,23 @@ module Rigor
596
618
  CLI::CoverageCommand.new(argv: @argv, out: @out, err: @err).run
597
619
  end
598
620
 
621
+ def run_plugins
622
+ require_relative "cli/plugins_command"
623
+
624
+ CLI::PluginsCommand.new(argv: @argv, out: @out, err: @err).run
625
+ end
626
+
627
+ def run_playground
628
+ begin
629
+ require "rigor/playground"
630
+ rescue LoadError
631
+ @err.puts "rigor playground requires the rigor-playground gem."
632
+ @err.puts "Install it with: gem install rigor-playground"
633
+ return EXIT_USAGE
634
+ end
635
+ Rigor::CLI::PlaygroundCommand.new(@argv[1..], @out, @err).run
636
+ end
637
+
599
638
  def write_result(result, format)
600
639
  case format
601
640
  when "json"
@@ -641,6 +680,8 @@ module Rigor
641
680
  mcp Run the Rigor MCP server over stdio (ADR-33)
642
681
  triage Summarise diagnostics: distribution, hotspots, hints (ADR-23)
643
682
  coverage Report type-precision coverage (precise vs Dynamic ratio)
683
+ plugins Report activation status of every configured plugin
684
+ playground Start the browser playground (requires rigor-playground gem)
644
685
  version Print the Rigor version
645
686
  help Print this help
646
687
  HELP
@@ -106,6 +106,8 @@ module Rigor
106
106
  params_node = params_root.parameters
107
107
  return {} if params_node.nil?
108
108
 
109
+ apply_auto_splat(params_node)
110
+
109
111
  bindings = {}
110
112
  bind_positionals(params_node, bindings, 0)
111
113
  bind_rest(params_node, bindings)
@@ -115,6 +117,39 @@ module Rigor
115
117
  bindings
116
118
  end
117
119
 
120
+ # Ruby blocks (NOT lambdas) auto-splat a single yielded
121
+ # Tuple-shaped value when the block declares more than one
122
+ # required positional parameter:
123
+ #
124
+ # { a: 1 }.each { |k, v| ... }
125
+ #
126
+ # yields `[key, value]` as a single arg, but the two-param
127
+ # block sees `k = key, v = value`. RBS / IteratorDispatch
128
+ # encode this as the block taking ONE `[K, V]` Tuple
129
+ # parameter; without this fix-up the binder would assign
130
+ # `k = Tuple[K, V]` and `v = Dynamic[Top]`, and any call
131
+ # on `k.<method-not-on-Tuple>` would false-fire.
132
+ #
133
+ # The rule fires only when (a) the receiver yields exactly
134
+ # one value (`expected_param_types.size == 1`), (b) the
135
+ # block declares more than one positional slot, and (c)
136
+ # that single expected element is a Tuple. Multi-arg yields
137
+ # (e.g. `each_with_index`'s `(element, index)` pair) are
138
+ # NOT auto-splatted — matching Ruby semantics where a
139
+ # multi-arg yield to a `|a, b, c|` block fills the extra
140
+ # slot with nil rather than splatting any element.
141
+ def apply_auto_splat(params_node)
142
+ return unless @expected_param_types.size == 1
143
+
144
+ pos_count = params_node.requireds.size + params_node.optionals.size + params_node.posts.size
145
+ return unless pos_count > 1
146
+
147
+ first = @expected_param_types[0]
148
+ return unless first.is_a?(Type::Tuple)
149
+
150
+ @expected_param_types = first.elements
151
+ end
152
+
118
153
  def bind_positionals(params_node, bindings, cursor)
119
154
  cursor = bind_required_positionals(params_node, bindings, cursor)
120
155
  cursor = bind_optional_positionals(params_node, bindings, cursor)
@@ -6,6 +6,7 @@ require_relative "../type"
6
6
  require_relative "../ast"
7
7
  require_relative "block_parameter_binder"
8
8
  require_relative "fallback"
9
+ require_relative "indexed_narrowing"
9
10
  require_relative "macro_block_self_type"
10
11
  require_relative "method_dispatcher"
11
12
  require_relative "narrowing"
@@ -1133,6 +1134,69 @@ module Rigor
1133
1134
  type_of(body.last)
1134
1135
  end
1135
1136
 
1137
+ # Indexed-collection narrowing — `receiver[key]` after a
1138
+ # prior `receiver[key] ||= default` reads the post-`||=`
1139
+ # type when the receiver and key are stable enough to
1140
+ # address. Sits ahead of `MethodDispatcher.dispatch` so
1141
+ # the standard `Hash#[]` / `Array#[]` answer (which would
1142
+ # fold to `Constant[nil]` for an empty `HashShape{}` or
1143
+ # `Tuple[]`) does not override the narrowing. See
1144
+ # {Inference::IndexedNarrowing}.
1145
+ def indexed_narrowing_for(node)
1146
+ IndexedNarrowing.lookup_for_call(node, scope) || method_chain_narrowing_for(node)
1147
+ end
1148
+
1149
+ # Stable single-hop chain narrowing — `receiver.method`
1150
+ # after an `is_a?` / `kind_of?` / `instance_of?` predicate
1151
+ # established the narrowing on the dominated edge. The
1152
+ # call MUST be no-arg + no-block + rooted at a local-var /
1153
+ # ivar read; everything else falls through to the
1154
+ # standard dispatcher. ROADMAP § Future cycles —
1155
+ # "Method-call receiver narrowing across stable
1156
+ # receivers" — Law-of-Demeter-justified single-hop scope.
1157
+ def method_chain_narrowing_for(node)
1158
+ return nil unless node.is_a?(Prism::CallNode)
1159
+ return nil unless node.block.nil?
1160
+ return nil unless node.arguments.nil? || node.arguments.arguments.empty?
1161
+
1162
+ case node.receiver
1163
+ when Prism::LocalVariableReadNode
1164
+ scope.method_chain_narrowing(:local, node.receiver.name, node.name)
1165
+ when Prism::InstanceVariableReadNode
1166
+ scope.method_chain_narrowing(:ivar, node.receiver.name, node.name)
1167
+ end
1168
+ end
1169
+
1170
+ # v0.0.3 A — implicit-self calls prefer a same-named
1171
+ # top-level `def` over RBS dispatch. Without this,
1172
+ # a helper like `def select(...)` defined inside an
1173
+ # `RSpec.describe ... do ... end` block mis-routes
1174
+ # through `Enumerable#select` / `Object#select` and
1175
+ # the caller observes `Array[Elem]` instead of the
1176
+ # helper's actual return type. The check fires only
1177
+ # for `node.receiver.nil?` (true implicit self), so
1178
+ # explicit-receiver dispatch is unaffected.
1179
+ def try_local_def_dispatch(node, receiver, arg_types)
1180
+ local_def = node.receiver.nil? ? scope.top_level_def_for(node.name) : nil
1181
+ return nil unless local_def
1182
+
1183
+ local_inference = infer_top_level_user_method(local_def, receiver, arg_types)
1184
+ return local_inference if local_inference && adoptable_self_call_result?(local_inference)
1185
+
1186
+ # The local def matches by name but the inference was
1187
+ # disqualified — either the parameter shape is too complex
1188
+ # for the first-iteration binder (kwargs / optionals /
1189
+ # rest), or ADR-24 slice 1's conservative gate declined
1190
+ # the resolved return type inside a class body (see
1191
+ # `adoptable_self_call_result?`). `Dynamic[Top]` is the
1192
+ # safest answer: RBS dispatch would be wrong (the method
1193
+ # is user-defined and shadows whatever ancestor method the
1194
+ # dispatch would find), and `Dynamic[Top]` propagates
1195
+ # correctly through downstream call chains without
1196
+ # surfacing misleading false-positive diagnostics.
1197
+ dynamic_top
1198
+ end
1199
+
1136
1200
  # Slice 2 routes call expressions through `MethodDispatcher`. The
1137
1201
  # receiver and every argument are typed first, then the dispatcher is
1138
1202
  # asked for a result type. A nil result triggers the fail-soft fallback
@@ -1140,40 +1204,15 @@ module Rigor
1140
1204
  # their own fallbacks for unrecognised receivers/args, so the tracer
1141
1205
  # captures both the immediate dispatch miss and the deeper cause).
1142
1206
  def call_type_for(node)
1207
+ narrowed = indexed_narrowing_for(node)
1208
+ return narrowed if narrowed
1209
+
1143
1210
  receiver = call_receiver_type_for(node)
1144
1211
  arg_types = call_arg_types(node)
1145
1212
  block_type = block_return_type_for(node, receiver, arg_types)
1146
1213
 
1147
- # v0.0.3 A implicit-self calls prefer a same-named
1148
- # top-level `def` over RBS dispatch. Without this,
1149
- # a helper like `def select(...)` defined inside an
1150
- # `RSpec.describe ... do ... end` block mis-routes
1151
- # through `Enumerable#select` / `Object#select` and
1152
- # the caller observes `Array[Elem]` instead of the
1153
- # helper's actual return type. The check fires only
1154
- # for `node.receiver.nil?` (true implicit self), so
1155
- # explicit-receiver dispatch is unaffected.
1156
- local_def = node.receiver.nil? ? scope.top_level_def_for(node.name) : nil
1157
- if local_def
1158
- local_inference = infer_top_level_user_method(local_def, receiver, arg_types)
1159
- return local_inference if local_inference && adoptable_self_call_result?(local_inference)
1160
-
1161
- # The local def matches by name but the inference
1162
- # was disqualified — either the parameter shape is
1163
- # too complex for the first-iteration binder
1164
- # (kwargs / optionals / rest), or ADR-24 slice 1's
1165
- # conservative gate declined the resolved return
1166
- # type inside a class body (see
1167
- # `adoptable_self_call_result?`).
1168
- # Returning `Dynamic[Top]` is the safest answer:
1169
- # we know RBS dispatch would be wrong (the
1170
- # method is user-defined and shadows whatever
1171
- # ancestor method the dispatch would find), and
1172
- # `Dynamic[Top]` propagates correctly through
1173
- # downstream call chains without surfacing
1174
- # misleading false-positive diagnostics.
1175
- return dynamic_top
1176
- end
1214
+ local_def_result = try_local_def_dispatch(node, receiver, arg_types)
1215
+ return local_def_result if local_def_result
1177
1216
 
1178
1217
  # v0.0.6 phase 2 — per-element block fold for Tuple
1179
1218
  # receivers. When `[a, b, c].map { |x| f(x) }` and the
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../type"
6
+ require_relative "mutation_widening"
7
+
8
+ module Rigor
9
+ module Inference
10
+ # Closes the "`params[:f] ||= []; params[:f] << x`" precision
11
+ # gap surfaced by the Redmine 6.1.2 `Query#as_params` survey
12
+ # (ROADMAP § Future cycles / Type-language / engine —
13
+ # "Indexed-collection narrowing through `Hash[k] ||= default`").
14
+ #
15
+ # After `receiver[key] ||= default` the next read at
16
+ # `receiver[key]` is known non-nil, but Rigor types each
17
+ # `Hash#[]` independently and the subsequent `<<` / `[]=` /
18
+ # other mutator dispatches against the un-narrowed result —
19
+ # which on a `HashShape{}` carrier folds to `Constant[nil]`.
20
+ #
21
+ # This module is the address-recogniser + invalidator shared
22
+ # by {Inference::StatementEvaluator}'s `eval_index_or_write`
23
+ # handler (which RECORDS the narrowing) and `eval_call`
24
+ # (which INVALIDATES on intervening writes / mutators) and
25
+ # by {Inference::ExpressionTyper}'s `call_type_for` (which
26
+ # CONSUMES the narrowing when typing a follow-up `[]` read).
27
+ #
28
+ # **Stable receivers.** A receiver is "stable" iff it is a
29
+ # `LocalVariableReadNode` or `InstanceVariableReadNode`.
30
+ # Method-call chains (`foo.bar[:k]`) and other shapes are
31
+ # rejected because a follow-up read against an identical-
32
+ # looking AST chain has no guarantee of resolving to the
33
+ # same runtime value — narrowing it would invent a fact.
34
+ #
35
+ # **Stable keys.** A key is "stable" iff it is a literal
36
+ # `SymbolNode` / `StringNode` / `IntegerNode`. Local-variable
37
+ # keys (`params[field]`) are excluded for the same
38
+ # invent-a-fact reason: the local could be rebound between
39
+ # the `||=` and the read.
40
+ #
41
+ # **Invalidation.** Three conditions drop a recorded
42
+ # narrowing:
43
+ # - The receiver variable is rebound (handled inside
44
+ # `Scope#with_local` / `Scope#with_ivar`).
45
+ # - An intervening `receiver[key] = value` writes the same
46
+ # slot — `:[]=` could rebind the slot to nil; conservative
47
+ # drop.
48
+ # - An intervening mutator from {MutationWidening::HASH_MUTATORS}
49
+ # or {MutationWidening::ARRAY_MUTATORS} runs against the
50
+ # receiver (e.g. `params.delete(:f)`, `params.clear`).
51
+ #
52
+ # All three are implemented in `StatementEvaluator#eval_call`'s
53
+ # post-dispatch path through {.invalidate_after_call}.
54
+ module IndexedNarrowing
55
+ # Literal Prism nodes whose Ruby value the analyzer trusts
56
+ # as a stable address. Symbol / String are the dominant
57
+ # Hash key shapes; Integer covers numerically-keyed Hashes
58
+ # and Array indices.
59
+ STABLE_KEY_NODES = [Prism::SymbolNode, Prism::StringNode, Prism::IntegerNode].freeze
60
+
61
+ module_function
62
+
63
+ # Returns `[receiver_kind, receiver_name]` when `node` is a
64
+ # `LocalVariableReadNode` or `InstanceVariableReadNode`,
65
+ # otherwise nil.
66
+ def stable_receiver(node)
67
+ case node
68
+ when Prism::LocalVariableReadNode then [:local, node.name]
69
+ when Prism::InstanceVariableReadNode then [:ivar, node.name]
70
+ end
71
+ end
72
+
73
+ # Returns the literal Ruby value when `node` is a stable
74
+ # key shape, otherwise nil. Symbols → `Symbol`,
75
+ # Strings → `String` (unescaped), Integers → `Integer`.
76
+ def stable_key(node)
77
+ case node
78
+ when Prism::SymbolNode then node.unescaped.to_sym
79
+ when Prism::StringNode then node.unescaped
80
+ when Prism::IntegerNode then node.value
81
+ end
82
+ end
83
+
84
+ # Returns `[receiver_kind, receiver_name, key]` when the
85
+ # CallNode is a `receiver[key]` read or write whose
86
+ # receiver and key are both stable, otherwise nil. Used
87
+ # by both the recorder (for `IndexOrWriteNode`'s
88
+ # receiver/arguments triplet) and the invalidator (for
89
+ # `CallNode :[]=` / mutator calls). Treats only the FIRST
90
+ # argument as the key; `:[]=`'s second argument is the
91
+ # rvalue and is not part of the address.
92
+ def stable_address(receiver_node, key_node)
93
+ receiver = stable_receiver(receiver_node)
94
+ return nil if receiver.nil?
95
+
96
+ key = stable_key(key_node)
97
+ return nil if key.nil?
98
+
99
+ [receiver.first, receiver.last, key]
100
+ end
101
+
102
+ # Looks up a recorded narrowing for `receiver[key]` against
103
+ # `scope`, returning the narrowed type or nil when no
104
+ # entry applies. Used by ExpressionTyper's `[]` dispatch
105
+ # to refine the result of a stable indexed read.
106
+ def lookup_for_call(node, scope)
107
+ return nil unless node.is_a?(Prism::CallNode)
108
+ return nil unless node.name == :[]
109
+ return nil if node.arguments.nil?
110
+ return nil unless node.arguments.arguments.size == 1
111
+
112
+ address = stable_address(node.receiver, node.arguments.arguments.first)
113
+ return nil if address.nil?
114
+
115
+ scope.indexed_narrowing(*address)
116
+ end
117
+
118
+ # Removes recorded narrowings invalidated by `call_node`.
119
+ # Two patterns:
120
+ #
121
+ # - `receiver[key] = value` (a `:[]=` against a stable
122
+ # address): drop the specific `(receiver, key)` entry.
123
+ # - Any mutator from `HASH_MUTATORS` / `ARRAY_MUTATORS`
124
+ # against a stable receiver: drop EVERY entry rooted
125
+ # at that receiver, because the mutator could rebind
126
+ # any slot.
127
+ #
128
+ # Returns the updated scope. Always-safe (only forgets;
129
+ # never invents).
130
+ def invalidate_after_call(call_node:, current_scope:)
131
+ return current_scope unless call_node.is_a?(Prism::CallNode)
132
+
133
+ if call_node.name == :[]=
134
+ invalidate_indexed_write(call_node, current_scope)
135
+ elsif mutator?(call_node.name)
136
+ invalidate_mutator(call_node, current_scope)
137
+ else
138
+ current_scope
139
+ end
140
+ end
141
+
142
+ def mutator?(method_name)
143
+ MutationWidening::HASH_MUTATORS.include?(method_name) ||
144
+ MutationWidening::ARRAY_MUTATORS.include?(method_name)
145
+ end
146
+
147
+ def invalidate_indexed_write(call_node, current_scope)
148
+ args = call_node.arguments&.arguments
149
+ return current_scope if args.nil? || args.empty?
150
+
151
+ address = stable_address(call_node.receiver, args.first)
152
+ return current_scope if address.nil?
153
+
154
+ current_scope.without_indexed_narrowing(*address)
155
+ end
156
+
157
+ def invalidate_mutator(call_node, current_scope)
158
+ receiver = stable_receiver(call_node.receiver)
159
+ return current_scope if receiver.nil?
160
+
161
+ current_scope.without_indexed_narrowings_for(*receiver)
162
+ end
163
+
164
+ # Companion invalidator for single-hop method-chain
165
+ # narrowings (ROADMAP § Future cycles — "Method-call
166
+ # receiver narrowing across stable receivers", B2 from
167
+ # the slice's design notes). Drops every
168
+ # `(receiver, *)` chain narrowing rooted at the call's
169
+ # OUTER stable receiver — matching the ROADMAP's "any
170
+ # intervening method call against the same receiver"
171
+ # criterion. A call against `x.last` (the OUTER receiver
172
+ # is a `CallNode`, not a stable root) does NOT drop
173
+ # narrowings keyed on `x`, so the worked-site
174
+ # `x.last << y` pattern correctly preserves the chain
175
+ # narrowing for any further `x.last` read in the same
176
+ # body. Always-safe (only forgets; never invents).
177
+ def invalidate_chain_after_call(call_node:, current_scope:)
178
+ return current_scope unless call_node.is_a?(Prism::CallNode)
179
+
180
+ receiver = stable_receiver(call_node.receiver)
181
+ return current_scope if receiver.nil?
182
+
183
+ current_scope.without_method_chain_narrowings_for(*receiver)
184
+ end
185
+ end
186
+ end
187
+ end
@@ -42,9 +42,33 @@ module Rigor
42
42
  when :inject, :reduce then inject_block_params(receiver, args)
43
43
  when :group_by, :partition then single_element_block_params(receiver)
44
44
  when :each_slice, :each_cons then slice_block_params(receiver)
45
+ when :new then class_new_block_params(receiver, args)
45
46
  end
46
47
  end
47
48
 
49
+ # `Class.new { |c| … }` and `Class.new(Parent) { |c| … }`
50
+ # — the block parameter is the freshly-created anonymous
51
+ # class, statically representable as the parent's singleton
52
+ # type (the new class inherits every singleton method the
53
+ # parent exposes, which is what callers use this form to
54
+ # configure: `c.table_name = …`, `c.attribute :foo`, etc.).
55
+ # No parent → `singleton(Object)`. RBS would otherwise widen
56
+ # the block param to bare `Nominal[Class]`, dropping access
57
+ # to the parent's class-side surface.
58
+ def class_new_block_params(receiver, args)
59
+ return nil unless class_metaclass_receiver?(receiver)
60
+
61
+ parent = args.first
62
+ return [Type::Combinator.singleton_of("Object")] if parent.nil?
63
+ return [parent] if parent.is_a?(Type::Singleton)
64
+
65
+ nil
66
+ end
67
+
68
+ def class_metaclass_receiver?(type)
69
+ type.is_a?(Type::Singleton) && type.class_name == "Class"
70
+ end
71
+
48
72
  def times_block_params(receiver)
49
73
  return nil unless integer_rooted?(receiver)
50
74