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
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "rbs_descriptor"
4
+ require_relative "rbs_cache_producer"
4
5
 
5
6
  module Rigor
6
7
  module Cache
@@ -18,19 +19,12 @@ module Rigor
18
19
  # Cache descriptor shape is shared with {RbsConstantTable} via
19
20
  # {RbsDescriptor.build}; a single signature change or rbs gem
20
21
  # bump invalidates both producers in lockstep.
21
- class RbsKnownClassNames
22
+ class RbsKnownClassNames < RbsCacheProducer
22
23
  PRODUCER_ID = "rbs.known_class_names"
23
24
 
24
25
  # @param loader [Rigor::Environment::RbsLoader]
25
26
  # @param store [Rigor::Cache::Store]
26
27
  # @return [Set<String>]
27
- def self.fetch(loader:, store:)
28
- descriptor = RbsDescriptor.build(loader)
29
- store.fetch_or_compute(producer_id: PRODUCER_ID, params: {}, descriptor: descriptor) do
30
- compute(loader)
31
- end
32
- end
33
-
34
28
  def self.compute(loader)
35
29
  names = Set.new
36
30
  loader.each_known_class_name { |name| names << name }
@@ -44,9 +44,10 @@ module Rigor
44
44
  # invocations can read from the same cache concurrently
45
45
  # without churning it. See
46
46
  # `docs/design/20260516-editor-mode.md` § "Cache behaviour".
47
- def initialize(root:, read_only: false)
47
+ def initialize(root:, read_only: false, max_bytes: nil)
48
48
  @root = root.to_s.dup.freeze
49
49
  @read_only = read_only
50
+ @max_bytes = max_bytes&.then { |n| Integer(n) }
50
51
  @hits = 0
51
52
  @misses = 0
52
53
  @writes = 0
@@ -197,6 +198,81 @@ module Rigor
197
198
  value
198
199
  end
199
200
 
201
+ # ADR-45 — record-and-validate variant. Unlike {fetch_or_compute},
202
+ # which keys the entry on its descriptor (so the inputs MUST be
203
+ # known before running), this keys on `key_descriptor` (the stable
204
+ # inputs known up front) and stores, alongside the value, a
205
+ # `dependency_descriptor` of the files the value actually read —
206
+ # including inputs discovered DURING the computation (e.g. a plugin
207
+ # reading a file mid-analysis). On the next run the stored
208
+ # dependencies are re-validated against the filesystem
209
+ # ({Descriptor#fresh?}); a stale dependency forces a recompute.
210
+ #
211
+ # The block MUST return `[value, dependency_descriptor]`. Disk reads
212
+ # are not in-process-memoised — validation always re-checks the
213
+ # filesystem — but a single run only looks up once.
214
+ def fetch_or_validate(producer_id:, key_descriptor:, params: {}, serialize: nil, deserialize: nil)
215
+ validate_producer_id!(producer_id)
216
+ ensure_schema_version!
217
+
218
+ key = key_descriptor.cache_key_for(producer_id: producer_id, params: params)
219
+ path = entry_path(producer_id, key)
220
+ cached = read_entry(path, deserialize: deserialize)
221
+ if cached && (pair = cached.value).is_a?(Array) && pair.size == 2 &&
222
+ pair[1].is_a?(Descriptor) && pair[1].fresh?
223
+ @monitor.synchronize { record(:hits, producer_id) }
224
+ return pair[0]
225
+ end
226
+
227
+ value, dependency_descriptor = block_given? ? yield : [nil, Descriptor.new]
228
+ wrote = false
229
+ unless @read_only
230
+ # A cache write must never break the run. If the value is not
231
+ # Marshal-clean (or any disk error occurs) skip caching and
232
+ # return the freshly-computed value — the next run recomputes.
233
+ begin
234
+ write_entry(path, key_descriptor, [value, dependency_descriptor], serialize: serialize)
235
+ wrote = true
236
+ rescue StandardError
237
+ wrote = false
238
+ end
239
+ end
240
+ @monitor.synchronize do
241
+ record(:misses, producer_id)
242
+ record(:writes, producer_id) if wrote
243
+ end
244
+ value
245
+ end
246
+
247
+ # ADR-6 § "Eviction" — LRU pass over the on-disk cache. No-op when
248
+ # `max_bytes:` was not configured or the store is read-only.
249
+ # Walks all `.entry` files, sorts by mtime ascending (oldest = least
250
+ # recently used), and unlinks from the oldest until the total is at
251
+ # or below the cap. Touch-on-disk-read ({read_entry}) is the
252
+ # cross-process LRU signal: every disk hit (not in-process-memo hit)
253
+ # updates the mtime so recently-read entries survive the eviction pass.
254
+ # Any FS error is swallowed — eviction must never break a run.
255
+ def evict!
256
+ return if @max_bytes.nil? || @read_only
257
+
258
+ entries = collect_entry_stats
259
+ total = entries.sum { |e| e[:bytes] }
260
+ return if total <= @max_bytes
261
+
262
+ entries.sort_by! { |e| e[:mtime] }
263
+ entries.each do |entry|
264
+ break if total <= @max_bytes
265
+
266
+ File.unlink(entry[:path])
267
+ total -= entry[:bytes]
268
+ rescue StandardError
269
+ next
270
+ end
271
+ nil
272
+ rescue StandardError
273
+ nil
274
+ end
275
+
200
276
  private
201
277
 
202
278
  Entry = Data.define(:descriptor_bytes, :value)
@@ -238,6 +314,7 @@ module Rigor
238
314
  value = safe_load(value_bytes, deserialize)
239
315
  return nil if value.equal?(LOAD_FAILED)
240
316
 
317
+ touch_for_lru(path) if @max_bytes
241
318
  Entry.new(descriptor_bytes, value)
242
319
  end
243
320
 
@@ -370,6 +447,27 @@ module Rigor
370
447
  end
371
448
  end
372
449
 
450
+ # Updates both atime and mtime of `path` to the current time — the
451
+ # cross-process LRU signal used by {evict!}. Best-effort: any FS
452
+ # error (read-only mount, deleted file) is silently ignored.
453
+ def touch_for_lru(path)
454
+ now = Time.now
455
+ File.utime(now, now, path)
456
+ rescue StandardError
457
+ nil
458
+ end
459
+
460
+ # Returns an array of `{ path:, mtime:, bytes: }` hashes for every
461
+ # `.entry` file under the cache root, skipping unreadable entries.
462
+ def collect_entry_stats
463
+ Dir.glob(File.join(@root, "**", "*.entry")).filter_map do |path|
464
+ stat = File.stat(path)
465
+ { path: path, mtime: stat.mtime, bytes: stat.size }
466
+ rescue StandardError
467
+ nil
468
+ end
469
+ end
470
+
373
471
  def read_varint(bytes, offset)
374
472
  result = 0
375
473
  shift = 0
@@ -9,6 +9,7 @@ require_relative "../scope"
9
9
  require_relative "../inference/def_return_typer"
10
10
  require_relative "../inference/scope_indexer"
11
11
  require_relative "prism_colorizer"
12
+ require_relative "command"
12
13
 
13
14
  module Rigor
14
15
  class CLI
@@ -25,19 +26,13 @@ module Rigor
25
26
  # since the appended text is always a comment — and printed to
26
27
  # stdout with IRB-style syntax highlighting via
27
28
  # {PrismColorizer}.
28
- class AnnotateCommand
29
+ class AnnotateCommand < Command
29
30
  USAGE = "Usage: rigor annotate [options] FILE"
30
31
 
31
32
  # Appended ` #=> dump_type: <type>` suffix. Matched and
32
33
  # stripped before re-annotating so re-running is idempotent.
33
34
  ANNOTATION_PATTERN = /\s*#=>\s*dump_type:.*\z/
34
35
 
35
- def initialize(argv:, out:, err:)
36
- @argv = argv
37
- @out = out
38
- @err = err
39
- end
40
-
41
36
  # @return [Integer] CLI exit status.
42
37
  def run
43
38
  options = parse_options
@@ -6,6 +6,7 @@ require_relative "../analysis/baseline"
6
6
  require_relative "../analysis/runner"
7
7
  require_relative "../cache/store"
8
8
  require_relative "../configuration"
9
+ require_relative "command"
9
10
 
10
11
  module Rigor
11
12
  class CLI
@@ -20,18 +21,12 @@ module Rigor
20
21
  # rigor baseline dump
21
22
  # rigor baseline drift
22
23
  # rigor baseline prune
23
- class BaselineCommand # rubocop:disable Metrics/ClassLength
24
+ class BaselineCommand < Command # rubocop:disable Metrics/ClassLength
24
25
  EXIT_USAGE = 64
25
26
  DEFAULT_BASELINE_PATH = ".rigor-baseline.yml"
26
27
 
27
28
  SUBCOMMANDS = %w[generate regenerate dump drift prune].freeze
28
29
 
29
- def initialize(argv:, out: $stdout, err: $stderr)
30
- @argv = argv
31
- @out = out
32
- @err = err
33
- end
34
-
35
30
  def run
36
31
  subcommand = @argv.shift
37
32
  case subcommand
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ class CLI
5
+ # Base class for the `rigor <subcommand>` command objects.
6
+ #
7
+ # Every subcommand captured the same invariant wiring — the argument
8
+ # vector plus the output and error streams — in an identical
9
+ # `initialize(argv:, out:, err:)`, and defaulted the streams
10
+ # inconsistently (some to `$stdout` / `$stderr`, most not at all).
11
+ # Centralising it here gives one consistent shape and lets a test
12
+ # instantiate a command with just `argv:` (the streams default so a
13
+ # spec can pass a `StringIO` for one and ignore the other).
14
+ #
15
+ # Subclasses read the `@argv` / `@out` / `@err` ivars directly, as
16
+ # they did before.
17
+ class Command
18
+ def initialize(argv:, out: $stdout, err: $stderr)
19
+ @argv = argv
20
+ @out = out
21
+ @err = err
22
+ end
23
+
24
+ private
25
+
26
+ # Expands `args` (a mix of files and directories) into a unique
27
+ # list of `.rb` paths, recursing into directories. Returns nil —
28
+ # after writing `<command_name>: not a file or directory: <arg>` to
29
+ # `@err` — on the first arg that is neither. Shared by the
30
+ # path-walking commands (`type-scan`, `coverage`).
31
+ def collect_paths(args, command_name:)
32
+ paths = []
33
+ args.each do |arg|
34
+ if File.directory?(arg)
35
+ paths.concat(Dir.glob(File.join(arg, "**/*.rb")))
36
+ elsif File.file?(arg)
37
+ paths << arg
38
+ else
39
+ @err.puts("#{command_name}: not a file or directory: #{arg}")
40
+ return nil
41
+ end
42
+ end
43
+ paths.uniq
44
+ end
45
+ end
46
+ end
47
+ end
@@ -9,6 +9,7 @@ require_relative "../inference/precision_scanner"
9
9
  require_relative "../scope"
10
10
  require_relative "coverage_report"
11
11
  require_relative "coverage_renderer"
12
+ require_relative "command"
12
13
 
13
14
  module Rigor
14
15
  class CLI
@@ -25,19 +26,13 @@ module Rigor
25
26
  # 0 — scan complete, precision ratio ≥ threshold (or no threshold given)
26
27
  # 1 — precision ratio < threshold, or parse errors encountered
27
28
  # 64 — usage error
28
- class CoverageCommand
29
+ class CoverageCommand < Command
29
30
  USAGE = "Usage: rigor coverage [options] PATH..."
30
31
 
31
- def initialize(argv:, out:, err:)
32
- @argv = argv
33
- @out = out
34
- @err = err
35
- end
36
-
37
32
  # @return [Integer] CLI exit status.
38
33
  def run
39
34
  options = parse_options
40
- paths = collect_paths(@argv)
35
+ paths = collect_paths(@argv, command_name: "coverage")
41
36
  return CLI::EXIT_USAGE if paths.nil?
42
37
  return usage_error if paths.empty?
43
38
 
@@ -64,21 +59,6 @@ module Rigor
64
59
  options
65
60
  end
66
61
 
67
- def collect_paths(args)
68
- paths = []
69
- args.each do |arg|
70
- if File.directory?(arg)
71
- paths.concat(Dir.glob(File.join(arg, "**/*.rb")))
72
- elsif File.file?(arg)
73
- paths << arg
74
- else
75
- @err.puts("coverage: not a file or directory: #{arg}")
76
- return nil
77
- end
78
- end
79
- paths.uniq
80
- end
81
-
82
62
  def usage_error
83
63
  @err.puts("coverage: at least one path is required")
84
64
  @err.puts(USAGE)
@@ -1,11 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require_relative "renderable"
4
5
 
5
6
  module Rigor
6
7
  class CLI
7
8
  # Renders a `CoverageReport` as terminal-friendly text or JSON.
8
9
  class CoverageRenderer
10
+ include Renderable
11
+
9
12
  TIER_LABELS = {
10
13
  constant: "constant",
11
14
  nominal: "nominal",
@@ -21,14 +24,6 @@ module Rigor
21
24
  @out = out
22
25
  end
23
26
 
24
- def render(report, format:)
25
- case format
26
- when "text" then render_text(report)
27
- when "json" then render_json(report)
28
- else raise OptionParser::InvalidArgument, "unsupported format: #{format}"
29
- end
30
- end
31
-
32
27
  private
33
28
 
34
29
  def render_text(report)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "command"
4
+
3
5
  require "json"
4
6
  require "optionparser"
5
7
 
@@ -29,15 +31,9 @@ module Rigor
29
31
  # is `1` when any new diagnostic appears, `0` otherwise —
30
32
  # so adding new errors fails CI but legacy errors recorded
31
33
  # in the baseline don't.
32
- class DiffCommand
34
+ class DiffCommand < Command
33
35
  USAGE = "Usage: rigor diff [options] <baseline.json> [paths...]"
34
36
 
35
- def initialize(argv:, out:, err:)
36
- @argv = argv
37
- @out = out
38
- @err = err
39
- end
40
-
41
37
  # @return [Integer] CLI exit status.
42
38
  def run
43
39
  options = parse_options
@@ -4,6 +4,7 @@ require "json"
4
4
  require "optionparser"
5
5
 
6
6
  require_relative "../analysis/rule_catalog"
7
+ require_relative "command"
7
8
 
8
9
  module Rigor
9
10
  class CLI
@@ -17,15 +18,9 @@ module Rigor
17
18
  # beyond the rendered catalog. Useful when a user sees a
18
19
  # diagnostic in the editor and wants to know what the rule
19
20
  # means without leaving the terminal.
20
- class ExplainCommand
21
+ class ExplainCommand < Command
21
22
  USAGE = "Usage: rigor explain [options] [<rule>]"
22
23
 
23
- def initialize(argv:, out:, err:)
24
- @argv = argv
25
- @out = out
26
- @err = err
27
- end
28
-
29
24
  # @return [Integer] CLI exit status.
30
25
  def run
31
26
  options = parse_options
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "command"
4
+
3
5
  require "optionparser"
4
6
 
5
7
  module Rigor
@@ -11,15 +13,9 @@ module Rigor
11
13
  # The actual stdio JSON-RPC reader / writer is queued for slice 2;
12
14
  # invoking `rigor lsp` at slice 1 returns immediately after
13
15
  # validating the transport flag.
14
- class LspCommand
16
+ class LspCommand < Command
15
17
  USAGE = "Usage: rigor lsp [options]"
16
18
 
17
- def initialize(argv:, out:, err:)
18
- @argv = argv
19
- @out = out
20
- @err = err
21
- end
22
-
23
19
  # @return [Integer] CLI exit status.
24
20
  def run
25
21
  options = parse_options
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "command"
4
+
3
5
  require "optionparser"
4
6
 
5
7
  module Rigor
@@ -13,15 +15,9 @@ module Rigor
13
15
  # Slice 1 ships the stdio transport with seven read-only tools:
14
16
  # rigor_check, rigor_type_of, rigor_triage, rigor_annotate,
15
17
  # rigor_sig_gen, rigor_explain, rigor_coverage.
16
- class McpCommand
18
+ class McpCommand < Command
17
19
  USAGE = "Usage: rigor mcp [options]"
18
20
 
19
- def initialize(argv:, out:, err:)
20
- @argv = argv
21
- @out = out
22
- @err = err
23
- end
24
-
25
21
  # @return [Integer] CLI exit status.
26
22
  def run
27
23
  options = parse_options
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../analysis/buffer_binding"
4
+
5
+ module Rigor
6
+ class CLI
7
+ # Shared option plumbing for the subcommands.
8
+ #
9
+ # Today it owns the editor-mode surface that `rigor check` and
10
+ # `rigor type-of` both expose: the `--tmp-file` / `--instead-of` flag
11
+ # pair and the buffer-binding resolution that validates it. That
12
+ # resolution used to be copied verbatim into both `Rigor::CLI` and
13
+ # `TypeOfCommand`, so a fix to one (the paired-flag check, the
14
+ # readability check, the error wording) could silently miss the
15
+ # other. Centralising it keeps the two editor-mode entry points in
16
+ # lockstep.
17
+ module Options
18
+ module_function
19
+
20
+ # Defines the `--tmp-file` / `--instead-of` editor-mode flag pair
21
+ # on `parser`, writing into `options`.
22
+ def add_editor_mode(parser, options)
23
+ parser.on("--tmp-file=PATH",
24
+ "Editor mode: read source bytes from PATH instead of --instead-of (paired)") do |value|
25
+ options[:tmp_file] = value
26
+ end
27
+ parser.on("--instead-of=PATH",
28
+ "Editor mode: the logical project path the buffer represents (paired with --tmp-file)") do |value|
29
+ options[:instead_of] = value
30
+ end
31
+ end
32
+
33
+ # Resolves the editor-mode buffer binding from parsed `options`:
34
+ # returns nil when neither editor-mode flag is set, a
35
+ # `Analysis::BufferBinding` when the pair is valid, or
36
+ # `:usage_error` (after writing the reason to `err`) when the flags
37
+ # are unpaired or the temp file is unreadable.
38
+ def resolve_buffer_binding(options, err:)
39
+ tmp = options[:tmp_file]
40
+ instead = options[:instead_of]
41
+ return nil if tmp.nil? && instead.nil?
42
+
43
+ if tmp.nil? || instead.nil?
44
+ err.puts("--tmp-file and --instead-of must appear together")
45
+ return :usage_error
46
+ end
47
+
48
+ unless File.file?(tmp)
49
+ err.puts("--tmp-file #{tmp.inspect}: no such file or not readable")
50
+ return :usage_error
51
+ end
52
+
53
+ Analysis::BufferBinding.new(logical_path: instead, physical_path: tmp)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "command"
4
+
3
5
  module Rigor
4
6
  class CLI
5
7
  # `rigor plugin` (singular) — discover and read the plugin source
@@ -44,7 +46,7 @@ module Rigor
44
46
  # will not resolve — read them from the same environment that ran
45
47
  # the command (`rigor plugin print` inlines the body for exactly
46
48
  # this case: it works with no file-reading tool at all).
47
- class PluginCommand
49
+ class PluginCommand < Command
48
50
  USAGE = <<~USAGE
49
51
  Usage: rigor plugin <subcommand> [args]
50
52
 
@@ -72,12 +74,6 @@ module Rigor
72
74
  PLUGINS_ROOT = File.join(GEM_ROOT, "plugins")
73
75
  EXAMPLES_ROOT = File.join(GEM_ROOT, "examples")
74
76
 
75
- def initialize(argv:, out: $stdout, err: $stderr)
76
- @argv = argv
77
- @out = out
78
- @err = err
79
- end
80
-
81
77
  # @return [Integer] CLI exit status.
82
78
  def run
83
79
  subcommand = @argv.shift || "list"
@@ -9,6 +9,7 @@ require_relative "../plugin/services"
9
9
  require_relative "../reflection"
10
10
  require_relative "../type/combinator"
11
11
  require_relative "plugins_renderer"
12
+ require_relative "command"
12
13
 
13
14
  module Rigor
14
15
  class CLI
@@ -60,15 +61,9 @@ module Rigor
60
61
  # the RBS environment without conflict (requires constructing
61
62
  # the Environment, which is heavier than the loader-only
62
63
  # pass this slice does).
63
- class PluginsCommand # rubocop:disable Metrics/ClassLength
64
+ class PluginsCommand < Command # rubocop:disable Metrics/ClassLength
64
65
  USAGE = "Usage: rigor plugins [options]"
65
66
 
66
- def initialize(argv:, out: $stdout, err: $stderr)
67
- @argv = argv
68
- @out = out
69
- @err = err
70
- end
71
-
72
67
  # @return [Integer] CLI exit status.
73
68
  def run
74
69
  options = parse_options
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optionparser"
4
+
5
+ module Rigor
6
+ class CLI
7
+ # Output-format dispatch shared by the `--format text|json` renderers.
8
+ #
9
+ # Each renderer included this and then implemented `render_text` /
10
+ # `render_json`; the `render(data, format:)` entry point — route by
11
+ # the format string, raise one consistent `OptionParser::InvalidArgument`
12
+ # on anything else — was copied verbatim into every one. Centralising
13
+ # it keeps the unsupported-format wording and the text/json contract
14
+ # in a single place as new renderers and formats are added.
15
+ module Renderable
16
+ def render(data, format:)
17
+ case format
18
+ when "text" then render_text(data)
19
+ when "json" then render_json(data)
20
+ else
21
+ raise OptionParser::InvalidArgument, "unsupported format: #{format}"
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -4,6 +4,7 @@ require "optionparser"
4
4
 
5
5
  require_relative "../configuration"
6
6
  require_relative "../sig_gen"
7
+ require_relative "command"
7
8
 
8
9
  module Rigor
9
10
  class CLI
@@ -34,19 +35,13 @@ module Rigor
34
35
  # `--params=observed-strict` stays reserved-but-inert until
35
36
  # the capability-role catalog ships (rejected with a usage
36
37
  # error so the surface stays stable).
37
- class SigGenCommand
38
+ class SigGenCommand < Command
38
39
  USAGE = "Usage: rigor sig-gen [options] [paths]"
39
40
 
40
41
  VALID_MODES = %w[print diff write].freeze
41
42
  VALID_PARAM_POLICIES = %w[untyped observed observed-strict].freeze
42
43
  VALID_FORMATS = %w[text json].freeze
43
44
 
44
- def initialize(argv:, out:, err:)
45
- @argv = argv
46
- @out = out
47
- @err = err
48
- end
49
-
50
45
  # @return [Integer] CLI exit status.
51
46
  def run
52
47
  options = parse_options
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "command"
4
+
3
5
  require "optparse"
4
6
 
5
7
  module Rigor
@@ -29,7 +31,7 @@ module Rigor
29
31
  # as input to a Read tool.
30
32
  #
31
33
  # `rigor skill` with no subcommand is an alias for `list`.
32
- class SkillCommand
34
+ class SkillCommand < Command
33
35
  USAGE = <<~USAGE
34
36
  Usage: rigor skill <subcommand> [args]
35
37
 
@@ -48,12 +50,6 @@ module Rigor
48
50
  # `lib/rigor/cli/skill_command.rb` that is three directories up.
49
51
  SKILLS_ROOT = File.expand_path("../../../skills", __dir__)
50
52
 
51
- def initialize(argv:, out: $stdout, err: $stderr)
52
- @argv = argv
53
- @out = out
54
- @err = err
55
- end
56
-
57
53
  # @return [Integer] CLI exit status.
58
54
  def run
59
55
  subcommand = @argv.shift || "list"
@@ -7,6 +7,7 @@ require_relative "../analysis/runner"
7
7
  require_relative "../cache/store"
8
8
  require_relative "../triage"
9
9
  require_relative "triage_renderer"
10
+ require_relative "command"
10
11
 
11
12
  module Rigor
12
13
  class CLI
@@ -18,16 +19,10 @@ module Rigor
18
19
  # Read-only and advisory (WD4): never edits config, never
19
20
  # writes a baseline. Always exits 0 — it is an inspection
20
21
  # command, not a gate (`rigor check` remains the gate).
21
- class TriageCommand
22
+ class TriageCommand < Command
22
23
  USAGE = "Usage: rigor triage [options] [paths]"
23
24
  DEFAULT_SECTIONS = %i[distribution hotspots hints].freeze
24
25
 
25
- def initialize(argv:, out:, err:)
26
- @argv = argv
27
- @out = out
28
- @err = err
29
- end
30
-
31
26
  # @return [Integer] CLI exit status (always 0).
32
27
  def run
33
28
  options = parse_options