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.
Files changed (197) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +41 -6
  3. data/data/core_overlay/numeric.rbs +33 -0
  4. data/data/core_overlay/pathname.rbs +25 -0
  5. data/data/core_overlay/string_scanner.rbs +28 -0
  6. data/data/gem_overlay/activesupport/core_ext.rbs +473 -0
  7. data/data/vendored_gem_sigs/ast/ast.rbs +130 -0
  8. data/data/vendored_gem_sigs/bcrypt/bcrypt.rbs +47 -0
  9. data/data/vendored_gem_sigs/bundler/bundler.rbs +238 -0
  10. data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +34 -0
  11. data/data/vendored_gem_sigs/did_you_mean/did_you_mean_extras.rbs +34 -0
  12. data/data/vendored_gem_sigs/idn-ruby/idn.rbs +54 -0
  13. data/data/vendored_gem_sigs/mysql2/client.rbs +55 -0
  14. data/data/vendored_gem_sigs/mysql2/error.rbs +5 -0
  15. data/data/vendored_gem_sigs/mysql2/result.rbs +31 -0
  16. data/data/vendored_gem_sigs/mysql2/statement.rbs +5 -0
  17. data/data/vendored_gem_sigs/nokogiri/nokogiri.rbs +2332 -0
  18. data/data/vendored_gem_sigs/nokogiri/nokogiri_html5.rbs +47 -0
  19. data/data/vendored_gem_sigs/pg/pg.rbs +212 -0
  20. data/data/vendored_gem_sigs/prism/prism_supplement.rbs +44 -0
  21. data/data/vendored_gem_sigs/redis/errors.rbs +50 -0
  22. data/data/vendored_gem_sigs/redis/future.rbs +5 -0
  23. data/data/vendored_gem_sigs/redis/redis.rbs +348 -0
  24. data/data/vendored_gem_sigs/redis/redis_extras.rbs +130 -0
  25. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +226 -0
  26. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -23
  27. data/lib/rigor/analysis/check_rules/rule_walk.rb +3 -21
  28. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +24 -15
  29. data/lib/rigor/analysis/check_rules.rb +492 -71
  30. data/lib/rigor/analysis/dependency_source_inference/index.rb +4 -7
  31. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -18
  32. data/lib/rigor/analysis/dependency_source_inference.rb +3 -12
  33. data/lib/rigor/analysis/fact_store.rb +5 -4
  34. data/lib/rigor/analysis/rule_catalog.rb +153 -6
  35. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +17 -17
  36. data/lib/rigor/analysis/runner/project_pre_passes.rb +9 -8
  37. data/lib/rigor/analysis/runner.rb +17 -6
  38. data/lib/rigor/analysis/self_call_resolution_recorder.rb +3 -4
  39. data/lib/rigor/analysis/worker_session.rb +10 -14
  40. data/lib/rigor/builtins/predefined_constant_refinements.rb +151 -0
  41. data/lib/rigor/cache/store.rb +5 -3
  42. data/lib/rigor/cli/annotate_command.rb +28 -7
  43. data/lib/rigor/cli/baseline_command.rb +4 -3
  44. data/lib/rigor/cli/check_command.rb +138 -16
  45. data/lib/rigor/cli/coverage_command.rb +138 -31
  46. data/lib/rigor/cli/coverage_mutation.rb +149 -0
  47. data/lib/rigor/cli/coverage_scan.rb +57 -0
  48. data/lib/rigor/cli/explain_command.rb +2 -0
  49. data/lib/rigor/cli/fused_protection_renderer.rb +67 -0
  50. data/lib/rigor/cli/fused_protection_report.rb +76 -0
  51. data/lib/rigor/cli/lsp_command.rb +3 -7
  52. data/lib/rigor/cli/mutation_protection_renderer.rb +63 -0
  53. data/lib/rigor/cli/mutation_protection_report.rb +73 -0
  54. data/lib/rigor/cli/options.rb +9 -0
  55. data/lib/rigor/cli/plugins_command.rb +2 -1
  56. data/lib/rigor/cli/protection_renderer.rb +63 -0
  57. data/lib/rigor/cli/protection_report.rb +68 -0
  58. data/lib/rigor/cli/sig_gen_command.rb +2 -1
  59. data/lib/rigor/cli/trace_command.rb +2 -1
  60. data/lib/rigor/cli/triage_command.rb +2 -1
  61. data/lib/rigor/cli/type_of_command.rb +1 -1
  62. data/lib/rigor/cli/type_scan_command.rb +2 -1
  63. data/lib/rigor/cli.rb +3 -2
  64. data/lib/rigor/config_audit.rb +152 -0
  65. data/lib/rigor/configuration/dependencies.rb +2 -4
  66. data/lib/rigor/configuration.rb +57 -7
  67. data/lib/rigor/environment/bundle_sig_discovery.rb +61 -13
  68. data/lib/rigor/environment/class_registry.rb +4 -3
  69. data/lib/rigor/environment/constant_type_cache_holder.rb +43 -0
  70. data/lib/rigor/environment/lockfile_resolver.rb +1 -1
  71. data/lib/rigor/environment/rbs_collection_discovery.rb +1 -2
  72. data/lib/rigor/environment/rbs_coverage_report.rb +2 -1
  73. data/lib/rigor/environment/rbs_loader.rb +76 -5
  74. data/lib/rigor/environment.rb +66 -8
  75. data/lib/rigor/flow_contribution/fact.rb +1 -1
  76. data/lib/rigor/flow_contribution.rb +3 -5
  77. data/lib/rigor/inference/acceptance.rb +17 -9
  78. data/lib/rigor/inference/block_parameter_binder.rb +2 -3
  79. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -2
  80. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -2
  81. data/lib/rigor/inference/builtins/method_catalog.rb +19 -0
  82. data/lib/rigor/inference/builtins/string_catalog.rb +9 -1
  83. data/lib/rigor/inference/expression_typer.rb +20 -28
  84. data/lib/rigor/inference/hkt_body.rb +8 -11
  85. data/lib/rigor/inference/hkt_body_parser.rb +10 -12
  86. data/lib/rigor/inference/hkt_registry.rb +10 -11
  87. data/lib/rigor/inference/method_dispatcher/call_context.rb +1 -4
  88. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +169 -24
  89. data/lib/rigor/inference/method_dispatcher/data_folding.rb +9 -73
  90. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -7
  91. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +10 -16
  92. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +25 -13
  93. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +93 -0
  94. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -3
  95. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +24 -22
  96. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +90 -15
  97. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +303 -0
  98. data/lib/rigor/inference/method_dispatcher.rb +40 -48
  99. data/lib/rigor/inference/mutation_widening.rb +5 -11
  100. data/lib/rigor/inference/narrowing.rb +14 -16
  101. data/lib/rigor/inference/parameter_inference_collector.rb +367 -0
  102. data/lib/rigor/inference/project_patched_methods.rb +4 -7
  103. data/lib/rigor/inference/project_patched_scanner.rb +2 -13
  104. data/lib/rigor/inference/protection_scanner.rb +86 -0
  105. data/lib/rigor/inference/scope_indexer.rb +129 -55
  106. data/lib/rigor/inference/statement_evaluator.rb +271 -114
  107. data/lib/rigor/inference/struct_fold_safety.rb +181 -0
  108. data/lib/rigor/inference/synthetic_method.rb +7 -7
  109. data/lib/rigor/language_server/completion_provider.rb +6 -12
  110. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -4
  111. data/lib/rigor/language_server/document_symbol_provider.rb +3 -3
  112. data/lib/rigor/language_server/hover_provider.rb +2 -3
  113. data/lib/rigor/language_server/hover_renderer.rb +2 -11
  114. data/lib/rigor/language_server/server.rb +9 -17
  115. data/lib/rigor/language_server.rb +4 -5
  116. data/lib/rigor/plugin/base.rb +10 -8
  117. data/lib/rigor/plugin/macro/block_as_method.rb +3 -4
  118. data/lib/rigor/plugin/macro/heredoc_template.rb +4 -7
  119. data/lib/rigor/plugin/macro/trait_registry.rb +3 -6
  120. data/lib/rigor/plugin/macro.rb +4 -5
  121. data/lib/rigor/plugin/manifest.rb +45 -66
  122. data/lib/rigor/plugin/registry.rb +6 -7
  123. data/lib/rigor/plugin/type_node_resolver.rb +6 -8
  124. data/lib/rigor/protection/diagnostic_oracle.rb +51 -0
  125. data/lib/rigor/protection/mutation_scanner.rb +180 -0
  126. data/lib/rigor/protection/mutator.rb +267 -0
  127. data/lib/rigor/protection/test_suite_oracle.rb +68 -0
  128. data/lib/rigor/rbs_extended.rb +24 -36
  129. data/lib/rigor/reflection.rb +4 -7
  130. data/lib/rigor/scope/discovery_index.rb +14 -2
  131. data/lib/rigor/scope.rb +54 -11
  132. data/lib/rigor/sig_gen/observed_call.rb +3 -3
  133. data/lib/rigor/sig_gen/writer.rb +40 -2
  134. data/lib/rigor/signature_path_audit.rb +92 -0
  135. data/lib/rigor/source/constant_path.rb +62 -0
  136. data/lib/rigor/source.rb +1 -0
  137. data/lib/rigor/type/bound_method.rb +2 -11
  138. data/lib/rigor/type/combinator.rb +16 -3
  139. data/lib/rigor/type/constant.rb +2 -11
  140. data/lib/rigor/type/data_class.rb +2 -11
  141. data/lib/rigor/type/data_instance.rb +2 -11
  142. data/lib/rigor/type/hash_shape.rb +2 -11
  143. data/lib/rigor/type/integer_range.rb +2 -11
  144. data/lib/rigor/type/intersection.rb +2 -11
  145. data/lib/rigor/type/nominal.rb +2 -11
  146. data/lib/rigor/type/plain_lattice.rb +37 -0
  147. data/lib/rigor/type/refined.rb +72 -13
  148. data/lib/rigor/type/singleton.rb +2 -11
  149. data/lib/rigor/type/struct_class.rb +75 -0
  150. data/lib/rigor/type/struct_instance.rb +93 -0
  151. data/lib/rigor/type/tuple.rb +5 -15
  152. data/lib/rigor/type.rb +2 -0
  153. data/lib/rigor/version.rb +1 -1
  154. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  155. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +3 -3
  156. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +3 -3
  157. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +5 -13
  158. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +11 -17
  159. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +7 -10
  160. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +3 -2
  161. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  162. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +6 -8
  163. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +5 -7
  164. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +1 -2
  165. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +9 -11
  166. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +8 -9
  167. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +13 -12
  168. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +3 -4
  169. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +8 -8
  170. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +9 -11
  171. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +7 -8
  172. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +7 -9
  173. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +12 -13
  174. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +15 -23
  175. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +3 -3
  176. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +3 -3
  177. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +2 -4
  178. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +27 -11
  179. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +1 -1
  180. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -6
  181. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +12 -18
  182. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +5 -5
  183. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -4
  184. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +1 -1
  185. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  186. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +19 -14
  187. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +0 -1
  188. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +5 -4
  189. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +2 -3
  190. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +7 -11
  191. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +4 -5
  192. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +6 -9
  193. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +5 -15
  194. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +28 -41
  195. data/sig/rigor/scope.rbs +9 -1
  196. data/sig/rigor/type.rbs +36 -1
  197. 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
@@ -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
- opts.on("--config=PATH", "Path to the Rigor configuration file") { |v| options[:config] = v }
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
- opts.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
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
- opts.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
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
- opts.on("--config=PATH", "Path to the Rigor configuration file") { |v| options[:config] = v }
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
- opts.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
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
- opts.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
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: reserved for future plugin contributions
155
- # (no plugins are loaded today).
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
- # Slice 1 lands the parser only `Configuration#dependencies`
13
- # is read, but no analyzer machinery consumes it yet. Slice 2
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).