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.
- checksums.yaml +4 -4
- data/README.md +159 -224
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -3
- data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +29 -0
- data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
- data/lib/rigor/analysis/check_rules/rule_walk.rb +169 -23
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +9 -3
- data/lib/rigor/analysis/check_rules.rb +266 -63
- data/lib/rigor/analysis/diagnostic.rb +8 -0
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +2 -1
- data/lib/rigor/analysis/runner/project_pre_passes.rb +4 -1
- data/lib/rigor/analysis/runner.rb +58 -21
- data/lib/rigor/analysis/worker_session.rb +21 -11
- data/lib/rigor/bleeding_edge.rb +123 -0
- data/lib/rigor/cache/descriptor.rb +86 -8
- data/lib/rigor/cache/rbs_descriptor.rb +2 -1
- data/lib/rigor/cli/annotate_command.rb +100 -15
- data/lib/rigor/cli/check_command.rb +3 -0
- data/lib/rigor/cli/plugins_command.rb +2 -4
- data/lib/rigor/cli/plugins_renderer.rb +0 -2
- data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
- data/lib/rigor/cli/triage_command.rb +6 -3
- data/lib/rigor/cli/triage_renderer.rb +15 -1
- data/lib/rigor/cli.rb +9 -1
- data/lib/rigor/configuration/severity_profile.rb +13 -1
- data/lib/rigor/configuration.rb +57 -1
- data/lib/rigor/environment/rbs_loader.rb +25 -0
- data/lib/rigor/inference/body_fixpoint.rb +89 -0
- data/lib/rigor/inference/budget_trace.rb +29 -2
- data/lib/rigor/inference/expression_typer.rb +1052 -43
- data/lib/rigor/inference/macro_block_self_type.rb +2 -2
- data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +54 -14
- data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +148 -10
- data/lib/rigor/inference/method_dispatcher.rb +72 -1
- data/lib/rigor/inference/method_parameter_binder.rb +56 -2
- data/lib/rigor/inference/multi_target_binder.rb +46 -3
- data/lib/rigor/inference/mutation_widening.rb +142 -0
- data/lib/rigor/inference/narrowing.rb +270 -37
- data/lib/rigor/inference/scope_indexer.rb +696 -25
- data/lib/rigor/inference/statement_evaluator.rb +963 -16
- data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
- data/lib/rigor/plugin/base.rb +235 -79
- data/lib/rigor/plugin/macro/block_as_method.rb +22 -21
- data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
- data/lib/rigor/plugin/macro.rb +2 -3
- data/lib/rigor/plugin/manifest.rb +4 -24
- data/lib/rigor/plugin/node_rule_walk.rb +59 -14
- data/lib/rigor/plugin/registry.rb +12 -11
- data/lib/rigor/scope/discovery_index.rb +2 -0
- data/lib/rigor/scope.rb +132 -6
- data/lib/rigor/sig_gen/generator.rb +8 -0
- data/lib/rigor/triage/catalogue.rb +4 -19
- data/lib/rigor/triage.rb +69 -1
- data/lib/rigor/type/combinator.rb +29 -0
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +13 -29
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +27 -90
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +20 -19
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +10 -8
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
- data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +1 -1
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +21 -34
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +11 -18
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +2 -13
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
- data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +25 -0
- data/sig/rigor/analysis/fact_store.rbs +3 -0
- data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
- data/sig/rigor/plugin/base.rbs +5 -2
- data/sig/rigor/plugin/manifest.rbs +1 -2
- data/sig/rigor/scope.rbs +10 -1
- data/sig/rigor/type.rbs +1 -0
- data/sig/rigor.rbs +1 -1
- data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
- data/skills/rigor-plugin-author/SKILL.md +6 -4
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
- data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
- metadata +7 -2
- 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 `#=>
|
|
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
|
|
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
|
-
#
|
|
33
|
-
#
|
|
34
|
-
|
|
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 ` #=>
|
|
122
|
-
#
|
|
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)} #=>
|
|
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
|
-
|
|
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].
|
|
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:` /
|
|
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
|
-
|
|
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
|
data/lib/rigor/configuration.rb
CHANGED
|
@@ -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
|
|