rigortype 0.1.18 → 0.2.0

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 (210) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +159 -224
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -3
  4. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
  5. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +32 -23
  6. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
  7. data/lib/rigor/analysis/check_rules/rule_walk.rb +151 -23
  8. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +24 -15
  9. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +9 -3
  10. data/lib/rigor/analysis/check_rules.rb +756 -132
  11. data/lib/rigor/analysis/dependency_source_inference/index.rb +4 -7
  12. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -18
  13. data/lib/rigor/analysis/dependency_source_inference.rb +3 -12
  14. data/lib/rigor/analysis/diagnostic.rb +8 -0
  15. data/lib/rigor/analysis/fact_store.rb +5 -4
  16. data/lib/rigor/analysis/rule_catalog.rb +153 -6
  17. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +19 -18
  18. data/lib/rigor/analysis/runner/project_pre_passes.rb +13 -9
  19. data/lib/rigor/analysis/runner.rb +75 -27
  20. data/lib/rigor/analysis/self_call_resolution_recorder.rb +3 -4
  21. data/lib/rigor/analysis/worker_session.rb +31 -25
  22. data/lib/rigor/bleeding_edge.rb +123 -0
  23. data/lib/rigor/builtins/predefined_constant_refinements.rb +151 -0
  24. data/lib/rigor/cache/descriptor.rb +86 -8
  25. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  26. data/lib/rigor/cache/store.rb +5 -3
  27. data/lib/rigor/cli/annotate_command.rb +122 -16
  28. data/lib/rigor/cli/baseline_command.rb +4 -3
  29. data/lib/rigor/cli/check_command.rb +118 -16
  30. data/lib/rigor/cli/coverage_command.rb +148 -16
  31. data/lib/rigor/cli/coverage_scan.rb +57 -0
  32. data/lib/rigor/cli/explain_command.rb +2 -0
  33. data/lib/rigor/cli/lsp_command.rb +3 -7
  34. data/lib/rigor/cli/mutation_protection_renderer.rb +63 -0
  35. data/lib/rigor/cli/mutation_protection_report.rb +73 -0
  36. data/lib/rigor/cli/options.rb +9 -0
  37. data/lib/rigor/cli/plugins_command.rb +4 -5
  38. data/lib/rigor/cli/plugins_renderer.rb +0 -2
  39. data/lib/rigor/cli/protection_renderer.rb +63 -0
  40. data/lib/rigor/cli/protection_report.rb +68 -0
  41. data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
  42. data/lib/rigor/cli/sig_gen_command.rb +2 -1
  43. data/lib/rigor/cli/trace_command.rb +2 -1
  44. data/lib/rigor/cli/triage_command.rb +8 -4
  45. data/lib/rigor/cli/triage_renderer.rb +15 -1
  46. data/lib/rigor/cli/type_of_command.rb +1 -1
  47. data/lib/rigor/cli/type_scan_command.rb +2 -1
  48. data/lib/rigor/cli.rb +12 -3
  49. data/lib/rigor/configuration/dependencies.rb +2 -4
  50. data/lib/rigor/configuration/severity_profile.rb +13 -1
  51. data/lib/rigor/configuration.rb +100 -6
  52. data/lib/rigor/environment/bundle_sig_discovery.rb +61 -13
  53. data/lib/rigor/environment/class_registry.rb +4 -3
  54. data/lib/rigor/environment/constant_type_cache_holder.rb +43 -0
  55. data/lib/rigor/environment/lockfile_resolver.rb +1 -1
  56. data/lib/rigor/environment/rbs_collection_discovery.rb +1 -2
  57. data/lib/rigor/environment/rbs_coverage_report.rb +2 -1
  58. data/lib/rigor/environment/rbs_loader.rb +74 -5
  59. data/lib/rigor/environment.rb +17 -7
  60. data/lib/rigor/flow_contribution/fact.rb +1 -1
  61. data/lib/rigor/flow_contribution.rb +3 -5
  62. data/lib/rigor/inference/acceptance.rb +17 -9
  63. data/lib/rigor/inference/block_parameter_binder.rb +2 -3
  64. data/lib/rigor/inference/body_fixpoint.rb +89 -0
  65. data/lib/rigor/inference/budget_trace.rb +29 -2
  66. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -2
  67. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -2
  68. data/lib/rigor/inference/builtins/method_catalog.rb +19 -0
  69. data/lib/rigor/inference/builtins/string_catalog.rb +9 -1
  70. data/lib/rigor/inference/expression_typer.rb +1072 -71
  71. data/lib/rigor/inference/hkt_body.rb +8 -11
  72. data/lib/rigor/inference/hkt_body_parser.rb +10 -12
  73. data/lib/rigor/inference/hkt_registry.rb +10 -11
  74. data/lib/rigor/inference/macro_block_self_type.rb +2 -2
  75. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
  76. data/lib/rigor/inference/method_dispatcher/call_context.rb +1 -4
  77. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +210 -35
  78. data/lib/rigor/inference/method_dispatcher/data_folding.rb +9 -73
  79. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -7
  80. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +10 -16
  81. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +25 -13
  82. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +93 -0
  83. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -3
  84. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +24 -22
  85. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
  86. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
  87. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +237 -24
  88. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +303 -0
  89. data/lib/rigor/inference/method_dispatcher.rb +112 -49
  90. data/lib/rigor/inference/method_parameter_binder.rb +56 -2
  91. data/lib/rigor/inference/multi_target_binder.rb +46 -3
  92. data/lib/rigor/inference/mutation_widening.rb +147 -11
  93. data/lib/rigor/inference/narrowing.rb +284 -53
  94. data/lib/rigor/inference/parameter_inference_collector.rb +367 -0
  95. data/lib/rigor/inference/project_patched_methods.rb +4 -7
  96. data/lib/rigor/inference/project_patched_scanner.rb +2 -13
  97. data/lib/rigor/inference/protection_scanner.rb +86 -0
  98. data/lib/rigor/inference/scope_indexer.rb +821 -76
  99. data/lib/rigor/inference/statement_evaluator.rb +1179 -102
  100. data/lib/rigor/inference/struct_fold_safety.rb +181 -0
  101. data/lib/rigor/inference/synthetic_method.rb +7 -7
  102. data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
  103. data/lib/rigor/language_server/completion_provider.rb +6 -12
  104. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -4
  105. data/lib/rigor/language_server/document_symbol_provider.rb +3 -3
  106. data/lib/rigor/language_server/hover_provider.rb +2 -3
  107. data/lib/rigor/language_server/hover_renderer.rb +2 -11
  108. data/lib/rigor/language_server/server.rb +9 -17
  109. data/lib/rigor/language_server.rb +4 -5
  110. data/lib/rigor/plugin/base.rb +245 -87
  111. data/lib/rigor/plugin/macro/block_as_method.rb +25 -25
  112. data/lib/rigor/plugin/macro/heredoc_template.rb +4 -7
  113. data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
  114. data/lib/rigor/plugin/macro/trait_registry.rb +3 -6
  115. data/lib/rigor/plugin/macro.rb +6 -8
  116. data/lib/rigor/plugin/manifest.rb +49 -90
  117. data/lib/rigor/plugin/node_rule_walk.rb +59 -14
  118. data/lib/rigor/plugin/registry.rb +18 -18
  119. data/lib/rigor/plugin/type_node_resolver.rb +6 -8
  120. data/lib/rigor/protection/mutation_scanner.rb +120 -0
  121. data/lib/rigor/protection/mutator.rb +246 -0
  122. data/lib/rigor/rbs_extended.rb +24 -36
  123. data/lib/rigor/reflection.rb +4 -7
  124. data/lib/rigor/scope/discovery_index.rb +16 -2
  125. data/lib/rigor/scope.rb +185 -16
  126. data/lib/rigor/sig_gen/generator.rb +8 -0
  127. data/lib/rigor/sig_gen/observed_call.rb +3 -3
  128. data/lib/rigor/sig_gen/writer.rb +40 -2
  129. data/lib/rigor/source/constant_path.rb +62 -0
  130. data/lib/rigor/source.rb +1 -0
  131. data/lib/rigor/triage/catalogue.rb +4 -19
  132. data/lib/rigor/triage.rb +69 -1
  133. data/lib/rigor/type/bound_method.rb +2 -11
  134. data/lib/rigor/type/combinator.rb +45 -3
  135. data/lib/rigor/type/constant.rb +2 -11
  136. data/lib/rigor/type/data_class.rb +2 -11
  137. data/lib/rigor/type/data_instance.rb +2 -11
  138. data/lib/rigor/type/hash_shape.rb +2 -11
  139. data/lib/rigor/type/integer_range.rb +2 -11
  140. data/lib/rigor/type/intersection.rb +2 -11
  141. data/lib/rigor/type/nominal.rb +2 -11
  142. data/lib/rigor/type/plain_lattice.rb +37 -0
  143. data/lib/rigor/type/refined.rb +72 -13
  144. data/lib/rigor/type/singleton.rb +2 -11
  145. data/lib/rigor/type/struct_class.rb +75 -0
  146. data/lib/rigor/type/struct_instance.rb +93 -0
  147. data/lib/rigor/type/tuple.rb +5 -15
  148. data/lib/rigor/type.rb +2 -0
  149. data/lib/rigor/version.rb +1 -1
  150. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  151. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +3 -3
  152. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +16 -32
  153. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +5 -13
  154. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
  155. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +11 -17
  156. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +34 -100
  157. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +3 -2
  158. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
  159. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  160. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +26 -27
  161. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +5 -7
  162. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +9 -8
  163. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +9 -11
  164. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +8 -9
  165. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +13 -12
  166. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +3 -4
  167. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +8 -8
  168. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +9 -11
  169. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +7 -8
  170. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +18 -49
  171. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +12 -13
  172. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +15 -23
  173. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +4 -4
  174. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +3 -3
  175. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
  176. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +2 -4
  177. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +27 -11
  178. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +22 -35
  179. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -6
  180. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +12 -18
  181. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +16 -23
  182. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  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 +21 -27
  187. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +0 -1
  188. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
  189. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +5 -4
  190. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
  191. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
  192. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +2 -3
  193. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +7 -11
  194. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +4 -5
  195. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +6 -9
  196. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +5 -15
  197. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +52 -40
  198. data/sig/rigor/analysis/fact_store.rbs +3 -0
  199. data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
  200. data/sig/rigor/plugin/base.rbs +5 -2
  201. data/sig/rigor/plugin/manifest.rbs +1 -2
  202. data/sig/rigor/scope.rbs +18 -1
  203. data/sig/rigor/type.rbs +37 -1
  204. data/sig/rigor.rbs +1 -1
  205. data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
  206. data/skills/rigor-plugin-author/SKILL.md +6 -4
  207. data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
  208. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
  209. metadata +25 -2
  210. data/lib/rigor/plugin/macro/external_file.rb +0 -143
@@ -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"
@@ -30,7 +31,7 @@ module Rigor
30
31
  # - every manifest-declared extension surface
31
32
  # (`open_receivers:` / `owns_receivers:` / `produces:` /
32
33
  # `consumes:` / `block_as_methods:` / `heredoc_templates:` /
33
- # `trait_registries:` / `external_files:` /
34
+ # `trait_registries:` /
34
35
  # `type_node_resolvers:` / `hkt_registrations:` /
35
36
  # `hkt_definitions:` / `protocol_contracts:` /
36
37
  # `source_rbs_synthesizer:`);
@@ -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
@@ -233,7 +234,6 @@ module Rigor
233
234
  block_as_methods: manifest.block_as_methods.size,
234
235
  heredoc_templates: manifest.heredoc_templates.size,
235
236
  trait_registries: manifest.trait_registries.size,
236
- external_files: manifest.external_files.size,
237
237
  type_node_resolvers: manifest.type_node_resolvers.size,
238
238
  hkt_registrations: manifest.hkt_registrations.size,
239
239
  hkt_definitions: manifest.hkt_definitions.size,
@@ -257,7 +257,6 @@ module Rigor
257
257
  block_as_methods: 0,
258
258
  heredoc_templates: 0,
259
259
  trait_registries: 0,
260
- external_files: 0,
261
260
  type_node_resolvers: 0,
262
261
  hkt_registrations: 0,
263
262
  hkt_definitions: 0,
@@ -337,7 +336,7 @@ module Rigor
337
336
  signature_paths: [],
338
337
  open_receivers: [], owns_receivers: [], produces: [], consumes: [],
339
338
  block_as_methods: 0, heredoc_templates: 0, trait_registries: 0,
340
- external_files: 0, type_node_resolvers: 0,
339
+ type_node_resolvers: 0,
341
340
  hkt_registrations: 0, hkt_definitions: 0,
342
341
  protocol_contracts: 0, source_rbs_synthesizer: false,
343
342
  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,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
@@ -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
@@ -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"
@@ -21,7 +22,7 @@ module Rigor
21
22
  # command, not a gate (`rigor check` remains the gate).
22
23
  class TriageCommand < Command
23
24
  USAGE = "Usage: rigor triage [options] [paths]"
24
- DEFAULT_SECTIONS = %i[distribution hotspots hints].freeze
25
+ DEFAULT_SECTIONS = %i[distribution selectors hotspots hints].freeze
25
26
 
26
27
  # @return [Integer] CLI exit status (always 0).
27
28
  def run
@@ -42,12 +43,15 @@ 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] }
49
- opts.on("--no-hints", "Print distribution + hotspots only") do
50
- options[:sections] = %i[distribution hotspots]
50
+ opts.on("--no-hints", "Print distribution + selectors + hotspots only") do
51
+ options[:sections] = %i[distribution selectors hotspots]
52
+ end
53
+ opts.on("--selectors-only", "Print only the class/method selectors section") do
54
+ options[:sections] = %i[selectors]
51
55
  end
52
56
  end.parse!(@argv)
53
57
  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
 
@@ -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
@@ -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)
@@ -150,8 +151,9 @@ module Rigor
150
151
  # - target_ruby: minimum Ruby version your project targets.
151
152
  # - paths: directories scanned by `rigor check` and
152
153
  # `rigor type-scan` when no path is given.
153
- # - plugins: reserved for future plugin contributions
154
- # (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, …).
155
157
  # - disable: list of `rigor check` rule identifiers to
156
158
  # silence project-wide. The shipped rules are
157
159
  # call.undefined-method, call.wrong-arity,
@@ -289,6 +291,12 @@ module Rigor
289
291
  CLI::PluginCommand.new(argv: @argv, out: @out, err: @err).run
290
292
  end
291
293
 
294
+ def run_show_bleedingedge
295
+ require_relative "cli/show_bleedingedge_command"
296
+
297
+ CLI::ShowBleedingedgeCommand.new(argv: @argv, out: @out, err: @err).run
298
+ end
299
+
292
300
  def help
293
301
  <<~HELP
294
302
  Usage: rigor <command> [options]
@@ -311,6 +319,7 @@ module Rigor
311
319
  plugin Browse bundled plugin source as worked examples (list/path/print/root)
312
320
  playground Start the browser playground (requires rigor-playground gem)
313
321
  skill List or print bundled Agent Skills (rigor-project-init, ...)
322
+ show-bleedingedge Show the bleeding-edge overlay + what your config adopts (ADR-50)
314
323
  version Print the Rigor version
315
324
  help Print this help
316
325
  HELP
@@ -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).
@@ -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