rigortype 0.1.19 → 0.2.1
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 +41 -6
- data/data/core_overlay/numeric.rbs +33 -0
- data/data/core_overlay/pathname.rbs +25 -0
- data/data/core_overlay/string_scanner.rbs +28 -0
- data/data/gem_overlay/activesupport/core_ext.rbs +473 -0
- data/data/vendored_gem_sigs/ast/ast.rbs +130 -0
- data/data/vendored_gem_sigs/bcrypt/bcrypt.rbs +47 -0
- data/data/vendored_gem_sigs/bundler/bundler.rbs +238 -0
- data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +34 -0
- data/data/vendored_gem_sigs/did_you_mean/did_you_mean_extras.rbs +34 -0
- data/data/vendored_gem_sigs/idn-ruby/idn.rbs +54 -0
- data/data/vendored_gem_sigs/mysql2/client.rbs +55 -0
- data/data/vendored_gem_sigs/mysql2/error.rbs +5 -0
- data/data/vendored_gem_sigs/mysql2/result.rbs +31 -0
- data/data/vendored_gem_sigs/mysql2/statement.rbs +5 -0
- data/data/vendored_gem_sigs/nokogiri/nokogiri.rbs +2332 -0
- data/data/vendored_gem_sigs/nokogiri/nokogiri_html5.rbs +47 -0
- data/data/vendored_gem_sigs/pg/pg.rbs +212 -0
- data/data/vendored_gem_sigs/prism/prism_supplement.rbs +44 -0
- data/data/vendored_gem_sigs/redis/errors.rbs +50 -0
- data/data/vendored_gem_sigs/redis/future.rbs +5 -0
- data/data/vendored_gem_sigs/redis/redis.rbs +348 -0
- data/data/vendored_gem_sigs/redis/redis_extras.rbs +130 -0
- data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +226 -0
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -23
- data/lib/rigor/analysis/check_rules/rule_walk.rb +3 -21
- data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +24 -15
- data/lib/rigor/analysis/check_rules.rb +492 -71
- data/lib/rigor/analysis/dependency_source_inference/index.rb +4 -7
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -18
- data/lib/rigor/analysis/dependency_source_inference.rb +3 -12
- data/lib/rigor/analysis/fact_store.rb +5 -4
- data/lib/rigor/analysis/rule_catalog.rb +153 -6
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +17 -17
- data/lib/rigor/analysis/runner/project_pre_passes.rb +9 -8
- data/lib/rigor/analysis/runner.rb +17 -6
- data/lib/rigor/analysis/self_call_resolution_recorder.rb +3 -4
- data/lib/rigor/analysis/worker_session.rb +10 -14
- data/lib/rigor/builtins/predefined_constant_refinements.rb +151 -0
- data/lib/rigor/cache/store.rb +5 -3
- data/lib/rigor/cli/annotate_command.rb +28 -7
- data/lib/rigor/cli/baseline_command.rb +4 -3
- data/lib/rigor/cli/check_command.rb +138 -16
- data/lib/rigor/cli/coverage_command.rb +138 -31
- data/lib/rigor/cli/coverage_mutation.rb +149 -0
- data/lib/rigor/cli/coverage_scan.rb +57 -0
- data/lib/rigor/cli/explain_command.rb +2 -0
- data/lib/rigor/cli/fused_protection_renderer.rb +67 -0
- data/lib/rigor/cli/fused_protection_report.rb +76 -0
- data/lib/rigor/cli/lsp_command.rb +3 -7
- data/lib/rigor/cli/mutation_protection_renderer.rb +63 -0
- data/lib/rigor/cli/mutation_protection_report.rb +73 -0
- data/lib/rigor/cli/options.rb +9 -0
- data/lib/rigor/cli/plugins_command.rb +2 -1
- data/lib/rigor/cli/protection_renderer.rb +63 -0
- data/lib/rigor/cli/protection_report.rb +68 -0
- data/lib/rigor/cli/sig_gen_command.rb +2 -1
- data/lib/rigor/cli/trace_command.rb +2 -1
- data/lib/rigor/cli/triage_command.rb +2 -1
- data/lib/rigor/cli/type_of_command.rb +1 -1
- data/lib/rigor/cli/type_scan_command.rb +2 -1
- data/lib/rigor/cli.rb +3 -2
- data/lib/rigor/config_audit.rb +152 -0
- data/lib/rigor/configuration/dependencies.rb +2 -4
- data/lib/rigor/configuration.rb +57 -7
- data/lib/rigor/environment/bundle_sig_discovery.rb +61 -13
- data/lib/rigor/environment/class_registry.rb +4 -3
- data/lib/rigor/environment/constant_type_cache_holder.rb +43 -0
- data/lib/rigor/environment/lockfile_resolver.rb +1 -1
- data/lib/rigor/environment/rbs_collection_discovery.rb +1 -2
- data/lib/rigor/environment/rbs_coverage_report.rb +2 -1
- data/lib/rigor/environment/rbs_loader.rb +76 -5
- data/lib/rigor/environment.rb +66 -8
- data/lib/rigor/flow_contribution/fact.rb +1 -1
- data/lib/rigor/flow_contribution.rb +3 -5
- data/lib/rigor/inference/acceptance.rb +17 -9
- data/lib/rigor/inference/block_parameter_binder.rb +2 -3
- data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -2
- data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -2
- data/lib/rigor/inference/builtins/method_catalog.rb +19 -0
- data/lib/rigor/inference/builtins/string_catalog.rb +9 -1
- data/lib/rigor/inference/expression_typer.rb +20 -28
- data/lib/rigor/inference/hkt_body.rb +8 -11
- data/lib/rigor/inference/hkt_body_parser.rb +10 -12
- data/lib/rigor/inference/hkt_registry.rb +10 -11
- data/lib/rigor/inference/method_dispatcher/call_context.rb +1 -4
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +169 -24
- data/lib/rigor/inference/method_dispatcher/data_folding.rb +9 -73
- data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -7
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +10 -16
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +25 -13
- data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +93 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -3
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +24 -22
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +90 -15
- data/lib/rigor/inference/method_dispatcher/struct_folding.rb +303 -0
- data/lib/rigor/inference/method_dispatcher.rb +40 -48
- data/lib/rigor/inference/mutation_widening.rb +5 -11
- data/lib/rigor/inference/narrowing.rb +14 -16
- data/lib/rigor/inference/parameter_inference_collector.rb +367 -0
- data/lib/rigor/inference/project_patched_methods.rb +4 -7
- data/lib/rigor/inference/project_patched_scanner.rb +2 -13
- data/lib/rigor/inference/protection_scanner.rb +86 -0
- data/lib/rigor/inference/scope_indexer.rb +129 -55
- data/lib/rigor/inference/statement_evaluator.rb +271 -114
- data/lib/rigor/inference/struct_fold_safety.rb +181 -0
- data/lib/rigor/inference/synthetic_method.rb +7 -7
- data/lib/rigor/language_server/completion_provider.rb +6 -12
- data/lib/rigor/language_server/diagnostic_publisher.rb +4 -4
- data/lib/rigor/language_server/document_symbol_provider.rb +3 -3
- data/lib/rigor/language_server/hover_provider.rb +2 -3
- data/lib/rigor/language_server/hover_renderer.rb +2 -11
- data/lib/rigor/language_server/server.rb +9 -17
- data/lib/rigor/language_server.rb +4 -5
- data/lib/rigor/plugin/base.rb +10 -8
- data/lib/rigor/plugin/macro/block_as_method.rb +3 -4
- data/lib/rigor/plugin/macro/heredoc_template.rb +4 -7
- data/lib/rigor/plugin/macro/trait_registry.rb +3 -6
- data/lib/rigor/plugin/macro.rb +4 -5
- data/lib/rigor/plugin/manifest.rb +45 -66
- data/lib/rigor/plugin/registry.rb +6 -7
- data/lib/rigor/plugin/type_node_resolver.rb +6 -8
- data/lib/rigor/protection/diagnostic_oracle.rb +51 -0
- data/lib/rigor/protection/mutation_scanner.rb +180 -0
- data/lib/rigor/protection/mutator.rb +267 -0
- data/lib/rigor/protection/test_suite_oracle.rb +68 -0
- data/lib/rigor/rbs_extended.rb +24 -36
- data/lib/rigor/reflection.rb +4 -7
- data/lib/rigor/scope/discovery_index.rb +14 -2
- data/lib/rigor/scope.rb +54 -11
- data/lib/rigor/sig_gen/observed_call.rb +3 -3
- data/lib/rigor/sig_gen/writer.rb +40 -2
- data/lib/rigor/signature_path_audit.rb +92 -0
- data/lib/rigor/source/constant_path.rb +62 -0
- data/lib/rigor/source.rb +1 -0
- data/lib/rigor/type/bound_method.rb +2 -11
- data/lib/rigor/type/combinator.rb +16 -3
- data/lib/rigor/type/constant.rb +2 -11
- data/lib/rigor/type/data_class.rb +2 -11
- data/lib/rigor/type/data_instance.rb +2 -11
- data/lib/rigor/type/hash_shape.rb +2 -11
- data/lib/rigor/type/integer_range.rb +2 -11
- data/lib/rigor/type/intersection.rb +2 -11
- data/lib/rigor/type/nominal.rb +2 -11
- data/lib/rigor/type/plain_lattice.rb +37 -0
- data/lib/rigor/type/refined.rb +72 -13
- data/lib/rigor/type/singleton.rb +2 -11
- data/lib/rigor/type/struct_class.rb +75 -0
- data/lib/rigor/type/struct_instance.rb +93 -0
- data/lib/rigor/type/tuple.rb +5 -15
- data/lib/rigor/type.rb +2 -0
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +3 -3
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +3 -3
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +5 -13
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +11 -17
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +7 -10
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +3 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +6 -8
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +5 -7
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +1 -2
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +9 -11
- data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +8 -9
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +13 -12
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +3 -4
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +8 -8
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +9 -11
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +7 -8
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +7 -9
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +12 -13
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +15 -23
- data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +3 -3
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +3 -3
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +2 -4
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +27 -11
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +1 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -6
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +12 -18
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +5 -5
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -4
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +1 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +19 -14
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +0 -1
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +5 -4
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +2 -3
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +7 -11
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +4 -5
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +6 -9
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +5 -15
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +28 -41
- data/sig/rigor/scope.rbs +9 -1
- data/sig/rigor/type.rbs +36 -1
- metadata +49 -1
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
class CLI
|
|
7
|
+
# Renders a {MutationProtectionReport} (ADR-63 Tier 2) as text or JSON. The
|
|
8
|
+
# text form leads with the effectiveness ratio (caught breakages), then the
|
|
9
|
+
# breakages Rigor missed ("add a type here"), then the least-effective files.
|
|
10
|
+
# The framing is always *where to add a type*, never "your code is broken".
|
|
11
|
+
class MutationProtectionRenderer
|
|
12
|
+
TOP_CALLS = 15
|
|
13
|
+
TOP_FILES = 10
|
|
14
|
+
|
|
15
|
+
def initialize(out:)
|
|
16
|
+
@out = out
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def render(report, format:)
|
|
20
|
+
format == "json" ? render_json(report) : render_text(report)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def render_json(report)
|
|
26
|
+
@out.puts(JSON.pretty_generate(report.to_h))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def render_text(report)
|
|
30
|
+
pct = (report.ratio * 100).round(1)
|
|
31
|
+
@out.puts "Type-protection effectiveness (Tier 2 — mutation kill rate)"
|
|
32
|
+
@out.puts " caught breakages: #{report.total_killed} / #{report.grand_total} (#{pct}%)"
|
|
33
|
+
@out.puts " (effectiveness = when a type-visible bug was introduced, Rigor caught it)"
|
|
34
|
+
render_missed(report)
|
|
35
|
+
render_files(report)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def render_missed(report)
|
|
39
|
+
missed = report.missed
|
|
40
|
+
return if missed.empty?
|
|
41
|
+
|
|
42
|
+
@out.puts "\nAdd a type here — breakages Rigor missed (a wrong call that stayed silent):"
|
|
43
|
+
missed.first(TOP_CALLS).each do |call|
|
|
44
|
+
@out.puts format(" %<count>-5d #%<method>s e.g. %<sites>s",
|
|
45
|
+
count: call.count, method: call.method_name, sites: call.examples.join(" "))
|
|
46
|
+
end
|
|
47
|
+
@out.puts " (#{missed.size - TOP_CALLS} more)" if missed.size > TOP_CALLS
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def render_files(report)
|
|
51
|
+
worst = report.files.reject { |f| f.survived.zero? }.sort_by(&:ratio).first(TOP_FILES)
|
|
52
|
+
return if worst.empty?
|
|
53
|
+
|
|
54
|
+
@out.puts "\nLeast-effective files:"
|
|
55
|
+
worst.each do |file|
|
|
56
|
+
total = file.killed + file.survived
|
|
57
|
+
@out.puts format(" %<pct>5.1f%% %<path>s (%<n>d/%<total>d breakages caught)",
|
|
58
|
+
pct: file.ratio * 100, path: file.path, n: file.killed, total: total)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
class CLI
|
|
5
|
+
# ADR-63 Tier 2 — aggregates per-file {Protection::MutationScanner}
|
|
6
|
+
# results into a project-level *effectiveness* report: the kill ratio (when
|
|
7
|
+
# a type-visible bug was introduced, how often Rigor caught it), the per-file
|
|
8
|
+
# breakdown, and a ranked "add a type here" list keyed by the method whose
|
|
9
|
+
# breakage Rigor most often *missed* — the sites where a receiver annotation
|
|
10
|
+
# would buy real catching power.
|
|
11
|
+
#
|
|
12
|
+
# The framing is load-bearing (ADR-63 Criterion A / ADR-62 Criterion A): the
|
|
13
|
+
# number is *effectiveness*, the survivors are *missed breakages / where to
|
|
14
|
+
# add a type*, never "your code is broken".
|
|
15
|
+
FileEffectiveness = Data.define(:path, :killed, :survived, :ratio)
|
|
16
|
+
MissedBreakage = Data.define(:method_name, :count, :examples)
|
|
17
|
+
|
|
18
|
+
MutationProtectionReport = Data.define(:files, :missed, :parse_errors) do
|
|
19
|
+
def total_killed = files.sum(&:killed)
|
|
20
|
+
def total_survived = files.sum(&:survived)
|
|
21
|
+
def grand_total = total_killed + total_survived
|
|
22
|
+
def ratio = grand_total.zero? ? 1.0 : total_killed.to_f / grand_total
|
|
23
|
+
|
|
24
|
+
def to_h
|
|
25
|
+
{
|
|
26
|
+
"mode" => "mutation",
|
|
27
|
+
"killed" => total_killed,
|
|
28
|
+
"survived" => total_survived,
|
|
29
|
+
"effectiveness_ratio" => ratio.round(4),
|
|
30
|
+
"files" => files.map do |f|
|
|
31
|
+
{ "path" => f.path, "killed" => f.killed,
|
|
32
|
+
"survived" => f.survived, "ratio" => f.ratio.round(4) }
|
|
33
|
+
end,
|
|
34
|
+
"add_a_type_here" => missed.map do |m|
|
|
35
|
+
{ "method" => m.method_name, "count" => m.count, "examples" => m.examples }
|
|
36
|
+
end,
|
|
37
|
+
"parse_errors" => parse_errors
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class MutationProtectionAccumulator
|
|
43
|
+
def initialize
|
|
44
|
+
@files = []
|
|
45
|
+
@missed = Hash.new { |h, k| h[k] = { count: 0, examples: [] } }
|
|
46
|
+
@parse_errors = []
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def absorb(file_result)
|
|
50
|
+
@files << FileEffectiveness.new(
|
|
51
|
+
path: file_result.path, killed: file_result.killed,
|
|
52
|
+
survived: file_result.survived, ratio: file_result.ratio
|
|
53
|
+
)
|
|
54
|
+
file_result.sites.each do |site|
|
|
55
|
+
bucket = @missed[site.method_name]
|
|
56
|
+
bucket[:count] += 1
|
|
57
|
+
bucket[:examples] << "#{file_result.path}:#{site.line}" if bucket[:examples].size < 3
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def record_parse_error(path, errors)
|
|
62
|
+
@parse_errors << { "path" => path, "errors" => errors.size }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def to_report
|
|
66
|
+
missed = @missed
|
|
67
|
+
.map { |method, v| MissedBreakage.new(method_name: method, count: v[:count], examples: v[:examples]) }
|
|
68
|
+
.sort_by { |m| [-m.count, m.method_name] }
|
|
69
|
+
MutationProtectionReport.new(files: @files, missed: missed, parse_errors: @parse_errors)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
data/lib/rigor/cli/options.rb
CHANGED
|
@@ -17,6 +17,15 @@ module Rigor
|
|
|
17
17
|
module Options
|
|
18
18
|
module_function
|
|
19
19
|
|
|
20
|
+
# Defines the standard `--config=PATH` flag on `parser`, writing the
|
|
21
|
+
# path into `options[:config]`. Used by every subcommand that loads a
|
|
22
|
+
# `.rigor.yml`; the few whose `--config` help text is intentionally
|
|
23
|
+
# bespoke (`diff`, `mcp`, `show-bleedingedge`) keep their own
|
|
24
|
+
# `parser.on` rather than this shared wording.
|
|
25
|
+
def add_config(parser, options)
|
|
26
|
+
parser.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
|
|
27
|
+
end
|
|
28
|
+
|
|
20
29
|
# Defines the `--tmp-file` / `--instead-of` editor-mode flag pair
|
|
21
30
|
# on `parser`, writing into `options`.
|
|
22
31
|
def add_editor_mode(parser, options)
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "optparse"
|
|
4
4
|
|
|
5
5
|
require_relative "../configuration"
|
|
6
|
+
require_relative "options"
|
|
6
7
|
require_relative "../plugin"
|
|
7
8
|
require_relative "../plugin/loader"
|
|
8
9
|
require_relative "../plugin/services"
|
|
@@ -102,7 +103,7 @@ module Rigor
|
|
|
102
103
|
options = { config: nil, format: "text", strict: false, capabilities: false }
|
|
103
104
|
OptionParser.new do |opts|
|
|
104
105
|
opts.banner = USAGE
|
|
105
|
-
|
|
106
|
+
Options.add_config(opts, options)
|
|
106
107
|
opts.on("--format=FORMAT", "Output format: text (default) or json") { |v| options[:format] = v }
|
|
107
108
|
opts.on("--strict", "Exit 1 if any plugin failed to load (CI gate)") { options[:strict] = true }
|
|
108
109
|
opts.on("--capabilities", "Emit the per-plugin extension-protocol catalogue (ADR-37)") do
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
class CLI
|
|
7
|
+
# Renders an {ProtectionReport} (ADR-63 Tier 1) as text or JSON. The text
|
|
8
|
+
# form leads with the protected ratio, then the highest-traffic untyped
|
|
9
|
+
# dispatches ("add a type here"), then the lowest-protected files. The
|
|
10
|
+
# framing is always *where to add a type*, never "your code is broken".
|
|
11
|
+
class ProtectionRenderer
|
|
12
|
+
TOP_CALLS = 15
|
|
13
|
+
TOP_FILES = 10
|
|
14
|
+
|
|
15
|
+
def initialize(out:)
|
|
16
|
+
@out = out
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def render(report, format:)
|
|
20
|
+
format == "json" ? render_json(report) : render_text(report)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def render_json(report)
|
|
26
|
+
@out.puts(JSON.pretty_generate(report.to_h))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def render_text(report)
|
|
30
|
+
pct = (report.ratio * 100).round(1)
|
|
31
|
+
@out.puts "Type-protection coverage (Tier 1 — dispatch-site receiver concreteness)"
|
|
32
|
+
@out.puts " protected dispatch sites: #{report.total_protected} / #{report.grand_total} (#{pct}%)"
|
|
33
|
+
@out.puts " (protected = Rigor can catch a wrong call here; an upper bound on real protection)"
|
|
34
|
+
render_untyped_calls(report)
|
|
35
|
+
render_files(report)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def render_untyped_calls(report)
|
|
39
|
+
calls = report.untyped_calls
|
|
40
|
+
return if calls.empty?
|
|
41
|
+
|
|
42
|
+
@out.puts "\nAdd a type here — methods most often called on an untyped receiver:"
|
|
43
|
+
calls.first(TOP_CALLS).each do |call|
|
|
44
|
+
@out.puts format(" %<count>-5d #%<method>s e.g. %<sites>s",
|
|
45
|
+
count: call.count, method: call.method_name, sites: call.examples.join(" "))
|
|
46
|
+
end
|
|
47
|
+
@out.puts " (#{calls.size - TOP_CALLS} more)" if calls.size > TOP_CALLS
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def render_files(report)
|
|
51
|
+
worst = report.files.reject { |f| f.unprotected_count.zero? }.sort_by(&:ratio).first(TOP_FILES)
|
|
52
|
+
return if worst.empty?
|
|
53
|
+
|
|
54
|
+
@out.puts "\nLeast-protected files:"
|
|
55
|
+
worst.each do |file|
|
|
56
|
+
total = file.protected_count + file.unprotected_count
|
|
57
|
+
@out.puts format(" %<pct>5.1f%% %<path>s (%<n>d/%<total>d protected)",
|
|
58
|
+
pct: file.ratio * 100, path: file.path, n: file.protected_count, total: total)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
class CLI
|
|
5
|
+
# ADR-63 Tier 1 — aggregates per-file {Inference::ProtectionScanner}
|
|
6
|
+
# results into a project-level protection report: the protected ratio, the
|
|
7
|
+
# per-file breakdown, and a ranked "add a type here" list keyed by the
|
|
8
|
+
# method called on an unprotected (`Dynamic`) receiver — the highest-traffic
|
|
9
|
+
# untyped dispatches, where a receiver annotation buys the most catching
|
|
10
|
+
# power.
|
|
11
|
+
FileProtection = Data.define(:path, :protected_count, :unprotected_count, :ratio)
|
|
12
|
+
UntypedCall = Data.define(:method_name, :count, :examples)
|
|
13
|
+
|
|
14
|
+
ProtectionReport = Data.define(:files, :untyped_calls, :parse_errors) do
|
|
15
|
+
def total_protected = files.sum(&:protected_count)
|
|
16
|
+
def total_unprotected = files.sum(&:unprotected_count)
|
|
17
|
+
def grand_total = total_protected + total_unprotected
|
|
18
|
+
def ratio = grand_total.zero? ? 1.0 : total_protected.to_f / grand_total
|
|
19
|
+
|
|
20
|
+
def to_h
|
|
21
|
+
{
|
|
22
|
+
"protected" => total_protected,
|
|
23
|
+
"unprotected" => total_unprotected,
|
|
24
|
+
"protection_ratio" => ratio.round(4),
|
|
25
|
+
"files" => files.map do |f|
|
|
26
|
+
{ "path" => f.path, "protected" => f.protected_count,
|
|
27
|
+
"unprotected" => f.unprotected_count, "ratio" => f.ratio.round(4) }
|
|
28
|
+
end,
|
|
29
|
+
"add_a_type_here" => untyped_calls.map do |c|
|
|
30
|
+
{ "method" => c.method_name, "count" => c.count, "examples" => c.examples }
|
|
31
|
+
end,
|
|
32
|
+
"parse_errors" => parse_errors
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class ProtectionAccumulator
|
|
38
|
+
def initialize
|
|
39
|
+
@files = []
|
|
40
|
+
@calls = Hash.new { |h, k| h[k] = { count: 0, examples: [] } }
|
|
41
|
+
@parse_errors = []
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def absorb(path, file_result)
|
|
45
|
+
@files << FileProtection.new(
|
|
46
|
+
path: path, protected_count: file_result.protected_count,
|
|
47
|
+
unprotected_count: file_result.unprotected_count, ratio: file_result.ratio
|
|
48
|
+
)
|
|
49
|
+
file_result.sites.each do |site|
|
|
50
|
+
bucket = @calls[site.method_name]
|
|
51
|
+
bucket[:count] += 1
|
|
52
|
+
bucket[:examples] << "#{path}:#{site.line}" if bucket[:examples].size < 3
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def record_parse_error(path, errors)
|
|
57
|
+
@parse_errors << { "path" => path, "errors" => errors.size }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def to_report
|
|
61
|
+
untyped = @calls
|
|
62
|
+
.map { |method, v| UntypedCall.new(method_name: method, count: v[:count], examples: v[:examples]) }
|
|
63
|
+
.sort_by { |c| [-c.count, c.method_name] }
|
|
64
|
+
ProtectionReport.new(files: @files, untyped_calls: untyped, parse_errors: @parse_errors)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "optionparser"
|
|
4
4
|
|
|
5
5
|
require_relative "../configuration"
|
|
6
|
+
require_relative "options"
|
|
6
7
|
require_relative "../sig_gen"
|
|
7
8
|
require_relative "command"
|
|
8
9
|
|
|
@@ -145,7 +146,7 @@ module Rigor
|
|
|
145
146
|
opts.on("--tighter-returns", "Emit only tighter-return classifications") do
|
|
146
147
|
options[:selection] << SigGen::Classification::TIGHTER_RETURN
|
|
147
148
|
end
|
|
148
|
-
|
|
149
|
+
Options.add_config(opts, options)
|
|
149
150
|
end
|
|
150
151
|
end
|
|
151
152
|
|
|
@@ -5,6 +5,7 @@ require "optionparser"
|
|
|
5
5
|
require "prism"
|
|
6
6
|
|
|
7
7
|
require_relative "../configuration"
|
|
8
|
+
require_relative "options"
|
|
8
9
|
require_relative "../environment"
|
|
9
10
|
require_relative "../scope"
|
|
10
11
|
require_relative "../inference/flow_tracer"
|
|
@@ -63,7 +64,7 @@ module Rigor
|
|
|
63
64
|
options[:line] = value
|
|
64
65
|
end
|
|
65
66
|
opts.on("--verbose", "Include every expression enter/result frame") { options[:verbose] = true }
|
|
66
|
-
|
|
67
|
+
Options.add_config(opts, options)
|
|
67
68
|
end
|
|
68
69
|
parser.parse!(@argv)
|
|
69
70
|
options
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "optionparser"
|
|
4
4
|
|
|
5
5
|
require_relative "../configuration"
|
|
6
|
+
require_relative "options"
|
|
6
7
|
require_relative "../analysis/runner"
|
|
7
8
|
require_relative "../cache/store"
|
|
8
9
|
require_relative "../triage"
|
|
@@ -42,7 +43,7 @@ module Rigor
|
|
|
42
43
|
options = { config: nil, format: "text", top: 10, sections: DEFAULT_SECTIONS }
|
|
43
44
|
OptionParser.new do |opts|
|
|
44
45
|
opts.banner = USAGE
|
|
45
|
-
|
|
46
|
+
Options.add_config(opts, options)
|
|
46
47
|
opts.on("--format=FORMAT", "Output format: text (default) or json") { |v| options[:format] = v }
|
|
47
48
|
opts.on("--top=N", Integer, "Hotspot-file count (default 10)") { |v| options[:top] = v }
|
|
48
49
|
opts.on("--hints-only", "Print only the heuristic-hints section") { options[:sections] = %i[hints] }
|
|
@@ -53,7 +53,7 @@ module Rigor
|
|
|
53
53
|
opts.banner = USAGE
|
|
54
54
|
opts.on("--format=FORMAT", "Output format: text or json") { |value| options[:format] = value }
|
|
55
55
|
opts.on("--trace", "Record fail-soft fallbacks via FallbackTracer") { options[:trace] = true }
|
|
56
|
-
|
|
56
|
+
Options.add_config(opts, options)
|
|
57
57
|
Options.add_editor_mode(opts, options)
|
|
58
58
|
end
|
|
59
59
|
parser.parse!(@argv)
|
|
@@ -4,6 +4,7 @@ require "optionparser"
|
|
|
4
4
|
require "prism"
|
|
5
5
|
|
|
6
6
|
require_relative "../configuration"
|
|
7
|
+
require_relative "options"
|
|
7
8
|
require_relative "../environment"
|
|
8
9
|
require_relative "../inference/coverage_scanner"
|
|
9
10
|
require_relative "../scope"
|
|
@@ -46,7 +47,7 @@ module Rigor
|
|
|
46
47
|
parser = OptionParser.new do |opts|
|
|
47
48
|
opts.banner = USAGE
|
|
48
49
|
opts.on("--format=FORMAT", "Output format: text or json") { |value| options[:format] = value }
|
|
49
|
-
|
|
50
|
+
Options.add_config(opts, options)
|
|
50
51
|
opts.on("--limit=N", Integer, "Max example events to print (text only)") do |value|
|
|
51
52
|
options[:limit] = value
|
|
52
53
|
end
|
data/lib/rigor/cli.rb
CHANGED
|
@@ -151,8 +151,9 @@ module Rigor
|
|
|
151
151
|
# - target_ruby: minimum Ruby version your project targets.
|
|
152
152
|
# - paths: directories scanned by `rigor check` and
|
|
153
153
|
# `rigor type-scan` when no path is given.
|
|
154
|
-
# - plugins:
|
|
155
|
-
#
|
|
154
|
+
# - plugins: opt-in list of plugin gem names to load.
|
|
155
|
+
# See https://github.com/rigortype/rigor/tree/main/plugins
|
|
156
|
+
# for production plugins (rigor-activerecord, rigor-sorbet, …).
|
|
156
157
|
# - disable: list of `rigor check` rule identifiers to
|
|
157
158
|
# silence project-wide. The shipped rules are
|
|
158
159
|
# call.undefined-method, call.wrong-arity,
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "signature_path_audit"
|
|
4
|
+
require_relative "analysis/check_rules"
|
|
5
|
+
|
|
6
|
+
module Rigor
|
|
7
|
+
# Audits a loaded {Configuration} for the class of mistake where a
|
|
8
|
+
# configured value silently resolves to nothing — a typo'd or moved
|
|
9
|
+
# path, an unknown library name, an inert rule id. The shared failure
|
|
10
|
+
# mode is that the loader filters the bad entry without a word, so the
|
|
11
|
+
# only symptom is downstream and confusing: missing signatures turn
|
|
12
|
+
# every call into the types they were meant to cover into a
|
|
13
|
+
# high-confidence `call.undefined-method`, and an unrecognised
|
|
14
|
+
# suppression token leaves the rule firing as if the `disable:` line
|
|
15
|
+
# were never written. This module surfaces each such entry up front so
|
|
16
|
+
# the cause is visible instead of inferred.
|
|
17
|
+
#
|
|
18
|
+
# Every check is held to the same bar that {SignaturePathAudit} set:
|
|
19
|
+
# it mirrors the loader's own acceptance test, so a warning means the
|
|
20
|
+
# loader really did load nothing, and it never fires on a setup that
|
|
21
|
+
# works. In particular the rule-token check only flags a token under a
|
|
22
|
+
# built-in family (`call`, `flow`, …) — a plugin- or `rbs_extended.*`
|
|
23
|
+
# rule id (whose family Rigor cannot statically enumerate, since a
|
|
24
|
+
# `node_rule` block emits any `rule:` string it likes) is left alone, so
|
|
25
|
+
# disabling a plugin rule is never mistaken for a typo.
|
|
26
|
+
module ConfigAudit
|
|
27
|
+
# One config-level finding. `kind` discriminates the source key
|
|
28
|
+
# (`:signature_path`, `:library`, `:disabled_rule`,
|
|
29
|
+
# `:severity_override`, `:bundler_bundle_path`, `:bundler_lockfile`,
|
|
30
|
+
# `:rbs_collection_lockfile`); `fields` carries the kind-specific
|
|
31
|
+
# structured data merged into {#to_h} for JSON consumers.
|
|
32
|
+
Warning = Data.define(:kind, :message, :fields) do
|
|
33
|
+
def to_h
|
|
34
|
+
{ "kind" => kind.to_s, "message" => message }.merge(fields)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param configuration [Rigor::Configuration]
|
|
39
|
+
# @param project_root [String] the directory the run's relative
|
|
40
|
+
# bundler / collection paths resolve against (the CLI's CWD), used
|
|
41
|
+
# only by the explicit-path checks.
|
|
42
|
+
# @return [Array<Warning>]
|
|
43
|
+
def self.warnings(configuration, project_root: Dir.pwd)
|
|
44
|
+
signature_path_warnings(configuration) +
|
|
45
|
+
library_warnings(configuration) +
|
|
46
|
+
rule_token_warnings(configuration) +
|
|
47
|
+
explicit_path_warnings(configuration, project_root)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# `signature_paths:` entries that resolve to nothing — delegated to
|
|
51
|
+
# {SignaturePathAudit}, which mirrors the loader's `directory?` +
|
|
52
|
+
# recursive `**/*.rbs` acceptance test.
|
|
53
|
+
def self.signature_path_warnings(configuration)
|
|
54
|
+
SignaturePathAudit.warnings(configuration.signature_paths).map do |entry|
|
|
55
|
+
Warning.new(
|
|
56
|
+
kind: :signature_path,
|
|
57
|
+
message: entry.message,
|
|
58
|
+
fields: { "path" => entry.path, "status" => entry.status.to_s, "rbs_file_count" => entry.rbs_file_count }
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# `libraries:` entries RBS does not recognise. Uses the same
|
|
64
|
+
# `RBS::EnvironmentLoader#has_library?` guard the loader filters
|
|
65
|
+
# through ({Environment::RbsLoader} `build_env_for`), so a flagged
|
|
66
|
+
# name is exactly one the loader skipped. Fails soft: if RBS itself
|
|
67
|
+
# raises, no warning rather than a crash.
|
|
68
|
+
def self.library_warnings(configuration)
|
|
69
|
+
configured = Array(configuration.libraries)
|
|
70
|
+
return [] if configured.empty?
|
|
71
|
+
|
|
72
|
+
loader = ::RBS::EnvironmentLoader.new
|
|
73
|
+
configured.reject { |lib| loader.has_library?(library: lib.to_s, version: nil) }.map do |lib|
|
|
74
|
+
Warning.new(
|
|
75
|
+
kind: :library,
|
|
76
|
+
message: "libraries: #{lib.to_s.inspect} is not an available RBS library (no signatures loaded from it)",
|
|
77
|
+
fields: { "name" => lib.to_s }
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
rescue StandardError
|
|
81
|
+
[]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# `disable:` tokens and `severity_overrides:` keys that name no rule.
|
|
85
|
+
# Restricted to tokens under a built-in family so a plugin rule id is
|
|
86
|
+
# never mis-flagged (see the module docstring).
|
|
87
|
+
def self.rule_token_warnings(configuration)
|
|
88
|
+
disable = Array(configuration.disabled_rules).filter_map do |token|
|
|
89
|
+
next unless inert_builtin_token?(token.to_s)
|
|
90
|
+
|
|
91
|
+
Warning.new(
|
|
92
|
+
kind: :disabled_rule,
|
|
93
|
+
message: "disable: #{token.to_s.inspect} is not a recognized rule id; the suppression has no effect",
|
|
94
|
+
fields: { "token" => token.to_s }
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
overrides = configuration.severity_overrides.keys.filter_map do |key|
|
|
98
|
+
next unless inert_builtin_token?(key.to_s)
|
|
99
|
+
|
|
100
|
+
Warning.new(
|
|
101
|
+
kind: :severity_override,
|
|
102
|
+
message: "severity_overrides: #{key.to_s.inspect} is not a recognized rule id; the override has no effect",
|
|
103
|
+
fields: { "token" => key.to_s }
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
disable + overrides
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# True when `token` looks like a built-in rule id but matches none —
|
|
110
|
+
# its first segment is a built-in family yet it is neither a bare
|
|
111
|
+
# family wildcard nor a known canonical id. A token whose family is
|
|
112
|
+
# not built-in (a plugin / `rbs_extended.*` rule, or a bare legacy
|
|
113
|
+
# alias) is deliberately NOT flagged: it may resolve at run time, so
|
|
114
|
+
# under-warning is the FP-safe choice.
|
|
115
|
+
def self.inert_builtin_token?(token)
|
|
116
|
+
family = token.split(".").first
|
|
117
|
+
return false unless Analysis::CheckRules::RULE_FAMILIES.include?(family)
|
|
118
|
+
return false if token == family
|
|
119
|
+
return false if Analysis::CheckRules::ALL_RULES.include?(token)
|
|
120
|
+
|
|
121
|
+
true
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Explicitly-configured bundler / rbs-collection paths that do not
|
|
125
|
+
# exist. Only the explicit form is audited (a nil value means
|
|
126
|
+
# auto-detection, which finding nothing is normal); messages stay
|
|
127
|
+
# factual about the path rather than guessing the fallback.
|
|
128
|
+
def self.explicit_path_warnings(configuration, project_root)
|
|
129
|
+
warnings = []
|
|
130
|
+
add_missing_dir(warnings, configuration.bundler_bundle_path, project_root,
|
|
131
|
+
:bundler_bundle_path, "bundler.bundle_path")
|
|
132
|
+
add_missing_file(warnings, configuration.bundler_lockfile, project_root,
|
|
133
|
+
:bundler_lockfile, "bundler.lockfile")
|
|
134
|
+
add_missing_file(warnings, configuration.rbs_collection_lockfile, project_root,
|
|
135
|
+
:rbs_collection_lockfile, "rbs_collection.lockfile")
|
|
136
|
+
warnings
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def self.add_missing_dir(warnings, path, project_root, kind, key)
|
|
140
|
+
return if path.nil? || File.directory?(File.expand_path(path, project_root))
|
|
141
|
+
|
|
142
|
+
warnings << Warning.new(kind: kind, message: "#{key}: #{path.inspect} is not a directory",
|
|
143
|
+
fields: { "path" => path })
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def self.add_missing_file(warnings, path, project_root, kind, key)
|
|
147
|
+
return if path.nil? || File.file?(File.expand_path(path, project_root))
|
|
148
|
+
|
|
149
|
+
warnings << Warning.new(kind: kind, message: "#{key}: #{path.inspect} does not exist", fields: { "path" => path })
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -9,10 +9,8 @@ module Rigor
|
|
|
9
9
|
# inference instead of degrading to `Dynamic[top]` at the
|
|
10
10
|
# dependency boundary.
|
|
11
11
|
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
# wires `Analysis::DependencySourceInference` against this
|
|
15
|
-
# value object.
|
|
12
|
+
# Parser for the `dependencies:` YAML section; consumed by
|
|
13
|
+
# `Analysis::DependencySourceInference` (ADR-10).
|
|
16
14
|
class Dependencies
|
|
17
15
|
# Walking modes per
|
|
18
16
|
# [ADR-10 § "Decision"](../../../docs/adr/10-dependency-source-inference.md#decision).
|