rigortype 0.1.18 → 0.1.19

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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +159 -224
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -3
  4. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
  5. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +29 -0
  6. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
  7. data/lib/rigor/analysis/check_rules/rule_walk.rb +169 -23
  8. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +9 -3
  9. data/lib/rigor/analysis/check_rules.rb +266 -63
  10. data/lib/rigor/analysis/diagnostic.rb +8 -0
  11. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +2 -1
  12. data/lib/rigor/analysis/runner/project_pre_passes.rb +4 -1
  13. data/lib/rigor/analysis/runner.rb +58 -21
  14. data/lib/rigor/analysis/worker_session.rb +21 -11
  15. data/lib/rigor/bleeding_edge.rb +123 -0
  16. data/lib/rigor/cache/descriptor.rb +86 -8
  17. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  18. data/lib/rigor/cli/annotate_command.rb +100 -15
  19. data/lib/rigor/cli/check_command.rb +3 -0
  20. data/lib/rigor/cli/plugins_command.rb +2 -4
  21. data/lib/rigor/cli/plugins_renderer.rb +0 -2
  22. data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
  23. data/lib/rigor/cli/triage_command.rb +6 -3
  24. data/lib/rigor/cli/triage_renderer.rb +15 -1
  25. data/lib/rigor/cli.rb +9 -1
  26. data/lib/rigor/configuration/severity_profile.rb +13 -1
  27. data/lib/rigor/configuration.rb +57 -1
  28. data/lib/rigor/environment/rbs_loader.rb +25 -0
  29. data/lib/rigor/inference/body_fixpoint.rb +89 -0
  30. data/lib/rigor/inference/budget_trace.rb +29 -2
  31. data/lib/rigor/inference/expression_typer.rb +1052 -43
  32. data/lib/rigor/inference/macro_block_self_type.rb +2 -2
  33. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
  34. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +54 -14
  35. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
  36. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
  37. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +148 -10
  38. data/lib/rigor/inference/method_dispatcher.rb +72 -1
  39. data/lib/rigor/inference/method_parameter_binder.rb +56 -2
  40. data/lib/rigor/inference/multi_target_binder.rb +46 -3
  41. data/lib/rigor/inference/mutation_widening.rb +142 -0
  42. data/lib/rigor/inference/narrowing.rb +270 -37
  43. data/lib/rigor/inference/scope_indexer.rb +696 -25
  44. data/lib/rigor/inference/statement_evaluator.rb +963 -16
  45. data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
  46. data/lib/rigor/plugin/base.rb +235 -79
  47. data/lib/rigor/plugin/macro/block_as_method.rb +22 -21
  48. data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
  49. data/lib/rigor/plugin/macro.rb +2 -3
  50. data/lib/rigor/plugin/manifest.rb +4 -24
  51. data/lib/rigor/plugin/node_rule_walk.rb +59 -14
  52. data/lib/rigor/plugin/registry.rb +12 -11
  53. data/lib/rigor/scope/discovery_index.rb +2 -0
  54. data/lib/rigor/scope.rb +132 -6
  55. data/lib/rigor/sig_gen/generator.rb +8 -0
  56. data/lib/rigor/triage/catalogue.rb +4 -19
  57. data/lib/rigor/triage.rb +69 -1
  58. data/lib/rigor/type/combinator.rb +29 -0
  59. data/lib/rigor/version.rb +1 -1
  60. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +13 -29
  61. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
  62. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +27 -90
  63. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
  64. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +20 -19
  65. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +10 -8
  66. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
  67. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +1 -1
  68. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
  69. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +21 -34
  70. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +11 -18
  71. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  72. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +2 -13
  73. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
  74. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
  75. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
  76. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +25 -0
  77. data/sig/rigor/analysis/fact_store.rbs +3 -0
  78. data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
  79. data/sig/rigor/plugin/base.rbs +5 -2
  80. data/sig/rigor/plugin/manifest.rbs +1 -2
  81. data/sig/rigor/scope.rbs +10 -1
  82. data/sig/rigor/type.rbs +1 -0
  83. data/sig/rigor.rbs +1 -1
  84. data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
  85. data/skills/rigor-plugin-author/SKILL.md +6 -4
  86. data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
  87. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
  88. metadata +7 -2
  89. data/lib/rigor/plugin/macro/external_file.rb +0 -143
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "English"
3
4
  require "optionparser"
4
5
  require "prism"
5
6
 
@@ -8,6 +9,7 @@ require_relative "../environment"
8
9
  require_relative "../scope"
9
10
  require_relative "../inference/def_return_typer"
10
11
  require_relative "../inference/scope_indexer"
12
+ require_relative "../inference/statement_evaluator"
11
13
  require_relative "prism_colorizer"
12
14
  require_relative "command"
13
15
 
@@ -20,18 +22,33 @@ module Rigor
20
22
  # (so `1; 2; 3` reports `3`), or, for a line that no statement
21
23
  # closes, the widest expression ending there (so the `if nil`
22
24
  # header reports its condition). It infers that expression's
23
- # type and appends a `#=> dump_type: <type>` comment.
25
+ # type and appends a `#=> <type>` comment (the xmpfilter /
26
+ # seeing_is_believing convention).
24
27
  #
25
28
  # The annotated source is re-parsed with Prism — a sanity gate,
26
29
  # since the appended text is always a comment — and printed to
27
- # stdout with IRB-style syntax highlighting via
30
+ # stdout. When colour is enabled and `bat`
31
+ # (https://github.com/sharkdp/bat) is on PATH it is used for
32
+ # highlighting; otherwise IRB-style highlighting via
28
33
  # {PrismColorizer}.
29
34
  class AnnotateCommand < Command
30
35
  USAGE = "Usage: rigor annotate [options] FILE"
31
36
 
32
- # Appended ` #=> dump_type: <type>` suffix. Matched and
33
- # stripped before re-annotating so re-running is idempotent.
34
- ANNOTATION_PATTERN = /\s*#=>\s*dump_type:.*\z/
37
+ # Trailing `#=> …` annotation comment. Matched and stripped
38
+ # before re-annotating so re-running is idempotent — this
39
+ # follows xmpfilter's convention of owning the `#=>` marker,
40
+ # and also absorbs the pre-v0.2.0 `#=> dump_type: <type>`
41
+ # spelling. The leading `\s` requirement keeps a `#=>` inside
42
+ # a string literal (no preceding whitespace ambiguity aside)
43
+ # from matching mid-expression.
44
+ ANNOTATION_PATTERN = /\s+#=>(?:\s.*)?\z/
45
+
46
+ # Arguments for highlighting through `bat`: the annotated
47
+ # text arrives on stdin, so the language must be explicit;
48
+ # `--style=plain` drops the grid/header chrome so the output
49
+ # matches the PrismColorizer fallback line-for-line; paging
50
+ # stays off because the CLI may itself sit in a pipeline.
51
+ BAT_ARGS = %w[--language=ruby --style=plain --paging=never --color=always].freeze
35
52
 
36
53
  # @return [Integer] CLI exit status.
37
54
  def run
@@ -54,7 +71,7 @@ module Rigor
54
71
  def parse_options
55
72
  # Default: colour a tty, unless `NO_COLOR` opts out. An
56
73
  # explicit `--color` / `--no-color` overrides both.
57
- options = { config: nil, color: @out.tty? && !no_color_env? }
74
+ options = { config: nil, color: @out.tty? && !no_color_env?, bat: nil }
58
75
 
59
76
  parser = OptionParser.new do |opts|
60
77
  opts.banner = USAGE
@@ -63,6 +80,10 @@ module Rigor
63
80
  "Force or disable ANSI colour (default: auto-detect a tty; honours NO_COLOR)") do |value|
64
81
  options[:color] = value
65
82
  end
83
+ opts.on("--[no-]bat",
84
+ "Force or disable highlighting through bat (default: when colour is on and bat is found)") do |value|
85
+ options[:bat] = value
86
+ end
66
87
  end
67
88
  parser.parse!(@argv)
68
89
 
@@ -91,12 +112,17 @@ module Rigor
91
112
  parse_result = Prism.parse(source, filepath: file, version: configuration.target_ruby)
92
113
  return 1 if parse_errors?(parse_result, file)
93
114
 
115
+ # `converged_loop_recording` re-records fixpoint-tracked loop
116
+ # bodies from their converged (post-writeback) bindings, so a
117
+ # loop-body line annotates the joined widened type (`Integer`)
118
+ # rather than a stale first-iterations constant (`1 | 2`).
94
119
  scope_index = Inference::ScopeIndexer.index(
95
- parse_result.value, default_scope: base_scope(configuration)
120
+ parse_result.value, default_scope: base_scope(configuration),
121
+ converged_loop_recording: true
96
122
  )
97
123
  line_types = LineTypeCollector.new(scope_index).collect(parse_result.value)
98
124
 
99
- @out.puts(render(annotate(source, line_types), color: options.fetch(:color)))
125
+ @out.puts(render(annotate(source, line_types), color: options.fetch(:color), bat: options.fetch(:bat)))
100
126
  0
101
127
  end
102
128
 
@@ -118,8 +144,8 @@ module Rigor
118
144
  true
119
145
  end
120
146
 
121
- # Appends ` #=> dump_type: <type>` to every line a type was
122
- # inferred for, aligning the comment column.
147
+ # Appends ` #=> <type>` to every line a type was inferred
148
+ # for, aligning the comment column.
123
149
  def annotate(source, line_types)
124
150
  lines = source.lines
125
151
  column = annotation_column(lines, line_types)
@@ -130,7 +156,7 @@ module Rigor
130
156
  code = line.chomp.sub(ANNOTATION_PATTERN, "")
131
157
  next "#{code}#{eol}" if type.nil?
132
158
 
133
- "#{code.ljust(column)} #=> dump_type: #{type.describe(:short)}#{eol}"
159
+ "#{code.ljust(column)} #=> #{type.describe(:short)}#{eol}"
134
160
  end.join
135
161
  end
136
162
 
@@ -143,11 +169,46 @@ module Rigor
143
169
  widths.max || 0
144
170
  end
145
171
 
146
- def render(annotated, color:)
172
+ def render(annotated, color:, bat: nil)
147
173
  return annotated unless color
148
174
  return annotated unless Prism.parse(annotated).success?
149
175
 
150
- PrismColorizer.colorize(annotated)
176
+ rendered = render_with_bat(annotated, forced: bat) unless bat == false
177
+ rendered || PrismColorizer.colorize(annotated)
178
+ end
179
+
180
+ # Pipes the annotated source through `bat` and returns its
181
+ # highlighted output, or nil when bat is unavailable or
182
+ # fails (broken install, killed mid-write) — the caller
183
+ # falls back to {PrismColorizer}. An explicit `--bat` with
184
+ # no bat on PATH warns instead of failing silently.
185
+ def render_with_bat(annotated, forced: nil)
186
+ executable = bat_executable
187
+ if executable.nil?
188
+ @err.puts("annotate: --bat requested but no `bat` executable found on PATH") if forced
189
+ return nil
190
+ end
191
+
192
+ output = IO.popen([executable, *BAT_ARGS], "r+") do |io|
193
+ io.write(annotated)
194
+ io.close_write
195
+ io.read
196
+ end
197
+ return nil unless $CHILD_STATUS&.success?
198
+
199
+ output.empty? ? nil : output
200
+ rescue SystemCallError, IOError
201
+ nil
202
+ end
203
+
204
+ def bat_executable
205
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |dir|
206
+ next if dir.empty?
207
+
208
+ candidate = File.join(dir, "bat")
209
+ return candidate if File.file?(candidate) && File.executable?(candidate)
210
+ end
211
+ nil
151
212
  end
152
213
  end
153
214
 
@@ -203,11 +264,30 @@ module Rigor
203
264
  widest_per_line(program).each do |line, node|
204
265
  next if by_line.key?(line)
205
266
 
206
- type = type_of(node)
267
+ type = node.is_a?(Prism::BlockParametersNode) ? block_params_type(node) : type_of(node)
207
268
  by_line[line] = type unless type.nil?
208
269
  end
209
270
  end
210
271
 
272
+ # A `do |i|` header line's widest node is its BlockParametersNode —
273
+ # not an expression, so evaluating it would only echo the
274
+ # `Dynamic[top]` fallback. Annotate the line with the parameters'
275
+ # inferred bindings instead (the single param's type, or a tuple
276
+ # for multi-param blocks); decline (nil) when any param has no
277
+ # plain name or no recorded binding, leaving the line bare.
278
+ def block_params_type(params_node)
279
+ inner = params_node.parameters
280
+ return nil if inner.nil? || inner.requireds.empty?
281
+
282
+ scope = @scope_index[params_node]
283
+ types = inner.requireds.map do |param|
284
+ return nil unless param.respond_to?(:name)
285
+
286
+ scope.local(param.name) or return nil
287
+ end
288
+ types.size == 1 ? types.first : Type::Combinator.tuple_of(*types)
289
+ end
290
+
211
291
  def widest_per_line(program)
212
292
  widest = {}
213
293
  walk(program) do |node|
@@ -231,8 +311,13 @@ module Rigor
231
311
  node.compact_child_nodes.each { |child| walk(child, &block) }
232
312
  end
233
313
 
314
+ # Types the node through the flow evaluator (not the bare
315
+ # expression typer) under its recorded entry scope, so flow-only
316
+ # forms type as the engine sees them — `i += 1` dispatches `+` on
317
+ # `i`'s binding (`Integer`, post-fixpoint) instead of echoing the
318
+ # RHS literal's `1`.
234
319
  def type_of(node)
235
- @scope_index[node].type_of(node)
320
+ Inference::StatementEvaluator.new(scope: @scope_index[node]).evaluate(node).first
236
321
  rescue StandardError
237
322
  nil
238
323
  end
@@ -473,6 +473,9 @@ module Rigor
473
473
  @err.puts(" recursion-guard hits: #{counts[Inference::BudgetTrace::RECURSION_GUARD]}")
474
474
  @err.puts(" ancestor-walk-limit hits: #{counts[Inference::BudgetTrace::ANCESTOR_WALK_LIMIT]}")
475
475
  @err.puts(" hkt-fuel-exhausted hits: #{counts[Inference::BudgetTrace::HKT_FUEL_EXHAUSTED]}")
476
+ @err.puts(" recursion-unroll-fuel hits: #{counts[Inference::BudgetTrace::RECURSION_UNROLL_FUEL]}")
477
+ @err.puts(" recursion-fixpoint-cap hits: #{counts[Inference::BudgetTrace::RECURSION_FIXPOINT_CAP]}")
478
+ @err.puts(" block-writeback-cap hits: #{counts[Inference::BudgetTrace::BLOCK_WRITEBACK_CAP]}")
476
479
  write_budget_distributions
477
480
  end
478
481
 
@@ -30,7 +30,7 @@ module Rigor
30
30
  # - every manifest-declared extension surface
31
31
  # (`open_receivers:` / `owns_receivers:` / `produces:` /
32
32
  # `consumes:` / `block_as_methods:` / `heredoc_templates:` /
33
- # `trait_registries:` / `external_files:` /
33
+ # `trait_registries:` /
34
34
  # `type_node_resolvers:` / `hkt_registrations:` /
35
35
  # `hkt_definitions:` / `protocol_contracts:` /
36
36
  # `source_rbs_synthesizer:`);
@@ -233,7 +233,6 @@ module Rigor
233
233
  block_as_methods: manifest.block_as_methods.size,
234
234
  heredoc_templates: manifest.heredoc_templates.size,
235
235
  trait_registries: manifest.trait_registries.size,
236
- external_files: manifest.external_files.size,
237
236
  type_node_resolvers: manifest.type_node_resolvers.size,
238
237
  hkt_registrations: manifest.hkt_registrations.size,
239
238
  hkt_definitions: manifest.hkt_definitions.size,
@@ -257,7 +256,6 @@ module Rigor
257
256
  block_as_methods: 0,
258
257
  heredoc_templates: 0,
259
258
  trait_registries: 0,
260
- external_files: 0,
261
259
  type_node_resolvers: 0,
262
260
  hkt_registrations: 0,
263
261
  hkt_definitions: 0,
@@ -337,7 +335,7 @@ module Rigor
337
335
  signature_paths: [],
338
336
  open_receivers: [], owns_receivers: [], produces: [], consumes: [],
339
337
  block_as_methods: 0, heredoc_templates: 0, trait_registries: 0,
340
- external_files: 0, type_node_resolvers: 0,
338
+ type_node_resolvers: 0,
341
339
  hkt_registrations: 0, hkt_definitions: 0,
342
340
  protocol_contracts: 0, source_rbs_synthesizer: false,
343
341
  node_rule_types: [], dynamic_return_receivers: [], type_specifier_methods: [],
@@ -209,7 +209,6 @@ module Rigor
209
209
  parts << "block_as_methods=#{row[:block_as_methods]}" if row[:block_as_methods].positive?
210
210
  parts << "heredoc_templates=#{row[:heredoc_templates]}" if row[:heredoc_templates].positive?
211
211
  parts << "trait_registries=#{row[:trait_registries]}" if row[:trait_registries].positive?
212
- parts << "external_files=#{row[:external_files]}" if row[:external_files].positive?
213
212
  return [] if parts.empty?
214
213
 
215
214
  [" macro substrate: #{parts.join(', ')}"]
@@ -233,7 +232,6 @@ module Rigor
233
232
  "block_as_methods" => row[:block_as_methods],
234
233
  "heredoc_templates" => row[:heredoc_templates],
235
234
  "trait_registries" => row[:trait_registries],
236
- "external_files" => row[:external_files],
237
235
  "type_node_resolvers" => row[:type_node_resolvers],
238
236
  "hkt_registrations" => row[:hkt_registrations],
239
237
  "hkt_definitions" => row[:hkt_definitions],
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optionparser"
5
+
6
+ require_relative "../bleeding_edge"
7
+ require_relative "../configuration"
8
+ require_relative "command"
9
+
10
+ module Rigor
11
+ class CLI
12
+ # Executes `rigor show-bleedingedge` (ADR-50 § WD2).
13
+ #
14
+ # Prints the bleeding-edge overlay — the Rigor-maintained set of the
15
+ # next major's queued changes ({Rigor::BleedingEdge}) — as an
16
+ # explicit list, and reports which of them the project's
17
+ # `bleeding_edge:` configuration adopts. The overlay is empty in this
18
+ # release, so the command currently reports an empty set; it becomes
19
+ # the inspection surface ADR-50 describes once a feature is queued.
20
+ #
21
+ # Read-only: it loads `.rigor.yml` to resolve the active selection
22
+ # but runs no analysis.
23
+ class ShowBleedingedgeCommand < Command
24
+ USAGE = "Usage: rigor show-bleedingedge [options]"
25
+
26
+ # @return [Integer] CLI exit status.
27
+ def run
28
+ options = parse_options
29
+ configuration = load_configuration(options)
30
+ return CLI::EXIT_USAGE if configuration.nil?
31
+
32
+ case options.fetch(:format)
33
+ when "json" then render_json(configuration)
34
+ else render_text(configuration)
35
+ end
36
+ 0
37
+ end
38
+
39
+ private
40
+
41
+ def parse_options
42
+ options = { format: "text", config: nil }
43
+ OptionParser.new do |opt|
44
+ opt.banner = USAGE
45
+ opt.on("--format=FORMAT", %w[text json], "Output format (text | json). Default: text.") do |fmt|
46
+ options[:format] = fmt
47
+ end
48
+ opt.on("--config=PATH", "Path to a .rigor.yml (default: auto-discovery).") do |path|
49
+ options[:config] = path
50
+ end
51
+ end.parse!(@argv)
52
+ options
53
+ end
54
+
55
+ def load_configuration(options)
56
+ Configuration.load(options.fetch(:config))
57
+ rescue StandardError => e
58
+ @err.puts("show-bleedingedge: could not load configuration: #{e.message}")
59
+ nil
60
+ end
61
+
62
+ def render_json(configuration)
63
+ selector = configuration.bleeding_edge
64
+ @out.puts(JSON.pretty_generate(
65
+ "overlay" => BleedingEdge.features.map(&:to_h),
66
+ "selector" => configuration.to_h.fetch("bleeding_edge"),
67
+ "active" => BleedingEdge.active_features(selector).map(&:id),
68
+ "unknown_selected" => BleedingEdge.unknown_selected_ids(selector)
69
+ ))
70
+ end
71
+
72
+ def render_text(configuration)
73
+ @out.puts("Bleeding-edge overlay (ADR-50 § WD2)")
74
+ @out.puts("")
75
+ if BleedingEdge.features.empty?
76
+ render_empty_overlay
77
+ else
78
+ render_overlay
79
+ end
80
+ @out.puts("")
81
+ render_selection(configuration)
82
+ end
83
+
84
+ def render_empty_overlay
85
+ @out.puts("The overlay is empty in this release — no features are queued for")
86
+ @out.puts("the next major. The `bleeding_edge:` mechanism is wired and ready;")
87
+ @out.puts("there is simply nothing to adopt yet.")
88
+ end
89
+
90
+ def render_overlay
91
+ @out.puts("#{BleedingEdge.features.length} feature(s) queued for the next major:")
92
+ @out.puts("")
93
+ BleedingEdge.features.each do |feature|
94
+ @out.puts(" #{feature.id}")
95
+ @out.puts(" #{feature.summary}")
96
+ feature.severity_overrides.each do |rule, severity|
97
+ @out.puts(" severity: #{rule} → :#{severity}")
98
+ end
99
+ end
100
+ end
101
+
102
+ def render_selection(configuration)
103
+ selector = configuration.bleeding_edge
104
+ active = BleedingEdge.active_features(selector).map(&:id)
105
+ @out.puts("Your configuration adopts: #{active.empty? ? '(none)' : active.join(', ')}")
106
+
107
+ unknown = BleedingEdge.unknown_selected_ids(selector)
108
+ return if unknown.empty?
109
+
110
+ @out.puts("Selected but not in this overlay (ignored): #{unknown.join(', ')}")
111
+ end
112
+ end
113
+ end
114
+ end
@@ -21,7 +21,7 @@ module Rigor
21
21
  # command, not a gate (`rigor check` remains the gate).
22
22
  class TriageCommand < Command
23
23
  USAGE = "Usage: rigor triage [options] [paths]"
24
- DEFAULT_SECTIONS = %i[distribution hotspots hints].freeze
24
+ DEFAULT_SECTIONS = %i[distribution selectors hotspots hints].freeze
25
25
 
26
26
  # @return [Integer] CLI exit status (always 0).
27
27
  def run
@@ -46,8 +46,11 @@ module Rigor
46
46
  opts.on("--format=FORMAT", "Output format: text (default) or json") { |v| options[:format] = v }
47
47
  opts.on("--top=N", Integer, "Hotspot-file count (default 10)") { |v| options[:top] = v }
48
48
  opts.on("--hints-only", "Print only the heuristic-hints section") { options[:sections] = %i[hints] }
49
- opts.on("--no-hints", "Print distribution + hotspots only") do
50
- options[:sections] = %i[distribution hotspots]
49
+ opts.on("--no-hints", "Print distribution + selectors + hotspots only") do
50
+ options[:sections] = %i[distribution selectors hotspots]
51
+ end
52
+ opts.on("--selectors-only", "Print only the class/method selectors section") do
53
+ options[:sections] = %i[selectors]
51
54
  end
52
55
  end.parse!(@argv)
53
56
  validate!(options)
@@ -10,10 +10,11 @@ module Rigor
10
10
  # triage` text report or as `--format json`.
11
11
  class TriageRenderer
12
12
  BAR_WIDTH = 24
13
+ SELECTOR_ROWS = 15 # text-output cap; `--format json` carries the full list
13
14
 
14
15
  def initialize(report, sections:)
15
16
  @report = report
16
- @sections = sections # subset of %i[distribution hotspots hints]
17
+ @sections = sections # subset of %i[distribution selectors hotspots hints]
17
18
  end
18
19
 
19
20
  def json
@@ -23,6 +24,7 @@ module Rigor
23
24
  def text
24
25
  blocks = []
25
26
  blocks << distribution_block if @sections.include?(:distribution)
27
+ blocks << selectors_block if @sections.include?(:selectors)
26
28
  blocks << hotspots_block if @sections.include?(:hotspots)
27
29
  blocks << hints_block if @sections.include?(:hints)
28
30
  "#{blocks.join("\n\n")}\n"
@@ -42,6 +44,18 @@ module Rigor
42
44
  lines.join("\n")
43
45
  end
44
46
 
47
+ def selectors_block
48
+ return "Selectors — by class / method\n (none)" if @report.selectors.empty?
49
+
50
+ lines = ["Selectors — by class / method (top #{SELECTOR_ROWS}; full list in --format json)"]
51
+ @report.selectors.first(SELECTOR_ROWS).each do |sel|
52
+ label = sel.receiver ? "#{sel.receiver}##{sel.method_name}" : sel.method_name
53
+ lines << format(" %<label>-44s %<count>5d %<files>3d file(s)",
54
+ label: label, count: sel.count, files: sel.files)
55
+ end
56
+ lines.join("\n")
57
+ end
58
+
45
59
  def hotspots_block
46
60
  return "Hotspot files\n (none)" if @report.hotspots.empty?
47
61
 
data/lib/rigor/cli.rb CHANGED
@@ -38,7 +38,8 @@ module Rigor
38
38
  "plugins" => :run_plugins,
39
39
  "plugin" => :run_plugin,
40
40
  "playground" => :run_playground,
41
- "skill" => :run_skill
41
+ "skill" => :run_skill,
42
+ "show-bleedingedge" => :run_show_bleedingedge
42
43
  }.freeze
43
44
 
44
45
  def self.start(argv = ARGV, out: $stdout, err: $stderr)
@@ -289,6 +290,12 @@ module Rigor
289
290
  CLI::PluginCommand.new(argv: @argv, out: @out, err: @err).run
290
291
  end
291
292
 
293
+ def run_show_bleedingedge
294
+ require_relative "cli/show_bleedingedge_command"
295
+
296
+ CLI::ShowBleedingedgeCommand.new(argv: @argv, out: @out, err: @err).run
297
+ end
298
+
292
299
  def help
293
300
  <<~HELP
294
301
  Usage: rigor <command> [options]
@@ -311,6 +318,7 @@ module Rigor
311
318
  plugin Browse bundled plugin source as worked examples (list/path/print/root)
312
319
  playground Start the browser playground (requires rigor-playground gem)
313
320
  skill List or print bundled Agent Skills (rigor-project-init, ...)
321
+ show-bleedingedge Show the bleeding-edge overlay + what your config adopts (ADR-50)
314
322
  version Print the Rigor version
315
323
  help Print this help
316
324
  HELP
@@ -130,14 +130,26 @@ module Rigor
130
130
  # Keys are canonical rule ids; values are
131
131
  # {VALID_SEVERITIES} symbols. Family-wildcard keys
132
132
  # (`call`) match every rule under that prefix.
133
+ # @param bleeding_edge_overrides [Hash{String => Symbol}] the
134
+ # severity map imposed by the active ADR-50 § WD2 bleeding-edge
135
+ # features ({Rigor::BleedingEdge.severity_overrides_for}).
136
+ # Consulted *below* the user's own `overrides` (so an explicit
137
+ # `severity_overrides:` entry, exact or family wildcard, always
138
+ # wins) and *above* the profile table. Exact rule ids only — the
139
+ # overlay never carries family wildcards. Empty while the
140
+ # overlay is unpopulated, so the default leaves resolution
141
+ # bit-for-bit unchanged.
133
142
  # @return [Symbol] the resolved severity. Returns `:off` to
134
143
  # mean "drop the diagnostic entirely".
135
- def resolve(rule:, authored_severity:, profile: DEFAULT_PROFILE, overrides: {})
144
+ def resolve(rule:, authored_severity:, profile: DEFAULT_PROFILE, overrides: {}, bleeding_edge_overrides: {})
136
145
  return authored_severity if rule.nil?
137
146
 
138
147
  override = overrides[rule] || family_override(rule, overrides)
139
148
  return override.to_sym if override
140
149
 
150
+ bleeding = bleeding_edge_overrides[rule]
151
+ return bleeding.to_sym if bleeding
152
+
141
153
  profile_table = PROFILES[profile] || PROFILES.fetch(DEFAULT_PROFILE)
142
154
  profile_table.fetch(rule, authored_severity)
143
155
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "yaml"
4
4
 
5
+ require_relative "bleeding_edge"
5
6
  require_relative "configuration/dependencies"
6
7
  require_relative "configuration/severity_profile"
7
8
 
@@ -87,6 +88,15 @@ module Rigor
87
88
  },
88
89
  "severity_profile" => "balanced",
89
90
  "severity_overrides" => {},
91
+ # ADR-50 § WD2 — bleeding-edge overlay opt-in. Selects which of
92
+ # the *next major's* queued changes ({Rigor::BleedingEdge}) this
93
+ # project adopts early. Orthogonal to `severity_profile:`. Accepts
94
+ # `false` (default — adopt none), `true` (adopt the whole
95
+ # overlay), a list of feature ids (adopt only those), or
96
+ # `{ all: true, except: [ids] }` (adopt all but the named). The
97
+ # overlay is empty today, so every form is currently a no-op; it
98
+ # becomes live when the first discipline is queued for a major.
99
+ "bleeding_edge" => false,
90
100
  "dependencies" => {
91
101
  "source_inference" => [],
92
102
  "budget_per_gem" => Configuration::Dependencies::DEFAULT_BUDGET_PER_GEM
@@ -181,6 +191,7 @@ module Rigor
181
191
  :plugins_io_network, :plugins_io_allowed_paths,
182
192
  :plugins_io_allowed_url_hosts,
183
193
  :severity_profile, :severity_overrides,
194
+ :bleeding_edge, :bleeding_edge_severity_overrides,
184
195
  :dependencies, :parallel_workers,
185
196
  :bundler_bundle_path, :bundler_auto_detect, :bundler_lockfile,
186
197
  :rbs_collection_lockfile, :rbs_collection_auto_detect,
@@ -355,6 +366,10 @@ module Rigor
355
366
  @severity_overrides = coerce_severity_overrides(
356
367
  data.fetch("severity_overrides", DEFAULTS.fetch("severity_overrides"))
357
368
  )
369
+ @bleeding_edge = coerce_bleeding_edge(
370
+ data.fetch("bleeding_edge", DEFAULTS.fetch("bleeding_edge"))
371
+ )
372
+ @bleeding_edge_severity_overrides = BleedingEdge.severity_overrides_for(@bleeding_edge)
358
373
  @dependencies = Dependencies.from_h(
359
374
  data.fetch("dependencies", DEFAULTS.fetch("dependencies"))
360
375
  )
@@ -383,7 +398,7 @@ module Rigor
383
398
  end
384
399
  # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
385
400
 
386
- def to_h # rubocop:disable Metrics/MethodLength
401
+ def to_h # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
387
402
  {
388
403
  "target_ruby" => target_ruby,
389
404
  "paths" => paths,
@@ -405,6 +420,7 @@ module Rigor
405
420
  },
406
421
  "severity_profile" => severity_profile.to_s,
407
422
  "severity_overrides" => severity_overrides.to_h { |k, v| [k, v.to_s] },
423
+ "bleeding_edge" => bleeding_edge_to_h,
408
424
  "dependencies" => dependencies.to_h,
409
425
  "parallel" => {
410
426
  "workers" => parallel_workers
@@ -566,5 +582,45 @@ module Rigor
566
582
  [k.to_s, sym]
567
583
  end.freeze
568
584
  end
585
+
586
+ # ADR-50 § WD2 — normalizes the `bleeding_edge:` selector to a
587
+ # canonical `{ "mode" => … }` hash (interpreted by
588
+ # {Rigor::BleedingEdge}). Validates *shape* only; membership against
589
+ # the overlay is intentionally NOT checked here (an unknown id stays
590
+ # inert, like an unknown `severity_overrides:` rule). Deep-frozen so
591
+ # the Configuration stays `Ractor.shareable?`.
592
+ def coerce_bleeding_edge(value)
593
+ case value
594
+ when nil, false then { "mode" => "none" }
595
+ when true then { "mode" => "all" }
596
+ when Array then { "mode" => "list", "ids" => value.map(&:to_s).freeze }
597
+ when Hash then coerce_bleeding_edge_hash(value)
598
+ else
599
+ raise ArgumentError,
600
+ "bleeding_edge must be true, false, a list of feature ids, " \
601
+ "or { all: true, except: [...] }, got #{value.inspect}"
602
+ end.freeze
603
+ end
604
+
605
+ def coerce_bleeding_edge_hash(value)
606
+ hash = value.to_h { |k, v| [k.to_s, v] }
607
+ if hash.fetch("all", false) == true
608
+ { "mode" => "all", "except" => Array(hash["except"]).map(&:to_s).freeze }
609
+ else
610
+ { "mode" => "none" }
611
+ end
612
+ end
613
+
614
+ # Renders the normalized selector back into the user-facing
615
+ # `bleeding_edge:` form for `#to_h` round-trips.
616
+ def bleeding_edge_to_h
617
+ case bleeding_edge["mode"]
618
+ when "all"
619
+ except = bleeding_edge["except"] || []
620
+ except.empty? || { "all" => true, "except" => except }
621
+ when "list" then bleeding_edge["ids"] || []
622
+ else false
623
+ end
624
+ end
569
625
  end
570
626
  end
@@ -88,6 +88,15 @@ module Rigor
88
88
  vendored_gem_sig_paths.each do |path|
89
89
  rbs_loader.add(path: path) if path.directory?
90
90
  end
91
+ # Rigor-owned core overlay — loaded LAST so an upstream
92
+ # declaration always wins on conflict; these reopenings only
93
+ # fill genuine holes (e.g. `Numeric#to_f`/`to_i`/`to_r`, which
94
+ # upstream RBS declares on the concrete subclasses but not on
95
+ # the abstract `Numeric` that Rigor's arithmetic-chain widening
96
+ # produces).
97
+ core_overlay_sig_paths.each do |path|
98
+ rbs_loader.add(path: path) if path.directory?
99
+ end
91
100
  env = RBS::Environment.from_loader(rbs_loader)
92
101
  add_virtual_rbs(env, virtual_rbs)
93
102
  synthesize_missing_namespaces(env)
@@ -298,6 +307,22 @@ module Rigor
298
307
  ).freeze
299
308
  private_constant :VENDORED_GEM_SIGS_ROOT
300
309
 
310
+ # Rigor-owned core-overlay RBS (`data/core_overlay/`). Reopens
311
+ # Ruby core classes to add methods upstream `ruby/rbs` omits but
312
+ # which every concrete value answers at runtime — loaded last so
313
+ # upstream always wins on conflict. Public so the cache descriptor
314
+ # can digest these files into the env-blob key.
315
+ CORE_OVERLAY_SIGS_ROOT = File.expand_path(
316
+ "../../../data/core_overlay",
317
+ __dir__
318
+ ).freeze
319
+
320
+ def core_overlay_sig_paths
321
+ return [] unless File.directory?(CORE_OVERLAY_SIGS_ROOT)
322
+
323
+ [Pathname(CORE_OVERLAY_SIGS_ROOT)]
324
+ end
325
+
301
326
  def vendored_gem_sig_paths
302
327
  return [] unless File.directory?(VENDORED_GEM_SIGS_ROOT)
303
328