rigortype 0.1.16 → 0.1.17

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 (136) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +100 -0
  3. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +209 -0
  4. data/lib/rigor/analysis/check_rules.rb +149 -70
  5. data/lib/rigor/analysis/dependency_recorder.rb +122 -0
  6. data/lib/rigor/analysis/diagnostic.rb +18 -0
  7. data/lib/rigor/analysis/incremental.rb +162 -0
  8. data/lib/rigor/analysis/incremental_session.rb +337 -0
  9. data/lib/rigor/analysis/rule_catalog.rb +48 -0
  10. data/lib/rigor/analysis/runner.rb +434 -37
  11. data/lib/rigor/analysis/self_call_resolution_recorder.rb +121 -0
  12. data/lib/rigor/builtins/static_return_refinements.rb +7 -1
  13. data/lib/rigor/cache/descriptor.rb +50 -49
  14. data/lib/rigor/cache/incremental_snapshot.rb +147 -0
  15. data/lib/rigor/cache/rbs_cache_producer.rb +30 -0
  16. data/lib/rigor/cache/rbs_class_ancestor_table.rb +2 -8
  17. data/lib/rigor/cache/rbs_class_type_param_names.rb +2 -8
  18. data/lib/rigor/cache/rbs_constant_table.rb +2 -8
  19. data/lib/rigor/cache/rbs_environment.rb +2 -8
  20. data/lib/rigor/cache/rbs_instance_definitions.rb +3 -16
  21. data/lib/rigor/cache/rbs_known_class_names.rb +2 -8
  22. data/lib/rigor/cache/store.rb +99 -1
  23. data/lib/rigor/cli/annotate_command.rb +2 -7
  24. data/lib/rigor/cli/baseline_command.rb +2 -7
  25. data/lib/rigor/cli/command.rb +47 -0
  26. data/lib/rigor/cli/coverage_command.rb +3 -23
  27. data/lib/rigor/cli/coverage_renderer.rb +3 -8
  28. data/lib/rigor/cli/diff_command.rb +3 -7
  29. data/lib/rigor/cli/explain_command.rb +2 -7
  30. data/lib/rigor/cli/lsp_command.rb +3 -7
  31. data/lib/rigor/cli/mcp_command.rb +3 -7
  32. data/lib/rigor/cli/options.rb +57 -0
  33. data/lib/rigor/cli/plugin_command.rb +3 -7
  34. data/lib/rigor/cli/plugins_command.rb +2 -7
  35. data/lib/rigor/cli/renderable.rb +26 -0
  36. data/lib/rigor/cli/sig_gen_command.rb +2 -7
  37. data/lib/rigor/cli/skill_command.rb +3 -7
  38. data/lib/rigor/cli/triage_command.rb +2 -7
  39. data/lib/rigor/cli/type_of_command.rb +5 -38
  40. data/lib/rigor/cli/type_of_renderer.rb +4 -9
  41. data/lib/rigor/cli/type_scan_command.rb +3 -23
  42. data/lib/rigor/cli/type_scan_renderer.rb +4 -9
  43. data/lib/rigor/cli.rb +125 -43
  44. data/lib/rigor/configuration/dependencies.rb +18 -1
  45. data/lib/rigor/configuration/severity_profile.rb +22 -3
  46. data/lib/rigor/configuration.rb +13 -3
  47. data/lib/rigor/environment/rbs_loader.rb +76 -3
  48. data/lib/rigor/inference/block_parameter_binder.rb +1 -2
  49. data/lib/rigor/inference/builtins/array_catalog.rb +2 -5
  50. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -5
  51. data/lib/rigor/inference/builtins/complex_catalog.rb +2 -5
  52. data/lib/rigor/inference/builtins/date_catalog.rb +2 -5
  53. data/lib/rigor/inference/builtins/encoding_catalog.rb +2 -5
  54. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -5
  55. data/lib/rigor/inference/builtins/exception_catalog.rb +2 -5
  56. data/lib/rigor/inference/builtins/hash_catalog.rb +2 -5
  57. data/lib/rigor/inference/builtins/method_catalog.rb +15 -0
  58. data/lib/rigor/inference/builtins/numeric_catalog.rb +21 -93
  59. data/lib/rigor/inference/builtins/pathname_catalog.rb +2 -5
  60. data/lib/rigor/inference/builtins/proc_catalog.rb +2 -5
  61. data/lib/rigor/inference/builtins/random_catalog.rb +2 -5
  62. data/lib/rigor/inference/builtins/range_catalog.rb +2 -5
  63. data/lib/rigor/inference/builtins/rational_catalog.rb +2 -5
  64. data/lib/rigor/inference/builtins/re_catalog.rb +2 -5
  65. data/lib/rigor/inference/builtins/set_catalog.rb +2 -5
  66. data/lib/rigor/inference/builtins/string_catalog.rb +2 -5
  67. data/lib/rigor/inference/builtins/struct_catalog.rb +2 -5
  68. data/lib/rigor/inference/builtins/time_catalog.rb +2 -5
  69. data/lib/rigor/inference/expression_typer.rb +140 -20
  70. data/lib/rigor/inference/method_dispatcher/block_folding.rb +5 -1
  71. data/lib/rigor/inference/method_dispatcher/call_context.rb +65 -0
  72. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +11 -10
  73. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +12 -6
  74. data/lib/rigor/inference/method_dispatcher/data_folding.rb +246 -0
  75. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -2
  76. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -2
  77. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -1
  78. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +4 -1
  79. data/lib/rigor/inference/method_dispatcher/math_folding.rb +6 -6
  80. data/lib/rigor/inference/method_dispatcher/method_folding.rb +12 -7
  81. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +23 -13
  82. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +9 -9
  83. data/lib/rigor/inference/method_dispatcher/set_folding.rb +6 -6
  84. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +120 -9
  85. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +12 -12
  86. data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +49 -0
  87. data/lib/rigor/inference/method_dispatcher/time_folding.rb +6 -6
  88. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +9 -9
  89. data/lib/rigor/inference/method_dispatcher.rb +99 -59
  90. data/lib/rigor/inference/narrowing.rb +202 -5
  91. data/lib/rigor/inference/scope_indexer.rb +134 -7
  92. data/lib/rigor/inference/statement_evaluator.rb +105 -26
  93. data/lib/rigor/language_server/buffer_resolution.rb +33 -0
  94. data/lib/rigor/language_server/completion_provider.rb +4 -4
  95. data/lib/rigor/language_server/document_symbol_provider.rb +4 -4
  96. data/lib/rigor/language_server/folding_range_provider.rb +4 -4
  97. data/lib/rigor/language_server/hover_provider.rb +4 -4
  98. data/lib/rigor/language_server/selection_range_provider.rb +4 -4
  99. data/lib/rigor/language_server/signature_help_provider.rb +4 -4
  100. data/lib/rigor/plugin/base.rb +20 -4
  101. data/lib/rigor/plugin/registry.rb +39 -1
  102. data/lib/rigor/rbs_extended/conformance_checker.rb +208 -0
  103. data/lib/rigor/rbs_extended.rb +39 -0
  104. data/lib/rigor/scope.rb +123 -9
  105. data/lib/rigor/type/acceptance_router.rb +19 -0
  106. data/lib/rigor/type/accepts_result.rb +3 -10
  107. data/lib/rigor/type/app.rb +3 -7
  108. data/lib/rigor/type/bot.rb +2 -3
  109. data/lib/rigor/type/bound_method.rb +5 -12
  110. data/lib/rigor/type/combinator.rb +17 -0
  111. data/lib/rigor/type/constant.rb +2 -3
  112. data/lib/rigor/type/data_class.rb +80 -0
  113. data/lib/rigor/type/data_instance.rb +100 -0
  114. data/lib/rigor/type/difference.rb +5 -10
  115. data/lib/rigor/type/dynamic.rb +5 -10
  116. data/lib/rigor/type/hash_shape.rb +5 -15
  117. data/lib/rigor/type/integer_range.rb +5 -10
  118. data/lib/rigor/type/intersection.rb +5 -10
  119. data/lib/rigor/type/nominal.rb +5 -10
  120. data/lib/rigor/type/refined.rb +5 -10
  121. data/lib/rigor/type/singleton.rb +5 -10
  122. data/lib/rigor/type/top.rb +2 -3
  123. data/lib/rigor/type/tuple.rb +5 -10
  124. data/lib/rigor/type/union.rb +5 -10
  125. data/lib/rigor/type.rb +2 -0
  126. data/lib/rigor/value_semantics.rb +77 -0
  127. data/lib/rigor/version.rb +1 -1
  128. data/lib/rigor.rb +1 -0
  129. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +12 -2
  130. data/sig/rigor/cache.rbs +19 -0
  131. data/sig/rigor/inference.rbs +22 -0
  132. data/sig/rigor/rbs_extended.rbs +2 -0
  133. data/sig/rigor/scope.rbs +5 -0
  134. data/sig/rigor/type.rbs +58 -1
  135. data/sig/rigor.rbs +6 -1
  136. metadata +22 -1
@@ -11,6 +11,8 @@ require_relative "../source/node_locator"
11
11
  require_relative "../inference/fallback_tracer"
12
12
  require_relative "../inference/scope_indexer"
13
13
  require_relative "type_of_renderer"
14
+ require_relative "command"
15
+ require_relative "options"
14
16
 
15
17
  module Rigor
16
18
  class CLI
@@ -25,21 +27,15 @@ module Rigor
25
27
  # dispatching and lets us evolve the type-of UX (extra flags, watch mode,
26
28
  # streaming output) without bloating the CLI shell. Output formatting is
27
29
  # delegated to {TypeOfRenderer}.
28
- class TypeOfCommand
30
+ class TypeOfCommand < Command
29
31
  USAGE = "Usage: rigor type-of [options] FILE:LINE:COL"
30
32
 
31
33
  Result = Data.define(:file, :line, :column, :node, :type, :tracer)
32
34
 
33
- def initialize(argv:, out:, err:)
34
- @argv = argv
35
- @out = out
36
- @err = err
37
- end
38
-
39
35
  # @return [Integer] CLI exit status.
40
36
  def run
41
37
  options = parse_options
42
- buffer = resolve_buffer_binding(options)
38
+ buffer = Options.resolve_buffer_binding(options, err: @err)
43
39
  return CLI::EXIT_USAGE if buffer == :usage_error
44
40
 
45
41
  target = parse_position_argument(@argv)
@@ -58,42 +54,13 @@ module Rigor
58
54
  opts.on("--format=FORMAT", "Output format: text or json") { |value| options[:format] = value }
59
55
  opts.on("--trace", "Record fail-soft fallbacks via FallbackTracer") { options[:trace] = true }
60
56
  opts.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
61
- opts.on("--tmp-file=PATH",
62
- "Editor mode: read source bytes from PATH instead of --instead-of (paired)") do |value|
63
- options[:tmp_file] = value
64
- end
65
- opts.on("--instead-of=PATH",
66
- "Editor mode: the logical project path the buffer represents (paired with --tmp-file)") do |value|
67
- options[:instead_of] = value
68
- end
57
+ Options.add_editor_mode(opts, options)
69
58
  end
70
59
  parser.parse!(@argv)
71
60
 
72
61
  options
73
62
  end
74
63
 
75
- # Mirrors `Rigor::CLI#resolve_buffer_binding` (the `check`
76
- # path). Returns nil / BufferBinding / :usage_error. The
77
- # symbol return path lets the caller translate to
78
- # `CLI::EXIT_USAGE` without raising.
79
- def resolve_buffer_binding(options)
80
- tmp = options[:tmp_file]
81
- instead = options[:instead_of]
82
- return nil if tmp.nil? && instead.nil?
83
-
84
- if tmp.nil? || instead.nil?
85
- @err.puts("--tmp-file and --instead-of must appear together")
86
- return :usage_error
87
- end
88
-
89
- unless File.file?(tmp)
90
- @err.puts("--tmp-file #{tmp.inspect}: no such file or not readable")
91
- return :usage_error
92
- end
93
-
94
- Rigor::Analysis::BufferBinding.new(logical_path: instead, physical_path: tmp)
95
- end
96
-
97
64
  def execute(target:, options:, buffer: nil)
98
65
  file, line, column = target
99
66
  # Under editor mode the logical `file` may not exist on disk
@@ -3,6 +3,8 @@
3
3
  require "json"
4
4
  require "optionparser"
5
5
 
6
+ require_relative "renderable"
7
+
6
8
  module Rigor
7
9
  class CLI
8
10
  # Renders a `TypeOfCommand::Result` as either human-readable text or a
@@ -12,19 +14,12 @@ module Rigor
12
14
  # output formats (sexp, lsp-style hover payloads, color decoration) can
13
15
  # plug in without disturbing argument parsing or the inference call site.
14
16
  class TypeOfRenderer
17
+ include Renderable
18
+
15
19
  def initialize(out:)
16
20
  @out = out
17
21
  end
18
22
 
19
- def render(result, format:)
20
- case format
21
- when "text" then render_text(result)
22
- when "json" then render_json(result)
23
- else
24
- raise OptionParser::InvalidArgument, "unsupported format: #{format}"
25
- end
26
- end
27
-
28
23
  private
29
24
 
30
25
  def render_text(result)
@@ -9,6 +9,7 @@ require_relative "../inference/coverage_scanner"
9
9
  require_relative "../scope"
10
10
  require_relative "type_scan_renderer"
11
11
  require_relative "type_scan_report"
12
+ require_relative "command"
12
13
 
13
14
  module Rigor
14
15
  class CLI
@@ -19,21 +20,15 @@ module Rigor
19
20
  # the inference engine's directly recognized classes. It is the project's
20
21
  # primary CI gate for tracking how much of an input source the engine can
21
22
  # name without falling back to `Dynamic[Top]`.
22
- class TypeScanCommand
23
+ class TypeScanCommand < Command
23
24
  USAGE = "Usage: rigor type-scan [options] PATH..."
24
25
 
25
26
  LocatedEvent = Data.define(:file, :event)
26
27
 
27
- def initialize(argv:, out:, err:)
28
- @argv = argv
29
- @out = out
30
- @err = err
31
- end
32
-
33
28
  # @return [Integer] CLI exit status.
34
29
  def run
35
30
  options = parse_options
36
- paths = collect_paths(@argv)
31
+ paths = collect_paths(@argv, command_name: "type-scan")
37
32
  return CLI::EXIT_USAGE if paths.nil?
38
33
  return usage_error if paths.empty?
39
34
 
@@ -67,21 +62,6 @@ module Rigor
67
62
  options
68
63
  end
69
64
 
70
- def collect_paths(args)
71
- paths = []
72
- args.each do |arg|
73
- if File.directory?(arg)
74
- paths.concat(Dir.glob(File.join(arg, "**/*.rb")))
75
- elsif File.file?(arg)
76
- paths << arg
77
- else
78
- @err.puts("type-scan: not a file or directory: #{arg}")
79
- return nil
80
- end
81
- end
82
- paths.uniq
83
- end
84
-
85
65
  def usage_error
86
66
  @err.puts("type-scan: at least one path is required")
87
67
  @err.puts(USAGE)
@@ -3,6 +3,8 @@
3
3
  require "json"
4
4
  require "optionparser"
5
5
 
6
+ require_relative "renderable"
7
+
6
8
  module Rigor
7
9
  class CLI
8
10
  # Renders a `TypeScanCommand::Report` as either a terminal-friendly text
@@ -11,19 +13,12 @@ module Rigor
11
13
  # the two formats stay in lockstep; that pairing is why this class is a
12
14
  # bit longer than the default class-length budget.
13
15
  class TypeScanRenderer
16
+ include Renderable
17
+
14
18
  def initialize(out:)
15
19
  @out = out
16
20
  end
17
21
 
18
- def render(report, format:)
19
- case format
20
- when "text" then render_text(report)
21
- when "json" then render_json(report)
22
- else
23
- raise OptionParser::InvalidArgument, "unsupported format: #{format}"
24
- end
25
- end
26
-
27
22
  private
28
23
 
29
24
  def render_text(report)
data/lib/rigor/cli.rb CHANGED
@@ -9,6 +9,7 @@ require_relative "configuration"
9
9
  require_relative "version"
10
10
  require_relative "analysis/diagnostic"
11
11
  require_relative "analysis/result"
12
+ require_relative "cli/options"
12
13
 
13
14
  module Rigor
14
15
  # The CLI class is a dispatcher: each `run_*` method delegates to a
@@ -77,16 +78,19 @@ module Rigor
77
78
  EXIT_USAGE
78
79
  end
79
80
 
80
- def run_check
81
+ def run_check # rubocop:disable Metrics/AbcSize
81
82
  load_check_dependencies
82
83
  options = parse_check_options
83
- buffer = resolve_buffer_binding(options)
84
+ buffer = Options.resolve_buffer_binding(options, err: @err)
84
85
  return EXIT_USAGE if buffer == :usage_error
85
86
 
86
87
  configuration = load_check_configuration(options)
87
88
  cache_root = configuration.cache_path
88
89
  handle_clear_cache(cache_root) if options.fetch(:clear_cache)
89
90
 
91
+ special = dispatch_special_check_mode(configuration, options, cache_root)
92
+ return special unless special.nil?
93
+
90
94
  runner = build_check_runner(
91
95
  configuration: configuration, options: options,
92
96
  buffer: buffer, cache_root: cache_root
@@ -97,6 +101,7 @@ module Rigor
97
101
  write_result(result, options.fetch(:format))
98
102
  write_run_stats(result.stats) if result.stats
99
103
  write_trace_appendices
104
+ runner.cache_store&.evict!
100
105
  write_cache_stats(cache_root, runner.cache_store) if options.fetch(:cache_stats)
101
106
 
102
107
  exit_code = result.success? ? 0 : 1
@@ -104,6 +109,95 @@ module Rigor
104
109
  exit_code
105
110
  end
106
111
 
112
+ # ADR-46 — the two incremental-analysis check modes both fully handle
113
+ # the run and return an exit code (so `run_check` short-circuits);
114
+ # returns nil for an ordinary check.
115
+ def dispatch_special_check_mode(configuration, options, cache_root)
116
+ return run_verify_incremental(configuration) if options.fetch(:verify_incremental)
117
+ return run_incremental_check(configuration, options, cache_root) if options.fetch(:incremental)
118
+
119
+ nil
120
+ end
121
+
122
+ # ADR-46 — the incremental-analysis acceptance gate. Runs a baseline
123
+ # analysis (recording cross-file dependencies), then re-analyzes a
124
+ # representative subset of files and serves the rest from the per-file
125
+ # cache (the body tier), and asserts the merged diagnostics are
126
+ # byte-identical to a full `--no-cache` analysis. A mismatch means the
127
+ # incremental machinery would serve a stale — manufactured —
128
+ # diagnostic, the soundness failure this gate exists to catch. Prints a
129
+ # one-line PASS (exit 0) or the differing diagnostics (exit 1).
130
+ def run_verify_incremental(configuration)
131
+ paths = @argv.empty? ? nil : @argv
132
+ session = Analysis::IncrementalSession.new(configuration: configuration, paths: paths)
133
+ session.baseline
134
+ analyzed = session.analyzed_files
135
+
136
+ # Every other file forms the re-analyzed subset, so the run exercises
137
+ # BOTH the subset-analysis path and the cache-serving path.
138
+ subset = analyzed.each_with_index.select { |_, index| index.even? }.map(&:first)
139
+ incremental = normalize_diagnostics(session.reanalyze_subset(subset))
140
+ full = normalize_diagnostics(verify_full_diagnostics(configuration, paths))
141
+
142
+ report_verify_incremental(incremental, full, subset_size: subset.size, total: analyzed.size)
143
+ end
144
+
145
+ # ADR-46 — cross-process incremental analysis (`--incremental`). Derives
146
+ # the global fingerprint cheaply (no RBS env build), loads the disk
147
+ # snapshot, and on a fingerprint hit re-analyzes only the files changed
148
+ # since the last run (plus their dependents), serving the rest from the
149
+ # snapshot; on a miss runs a full baseline. Persists the updated
150
+ # snapshot for the next invocation. Diagnostics are identical to a full
151
+ # run (the `--verify-incremental` gate enforces this); the win is
152
+ # skipping per-file inference for unchanged files.
153
+ def run_incremental_check(configuration, options, cache_root)
154
+ paths = @argv.empty? ? nil : @argv
155
+ probe = Analysis::Runner.new(configuration: configuration, cache_store: nil)
156
+ files = paths ? probe.analysis_file_set(paths) : probe.analysis_file_set
157
+ fingerprint = Cache::IncrementalSnapshot.fingerprint(
158
+ configuration: configuration, roots: paths || configuration.paths
159
+ )
160
+ snapshot = Cache::IncrementalSnapshot.new(root: cache_root)
161
+ session = Analysis::IncrementalSession.new(configuration: configuration, paths: paths)
162
+
163
+ diagnostics, warm = session.run_incremental(snapshot: snapshot, fingerprint: fingerprint)
164
+ @err.puts("rigor: --incremental #{warm ? 'warm — reused cached diagnostics' : 'cold — full analysis'} " \
165
+ "(#{files.size} files)")
166
+
167
+ result = apply_baseline_filter(Analysis::Result.new(diagnostics: diagnostics, stats: nil), configuration, options)
168
+ write_result(result, options.fetch(:format))
169
+ result.success? ? 0 : 1
170
+ end
171
+
172
+ def verify_full_diagnostics(configuration, paths)
173
+ runner = Analysis::Runner.new(configuration: configuration, cache_store: nil)
174
+ (paths ? runner.run(paths) : runner.run).diagnostics
175
+ end
176
+
177
+ def normalize_diagnostics(diagnostics)
178
+ diagnostics.map(&:to_h).sort_by do |hash|
179
+ [hash["path"].to_s, hash["line"].to_i, hash["column"].to_i, hash["rule"].to_s, hash["message"].to_s]
180
+ end
181
+ end
182
+
183
+ def report_verify_incremental(incremental, full, subset_size:, total:)
184
+ if incremental == full
185
+ @out.puts("rigor: --verify-incremental OK — incremental " \
186
+ "(#{subset_size}/#{total} files re-analyzed, rest from cache) " \
187
+ "matches full (#{full.size} diagnostics)")
188
+ return 0
189
+ end
190
+
191
+ only_incremental = incremental - full
192
+ only_full = full - incremental
193
+ @err.puts("rigor: --verify-incremental FAILED — incremental and full diagnostics differ.")
194
+ @err.puts(" incremental-only: #{only_incremental.size}, full-only: #{only_full.size}")
195
+ (only_incremental + only_full).first(10).each do |hash|
196
+ @err.puts(" #{hash['path']}:#{hash['line']}:#{hash['column']}: [#{hash['rule']}] #{hash['message']}")
197
+ end
198
+ 1
199
+ end
200
+
107
201
  # ADR-22 slice 5 — the `--baseline-strict` CI gate. When the
108
202
  # flag is set, ANY baseline drift fails the run — not only
109
203
  # excess drift (a bucket over threshold, which already fails
@@ -190,7 +284,14 @@ module Rigor
190
284
  end
191
285
 
192
286
  def build_check_runner(configuration:, options:, buffer:, cache_root:)
193
- cache_store = options.fetch(:no_cache) ? nil : Cache::Store.new(root: cache_root)
287
+ cache_store = if options.fetch(:no_cache)
288
+ nil
289
+ else
290
+ Cache::Store.new(
291
+ root: cache_root,
292
+ max_bytes: configuration.cache_max_bytes
293
+ )
294
+ end
194
295
  Analysis::Runner.new(
195
296
  configuration: configuration,
196
297
  explain: options.fetch(:explain),
@@ -201,37 +302,6 @@ module Rigor
201
302
  )
202
303
  end
203
304
 
204
- # Editor-mode CLI envelope. The `--tmp-file=PATH` /
205
- # `--instead-of=PATH` pair binds an in-flight buffer file to
206
- # the logical project path it represents (see
207
- # `docs/design/20260516-editor-mode.md`). Both flags must
208
- # appear together; either alone is a usage error. The
209
- # physical file must be readable; missing-file is a usage
210
- # error too so editors get one consistent failure shape.
211
- #
212
- # Returns:
213
- # - `nil` when neither flag was supplied (legacy path).
214
- # - `Rigor::Analysis::BufferBinding` when the pair is valid.
215
- # - `:usage_error` after writing one diagnostic to stderr;
216
- # the caller MUST translate this to `EXIT_USAGE`.
217
- def resolve_buffer_binding(options)
218
- tmp = options[:tmp_file]
219
- instead = options[:instead_of]
220
- return nil if tmp.nil? && instead.nil?
221
-
222
- if tmp.nil? || instead.nil?
223
- @err.puts("--tmp-file and --instead-of must appear together")
224
- return :usage_error
225
- end
226
-
227
- unless File.file?(tmp)
228
- @err.puts("--tmp-file #{tmp.inspect}: no such file or not readable")
229
- return :usage_error
230
- end
231
-
232
- Analysis::BufferBinding.new(logical_path: instead, physical_path: tmp)
233
- end
234
-
235
305
  # ADR-15 Phase 4c — resolves the worker count by
236
306
  # precedence: CLI `--workers=N` (most explicit) > env
237
307
  # `RIGOR_RACTOR_WORKERS` > config `.rigor.yml`
@@ -293,7 +363,18 @@ module Rigor
293
363
  # `.rigor.yml`. Intended for single-file / ad-hoc CI use;
294
364
  # ordinary projects should configure the plugin in
295
365
  # `.rigor.yml`.
296
- treat_all_as_inline_rbs: false
366
+ treat_all_as_inline_rbs: false,
367
+ # ADR-46 — the incremental-analysis acceptance gate. Runs a
368
+ # baseline analysis, re-analyzes a subset and serves the rest from
369
+ # the per-file cache, and asserts the merged diagnostics are
370
+ # byte-identical to a full `--no-cache` run. Exits non-zero on any
371
+ # mismatch. Off by default.
372
+ verify_incremental: false,
373
+ # ADR-46 — cross-process incremental analysis. With a disk snapshot
374
+ # of the prior run's per-file diagnostics + dependency graph,
375
+ # re-analyzes only the changed closure and serves the rest from the
376
+ # snapshot. Off by default.
377
+ incremental: false
297
378
  }
298
379
  parser = OptionParser.new do |opts| # rubocop:disable Metrics/BlockLength
299
380
  opts.banner = "Usage: rigor check [options] [paths]"
@@ -311,14 +392,7 @@ module Rigor
311
392
  "Dispatch per-file analysis across N Ractor workers (default: 0; sequential)") do |value|
312
393
  options[:workers] = value
313
394
  end
314
- opts.on("--tmp-file=PATH",
315
- "Editor mode: read source bytes from PATH instead of --instead-of (paired)") do |value|
316
- options[:tmp_file] = value
317
- end
318
- opts.on("--instead-of=PATH",
319
- "Editor mode: the logical project path the buffer represents (paired with --tmp-file)") do |value|
320
- options[:instead_of] = value
321
- end
395
+ Options.add_editor_mode(opts, options)
322
396
  opts.on("--baseline=PATH",
323
397
  "ADR-22: load baseline from PATH (overrides .rigor.yml `baseline:`)") do |value|
324
398
  options[:baseline] = value
@@ -335,6 +409,14 @@ module Rigor
335
409
  "ADR-32: force-load rigor-rbs-inline with require_magic_comment: false") do
336
410
  options[:treat_all_as_inline_rbs] = true
337
411
  end
412
+ opts.on("--verify-incremental",
413
+ "ADR-46: assert incremental analysis matches a full run, then exit") do
414
+ options[:verify_incremental] = true
415
+ end
416
+ opts.on("--incremental",
417
+ "ADR-46: re-analyze only files changed since the last run (cross-process cache)") do
418
+ options[:incremental] = true
419
+ end
338
420
  end
339
421
  parser.parse!(@argv)
340
422
  options
@@ -77,7 +77,7 @@ module Rigor
77
77
  return new([]) if data.nil?
78
78
  raise ArgumentError, "dependencies: must be a Hash, got #{data.inspect}" unless data.is_a?(Hash)
79
79
 
80
- raw_entries = Array(data["source_inference"]).map { |raw| coerce_entry(raw) }
80
+ raw_entries = coerce_source_inference(data["source_inference"])
81
81
  entries, warnings = dedupe_entries(raw_entries)
82
82
  budget = coerce_budget_per_gem(data.fetch("budget_per_gem", DEFAULT_BUDGET_PER_GEM))
83
83
  strategy = coerce_budget_overrun_strategy(
@@ -158,6 +158,23 @@ module Rigor
158
158
 
159
159
  private
160
160
 
161
+ # `source_inference:` is a list of per-gem entries, or `false` /
162
+ # omitted to disable it (the default). Guard the Ruby
163
+ # `Array(false) == [false]` quirk that would otherwise feed `false`
164
+ # straight into coerce_entry and crash the whole run on a perfectly
165
+ # reasonable "off" config (`dependencies: { source_inference: false }`).
166
+ def coerce_source_inference(value)
167
+ return [] if value.nil? || value == false
168
+
169
+ unless value.is_a?(Array)
170
+ raise ArgumentError,
171
+ "dependencies.source_inference: must be a list of entries " \
172
+ "(or false / omitted to disable), got #{value.inspect}"
173
+ end
174
+
175
+ value.map { |raw| coerce_entry(raw) }
176
+ end
177
+
161
178
  def coerce_entry(raw)
162
179
  unless raw.is_a?(Hash)
163
180
  raise ArgumentError,
@@ -39,6 +39,7 @@ module Rigor
39
39
  PROFILES = {
40
40
  lenient: {
41
41
  "call.undefined-method" => :error,
42
+ "call.self-undefined-method" => :off,
42
43
  "call.unresolved-toplevel" => :off,
43
44
  "call.wrong-arity" => :error,
44
45
  "call.argument-type-mismatch" => :warning,
@@ -47,6 +48,7 @@ module Rigor
47
48
  "flow.unreachable-branch" => :info,
48
49
  "flow.dead-assignment" => :info,
49
50
  "flow.always-truthy-condition" => :info,
51
+ "flow.unreachable-clause" => :info,
50
52
  "assert.type-mismatch" => :error,
51
53
  "dump.type" => :info,
52
54
  "def.return-type-mismatch" => :warning,
@@ -54,10 +56,15 @@ module Rigor
54
56
  "def.override-visibility-reduced" => :off,
55
57
  "def.override-return-widened" => :off,
56
58
  "def.override-param-narrowed" => :off,
57
- "def.ivar-write-mismatch" => :warning
59
+ "def.ivar-write-mismatch" => :warning,
60
+ # Opt-in author assertion: you only see it if you wrote a
61
+ # `conforms-to` directive, so it stays a :warning even in
62
+ # lenient — it is never unsolicited noise.
63
+ "rbs_extended.unsatisfied-conformance" => :warning
58
64
  }.freeze,
59
65
  balanced: {
60
66
  "call.undefined-method" => :error,
67
+ "call.self-undefined-method" => :off,
61
68
  "call.unresolved-toplevel" => :warning,
62
69
  "call.wrong-arity" => :error,
63
70
  "call.argument-type-mismatch" => :error,
@@ -66,6 +73,11 @@ module Rigor
66
73
  "flow.unreachable-branch" => :warning,
67
74
  "flow.dead-assignment" => :warning,
68
75
  "flow.always-truthy-condition" => :warning,
76
+ # ADR-47 WD4: stays :info (not :warning like its siblings) in the
77
+ # default balanced profile until the regression-corpus FP gate is
78
+ # green; promote to :warning once Mastodon/GitLab/Redmine triage
79
+ # to zero net false positives.
80
+ "flow.unreachable-clause" => :info,
69
81
  "assert.type-mismatch" => :error,
70
82
  "dump.type" => :info,
71
83
  "def.return-type-mismatch" => :warning,
@@ -73,10 +85,12 @@ module Rigor
73
85
  "def.override-visibility-reduced" => :warning,
74
86
  "def.override-return-widened" => :warning,
75
87
  "def.override-param-narrowed" => :warning,
76
- "def.ivar-write-mismatch" => :warning
88
+ "def.ivar-write-mismatch" => :warning,
89
+ "rbs_extended.unsatisfied-conformance" => :warning
77
90
  }.freeze,
78
91
  strict: {
79
92
  "call.undefined-method" => :error,
93
+ "call.self-undefined-method" => :off,
80
94
  "call.unresolved-toplevel" => :error,
81
95
  "call.wrong-arity" => :error,
82
96
  "call.argument-type-mismatch" => :error,
@@ -85,6 +99,10 @@ module Rigor
85
99
  "flow.unreachable-branch" => :error,
86
100
  "flow.dead-assignment" => :error,
87
101
  "flow.always-truthy-condition" => :error,
102
+ # ADR-47: strict opts into the new rule at :warning (one notch
103
+ # below its :error siblings) while it proves out — see the
104
+ # balanced-profile note above.
105
+ "flow.unreachable-clause" => :warning,
88
106
  "assert.type-mismatch" => :error,
89
107
  "dump.type" => :error,
90
108
  "def.return-type-mismatch" => :error,
@@ -92,7 +110,8 @@ module Rigor
92
110
  "def.override-visibility-reduced" => :error,
93
111
  "def.override-return-widened" => :error,
94
112
  "def.override-param-narrowed" => :error,
95
- "def.ivar-write-mismatch" => :error
113
+ "def.ivar-write-mismatch" => :error,
114
+ "rbs_extended.unsatisfied-conformance" => :error
96
115
  }.freeze
97
116
  }.freeze
98
117
 
@@ -69,7 +69,13 @@ module Rigor
69
69
  "baseline" => nil,
70
70
  "fold_platform_specific_paths" => false,
71
71
  "cache" => {
72
- "path" => ".rigor/cache"
72
+ "path" => ".rigor/cache",
73
+ # LRU eviction cap in bytes. nil (the default) disables eviction;
74
+ # the cache grows until the user runs `rigor check --clear-cache`.
75
+ # Set to a positive integer (e.g. 536870912 for 512 MB) to keep the
76
+ # cache bounded — the least-recently-used entries are removed at the
77
+ # end of each run when the total exceeds this limit.
78
+ "max_bytes" => nil
73
79
  },
74
80
  "plugins_io" => {
75
81
  "network" => "disabled",
@@ -166,7 +172,8 @@ module Rigor
166
172
  PATH_KEYS = %w[paths signature_paths pre_eval].freeze
167
173
  private_constant :PATH_KEYS
168
174
 
169
- attr_reader :target_ruby, :paths, :exclude_patterns, :plugins, :cache_path, :disabled_rules,
175
+ attr_reader :target_ruby, :paths, :exclude_patterns, :plugins, :cache_path, :cache_max_bytes,
176
+ :disabled_rules,
170
177
  :libraries, :signature_paths, :fold_platform_specific_paths,
171
178
  :plugins_io_network, :plugins_io_allowed_paths,
172
179
  :plugins_io_allowed_url_hosts,
@@ -334,6 +341,8 @@ module Rigor
334
341
  "fold_platform_specific_paths", DEFAULTS.fetch("fold_platform_specific_paths")
335
342
  ) == true
336
343
  @cache_path = cache.fetch("path").to_s
344
+ raw_max = cache.fetch("max_bytes")
345
+ @cache_max_bytes = raw_max.nil? ? nil : Integer(raw_max)
337
346
  @plugins_io_network = coerce_network_policy(plugins_io.fetch("network"))
338
347
  @plugins_io_allowed_paths = Array(plugins_io.fetch("allowed_paths")).map(&:to_s).freeze
339
348
  @plugins_io_allowed_url_hosts = Array(plugins_io.fetch("allowed_url_hosts")).map(&:to_s).freeze
@@ -383,7 +392,8 @@ module Rigor
383
392
  "pre_eval" => pre_eval,
384
393
  "fold_platform_specific_paths" => fold_platform_specific_paths,
385
394
  "cache" => {
386
- "path" => cache_path
395
+ "path" => cache_path,
396
+ "max_bytes" => cache_max_bytes
387
397
  },
388
398
  "plugins_io" => {
389
399
  "network" => plugins_io_network.to_s,