rigortype 0.1.11 → 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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/erb_template_detector.rb +38 -0
  3. data/lib/rigor/analysis/runner.rb +6 -1
  4. data/lib/rigor/analysis/worker_session.rb +6 -1
  5. data/lib/rigor/cli/plugins_command.rb +308 -0
  6. data/lib/rigor/cli/plugins_renderer.rb +173 -0
  7. data/lib/rigor/cli.rb +28 -0
  8. data/lib/rigor/inference/block_parameter_binder.rb +35 -0
  9. data/lib/rigor/inference/expression_typer.rb +69 -30
  10. data/lib/rigor/inference/indexed_narrowing.rb +187 -0
  11. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +24 -0
  12. data/lib/rigor/inference/method_dispatcher.rb +23 -0
  13. data/lib/rigor/inference/mutation_widening.rb +285 -0
  14. data/lib/rigor/inference/narrowing.rb +72 -4
  15. data/lib/rigor/inference/scope_indexer.rb +409 -12
  16. data/lib/rigor/inference/statement_evaluator.rb +256 -4
  17. data/lib/rigor/scope.rb +181 -4
  18. data/lib/rigor/version.rb +1 -1
  19. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +22 -1
  20. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +94 -6
  21. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +11 -1
  22. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +7 -1
  23. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +135 -11
  24. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +94 -43
  25. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +138 -35
  26. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +17 -3
  27. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +10 -0
  28. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +13 -3
  29. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +6 -2
  30. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +83 -7
  31. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +4 -1
  32. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +16 -1
  33. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +81 -5
  34. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +11 -3
  35. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +194 -5
  36. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +264 -0
  37. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +100 -0
  38. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +175 -0
  39. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +64 -3
  40. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1107 -59
  41. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +81 -4
  42. data/sig/rigor/scope.rbs +22 -0
  43. metadata +9 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9c5853d88c57eb0ded87f9fbf801e3cfbbc7672be2a16bcc3171bd7e3f33122c
4
- data.tar.gz: d3f3b9d936dd4aab4a10c93b7879b34bee26aa1313007619baadb1381b87310c
3
+ metadata.gz: 182bad9de02b3b4579fe1c385fa740e30f2df85cad36d8c21647dfb09004b9eb
4
+ data.tar.gz: 398d4ebc670530522696122117592bd59b60d7f899ae0a8cd58b11c8506ec608
5
5
  SHA512:
6
- metadata.gz: bebba3258c508b893a7ca22e98b17838bcc7267399882956a4ced9c214e87947754f5a15ecf80029cf601eed58c93fc53ffcb50636002df9f75c00d498a0585b
7
- data.tar.gz: 4ac6679d930144ffc5a675a9189ed7ce20d500c5ae9d61820b44fcfbf3e02149b615b3b11a44fa48760c17a75b57ec6d6b5ee3e4a80a4b9139514c742c9c612a
6
+ metadata.gz: abd25775ea4973023dc7ef7132669a0bf556604c7378caef60aa3c2fbae4ef79bc2651cd3499cecb8046507214dbdc4cb56bdb953c301ec42b0ebb86291202b6
7
+ data.tar.gz: 9281f16a4b2d39847aaaf408844920dfb9f2e6a914df3544182d6d3832f28f03ca63d8a19aa54ca80977f3431351717a59d7ba704f3361ee92697be21d6193ab
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Analysis
5
+ # Detects when a `.rb` file is actually an ERB template — a Rails
6
+ # generator `templates/foo.rb` shape that uses `<%= ... %>` to
7
+ # interpolate identifiers. Prism rejects the template as invalid
8
+ # Ruby and the analyzer emits one parse-error diagnostic per scrap
9
+ # the parser tripped over (≈ 20 on Redmine's
10
+ # `lib/generators/redmine_plugin_model/templates/migration.rb`),
11
+ # all of them noise from the user's perspective. The runner / worker
12
+ # consults this detector before turning parse errors into
13
+ # diagnostics; an ERB-shaped source is silently skipped.
14
+ #
15
+ # Detection is byte-level on the raw source: any occurrence of the
16
+ # `%>` closing marker proves the file is an ERB template. `%>`
17
+ # cannot appear in valid Ruby — `%` is a binary operator that
18
+ # requires an operand on its right side, never `>`. The opening
19
+ # `<%` is ambiguous in principle (`x<%y` parses as `x < %y`, a
20
+ # comparison against a `%`-literal) but a real Ruby file with that
21
+ # shape would still not contain the closing `%>`.
22
+ module ErbTemplateDetector
23
+ ERB_CLOSING_MARKER = /%>/
24
+
25
+ module_function
26
+
27
+ # @param parse_result [Prism::ParseResult]
28
+ # @return [Boolean] true when the parsed source looks like an
29
+ # ERB template (parse errors expected; analysis should skip).
30
+ def template?(parse_result)
31
+ source = parse_result.source.source
32
+ return false unless source.is_a?(String) && !source.empty?
33
+
34
+ ERB_CLOSING_MARKER.match?(source)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -20,6 +20,7 @@ require_relative "buffer_binding"
20
20
  require_relative "check_rules"
21
21
  require_relative "dependency_source_inference"
22
22
  require_relative "diagnostic"
23
+ require_relative "erb_template_detector"
23
24
  require_relative "project_scan"
24
25
  require_relative "result"
25
26
  require_relative "run_stats"
@@ -1457,7 +1458,11 @@ module Rigor
1457
1458
 
1458
1459
  def analyze_file(path, environment) # rubocop:disable Metrics/MethodLength
1459
1460
  parse_result = parse_source(path)
1460
- return parse_diagnostics(path, parse_result) unless parse_result.errors.empty?
1461
+ unless parse_result.errors.empty?
1462
+ return [] if ErbTemplateDetector.template?(parse_result)
1463
+
1464
+ return parse_diagnostics(path, parse_result)
1465
+ end
1461
1466
 
1462
1467
  scope = seed_project_scope(Scope.empty(environment: environment, source_path: path))
1463
1468
  index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
@@ -15,6 +15,7 @@ require_relative "../inference/method_dispatcher/file_folding"
15
15
  require_relative "check_rules"
16
16
  require_relative "dependency_source_inference"
17
17
  require_relative "diagnostic"
18
+ require_relative "erb_template_detector"
18
19
 
19
20
  module Rigor
20
21
  module Analysis
@@ -158,7 +159,11 @@ module Rigor
158
159
  # is a per-run aggregate concern handled by the caller.
159
160
  def analyze(path)
160
161
  parse_result = parse_source(path)
161
- return parse_diagnostics(path, parse_result) unless parse_result.errors.empty?
162
+ unless parse_result.errors.empty?
163
+ return [] if ErbTemplateDetector.template?(parse_result)
164
+
165
+ return parse_diagnostics(path, parse_result)
166
+ end
162
167
 
163
168
  scope = Scope.empty(environment: @environment, source_path: path)
164
169
  index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
@@ -0,0 +1,308 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ require_relative "../configuration"
6
+ require_relative "../plugin"
7
+ require_relative "../plugin/loader"
8
+ require_relative "../plugin/services"
9
+ require_relative "../reflection"
10
+ require_relative "../type/combinator"
11
+ require_relative "plugins_renderer"
12
+
13
+ module Rigor
14
+ class CLI
15
+ # `rigor plugins` — reports the activation status of every
16
+ # plugin entry in `.rigor.yml` so users (and the
17
+ # `rigor-project-init` SKILL, CI gates, `rigor init`) can
18
+ # verify their plugin configuration is actually doing what
19
+ # they think.
20
+ #
21
+ # The command is read-only and idempotent: it loads the
22
+ # project's `.rigor.yml` (same discovery as `rigor check`),
23
+ # runs `Plugin::Loader.load` to attempt instantiation, then
24
+ # prints a table of:
25
+ #
26
+ # - load status (`loaded` / `load-error` with reason);
27
+ # - resolved manifest id, version, description;
28
+ # - `signature_paths:` (absolute paths + per-dir `.rbs` count);
29
+ # - every manifest-declared extension surface
30
+ # (`open_receivers:` / `owns_receivers:` / `produces:` /
31
+ # `consumes:` / `block_as_methods:` / `heredoc_templates:` /
32
+ # `trait_registries:` / `external_files:` /
33
+ # `type_node_resolvers:` / `hkt_registrations:` /
34
+ # `hkt_definitions:` / `protocol_contracts:` /
35
+ # `source_rbs_synthesizer:`).
36
+ #
37
+ # Output formats: `text` (default, human-readable table) and
38
+ # `json` (for tooling — SKILLs, CI gates, editor integrations).
39
+ #
40
+ # Exit codes:
41
+ # - `0` — every configured plugin loaded.
42
+ # - `1` — at least one plugin failed to load AND `--strict`
43
+ # was passed. Without `--strict` the command always exits 0;
44
+ # load errors are reported but not treated as a gate failure
45
+ # (matching `rigor triage`'s advisory shape).
46
+ #
47
+ # Future expansion (not in this slice):
48
+ # - Per-plugin diagnostic counts (would require running the
49
+ # full analysis pipeline; out of scope for an inspection
50
+ # command).
51
+ # - Verification that `signature_paths` actually merged into
52
+ # the RBS environment without conflict (requires constructing
53
+ # the Environment, which is heavier than the loader-only
54
+ # pass this slice does).
55
+ class PluginsCommand
56
+ USAGE = "Usage: rigor plugins [options]"
57
+
58
+ def initialize(argv:, out: $stdout, err: $stderr)
59
+ @argv = argv
60
+ @out = out
61
+ @err = err
62
+ end
63
+
64
+ # @return [Integer] CLI exit status.
65
+ def run
66
+ options = parse_options
67
+ config_path = options.fetch(:config) || Configuration.discover
68
+ configuration = Configuration.load(options.fetch(:config))
69
+ rows = build_rows(configuration)
70
+
71
+ renderer = PluginsRenderer.new(rows: rows, configuration_path: config_path)
72
+ @out.puts(options.fetch(:format) == "json" ? renderer.json : renderer.text)
73
+
74
+ any_load_errors = rows.any? { |row| row.fetch(:status) == :load_error }
75
+ return 1 if any_load_errors && options.fetch(:strict)
76
+
77
+ 0
78
+ end
79
+
80
+ private
81
+
82
+ def parse_options
83
+ options = { config: nil, format: "text", strict: false }
84
+ OptionParser.new do |opts|
85
+ opts.banner = USAGE
86
+ opts.on("--config=PATH", "Path to the Rigor configuration file") { |v| options[:config] = v }
87
+ opts.on("--format=FORMAT", "Output format: text (default) or json") { |v| options[:format] = v }
88
+ opts.on("--strict", "Exit 1 if any plugin failed to load (CI gate)") { options[:strict] = true }
89
+ end.parse!(@argv)
90
+ validate!(options)
91
+ options
92
+ end
93
+
94
+ def validate!(options)
95
+ return if %w[text json].include?(options.fetch(:format))
96
+
97
+ raise OptionParser::InvalidArgument, "unsupported format: #{options.fetch(:format)}"
98
+ end
99
+
100
+ # Runs the plugin loader against the project configuration
101
+ # and returns a row per declared plugin entry. Each row is
102
+ # a plain Hash with the fields enumerated in the class
103
+ # docstring so the renderer (text + JSON) reads from a
104
+ # single shape.
105
+ #
106
+ # The loader catches require / init failures and surfaces
107
+ # them through `registry.load_errors`; we merge those back
108
+ # into the per-entry rows by matching on `plugin_ref`.
109
+ def build_rows(configuration)
110
+ services = build_services(configuration)
111
+ registry = Plugin::Loader.load(configuration: configuration, services: services)
112
+
113
+ rows = configuration.plugins.map { |entry| row_for_entry(entry, registry) }
114
+ # Surface registry-level errors that did not bind to an
115
+ # entry (e.g. dependency-cycle errors that name multiple
116
+ # plugins). The renderer treats these as "orphan" errors.
117
+ orphan_errors = orphan_load_errors(registry, rows)
118
+ rows + orphan_errors
119
+ end
120
+
121
+ def build_services(configuration)
122
+ Plugin::Services.new(
123
+ reflection: Reflection,
124
+ type: Type::Combinator,
125
+ configuration: configuration,
126
+ cache_store: nil
127
+ )
128
+ end
129
+
130
+ def row_for_entry(entry, registry)
131
+ gem_name = entry_gem_name(entry)
132
+ config = entry_config(entry)
133
+ plugin = registry.plugins.find do |p|
134
+ # The loader has already matched the entry to a plugin
135
+ # class; we can identify it by either the gem name (when
136
+ # the entry was a String) or the explicit id (when the
137
+ # entry was a Hash with `id:`).
138
+ plugin_matches_entry?(p, gem_name, entry_id(entry))
139
+ end
140
+
141
+ if plugin
142
+ loaded_row(plugin, gem_name, config)
143
+ else
144
+ # Find the load error whose plugin_ref names this entry
145
+ # (the ref is set by Loader to the gem name on require
146
+ # failures and to the manifest id on later failures).
147
+ error = registry.load_errors.find do |e|
148
+ ref = e.plugin_ref.to_s
149
+ ref == gem_name || ref == entry_id(entry).to_s
150
+ end
151
+ load_error_row(gem_name, entry_id(entry), config, error)
152
+ end
153
+ end
154
+
155
+ def plugin_matches_entry?(plugin, gem_name, entry_id)
156
+ return true if entry_id && plugin.manifest.id == entry_id
157
+
158
+ # A bare gem entry conventionally has manifest.id equal to
159
+ # the gem name with the `rigor-` prefix stripped.
160
+ derived_id = gem_name.delete_prefix("rigor-")
161
+ [derived_id, gem_name].include?(plugin.manifest.id)
162
+ end
163
+
164
+ def loaded_row(plugin, gem_name, config)
165
+ manifest = plugin.manifest
166
+ identity_fields(gem_name, manifest, config)
167
+ .merge(extension_fields(plugin, manifest))
168
+ .merge(load_error: nil)
169
+ end
170
+
171
+ def identity_fields(gem_name, manifest, config)
172
+ {
173
+ gem: gem_name,
174
+ status: :loaded,
175
+ id: manifest.id,
176
+ version: manifest.version,
177
+ description: manifest.description,
178
+ config: config
179
+ }
180
+ end
181
+
182
+ def extension_fields(plugin, manifest)
183
+ {
184
+ signature_paths: signature_path_rows(plugin),
185
+ open_receivers: manifest.open_receivers.dup,
186
+ owns_receivers: manifest.owns_receivers.dup,
187
+ produces: manifest.produces.map(&:to_s),
188
+ consumes: manifest.consumes.map { |c| "#{c.plugin_id}/#{c.name}#{'?' if c.optional}" },
189
+ source_rbs_synthesizer: !manifest.source_rbs_synthesizer.nil?
190
+ }.merge(extension_count_fields(manifest))
191
+ end
192
+
193
+ def extension_count_fields(manifest)
194
+ {
195
+ block_as_methods: manifest.block_as_methods.size,
196
+ heredoc_templates: manifest.heredoc_templates.size,
197
+ trait_registries: manifest.trait_registries.size,
198
+ external_files: manifest.external_files.size,
199
+ type_node_resolvers: manifest.type_node_resolvers.size,
200
+ hkt_registrations: manifest.hkt_registrations.size,
201
+ hkt_definitions: manifest.hkt_definitions.size,
202
+ protocol_contracts: manifest.protocol_contracts.size
203
+ }
204
+ end
205
+
206
+ def load_error_row(gem_name, entry_id, config, error)
207
+ {
208
+ gem: gem_name,
209
+ status: :load_error,
210
+ id: entry_id,
211
+ version: nil,
212
+ description: nil,
213
+ config: config,
214
+ signature_paths: [],
215
+ open_receivers: [],
216
+ owns_receivers: [],
217
+ produces: [],
218
+ consumes: [],
219
+ block_as_methods: 0,
220
+ heredoc_templates: 0,
221
+ trait_registries: 0,
222
+ external_files: 0,
223
+ type_node_resolvers: 0,
224
+ hkt_registrations: 0,
225
+ hkt_definitions: 0,
226
+ protocol_contracts: 0,
227
+ source_rbs_synthesizer: false,
228
+ load_error: error&.message || "plugin did not register or could not be matched to a registered class"
229
+ }
230
+ end
231
+
232
+ # For each `signature_paths:` directory the plugin resolves
233
+ # against its gem root, report the absolute path and a
234
+ # quick `.rbs`-file count. The count is a sanity signal:
235
+ # an empty bundle directory loads silently today but
236
+ # contributes nothing.
237
+ def signature_path_rows(plugin)
238
+ plugin.signature_paths.map do |abs|
239
+ {
240
+ path: abs,
241
+ exists: File.directory?(abs),
242
+ rbs_files: rbs_file_count(abs)
243
+ }
244
+ end
245
+ end
246
+
247
+ def rbs_file_count(dir)
248
+ return 0 unless File.directory?(dir)
249
+
250
+ Dir.glob(File.join(dir, "**", "*.rbs")).size
251
+ end
252
+
253
+ def entry_gem_name(entry)
254
+ case entry
255
+ when String then entry
256
+ when Hash
257
+ string_keyed = entry.to_h { |k, v| [k.to_s, v] }
258
+ (string_keyed["gem"] || string_keyed["id"]).to_s
259
+ else entry.to_s
260
+ end
261
+ end
262
+
263
+ def entry_id(entry)
264
+ case entry
265
+ when Hash
266
+ string_keyed = entry.to_h { |k, v| [k.to_s, v] }
267
+ string_keyed["id"]
268
+ end
269
+ end
270
+
271
+ def entry_config(entry)
272
+ case entry
273
+ when Hash
274
+ string_keyed = entry.to_h { |k, v| [k.to_s, v] }
275
+ string_keyed["config"] || {}
276
+ else {}
277
+ end
278
+ end
279
+
280
+ # Build orphan-error rows for load errors whose `plugin_ref`
281
+ # did not bind to any configured plugin entry (e.g. a
282
+ # dependency-cycle naming a plugin we already accounted for
283
+ # but reported alongside another). De-duplicates against
284
+ # errors we already attached.
285
+ def orphan_load_errors(registry, rows)
286
+ attached_refs = rows.compact.flat_map { |row| [row[:gem], row[:id]].compact }.to_set
287
+ unattached = registry.load_errors.reject { |error| attached_refs.include?(error.plugin_ref.to_s) }
288
+ unattached.map do |error|
289
+ {
290
+ gem: error.plugin_ref.to_s,
291
+ status: :load_error,
292
+ id: nil,
293
+ version: nil,
294
+ description: nil,
295
+ config: {},
296
+ signature_paths: [],
297
+ open_receivers: [], owns_receivers: [], produces: [], consumes: [],
298
+ block_as_methods: 0, heredoc_templates: 0, trait_registries: 0,
299
+ external_files: 0, type_node_resolvers: 0,
300
+ hkt_registrations: 0, hkt_definitions: 0,
301
+ protocol_contracts: 0, source_rbs_synthesizer: false,
302
+ load_error: error.message
303
+ }
304
+ end
305
+ end
306
+ end
307
+ end
308
+ end
@@ -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
@@ -32,6 +32,7 @@ module Rigor
32
32
  "baseline" => :run_baseline,
33
33
  "triage" => :run_triage,
34
34
  "coverage" => :run_coverage,
35
+ "plugins" => :run_plugins,
35
36
  "playground" => :run_playground
36
37
  }.freeze
37
38
 
@@ -475,9 +476,29 @@ module Rigor
475
476
 
476
477
  File.write(path, init_template)
477
478
  @out.puts("Created #{path}")
479
+ print_init_next_steps(path)
478
480
  0
479
481
  end
480
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
+
481
502
  # Renders the starter `.rigor.yml` body. The template
482
503
  # serialises `Configuration::DEFAULTS` (so the on-disk file
483
504
  # round-trips through `Configuration.load`) and prepends a
@@ -597,6 +618,12 @@ module Rigor
597
618
  CLI::CoverageCommand.new(argv: @argv, out: @out, err: @err).run
598
619
  end
599
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
+
600
627
  def run_playground
601
628
  begin
602
629
  require "rigor/playground"
@@ -653,6 +680,7 @@ module Rigor
653
680
  mcp Run the Rigor MCP server over stdio (ADR-33)
654
681
  triage Summarise diagnostics: distribution, hotspots, hints (ADR-23)
655
682
  coverage Report type-precision coverage (precise vs Dynamic ratio)
683
+ plugins Report activation status of every configured plugin
656
684
  playground Start the browser playground (requires rigor-playground gem)
657
685
  version Print the Rigor version
658
686
  help Print this 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)