rigortype 0.1.19 → 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 (166) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -23
  3. data/lib/rigor/analysis/check_rules/rule_walk.rb +3 -21
  4. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +24 -15
  5. data/lib/rigor/analysis/check_rules.rb +492 -71
  6. data/lib/rigor/analysis/dependency_source_inference/index.rb +4 -7
  7. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -18
  8. data/lib/rigor/analysis/dependency_source_inference.rb +3 -12
  9. data/lib/rigor/analysis/fact_store.rb +5 -4
  10. data/lib/rigor/analysis/rule_catalog.rb +153 -6
  11. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +17 -17
  12. data/lib/rigor/analysis/runner/project_pre_passes.rb +9 -8
  13. data/lib/rigor/analysis/runner.rb +17 -6
  14. data/lib/rigor/analysis/self_call_resolution_recorder.rb +3 -4
  15. data/lib/rigor/analysis/worker_session.rb +10 -14
  16. data/lib/rigor/builtins/predefined_constant_refinements.rb +151 -0
  17. data/lib/rigor/cache/store.rb +5 -3
  18. data/lib/rigor/cli/annotate_command.rb +28 -7
  19. data/lib/rigor/cli/baseline_command.rb +4 -3
  20. data/lib/rigor/cli/check_command.rb +115 -16
  21. data/lib/rigor/cli/coverage_command.rb +148 -16
  22. data/lib/rigor/cli/coverage_scan.rb +57 -0
  23. data/lib/rigor/cli/explain_command.rb +2 -0
  24. data/lib/rigor/cli/lsp_command.rb +3 -7
  25. data/lib/rigor/cli/mutation_protection_renderer.rb +63 -0
  26. data/lib/rigor/cli/mutation_protection_report.rb +73 -0
  27. data/lib/rigor/cli/options.rb +9 -0
  28. data/lib/rigor/cli/plugins_command.rb +2 -1
  29. data/lib/rigor/cli/protection_renderer.rb +63 -0
  30. data/lib/rigor/cli/protection_report.rb +68 -0
  31. data/lib/rigor/cli/sig_gen_command.rb +2 -1
  32. data/lib/rigor/cli/trace_command.rb +2 -1
  33. data/lib/rigor/cli/triage_command.rb +2 -1
  34. data/lib/rigor/cli/type_of_command.rb +1 -1
  35. data/lib/rigor/cli/type_scan_command.rb +2 -1
  36. data/lib/rigor/cli.rb +3 -2
  37. data/lib/rigor/configuration/dependencies.rb +2 -4
  38. data/lib/rigor/configuration.rb +45 -7
  39. data/lib/rigor/environment/bundle_sig_discovery.rb +61 -13
  40. data/lib/rigor/environment/class_registry.rb +4 -3
  41. data/lib/rigor/environment/constant_type_cache_holder.rb +43 -0
  42. data/lib/rigor/environment/lockfile_resolver.rb +1 -1
  43. data/lib/rigor/environment/rbs_collection_discovery.rb +1 -2
  44. data/lib/rigor/environment/rbs_coverage_report.rb +2 -1
  45. data/lib/rigor/environment/rbs_loader.rb +49 -5
  46. data/lib/rigor/environment.rb +17 -7
  47. data/lib/rigor/flow_contribution/fact.rb +1 -1
  48. data/lib/rigor/flow_contribution.rb +3 -5
  49. data/lib/rigor/inference/acceptance.rb +17 -9
  50. data/lib/rigor/inference/block_parameter_binder.rb +2 -3
  51. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -2
  52. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -2
  53. data/lib/rigor/inference/builtins/method_catalog.rb +19 -0
  54. data/lib/rigor/inference/builtins/string_catalog.rb +9 -1
  55. data/lib/rigor/inference/expression_typer.rb +20 -28
  56. data/lib/rigor/inference/hkt_body.rb +8 -11
  57. data/lib/rigor/inference/hkt_body_parser.rb +10 -12
  58. data/lib/rigor/inference/hkt_registry.rb +10 -11
  59. data/lib/rigor/inference/method_dispatcher/call_context.rb +1 -4
  60. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +156 -21
  61. data/lib/rigor/inference/method_dispatcher/data_folding.rb +9 -73
  62. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -7
  63. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +10 -16
  64. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +25 -13
  65. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +93 -0
  66. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -3
  67. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +24 -22
  68. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +90 -15
  69. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +303 -0
  70. data/lib/rigor/inference/method_dispatcher.rb +40 -48
  71. data/lib/rigor/inference/mutation_widening.rb +5 -11
  72. data/lib/rigor/inference/narrowing.rb +14 -16
  73. data/lib/rigor/inference/parameter_inference_collector.rb +367 -0
  74. data/lib/rigor/inference/project_patched_methods.rb +4 -7
  75. data/lib/rigor/inference/project_patched_scanner.rb +2 -13
  76. data/lib/rigor/inference/protection_scanner.rb +86 -0
  77. data/lib/rigor/inference/scope_indexer.rb +129 -55
  78. data/lib/rigor/inference/statement_evaluator.rb +244 -114
  79. data/lib/rigor/inference/struct_fold_safety.rb +181 -0
  80. data/lib/rigor/inference/synthetic_method.rb +7 -7
  81. data/lib/rigor/language_server/completion_provider.rb +6 -12
  82. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -4
  83. data/lib/rigor/language_server/document_symbol_provider.rb +3 -3
  84. data/lib/rigor/language_server/hover_provider.rb +2 -3
  85. data/lib/rigor/language_server/hover_renderer.rb +2 -11
  86. data/lib/rigor/language_server/server.rb +9 -17
  87. data/lib/rigor/language_server.rb +4 -5
  88. data/lib/rigor/plugin/base.rb +10 -8
  89. data/lib/rigor/plugin/macro/block_as_method.rb +3 -4
  90. data/lib/rigor/plugin/macro/heredoc_template.rb +4 -7
  91. data/lib/rigor/plugin/macro/trait_registry.rb +3 -6
  92. data/lib/rigor/plugin/macro.rb +4 -5
  93. data/lib/rigor/plugin/manifest.rb +45 -66
  94. data/lib/rigor/plugin/registry.rb +6 -7
  95. data/lib/rigor/plugin/type_node_resolver.rb +6 -8
  96. data/lib/rigor/protection/mutation_scanner.rb +120 -0
  97. data/lib/rigor/protection/mutator.rb +246 -0
  98. data/lib/rigor/rbs_extended.rb +24 -36
  99. data/lib/rigor/reflection.rb +4 -7
  100. data/lib/rigor/scope/discovery_index.rb +14 -2
  101. data/lib/rigor/scope.rb +54 -11
  102. data/lib/rigor/sig_gen/observed_call.rb +3 -3
  103. data/lib/rigor/sig_gen/writer.rb +40 -2
  104. data/lib/rigor/source/constant_path.rb +62 -0
  105. data/lib/rigor/source.rb +1 -0
  106. data/lib/rigor/type/bound_method.rb +2 -11
  107. data/lib/rigor/type/combinator.rb +16 -3
  108. data/lib/rigor/type/constant.rb +2 -11
  109. data/lib/rigor/type/data_class.rb +2 -11
  110. data/lib/rigor/type/data_instance.rb +2 -11
  111. data/lib/rigor/type/hash_shape.rb +2 -11
  112. data/lib/rigor/type/integer_range.rb +2 -11
  113. data/lib/rigor/type/intersection.rb +2 -11
  114. data/lib/rigor/type/nominal.rb +2 -11
  115. data/lib/rigor/type/plain_lattice.rb +37 -0
  116. data/lib/rigor/type/refined.rb +72 -13
  117. data/lib/rigor/type/singleton.rb +2 -11
  118. data/lib/rigor/type/struct_class.rb +75 -0
  119. data/lib/rigor/type/struct_instance.rb +93 -0
  120. data/lib/rigor/type/tuple.rb +5 -15
  121. data/lib/rigor/type.rb +2 -0
  122. data/lib/rigor/version.rb +1 -1
  123. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  124. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +3 -3
  125. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +3 -3
  126. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +5 -13
  127. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +11 -17
  128. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +7 -10
  129. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +3 -2
  130. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  131. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +6 -8
  132. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +5 -7
  133. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +1 -2
  134. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +9 -11
  135. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +8 -9
  136. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +13 -12
  137. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +3 -4
  138. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +8 -8
  139. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +9 -11
  140. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +7 -8
  141. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +7 -9
  142. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +12 -13
  143. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +15 -23
  144. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +3 -3
  145. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +3 -3
  146. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +2 -4
  147. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +27 -11
  148. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +1 -1
  149. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -6
  150. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +12 -18
  151. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +5 -5
  152. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -4
  153. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +1 -1
  154. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  155. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +19 -14
  156. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +0 -1
  157. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +5 -4
  158. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +2 -3
  159. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +7 -11
  160. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +4 -5
  161. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +6 -9
  162. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +5 -15
  163. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +28 -41
  164. data/sig/rigor/scope.rbs +9 -1
  165. data/sig/rigor/type.rbs +36 -1
  166. metadata +19 -1
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../type"
4
+
5
+ module Rigor
6
+ module Builtins
7
+ # Refined types for predefined Ruby / stdlib constants whose upstream
8
+ # RBS signatures are broader than the constants' documented runtime
9
+ # invariants.
10
+ #
11
+ # Resolution is two-tiered:
12
+ #
13
+ # **Tier 1 — exact-value whitelist** (`FOLDED_CONSTANTS`):
14
+ # Constants whose value is bit-for-bit identical across every Ruby
15
+ # version and platform are folded to `Constant[T]`: the `Math::PI`
16
+ # / `Math::E` math constants (C's `M_PI` / `M_E`) and the four
17
+ # IEEE 754 binary64 magnitude constants `Float::INFINITY` /
18
+ # `::MAX` / `::MIN` / `::EPSILON` (each a single format-mandated bit
19
+ # pattern). Add new entries only when the value is truly
20
+ # cross-implementation invariant AND compares reflexively under
21
+ # `==` — the latter is why `Float::NAN` is deliberately EXCLUDED:
22
+ # `NaN == NaN` is `false`, so a `Constant[NAN]` would violate the
23
+ # `Type::Constant` `==` / `eql?` / `hash` contract (it would hash
24
+ # equal to itself yet compare unequal), corrupting type-equality
25
+ # and union dedup. The binary64 *integer* shape parameters
26
+ # (`Float::DIG` / `MANT_DIG` / `MAX_EXP` / …) are intentionally NOT
27
+ # folded: upstream RBS hedges them as "Usually defaults to …", and
28
+ # as plain `Integer`s they fall through Tier 2 to the RBS type
29
+ # harmlessly. `Complex::I` is deferred (no complex-fold consumer).
30
+ #
31
+ # **Tier 2 — runtime String inspection**:
32
+ # For any other constant, the module resolves it via `const_get`
33
+ # against the analyzer's own Ruby runtime. Core / stdlib constants
34
+ # (e.g. `RUBY_VERSION`, `RUBY_PLATFORM`) are always loaded into the
35
+ # analyzer process; project-defined constants are not (they live only
36
+ # in ASTs), so their `const_get` raises `NameError` and the lookup
37
+ # falls through to the RBS type tier.
38
+ #
39
+ # For a successfully resolved `String` value:
40
+ # - empty string → no refinement (fall through to RBS `String`)
41
+ # - a Ruby numeric literal → `numeric-string`
42
+ # - non-empty otherwise → `non-empty-string`
43
+ #
44
+ # **Exclusion set** (`RUNTIME_INSPECTION_EXCLUDED`):
45
+ # String constants that appear non-empty in the current runtime but
46
+ # are documented to be potentially empty in some build configuration
47
+ # or alternative implementation. Exclusions are populated by
48
+ # scanning Ruby's C source (version.c, etc.) and RBS comments for
49
+ # any constant whose documentation says "may be empty" or
50
+ # "platform-specific default". None are known today; the set
51
+ # exists as a safety net.
52
+ #
53
+ # This module is consulted by `Environment#constant_for_name` BEFORE
54
+ # the RBS constant-type table (widest types) but AFTER in-source
55
+ # constant writes (the user's own `Math::PI = 0.0` takes precedence
56
+ # via the lexical-candidate walk in `ExpressionTyper`).
57
+ module PredefinedConstantRefinements
58
+ # --- tier 1 -------------------------------------------------------
59
+
60
+ # Exact-value fold whitelist. Keys are unqualified constant paths
61
+ # (no leading "::") matching what `Environment#constant_for_name`
62
+ # receives.
63
+ FOLDED_CONSTANTS = {
64
+ # Math module — IEEE 754 bit-identical across all MRI / JRuby /
65
+ # TruffleRuby builds; folding enables precise constant arithmetic.
66
+ "Math::PI" => Type::Combinator.constant_of(::Math::PI).freeze,
67
+ "Math::E" => Type::Combinator.constant_of(::Math::E).freeze,
68
+
69
+ # Float magnitude limits — each a single format-mandated IEEE 754
70
+ # binary64 bit pattern (`+Inf`, `DBL_MAX`, `DBL_MIN`,
71
+ # `DBL_EPSILON`), reflexive under `==`. `Float::NAN` is excluded
72
+ # (non-reflexive `==` — see the module-level note).
73
+ "Float::INFINITY" => Type::Combinator.constant_of(::Float::INFINITY).freeze,
74
+ "Float::MAX" => Type::Combinator.constant_of(::Float::MAX).freeze,
75
+ "Float::MIN" => Type::Combinator.constant_of(::Float::MIN).freeze,
76
+ "Float::EPSILON" => Type::Combinator.constant_of(::Float::EPSILON).freeze
77
+ }.freeze
78
+ private_constant :FOLDED_CONSTANTS
79
+
80
+ # --- tier 2 -------------------------------------------------------
81
+
82
+ # String constants whose runtime value is non-empty in the current
83
+ # Ruby but that should NOT be narrowed because they are documented
84
+ # to be potentially empty in some build or implementation.
85
+ #
86
+ # Methodology: grep Ruby's version.c and similar C sources, and the
87
+ # RBS comment corpus, for any constant annotated with "may be empty"
88
+ # or "platform-specific default". Add the full qualified path
89
+ # (without leading "::") when a genuine risk is found.
90
+ RUNTIME_INSPECTION_EXCLUDED = Set[].freeze
91
+ private_constant :RUNTIME_INSPECTION_EXCLUDED
92
+
93
+ NON_EMPTY_STRING = Type::Combinator.non_empty_string.freeze
94
+ NUMERIC_STRING = Type::Combinator.numeric_string.freeze
95
+ private_constant :NON_EMPTY_STRING, :NUMERIC_STRING
96
+
97
+ # --- public API ---------------------------------------------------
98
+
99
+ # @param name [String] unqualified constant name (e.g. `"Math::PI"`,
100
+ # `"RUBY_VERSION"`, `"Ruby::ENGINE"`)
101
+ # @return [Rigor::Type, nil] refined type, or nil to fall through
102
+ def self.lookup(name)
103
+ FOLDED_CONSTANTS[name] || inspect_runtime_string(name)
104
+ end
105
+
106
+ # --- private ------------------------------------------------------
107
+
108
+ # Resolves `name` via `const_get` in the analyzer's runtime and
109
+ # returns a refined String carrier, or nil.
110
+ def self.inspect_runtime_string(name)
111
+ return nil if RUNTIME_INSPECTION_EXCLUDED.include?(name)
112
+
113
+ mod = ::Object
114
+ name.split("::").each do |part|
115
+ # Resolve only constants already present — never let analysing a
116
+ # reference drive the analyzer's own runtime to autoload or run a
117
+ # `const_missing` hook. A `Digest::UUID` reference in project code
118
+ # otherwise makes `const_get` trigger `Digest.const_missing` →
119
+ # `require "digest/uuid"`, and a missing optional library raises
120
+ # `LoadError` (a `ScriptError`, not the `NameError` the const_get
121
+ # walk expects), which would abort the whole run rather than fall
122
+ # through to the RBS tier. `const_defined?(part, false)` answers
123
+ # the same "is this resolvable here" question without the side
124
+ # effect — a project-defined constant (the common case) is simply
125
+ # absent and returns nil, no exception raised.
126
+ return nil unless mod.is_a?(::Module) && mod.const_defined?(part, false)
127
+
128
+ mod = mod.const_get(part, false)
129
+ end
130
+
131
+ return nil unless mod.is_a?(::String) && !mod.empty?
132
+
133
+ classify_string(mod)
134
+ rescue ::NameError, ::TypeError, ::LoadError
135
+ nil
136
+ end
137
+ private_class_method :inspect_runtime_string
138
+
139
+ # @param value [String] a non-empty string
140
+ # @return [Rigor::Type]
141
+ def self.classify_string(value)
142
+ if Type::Refined.ruby_numeric_literal?(value)
143
+ NUMERIC_STRING
144
+ else
145
+ NON_EMPTY_STRING
146
+ end
147
+ end
148
+ private_class_method :classify_string
149
+ end
150
+ end
151
+ end
@@ -13,9 +13,10 @@ module Rigor
13
13
  module Cache
14
14
  # Filesystem-backed cache store. Schema, layout, file format,
15
15
  # atomicity, and locking are fixed by [ADR-6](../../../docs/adr/6-cache-persistence-backend.md);
16
- # callers see the [`Rigor::Cache::Descriptor`](descriptor.rb)
17
- # value object plus this class' `#fetch_or_compute` entry point
18
- # and nothing else.
16
+ # callers use `#fetch_or_compute` (producer-keyed),
17
+ # `#fetch_or_validate` (record-and-validate for discovered-dep
18
+ # caches, ADR-45), `#stats`, `#evict!`, and `.disk_inventory`,
19
+ # plus the [`Rigor::Cache::Descriptor`](descriptor.rb) value object.
19
20
  #
20
21
  # Read failures (missing file, bad magic, format-version mismatch,
21
22
  # corrupt SHA-256 trailer, un-inflatable or unmarshal-able payload)
@@ -119,6 +120,7 @@ module Rigor
119
120
  # When the root does not exist or has no schema-version
120
121
  # marker, `schema_version` is nil and the producer list is
121
122
  # empty.
123
+ #
122
124
  # The `schema_version.txt` marker content. Covers BOTH
123
125
  # invalidation axes: the descriptor schema and the on-disk byte
124
126
  # layout ({FORMAT_VERSION}, ADR-54). A format bump leaves the
@@ -1,10 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "English"
4
+ require "json"
4
5
  require "optionparser"
5
6
  require "prism"
6
7
 
7
8
  require_relative "../configuration"
9
+ require_relative "options"
8
10
  require_relative "../environment"
9
11
  require_relative "../scope"
10
12
  require_relative "../inference/def_return_typer"
@@ -37,10 +39,10 @@ module Rigor
37
39
  # Trailing `#=> …` annotation comment. Matched and stripped
38
40
  # before re-annotating so re-running is idempotent — this
39
41
  # follows xmpfilter's convention of owning the `#=>` marker,
40
- # and also absorbs the pre-v0.2.0 `#=> dump_type: <type>`
41
- # spelling. The leading `\s` requirement keeps a `#=>` inside
42
- # a string literal (no preceding whitespace ambiguity aside)
43
- # from matching mid-expression.
42
+ # and also absorbs the older `#=> dump_type: <type>` spelling
43
+ # (idempotency across re-runs). The leading `\s` requirement
44
+ # keeps a `#=>` inside a string literal (no preceding
45
+ # whitespace ambiguity aside) from matching mid-expression.
44
46
  ANNOTATION_PATTERN = /\s+#=>(?:\s.*)?\z/
45
47
 
46
48
  # Arguments for highlighting through `bat`: the annotated
@@ -71,11 +73,15 @@ module Rigor
71
73
  def parse_options
72
74
  # Default: colour a tty, unless `NO_COLOR` opts out. An
73
75
  # explicit `--color` / `--no-color` overrides both.
74
- options = { config: nil, color: @out.tty? && !no_color_env?, bat: nil }
76
+ options = { config: nil, color: @out.tty? && !no_color_env?, bat: nil, format: :text }
75
77
 
76
78
  parser = OptionParser.new do |opts|
77
79
  opts.banner = USAGE
78
- opts.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
80
+ Options.add_config(opts, options)
81
+ opts.on("--format=FORMAT", %w[text json],
82
+ "Output format: text (default) or json (a { line => type } map)") do |value|
83
+ options[:format] = value.to_sym
84
+ end
79
85
  opts.on("--[no-]color",
80
86
  "Force or disable ANSI colour (default: auto-detect a tty; honours NO_COLOR)") do |value|
81
87
  options[:color] = value
@@ -122,10 +128,25 @@ module Rigor
122
128
  )
123
129
  line_types = LineTypeCollector.new(scope_index).collect(parse_result.value)
124
130
 
125
- @out.puts(render(annotate(source, line_types), color: options.fetch(:color), bat: options.fetch(:bat)))
131
+ case options.fetch(:format)
132
+ when :json
133
+ emit_json(line_types)
134
+ else
135
+ @out.puts(render(annotate(source, line_types), color: options.fetch(:color), bat: options.fetch(:bat)))
136
+ end
126
137
  0
127
138
  end
128
139
 
140
+ # `--format json` — emit the { line_number => type } map directly, the
141
+ # same data the text renderer consumes, so clients (the playground,
142
+ # editors) get structured annotations without reparsing the `#=> <type>`
143
+ # comment grammar. Values are the short type descriptions the text form
144
+ # also shows; keys are 1-based line numbers as strings (JSON object keys).
145
+ def emit_json(line_types)
146
+ annotations = line_types.keys.sort.to_h { |line| [line.to_s, line_types[line].describe(:short)] }
147
+ @out.puts(JSON.generate({ "annotations" => annotations }))
148
+ end
149
+
129
150
  def base_scope(configuration)
130
151
  Scope.empty(
131
152
  environment: Environment.for_project(
@@ -3,6 +3,7 @@
3
3
  require "optparse"
4
4
 
5
5
  require_relative "../analysis/baseline"
6
+ require_relative "options"
6
7
  require_relative "../analysis/runner"
7
8
  require_relative "../cache/store"
8
9
  require_relative "../configuration"
@@ -106,7 +107,7 @@ module Rigor
106
107
  }
107
108
  parser = OptionParser.new do |opts|
108
109
  opts.banner = "Usage: rigor baseline #{subcommand} [options]"
109
- opts.on("--config=PATH", "Path to the Rigor configuration file") { |v| options[:config] = v }
110
+ Options.add_config(opts, options)
110
111
  opts.on("--output=PATH", "Write baseline to PATH (default: #{DEFAULT_BASELINE_PATH})") do |v|
111
112
  options[:output] = v
112
113
  end
@@ -270,7 +271,7 @@ module Rigor
270
271
  }
271
272
  parser = OptionParser.new do |opts|
272
273
  opts.banner = "Usage: rigor baseline drift [options]"
273
- opts.on("--config=PATH", "Path to the Rigor configuration file") { |v| options[:config] = v }
274
+ Options.add_config(opts, options)
274
275
  opts.on("--baseline=PATH", "Path to the baseline file (default: #{DEFAULT_BASELINE_PATH})") do |v|
275
276
  options[:baseline] = v
276
277
  end
@@ -344,7 +345,7 @@ module Rigor
344
345
  }
345
346
  parser = OptionParser.new do |opts|
346
347
  opts.banner = "Usage: rigor baseline prune [options]"
347
- opts.on("--config=PATH", "Path to the Rigor configuration file") { |v| options[:config] = v }
348
+ Options.add_config(opts, options)
348
349
  opts.on("--baseline=PATH", "Path to the baseline file (default: #{DEFAULT_BASELINE_PATH})") do |v|
349
350
  options[:baseline] = v
350
351
  end
@@ -6,6 +6,8 @@ require "optionparser"
6
6
 
7
7
  require_relative "../configuration"
8
8
  require_relative "../analysis/result"
9
+ require_relative "../analysis/rule_catalog"
10
+ require_relative "coverage_scan"
9
11
  require_relative "command"
10
12
  require_relative "options"
11
13
  require_relative "diagnostic_formats"
@@ -35,6 +37,7 @@ module Rigor
35
37
  return CLI::EXIT_USAGE if buffer == :usage_error
36
38
 
37
39
  configuration = load_check_configuration(options)
40
+ configuration = apply_bleeding_edge_override(configuration, options)
38
41
  cache_root = configuration.cache_path
39
42
  handle_clear_cache(cache_root) if options.fetch(:clear_cache)
40
43
 
@@ -48,7 +51,8 @@ module Rigor
48
51
  raw_result = runner.run(@argv.empty? ? configuration.paths : @argv)
49
52
  result = apply_baseline_filter(raw_result, configuration, options)
50
53
 
51
- write_result(result, options.fetch(:format))
54
+ coverage = compute_coverage(runner, configuration, options)
55
+ write_result(result, options.fetch(:format), coverage: coverage)
52
56
  emit_ci_detected_output(result, options)
53
57
  write_run_stats(result.stats) if result.stats
54
58
  write_trace_appendices
@@ -276,29 +280,18 @@ module Rigor
276
280
 
277
281
  def parse_check_options # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
278
282
  options = {
279
- # `nil` triggers `Configuration.discover` (`.rigor.yml` then
280
- # `.rigor.dist.yml`); an explicit `--config=PATH` overrides.
281
283
  config: nil,
282
284
  format: "text",
283
285
  explain: false,
284
286
  cache_stats: false,
285
287
  clear_cache: false,
286
288
  no_cache: false,
287
- # Run-stats summary (target files, RBS class universe
288
- # breakdown, wall time, peak RSS) is on by default
289
- # because collection is ~free (single syscall for RSS,
290
- # one walk of `class_decl_paths` for the breakdown).
291
- # `--no-stats` suppresses it for callers that want a
292
- # diagnostic-only output stream.
293
289
  stats: true,
294
290
  # ADR-15 Phase 4c — when nil, falls back to
295
291
  # `RIGOR_RACTOR_WORKERS` then `.rigor.yml`
296
292
  # `parallel.workers:` then 0 (sequential). See
297
293
  # `resolve_workers` for the precedence chain.
298
294
  workers: nil,
299
- # Editor mode (`docs/design/20260516-editor-mode.md`).
300
- # Both must appear together; the runner uses the pair
301
- # to bind an in-flight buffer file to its logical path.
302
295
  tmp_file: nil,
303
296
  instead_of: nil,
304
297
  # ADR-22 — baseline filter. `:unset` means "fall through
@@ -335,17 +328,34 @@ module Rigor
335
328
  # the human output; for GitLab / reviewdog-routed CIs, print a
336
329
  # one-line hint. On by default; `--no-ci-detect` (or
337
330
  # `RIGOR_CI_DETECT=0`) disables it.
338
- ci_detect: true
331
+ ci_detect: true,
332
+ # ADR-50 § WD2 — the `--bleeding-edge[=ids]` / `--no-bleeding-edge`
333
+ # CLI mirror of the `bleeding_edge:` config key. `:unset` means "no
334
+ # flag — use the configured selection"; `true` adopts the whole
335
+ # overlay, `false` adopts none, and an Array of ids adopts only
336
+ # those (see `apply_bleeding_edge_override`).
337
+ bleeding_edge: :unset,
338
+ # Type-precision coverage block. Off by default — it is a
339
+ # second precision pass over the analyzed files (the same scan
340
+ # `rigor coverage` runs), so it is opt-in to keep the default
341
+ # check path's cost unchanged. When set, `--format json` gains
342
+ # a `coverage` object (scan_files + precision tiers) and the
343
+ # text output prints a one-line coverage summary.
344
+ coverage: false
339
345
  }
340
346
  parser = OptionParser.new do |opts| # rubocop:disable Metrics/BlockLength
341
347
  opts.banner = "Usage: rigor check [options] [paths]"
342
- opts.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
348
+ Options.add_config(opts, options)
343
349
  opts.on("--format=FORMAT",
344
350
  "Output format: text, json, sarif, github, gitlab, checkstyle, junit, teamcity") do |value|
345
351
  options[:format] = value
346
352
  end
347
353
  opts.on("--explain", "Surface fail-soft fallback events as :info diagnostics") { options[:explain] = true }
348
354
  opts.on("--cache-stats", "Print on-disk cache inventory at end of run") { options[:cache_stats] = true }
355
+ opts.on("--coverage",
356
+ "Add a type-precision coverage block (an extra precision pass over the analyzed files)") do
357
+ options[:coverage] = true
358
+ end
349
359
  opts.on("--clear-cache", "Remove the .rigor/cache directory before running") { options[:clear_cache] = true }
350
360
  opts.on("--no-cache", "Disable the persistent cache for this run") { options[:no_cache] = true }
351
361
  opts.on("--[no-]stats",
@@ -385,6 +395,18 @@ module Rigor
385
395
  "ADR-51: do not auto-emit CI-native output when a CI environment is detected") do
386
396
  options[:ci_detect] = false
387
397
  end
398
+ # ADR-50 § WD2 — `=[LIST]` (not ` [LIST]`) so a bare `--bleeding-edge`
399
+ # never swallows a following positional path: `rigor check
400
+ # --bleeding-edge lib` adopts the whole overlay and checks `lib`.
401
+ opts.on("--bleeding-edge=[LIST]",
402
+ "ADR-50: adopt the bleeding-edge overlay for this run " \
403
+ "(all features, or a comma-separated feature-id list)") do |value|
404
+ options[:bleeding_edge] = value.nil? || value.split(",").map(&:strip).reject(&:empty?)
405
+ end
406
+ opts.on("--no-bleeding-edge",
407
+ "ADR-50: ignore any configured bleeding_edge: selection for this run") do
408
+ options[:bleeding_edge] = false
409
+ end
388
410
  end
389
411
  parser.parse!(@argv)
390
412
  options
@@ -410,6 +432,20 @@ module Rigor
410
432
  Configuration.new(Configuration::DEFAULTS.merge(data))
411
433
  end
412
434
 
435
+ # ADR-50 § WD2 — applies the `--bleeding-edge[=ids]` / `--no-bleeding-edge`
436
+ # CLI selection over the configured `bleeding_edge:` value, mirroring the
437
+ # CLI-over-config precedence `--workers` and `--no-cache` follow. `:unset`
438
+ # (no flag) leaves the loaded configuration untouched; any other value is
439
+ # normalised by {Configuration#with_bleeding_edge}, so the two
440
+ # `SeverityProfile.resolve` sites (and the worker path, which receives the
441
+ # whole frozen Configuration) see the run's selection.
442
+ def apply_bleeding_edge_override(configuration, options)
443
+ selection = options.fetch(:bleeding_edge)
444
+ return configuration if selection == :unset
445
+
446
+ configuration.with_bleeding_edge(selection)
447
+ end
448
+
413
449
  def inject_treat_all_as_inline_rbs(entries)
414
450
  filtered = entries.reject { |entry| rigor_rbs_inline_entry?(entry) }
415
451
  filtered + [{
@@ -635,12 +671,15 @@ module Rigor
635
671
  format("%.1f MiB", bytes / (1024.0 * 1024.0))
636
672
  end
637
673
 
638
- def write_result(result, format)
674
+ def write_result(result, format, coverage: nil)
639
675
  case format
640
676
  when "json"
641
- @out.puts(JSON.pretty_generate(result.to_h))
677
+ payload = enrich_json(result.to_h)
678
+ payload["coverage"] = coverage_payload(coverage) if coverage
679
+ @out.puts(JSON.pretty_generate(payload))
642
680
  when "text"
643
681
  write_text_result(result)
682
+ write_coverage_summary(coverage) if coverage
644
683
  when ->(fmt) { CLI::DiagnosticFormats.supports?(fmt) }
645
684
  # ADR-51 — CI-native renderings (SARIF / GitHub Actions commands /
646
685
  # GitLab Code Quality). The `github` form is empty when there are no
@@ -652,6 +691,66 @@ module Rigor
652
691
  end
653
692
  end
654
693
 
694
+ # Runs the type-precision scan (`--coverage`) over the same file set
695
+ # the check analyzed and returns a `CoverageReport`, or nil when the
696
+ # flag is off. It is a second pass — the same scan `rigor coverage`
697
+ # runs, reused via {CoverageScan} — so it is opt-in to keep the
698
+ # default check path's cost unchanged.
699
+ def compute_coverage(runner, configuration, options)
700
+ return nil unless options.fetch(:coverage)
701
+
702
+ files = @argv.empty? ? runner.analysis_file_set : runner.analysis_file_set(@argv)
703
+ CoverageScan.precision_report(files: files, configuration: configuration)
704
+ end
705
+
706
+ # The `coverage` block embedded in `--format json`. Mirrors the
707
+ # `summary` of `rigor coverage --format json` (the same vocabulary —
708
+ # `precise_ratio`, not a separate `typed_ratio`) plus `scan_files`,
709
+ # so a consumer reads one stream to learn both what fired and how
710
+ # much of the analyzed surface Rigor could type.
711
+ def coverage_payload(report)
712
+ {
713
+ "scan_files" => report.files.size - report.parse_errors.size,
714
+ "parse_errors" => report.parse_errors.size,
715
+ "expressions_typed" => report.grand_total,
716
+ "precise_count" => report.precise_count,
717
+ "precise_ratio" => report.precision_ratio.round(4),
718
+ "dynamic_opaque_count" => report.opaque_count,
719
+ "dynamic_opaque_ratio" => report.opaque_ratio.round(4)
720
+ }
721
+ end
722
+
723
+ def write_coverage_summary(report)
724
+ files = report.files.size - report.parse_errors.size
725
+ pct = (report.precision_ratio * 100).round(1)
726
+ @out.puts("Type coverage: #{files} file(s), #{pct}% precise " \
727
+ "(#{report.precise_count}/#{report.grand_total} expressions). " \
728
+ "Run `rigor coverage` for the full per-file / per-tier breakdown.")
729
+ end
730
+
731
+ # Adds the per-rule `evidence_tier` and `documentation_url` fields
732
+ # to each diagnostic in the `--format json` payload. Both are pure
733
+ # functions of the rule id (the rule catalogue, ADR-61 / the
734
+ # 2026-06-15 feedback §4 + §5.1), so they enrich the presentation
735
+ # layer here rather than threading through every diagnostic
736
+ # construction site. Only built-in rules carry catalogue metadata;
737
+ # plugin / `rbs_extended` / parse-error diagnostics are left
738
+ # untouched (they host their own documentation and confidence).
739
+ def enrich_json(payload)
740
+ Array(payload["diagnostics"]).each do |diag|
741
+ next unless diag["source_family"] == Analysis::Diagnostic::DEFAULT_SOURCE_FAMILY.to_s
742
+
743
+ rule = diag["rule"]
744
+ next unless rule
745
+
746
+ tier = Analysis::RuleCatalog.evidence_tier(rule)
747
+ diag["evidence_tier"] = tier.to_s if tier
748
+ url = Analysis::RuleCatalog.documentation_url(rule)
749
+ diag["documentation_url"] = url if url
750
+ end
751
+ payload
752
+ end
753
+
655
754
  # ADR-51 WD7 — CI auto-detection. Only augments the default human
656
755
  # (`text`) output: an explicit `--format` means the caller is in control
657
756
  # and is left untouched. For a first-class stdout-native CI (GitHub