rigortype 0.2.5 → 0.2.7

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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -3
  3. data/docs/handbook/09-plugins.md +5 -2
  4. data/docs/handbook/appendix-liskov.md +5 -3
  5. data/docs/handbook/appendix-phpstan.md +2 -2
  6. data/docs/install.md +1 -1
  7. data/docs/manual/02-cli-reference.md +60 -2
  8. data/docs/manual/06-baseline.md +12 -0
  9. data/docs/manual/08-skills.md +21 -0
  10. data/docs/manual/11-ci.md +6 -6
  11. data/docs/manual/15-type-protection-coverage.md +29 -0
  12. data/docs/manual/plugins/rigor-minitest.md +1 -1
  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/coverage_command.rb +42 -10
  16. data/lib/rigor/cli/doctor_command.rb +295 -0
  17. data/lib/rigor/cli/plugins_command.rb +2 -2
  18. data/lib/rigor/cli/plugins_renderer.rb +1 -1
  19. data/lib/rigor/cli/protection_renderer.rb +32 -2
  20. data/lib/rigor/cli/protection_report.rb +32 -6
  21. data/lib/rigor/cli/skill_command.rb +52 -1
  22. data/lib/rigor/cli/upgrade_command.rb +25 -0
  23. data/lib/rigor/cli.rb +17 -1
  24. data/lib/rigor/environment/rbs_loader.rb +28 -0
  25. data/lib/rigor/flow_contribution/fact.rb +1 -1
  26. data/lib/rigor/inference/dynamic_origin.rb +67 -0
  27. data/lib/rigor/inference/expression_typer.rb +22 -10
  28. data/lib/rigor/inference/fallback.rb +2 -2
  29. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +16 -0
  30. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +41 -2
  31. data/lib/rigor/inference/method_dispatcher.rb +19 -4
  32. data/lib/rigor/inference/mutation_widening.rb +18 -0
  33. data/lib/rigor/inference/protection_scanner.rb +6 -3
  34. data/lib/rigor/inference/statement_evaluator.rb +5 -8
  35. data/lib/rigor/plugin/base.rb +34 -7
  36. data/lib/rigor/plugin/registry.rb +1 -1
  37. data/lib/rigor/scope.rb +16 -5
  38. data/lib/rigor/sig_gen/generator.rb +25 -0
  39. data/lib/rigor/sig_gen/method_candidate.rb +7 -2
  40. data/lib/rigor/sig_gen/writer.rb +60 -13
  41. data/lib/rigor/version.rb +1 -1
  42. data/lib/rigor.rb +1 -0
  43. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +63 -2
  44. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +2 -3
  45. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +14 -24
  46. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +10 -3
  47. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +1 -1
  48. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +1 -1
  49. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +1 -1
  50. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +36 -79
  51. data/sig/rigor/plugin/base.rbs +2 -0
  52. data/sig/rigor/scope.rbs +3 -1
  53. data/skills/rigor-ask/SKILL.md +21 -1
  54. data/skills/rigor-baseline-reduce/SKILL.md +16 -0
  55. data/skills/rigor-ci-setup/SKILL.md +96 -249
  56. data/skills/rigor-doctor/SKILL.md +39 -49
  57. data/skills/rigor-doctor/references/01-checks.md +52 -0
  58. data/skills/rigor-editor-setup/SKILL.md +14 -0
  59. data/skills/rigor-mcp-setup/SKILL.md +14 -0
  60. data/skills/rigor-monkeypatch-resolve/SKILL.md +15 -0
  61. data/skills/rigor-plugin-author/SKILL.md +24 -5
  62. data/skills/rigor-plugin-author/references/02-walker-and-types.md +8 -4
  63. data/skills/rigor-plugin-review/SKILL.md +174 -0
  64. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +214 -0
  65. data/skills/rigor-plugin-tune/SKILL.md +21 -2
  66. data/skills/rigor-project-init/SKILL.md +16 -0
  67. data/skills/rigor-protection-uplift/SKILL.md +15 -0
  68. data/skills/rigor-rbs-setup/SKILL.md +15 -0
  69. data/skills/rigor-upgrade/SKILL.md +16 -0
  70. metadata +11 -4
@@ -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
@@ -25,6 +25,12 @@ module Rigor
25
25
  # inline body plus the header's absolute
26
26
  # paths let the agent act with or without
27
27
  # a file-reading tool.
28
+ # - `rigor skill --full <name>`— the body AND every `references/*.md`
29
+ # inline: the complete, version-current
30
+ # procedure in one call. The thinned SKILL
31
+ # bodies point a *frozen* (vendored) copy
32
+ # at this so the reader follows the gem's
33
+ # current steps, not a stale local copy.
28
34
  # - `rigor skill --path <name>`— one-line absolute path, for a Read tool.
29
35
  # - `rigor skill --list` — table of name + absolute path.
30
36
  # - `rigor skill --describe` — ADR-73's live entry point: a cheap
@@ -43,12 +49,14 @@ module Rigor
43
49
  # verb, so it stays first-class alongside `--describe`.
44
50
  class SkillCommand < Command
45
51
  USAGE = <<~USAGE
46
- Usage: rigor skill [<name>] [--path <name>] [--list] [--describe]
52
+ Usage: rigor skill [<name>] [--full <name>] [--path <name>] [--list] [--describe]
47
53
 
48
54
  With no argument, lists the bundled skills.
49
55
 
50
56
  rigor skill List bundled skills
51
57
  rigor skill <name> Print the SKILL.md body for <name> (with a header)
58
+ rigor skill --full <name> Print the SKILL.md body AND its references/ inline
59
+ (the complete, version-current procedure in one call)
52
60
  rigor skill --path <name> Print the absolute path of the SKILL.md file for <name>
53
61
  rigor skill --list List bundled skills (name + absolute path)
54
62
  rigor skill --describe Report project state + recommend the next skill to run
@@ -56,6 +64,7 @@ module Rigor
56
64
  Examples:
57
65
  rigor skill
58
66
  rigor skill rigor-project-init
67
+ rigor skill --full rigor-baseline-reduce
59
68
  rigor skill --path rigor-baseline-reduce
60
69
  rigor skill --describe (also: rigor describe)
61
70
 
@@ -95,6 +104,9 @@ module Rigor
95
104
  when "--list"
96
105
  @argv.shift
97
106
  run_list
107
+ when "--full"
108
+ @argv.shift
109
+ run_full(@argv.shift)
98
110
  when "--path"
99
111
  @argv.shift
100
112
  run_path(@argv.shift)
@@ -147,6 +159,34 @@ module Rigor
147
159
  0
148
160
  end
149
161
 
162
+ # `rigor skill --full <name>` — the whole current procedure in one
163
+ # call: the SKILL.md body followed by each `references/*.md` inline.
164
+ # This is what the thinned SKILL bodies point a *frozen* copy at — a
165
+ # vendored copy of a skill (installed via `npx skills`) may lag the
166
+ # gem, so re-fetching the complete body here guarantees the reader
167
+ # follows the version that shipped with the installed Rigor, without
168
+ # needing a file-reading tool or reading a possibly-stale co-located
169
+ # `references/`.
170
+ def run_full(name)
171
+ return usage_error("`--full` requires a skill name") if name.nil?
172
+
173
+ skill = find_skill(name)
174
+ return name_error(name) if skill.nil?
175
+
176
+ @out.puts(render_print_header(skill))
177
+ @out.puts
178
+ @out.write(File.read(skill.fetch(:path)))
179
+
180
+ reference_files(skill).each do |ref|
181
+ @out.puts
182
+ @out.puts
183
+ @out.puts("<!-- ===== references/#{File.basename(ref)} (bundled with rigortype #{Rigor::VERSION}) ===== -->")
184
+ @out.puts
185
+ @out.write(File.read(ref))
186
+ end
187
+ 0
188
+ end
189
+
150
190
  def run_path(name)
151
191
  return usage_error("`--path` requires a skill name") if name.nil?
152
192
 
@@ -207,6 +247,17 @@ module Rigor
207
247
  discover_skills.find { |s| s.fetch(:name) == name }
208
248
  end
209
249
 
250
+ # The `references/*.md` files bundled alongside a skill, sorted so the
251
+ # `NN-` prefixes drive read order.
252
+ def reference_files(skill)
253
+ dir = File.join(File.dirname(skill.fetch(:path)), "references")
254
+ return [] unless File.directory?(dir)
255
+
256
+ # `Dir.glob` returns lexicographically sorted paths (Ruby 3.0+),
257
+ # so the `NN-` prefixes already drive read order.
258
+ Dir.glob(File.join(dir, "*.md"))
259
+ end
260
+
210
261
  def name_error(name)
211
262
  @err.puts("Unknown skill: #{name}")
212
263
  @err.puts("Available skills (try `rigor skill --list`):")
@@ -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
@@ -291,12 +291,29 @@ module Rigor
291
291
  # prefix of another name is declared `module` (it is a
292
292
  # namespace); a leaf is declared `class` (referenced types
293
293
  # appear in instance position far more often than as mixins).
294
+ #
295
+ # Names already declared in `base_env` are skipped — exactly the
296
+ # `declared.include?` guard {.collect_missing_namespaces} applies.
297
+ # Without it, stubbing a nested reference (`Foo::Bar::Baz`) re-emits
298
+ # its enclosing prefix (`Foo::Bar`) as a `module`, and when that
299
+ # prefix is already a `class` in the project's own `sig/` the kind
300
+ # mismatch makes `resolve_type_names` raise
301
+ # `RBS::DuplicatedDeclarationError`, collapsing the WHOLE env to nil
302
+ # (every type-of query then degrades to `Dynamic[Top]`). One
303
+ # malformed `.rbs` must not disproportionately blind the analysis:
304
+ # a subclass sig that references an inherited nested type
305
+ # (`class GitAdapter; def x: () -> GitAdapter::Revision`) was the
306
+ # real-world trigger — see the 2026-07-04 redmine onboarding note.
294
307
  def append_stub_declarations(base_env, missing)
308
+ declared = declared_type_names(base_env)
295
309
  names = missing.to_set
296
310
  missing.each do |name|
297
311
  parts = name.split("::")
298
312
  (1...parts.length).each { |i| names << parts[0, i].join("::") }
299
313
  end
314
+ names = names.reject { |name| declared.include?(name) }.to_set
315
+ return if names.empty?
316
+
300
317
  source = names.sort_by { |n| n.count(":") }.map do |name|
301
318
  keyword = names.any? { |other| other != name && other.start_with?("#{name}::") } ? "module" : "class"
302
319
  "#{keyword} #{name}\nend\n"
@@ -308,6 +325,17 @@ module Rigor
308
325
  nil
309
326
  end
310
327
 
328
+ # The `::`-stripped names of every class / module / class-alias
329
+ # declaration already present in `env`, so the synthesis paths
330
+ # never re-declare (and thereby duplicate) a real declaration.
331
+ def declared_type_names(env)
332
+ names = env.class_decls.keys.map { |n| n.to_s.sub(/\A::/, "") }
333
+ if env.respond_to?(:class_alias_decls)
334
+ names.concat(env.class_alias_decls.keys.map { |n| n.to_s.sub(/\A::/, "") })
335
+ end
336
+ names.to_set
337
+ end
338
+
311
339
  # ADR-32 WD4 — merge synthesised-from-source RBS strings
312
340
  # into the freshly-built environment. Each entry is a
313
341
  # `[virtual_filename, rbs_source]` pair. `virtual_filename`