rigortype 0.2.4 → 0.2.6

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/docs/handbook/09-plugins.md +5 -2
  3. data/docs/handbook/appendix-liskov.md +5 -3
  4. data/docs/handbook/appendix-phpstan.md +2 -2
  5. data/docs/install.md +1 -1
  6. data/docs/manual/02-cli-reference.md +58 -1
  7. data/docs/manual/06-baseline.md +12 -0
  8. data/docs/manual/11-ci.md +6 -6
  9. data/docs/manual/15-type-protection-coverage.md +29 -0
  10. data/docs/manual/plugins/rigor-minitest.md +1 -1
  11. data/docs/manual/plugins/rigor-rails-i18n.md +22 -3
  12. data/lib/rigor/analysis/incremental_session.rb +7 -2
  13. data/lib/rigor/cli/check_command.rb +4 -33
  14. data/lib/rigor/cli/check_runner_factory.rb +63 -0
  15. data/lib/rigor/cli/doctor_command.rb +295 -0
  16. data/lib/rigor/cli/plugins_command.rb +2 -2
  17. data/lib/rigor/cli/plugins_renderer.rb +1 -1
  18. data/lib/rigor/cli/protection_renderer.rb +32 -2
  19. data/lib/rigor/cli/protection_report.rb +32 -6
  20. data/lib/rigor/cli/upgrade_command.rb +25 -0
  21. data/lib/rigor/cli.rb +17 -1
  22. data/lib/rigor/flow_contribution/fact.rb +1 -1
  23. data/lib/rigor/inference/dynamic_origin.rb +67 -0
  24. data/lib/rigor/inference/expression_typer.rb +22 -10
  25. data/lib/rigor/inference/fallback.rb +2 -2
  26. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +16 -0
  27. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +41 -2
  28. data/lib/rigor/inference/method_dispatcher.rb +19 -4
  29. data/lib/rigor/inference/mutation_widening.rb +18 -0
  30. data/lib/rigor/inference/protection_scanner.rb +6 -3
  31. data/lib/rigor/inference/statement_evaluator.rb +5 -4
  32. data/lib/rigor/plugin/base.rb +34 -7
  33. data/lib/rigor/plugin/registry.rb +1 -1
  34. data/lib/rigor/scope.rb +16 -5
  35. data/lib/rigor/version.rb +1 -1
  36. data/lib/rigor.rb +1 -0
  37. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +1 -1
  38. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +1 -1
  39. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +52 -0
  40. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +123 -8
  41. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +1 -1
  42. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +3 -3
  43. data/sig/rigor/plugin/base.rbs +2 -0
  44. data/sig/rigor/scope.rbs +3 -1
  45. data/skills/rigor-plugin-author/SKILL.md +8 -5
  46. data/skills/rigor-plugin-author/references/02-walker-and-types.md +8 -4
  47. metadata +27 -3
@@ -0,0 +1,295 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optionparser"
5
+
6
+ require_relative "../configuration"
7
+ require_relative "../config_audit"
8
+ require_relative "../analysis/baseline"
9
+ require_relative "../analysis/result"
10
+ require_relative "../plugin"
11
+ require_relative "../plugin/loader"
12
+ require_relative "../plugin/services"
13
+ require_relative "../reflection"
14
+ require_relative "../type/combinator"
15
+ require_relative "check_runner_factory"
16
+ require_relative "command"
17
+ require_relative "options"
18
+
19
+ module Rigor
20
+ class CLI
21
+ # `rigor doctor` — classify existing project findings into setup-problem
22
+ # vs clean-run with a routed next action (ADR-77 WD1).
23
+ #
24
+ # It is a presentation/aggregation layer over data `rigor check` already
25
+ # produces — no new analysis pass and no new diagnostic rule. The
26
+ # command runs a scoped analysis to gather stats, audits the
27
+ # configuration, validates plugins, and checks baseline drift, then
28
+ # reports a small fixed set of checks with a hint for each failure.
29
+ class DoctorCommand < Command # rubocop:disable Metrics/ClassLength
30
+ USAGE = "Usage: rigor doctor [options]"
31
+
32
+ # Check identifiers — the stable JSON contract (`checks[].id`).
33
+ CHECK_CONFIG = "config_audit"
34
+ CHECK_RBS = "rbs_environment"
35
+ CHECK_PLUGINS = "plugins"
36
+ CHECK_BASELINE = "baseline"
37
+ CHECK_RAILS = "rails_plugins"
38
+
39
+ RAILS_LOCK_MARKERS = %w[railties actionpack activerecord actioncable].freeze
40
+ RAILS_PLUGIN_MARKERS = %w[
41
+ rigor-activerecord rigor-actionpack rigor-actionmailer rigor-activejob
42
+ rigor-rails-routes rigor-rails-i18n rigor-actioncable rigor-activestorage rigor-rails
43
+ ].freeze
44
+
45
+ # @return [Integer] CLI exit status.
46
+ def run
47
+ options = parse_options
48
+ configuration = Configuration.load(options.fetch(:config))
49
+ findings = []
50
+
51
+ # 1. Config audit (cheap — no analysis needed).
52
+ findings.concat(audit_config(configuration))
53
+
54
+ # 2. Run a scoped analysis to gather stats + diagnostics for the
55
+ # deeper checks. Use no-cache so the probe doesn't churn disk.
56
+ runner = CheckRunnerFactory.build(
57
+ configuration: configuration,
58
+ options: { no_cache: true, explain: false, stats: true, workers: 0 },
59
+ buffer: nil,
60
+ cache_root: configuration.cache_path
61
+ )
62
+ result = runner.run(configuration.paths)
63
+
64
+ # 3. RBS environment check.
65
+ findings.concat(check_rbs_environment(result))
66
+
67
+ # 4. Plugin load check.
68
+ findings.concat(check_plugins(configuration))
69
+
70
+ # 5. Baseline drift check.
71
+ findings.concat(check_baseline(configuration, result))
72
+
73
+ # 6. Rails locked but no Rails plugins.
74
+ findings.concat(check_rails_plugins(configuration))
75
+
76
+ report(findings, options.fetch(:format))
77
+ findings.any? { |f| f[:status] == :fail } ? 1 : 0
78
+ rescue OptionParser::InvalidArgument => e
79
+ @err.puts(e.message)
80
+ CLI::EXIT_USAGE
81
+ end
82
+
83
+ private
84
+
85
+ def parse_options
86
+ options = { config: nil, format: "text" }
87
+ OptionParser.new do |opts|
88
+ opts.banner = USAGE
89
+ Options.add_config(opts, options)
90
+ opts.on("--format=FORMAT", "Output format: text (default) or json") { |v| options[:format] = v }
91
+ end.parse!(@argv)
92
+ validate!(options)
93
+ options
94
+ end
95
+
96
+ def validate!(options)
97
+ return if %w[text json].include?(options.fetch(:format))
98
+
99
+ raise OptionParser::InvalidArgument, "unsupported format: #{options.fetch(:format)}"
100
+ end
101
+
102
+ # ------------------------------------------------------------------
103
+ # Individual checks — each returns an Array of finding Hashes.
104
+ # A finding has: :check, :status (:pass/:fail/:warn), :message, :hint
105
+ # ------------------------------------------------------------------
106
+
107
+ def audit_config(configuration)
108
+ warnings = ConfigAudit.warnings(configuration, project_root: Dir.pwd)
109
+ return [] if warnings.empty?
110
+
111
+ messages = warnings.map(&:message)
112
+ [
113
+ {
114
+ check: CHECK_CONFIG,
115
+ status: :fail,
116
+ message: "Configuration warnings: #{messages.size}",
117
+ hint: "Review and fix the flagged entries in your config file."
118
+ }
119
+ ]
120
+ end
121
+
122
+ def check_rbs_environment(result)
123
+ stats = result.stats
124
+ return [] unless stats
125
+
126
+ if stats.rbs_classes_total.zero?
127
+ [
128
+ {
129
+ check: CHECK_RBS,
130
+ status: :fail,
131
+ message: "RBS environment is empty (#{stats.rbs_classes_total} classes available)",
132
+ hint: "The RBS environment failed to build or loaded no signatures. " \
133
+ "Check `signature_paths:` for duplicate declarations, or run " \
134
+ "`rbs collection install` for gem signatures."
135
+ }
136
+ ]
137
+ else
138
+ [
139
+ {
140
+ check: CHECK_RBS,
141
+ status: :pass,
142
+ message: "RBS environment healthy (#{stats.rbs_classes_total} classes available)",
143
+ hint: nil
144
+ }
145
+ ]
146
+ end
147
+ end
148
+
149
+ def check_plugins(configuration)
150
+ services = Plugin::Services.new(
151
+ reflection: Reflection,
152
+ type: Type::Combinator,
153
+ configuration: configuration,
154
+ cache_store: nil
155
+ )
156
+ registry = Plugin::Loader.load(configuration: configuration, services: services)
157
+ errors = registry.load_errors
158
+ return [] if errors.empty?
159
+
160
+ [
161
+ {
162
+ check: CHECK_PLUGINS,
163
+ status: :fail,
164
+ message: "Plugin load errors: #{errors.size}",
165
+ hint: "Run `rigor plugins --strict` for the full per-plugin report."
166
+ }
167
+ ]
168
+ end
169
+
170
+ def check_baseline(configuration, result)
171
+ path = configuration.baseline_path
172
+ return [] if path.nil? || !File.exist?(path)
173
+
174
+ baseline = Analysis::Baseline.load(path, project_root: Dir.pwd)
175
+ return [] if baseline.nil? || baseline.empty?
176
+
177
+ drifted = baseline.audit(result.diagnostics).reject { |row| row.status == :within }
178
+ return [] if drifted.empty?
179
+
180
+ [
181
+ {
182
+ check: CHECK_BASELINE,
183
+ status: :fail,
184
+ message: "Baseline drift detected (#{baseline_drift_summary(drifted)})",
185
+ hint: "Run `rigor baseline regenerate` to refresh the baseline."
186
+ }
187
+ ]
188
+ rescue Analysis::Baseline::LoadError, Psych::SyntaxError => e
189
+ [
190
+ {
191
+ check: CHECK_BASELINE,
192
+ status: :warn,
193
+ message: "Baseline load failed: #{e.message}",
194
+ hint: "Check the baseline file path in your config."
195
+ }
196
+ ]
197
+ end
198
+
199
+ def baseline_drift_summary(drifted)
200
+ counts = drifted.group_by(&:status).transform_values(&:size)
201
+ parts = []
202
+ parts << "#{counts.fetch(:over, 0)} over threshold" if counts.key?(:over)
203
+ parts << "#{counts.fetch(:cleared, 0)} cleared" if counts.key?(:cleared)
204
+ parts << "#{counts.fetch(:reducible, 0)} reducible" if counts.key?(:reducible)
205
+ parts.join(", ")
206
+ end
207
+
208
+ def check_rails_plugins(configuration)
209
+ return [] unless rails_unconfigured?(configuration)
210
+
211
+ [
212
+ {
213
+ check: CHECK_RAILS,
214
+ status: :fail,
215
+ message: "Rails detected but no Rails plugins enabled",
216
+ hint: "Add Rails plugins (e.g. rigor-activerecord, rigor-actionpack) to " \
217
+ "`.rigor.yml` `plugins:` so framework calls resolve."
218
+ }
219
+ ]
220
+ end
221
+
222
+ # True when Rails is in Gemfile.lock but the config enables no
223
+ # Rails plugin — the same probe {ProjectStateProbe} uses.
224
+ def rails_unconfigured?(_configuration)
225
+ config_path = Configuration.discover
226
+ return false if config_path.nil?
227
+ return false unless File.file?(config_path)
228
+
229
+ lock = File.join(Dir.pwd, "Gemfile.lock")
230
+ return false unless File.file?(lock) && file_mentions_any?(lock, RAILS_LOCK_MARKERS)
231
+
232
+ !file_mentions_any?(config_path, RAILS_PLUGIN_MARKERS)
233
+ end
234
+
235
+ def file_mentions_any?(path, markers)
236
+ content = File.read(path)
237
+ markers.any? { |marker| content.include?(marker) }
238
+ rescue StandardError
239
+ false
240
+ end
241
+
242
+ # ------------------------------------------------------------------
243
+ # Reporting
244
+ # ------------------------------------------------------------------
245
+
246
+ def report(findings, format)
247
+ case format
248
+ when "json" then report_json(findings)
249
+ else report_text(findings)
250
+ end
251
+ end
252
+
253
+ def report_json(findings)
254
+ payload = {
255
+ "status" => findings.any? { |f| f[:status] == :fail } ? "issues_found" : "clean",
256
+ "checks" => findings.map do |f|
257
+ {
258
+ "id" => f[:check].to_s,
259
+ "status" => f[:status].to_s,
260
+ "message" => f[:message],
261
+ "hint" => f[:hint]
262
+ }
263
+ end
264
+ }
265
+ @out.puts(JSON.pretty_generate(payload))
266
+ end
267
+
268
+ def report_text(findings)
269
+ failures = findings.select { |f| f[:status] == :fail }
270
+ warnings = findings.select { |f| f[:status] == :warn }
271
+ passes = findings.select { |f| f[:status] == :pass }
272
+
273
+ if failures.empty? && warnings.empty?
274
+ @out.puts("rigor doctor: all checks passed — no setup problems detected.")
275
+ else
276
+ @out.puts("rigor doctor: #{failures.size} issue(s) found")
277
+ failures.each { |f| print_finding(f) }
278
+ warnings.each { |f| print_finding(f) } unless warnings.empty?
279
+ end
280
+
281
+ passes.each { |f| print_finding(f) } unless passes.empty?
282
+ end
283
+
284
+ def print_finding(finding)
285
+ label = case finding[:status]
286
+ when :fail then "[FAIL]"
287
+ when :warn then "[WARN]"
288
+ else "[PASS]"
289
+ end
290
+ @out.puts("#{label} #{finding[:check]}: #{finding[:message]}")
291
+ @out.puts(" → #{finding[:hint]}") if finding[:hint]
292
+ end
293
+ end
294
+ end
295
+ end
@@ -37,7 +37,7 @@ module Rigor
37
37
  # `source_rbs_synthesizer:`);
38
38
  # - the ADR-37 narrow extension protocols read off the plugin
39
39
  # class — `node_rule` node types, `dynamic_return` receivers,
40
- # `type_specifier` methods.
40
+ # `narrowing_facts` methods.
41
41
  #
42
42
  # `--capabilities` switches to a focused catalogue of just the
43
43
  # narrow-protocol gate values + produced/consumed facts (ADR-37
@@ -194,7 +194,7 @@ module Rigor
194
194
 
195
195
  # ADR-37 narrow extension protocols. Unlike the 10 declarative
196
196
  # manifest fields, these are class-level DSLs (`node_rule` /
197
- # `dynamic_return` / `type_specifier`), so they are read off the
197
+ # `dynamic_return` / `narrowing_facts`), so they are read off the
198
198
  # plugin class rather than the manifest. The gate values — node
199
199
  # types, receiver class names, specified method names — are the
200
200
  # greppable, enumerable surface the capability catalogue exposes.
@@ -170,7 +170,7 @@ module Rigor
170
170
  end
171
171
 
172
172
  # ADR-37 narrow extension protocols (node_rule / dynamic_return /
173
- # type_specifier). Surfaced in the full report alongside the
173
+ # narrowing_facts). Surfaced in the full report alongside the
174
174
  # declarative surfaces; `--capabilities` is the focused view.
175
175
  def narrow_protocol_lines(row)
176
176
  lines = []
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "json"
4
4
 
5
+ require_relative "../inference/dynamic_origin"
6
+
5
7
  module Rigor
6
8
  class CLI
7
9
  # Renders an {ProtectionReport} (ADR-63 Tier 1) as text or JSON. The text
@@ -12,6 +14,15 @@ module Rigor
12
14
  TOP_CALLS = 15
13
15
  TOP_FILES = 10
14
16
 
17
+ # ADR-73 P6 / ADR-75 WD2 — the actionable axis for each tractability
18
+ # category, shown next to a hole's origin so a user knows whether (and
19
+ # how) a type can close it.
20
+ TRACTABILITY_HINTS = {
21
+ Inference::DynamicOrigin::ADD_RBS => "add RBS",
22
+ Inference::DynamicOrigin::ENABLE_PLUGIN => "enable a plugin / pre_eval",
23
+ Inference::DynamicOrigin::ENGINE_GAP => "engine gap — report it"
24
+ }.freeze
25
+
15
26
  def initialize(out:)
16
27
  @out = out
17
28
  end
@@ -40,13 +51,32 @@ module Rigor
40
51
  return if calls.empty?
41
52
 
42
53
  @out.puts "\nAdd a type here — methods most often called on an untyped receiver:"
54
+ render_tractability_summary(report)
43
55
  calls.first(TOP_CALLS).each do |call|
44
- @out.puts format(" %<count>-5d #%<method>s e.g. %<sites>s",
45
- count: call.count, method: call.method_name, sites: call.examples.join(" "))
56
+ label = origin_label(call.dynamic_origin)
57
+ @out.puts format(" %<count>-5d #%<method>s e.g. %<sites>s%<label>s",
58
+ count: call.count, method: call.method_name,
59
+ sites: call.examples.join(" "), label: label)
46
60
  end
47
61
  @out.puts " (#{calls.size - TOP_CALLS} more)" if calls.size > TOP_CALLS
48
62
  end
49
63
 
64
+ def render_tractability_summary(report)
65
+ summary = report.tractability_summary
66
+ return if summary.empty?
67
+
68
+ parts = summary.sort_by { |_, n| -n }.map { |axis, n| "#{n} #{axis.to_s.tr('_', '-')}" }
69
+ @out.puts " by tractability: #{parts.join(' · ')} (add-rbs = closable with a type)"
70
+ end
71
+
72
+ def origin_label(origin)
73
+ return "" if origin.nil?
74
+
75
+ tractability = Inference::DynamicOrigin.tractability(origin)
76
+ hint = tractability ? " → #{TRACTABILITY_HINTS[tractability]}" : ""
77
+ " [#{origin.to_s.tr('_', '-')}#{hint}]"
78
+ end
79
+
50
80
  def render_files(report)
51
81
  worst = report.files.reject { |f| f.unprotected_count.zero? }.sort_by(&:ratio).first(TOP_FILES)
52
82
  return if worst.empty?
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../inference/dynamic_origin"
4
+
3
5
  module Rigor
4
6
  class CLI
5
7
  # ADR-63 Tier 1 — aggregates per-file {Inference::ProtectionScanner}
@@ -9,7 +11,7 @@ module Rigor
9
11
  # untyped dispatches, where a receiver annotation buys the most catching
10
12
  # power.
11
13
  FileProtection = Data.define(:path, :protected_count, :unprotected_count, :ratio)
12
- UntypedCall = Data.define(:method_name, :count, :examples)
14
+ UntypedCall = Data.define(:method_name, :count, :examples, :dynamic_origin)
13
15
 
14
16
  ProtectionReport = Data.define(:files, :untyped_calls, :parse_errors) do
15
17
  def total_protected = files.sum(&:protected_count)
@@ -17,17 +19,37 @@ module Rigor
17
19
  def grand_total = total_protected + total_unprotected
18
20
  def ratio = grand_total.zero? ? 1.0 : total_protected.to_f / grand_total
19
21
 
22
+ # ADR-73 P6 — total dispatch-site count per tractability axis across
23
+ # the classified holes (those with a recorded `dynamic_origin`), so a
24
+ # user sees at a glance how much of the untyped surface a type can
25
+ # actually close (`add_rbs`) vs. needs a plugin (`enable_plugin`) vs.
26
+ # is an engine gap. Keys are omitted when zero; an all-unclassified
27
+ # run yields `{}`.
28
+ def tractability_summary
29
+ untyped_calls.each_with_object(Hash.new(0)) do |c, acc|
30
+ axis = c.dynamic_origin && Inference::DynamicOrigin.tractability(c.dynamic_origin)
31
+ acc[axis] += c.count if axis
32
+ end
33
+ end
34
+
20
35
  def to_h
21
36
  {
22
37
  "protected" => total_protected,
23
38
  "unprotected" => total_unprotected,
24
39
  "protection_ratio" => ratio.round(4),
40
+ "tractability_summary" => tractability_summary.transform_keys(&:to_s),
25
41
  "files" => files.map do |f|
26
42
  { "path" => f.path, "protected" => f.protected_count,
27
43
  "unprotected" => f.unprotected_count, "ratio" => f.ratio.round(4) }
28
44
  end,
29
45
  "add_a_type_here" => untyped_calls.map do |c|
30
- { "method" => c.method_name, "count" => c.count, "examples" => c.examples }
46
+ entry = { "method" => c.method_name, "count" => c.count, "examples" => c.examples }
47
+ if c.dynamic_origin
48
+ entry["dynamic_origin"] = c.dynamic_origin
49
+ tractability = Inference::DynamicOrigin.tractability(c.dynamic_origin)
50
+ entry["tractability"] = tractability if tractability
51
+ end
52
+ entry
31
53
  end,
32
54
  "parse_errors" => parse_errors
33
55
  }
@@ -37,7 +59,7 @@ module Rigor
37
59
  class ProtectionAccumulator
38
60
  def initialize
39
61
  @files = []
40
- @calls = Hash.new { |h, k| h[k] = { count: 0, examples: [] } }
62
+ @calls = Hash.new { |h, k| h[k] = { count: 0, examples: [], origins: Hash.new(0) } }
41
63
  @parse_errors = []
42
64
  end
43
65
 
@@ -50,6 +72,7 @@ module Rigor
50
72
  bucket = @calls[site.method_name]
51
73
  bucket[:count] += 1
52
74
  bucket[:examples] << "#{path}:#{site.line}" if bucket[:examples].size < 3
75
+ bucket[:origins][site.dynamic_origin] += 1 if site.dynamic_origin
53
76
  end
54
77
  end
55
78
 
@@ -58,9 +81,12 @@ module Rigor
58
81
  end
59
82
 
60
83
  def to_report
61
- untyped = @calls
62
- .map { |method, v| UntypedCall.new(method_name: method, count: v[:count], examples: v[:examples]) }
63
- .sort_by { |c| [-c.count, c.method_name] }
84
+ calls = @calls.map do |method, v|
85
+ origin = v[:origins].max_by { |_, count| count }&.first
86
+ UntypedCall.new(method_name: method, count: v[:count], examples: v[:examples],
87
+ dynamic_origin: origin)
88
+ end
89
+ untyped = calls.sort_by { |c| [-c.count, c.method_name] }
64
90
  ProtectionReport.new(files: @files, untyped_calls: untyped, parse_errors: @parse_errors)
65
91
  end
66
92
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "command"
4
+
5
+ module Rigor
6
+ class CLI
7
+ # `rigor upgrade` — the ADR-50 WD7 migration command skeleton.
8
+ #
9
+ # The real body lands when a concrete backwards-compatibility break
10
+ # gives it a target (e.g. re-running `baseline regenerate` against a
11
+ # strengthened default profile, surfacing renamed suppression ids,
12
+ # reporting `bleeding_edge:` graduations). Until then it prints the
13
+ # current version and notes that upgrade is queued.
14
+ class UpgradeCommand < Command
15
+ USAGE = "Usage: rigor upgrade [options]"
16
+
17
+ # @return [Integer] CLI exit status.
18
+ def run
19
+ @out.puts("rigor upgrade: No migration target available yet (ADR-50 WD7, queued).")
20
+ @out.puts("Current version: #{Rigor::VERSION}")
21
+ 0
22
+ end
23
+ end
24
+ end
25
+ end
data/lib/rigor/cli.rb CHANGED
@@ -43,7 +43,9 @@ module Rigor
43
43
  "skill" => :run_skill,
44
44
  "describe" => :run_describe,
45
45
  "docs" => :run_docs,
46
- "show-bleedingedge" => :run_show_bleedingedge
46
+ "show-bleedingedge" => :run_show_bleedingedge,
47
+ "doctor" => :run_doctor,
48
+ "upgrade" => :run_upgrade
47
49
  }.freeze
48
50
 
49
51
  def self.start(argv = ARGV, out: $stdout, err: $stderr)
@@ -317,6 +319,18 @@ module Rigor
317
319
  CLI::ShowBleedingedgeCommand.new(argv: @argv, out: @out, err: @err).run
318
320
  end
319
321
 
322
+ def run_doctor
323
+ require_relative "cli/doctor_command"
324
+
325
+ CLI::DoctorCommand.new(argv: @argv, out: @out, err: @err).run
326
+ end
327
+
328
+ def run_upgrade
329
+ require_relative "cli/upgrade_command"
330
+
331
+ CLI::UpgradeCommand.new(argv: @argv, out: @out, err: @err).run
332
+ end
333
+
320
334
  def help
321
335
  <<~HELP
322
336
  Usage: rigor <command> [options]
@@ -342,6 +356,8 @@ module Rigor
342
356
  skill Recommend the next skill + list/print bundled Agent Skills (skill describe, skill <name>)
343
357
  docs Print the bundled docs offline (docs <name>, docs --list)
344
358
  show-bleedingedge Show the bleeding-edge overlay + what your config adopts (ADR-50)
359
+ doctor Classify setup problems vs clean run with routed next actions (ADR-77)
360
+ upgrade Migration command skeleton (ADR-50 WD7, queued)
345
361
  version Print the Rigor version
346
362
  help Print this help
347
363
  HELP
@@ -18,7 +18,7 @@ module Rigor
18
18
  # (`Rigor::RbsExtended::PredicateEffect`).
19
19
  # 3. RBS::Extended `assert*` directives
20
20
  # (`Rigor::RbsExtended::AssertEffect`).
21
- # 4. Plugin contributions via `type_specifier` DSL (ADR-52).
21
+ # 4. Plugin contributions via `narrowing_facts` DSL (ADR-52).
22
22
  #
23
23
  # Each of those four carriers translates to / from Fact at
24
24
  # its boundary; downstream of {Rigor::FlowContribution#to_element_list}
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Inference
5
+ # ADR-75 v1 cause set — precision-additive provenance for Dynamic[T]
6
+ # introduction sites. Each symbol identifies a distinct family of
7
+ # reason that an expression widened to Dynamic, surfaced by
8
+ # `rigor coverage --protection` so users can distinguish tractable
9
+ # holes (e.g. an explicit `untyped` RBS signature) from intractable
10
+ # ones (e.g. unsupported syntax).
11
+ #
12
+ # Provenance is a side-channel: it never participates in subtyping,
13
+ # consistency, normalization, or erasure, and no diagnostic fires
14
+ # from it.
15
+ module DynamicOrigin
16
+ # A gem with no resolvable RBS.
17
+ EXTERNAL_GEM_WITHOUT_RBS = :external_gem_without_rbs
18
+ # Value across macro/DSL expansion or plugin-declared dynamic return.
19
+ FRAMEWORK_DSL_BOUNDARY = :framework_dsl_boundary
20
+ # Budget/fuel guard widened to Dynamic.
21
+ ANALYZER_BUDGET_CUTOFF = :analyzer_budget_cutoff
22
+ # Authored `untyped` contract.
23
+ EXPLICIT_UNTYPED = :explicit_untyped
24
+ # Inference fallback on unmodeled construct.
25
+ UNSUPPORTED_SYNTAX = :unsupported_syntax
26
+
27
+ CAUSES = [
28
+ EXTERNAL_GEM_WITHOUT_RBS,
29
+ FRAMEWORK_DSL_BOUNDARY,
30
+ ANALYZER_BUDGET_CUTOFF,
31
+ EXPLICIT_UNTYPED,
32
+ UNSUPPORTED_SYNTAX
33
+ ].freeze
34
+
35
+ # ADR-73 P6 / ADR-75 WD2 — tractability: given the cause, can a user
36
+ # close this `Dynamic` hole, and on which axis. `coverage --protection`
37
+ # surfaces it so a user does not chase a hole a hand-written type
38
+ # cannot fix. Three coarse, action-oriented categories:
39
+ #
40
+ # - `:add_rbs` — write or install RBS (a missing gem signature,
41
+ # or an authored `untyped` to tighten).
42
+ # - `:enable_plugin`— a framework / DSL boundary; needs a plugin or
43
+ # `pre_eval:`, not a hand-written type.
44
+ # - `:engine_gap` — not user-closable (a budget cutoff or unmodeled
45
+ # syntax); report it.
46
+ ADD_RBS = :add_rbs
47
+ ENABLE_PLUGIN = :enable_plugin
48
+ ENGINE_GAP = :engine_gap
49
+
50
+ TRACTABILITY = {
51
+ EXTERNAL_GEM_WITHOUT_RBS => ADD_RBS,
52
+ EXPLICIT_UNTYPED => ADD_RBS,
53
+ FRAMEWORK_DSL_BOUNDARY => ENABLE_PLUGIN,
54
+ ANALYZER_BUDGET_CUTOFF => ENGINE_GAP,
55
+ UNSUPPORTED_SYNTAX => ENGINE_GAP
56
+ }.freeze
57
+
58
+ module_function
59
+
60
+ # @return [Symbol, nil] the tractability category for a cause, or nil
61
+ # when the cause is unknown / absent.
62
+ def tractability(cause)
63
+ TRACTABILITY[cause]
64
+ end
65
+ end
66
+ end
67
+ end