rigortype 0.2.0 → 0.2.2

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 (142) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +82 -20
  3. data/data/core_overlay/numeric.rbs +33 -0
  4. data/data/core_overlay/pathname.rbs +25 -0
  5. data/data/core_overlay/string_scanner.rbs +28 -0
  6. data/data/gem_overlay/activesupport/core_ext.rbs +473 -0
  7. data/data/vendored_gem_sigs/ast/ast.rbs +130 -0
  8. data/data/vendored_gem_sigs/bcrypt/bcrypt.rbs +47 -0
  9. data/data/vendored_gem_sigs/bundler/bundler.rbs +238 -0
  10. data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +34 -0
  11. data/data/vendored_gem_sigs/did_you_mean/did_you_mean_extras.rbs +34 -0
  12. data/data/vendored_gem_sigs/idn-ruby/idn.rbs +54 -0
  13. data/data/vendored_gem_sigs/mysql2/client.rbs +55 -0
  14. data/data/vendored_gem_sigs/mysql2/error.rbs +5 -0
  15. data/data/vendored_gem_sigs/mysql2/result.rbs +31 -0
  16. data/data/vendored_gem_sigs/mysql2/statement.rbs +5 -0
  17. data/data/vendored_gem_sigs/nokogiri/nokogiri.rbs +2332 -0
  18. data/data/vendored_gem_sigs/nokogiri/nokogiri_html5.rbs +47 -0
  19. data/data/vendored_gem_sigs/pg/pg.rbs +212 -0
  20. data/data/vendored_gem_sigs/prism/prism_supplement.rbs +44 -0
  21. data/data/vendored_gem_sigs/redis/errors.rbs +50 -0
  22. data/data/vendored_gem_sigs/redis/future.rbs +5 -0
  23. data/data/vendored_gem_sigs/redis/redis.rbs +348 -0
  24. data/data/vendored_gem_sigs/redis/redis_extras.rbs +130 -0
  25. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +226 -0
  26. data/docs/handbook/01-getting-started.md +311 -0
  27. data/docs/handbook/02-everyday-types.md +337 -0
  28. data/docs/handbook/03-narrowing.md +359 -0
  29. data/docs/handbook/04-tuples-and-shapes.md +321 -0
  30. data/docs/handbook/05-methods-and-blocks.md +339 -0
  31. data/docs/handbook/06-classes.md +305 -0
  32. data/docs/handbook/07-rbs-and-extended.md +427 -0
  33. data/docs/handbook/08-understanding-errors.md +373 -0
  34. data/docs/handbook/09-plugins.md +241 -0
  35. data/docs/handbook/10-sorbet.md +347 -0
  36. data/docs/handbook/11-sig-gen.md +312 -0
  37. data/docs/handbook/12-lightweight-hkt.md +333 -0
  38. data/docs/handbook/README.md +275 -0
  39. data/docs/handbook/appendix-elixir.md +370 -0
  40. data/docs/handbook/appendix-go.md +399 -0
  41. data/docs/handbook/appendix-java-csharp.md +470 -0
  42. data/docs/handbook/appendix-liskov.md +580 -0
  43. data/docs/handbook/appendix-mypy.md +370 -0
  44. data/docs/handbook/appendix-phpstan.md +338 -0
  45. data/docs/handbook/appendix-protocols-and-structural-typing.md +292 -0
  46. data/docs/handbook/appendix-rust.md +446 -0
  47. data/docs/handbook/appendix-steep.md +336 -0
  48. data/docs/handbook/appendix-type-theory.md +1662 -0
  49. data/docs/handbook/appendix-typeprof.md +416 -0
  50. data/docs/handbook/appendix-typescript.md +332 -0
  51. data/docs/install.md +189 -0
  52. data/docs/llms.txt +72 -0
  53. data/docs/manual/01-installation.md +342 -0
  54. data/docs/manual/02-cli-reference.md +557 -0
  55. data/docs/manual/03-configuration.md +152 -0
  56. data/docs/manual/04-diagnostics.md +206 -0
  57. data/docs/manual/05-inspecting-types.md +109 -0
  58. data/docs/manual/06-baseline.md +104 -0
  59. data/docs/manual/07-plugins.md +92 -0
  60. data/docs/manual/08-skills.md +143 -0
  61. data/docs/manual/09-editor-integration.md +245 -0
  62. data/docs/manual/10-mcp-server.md +532 -0
  63. data/docs/manual/11-ci.md +274 -0
  64. data/docs/manual/12-caching.md +116 -0
  65. data/docs/manual/13-troubleshooting.md +120 -0
  66. data/docs/manual/14-rails-quickstart.md +332 -0
  67. data/docs/manual/15-type-protection-coverage.md +204 -0
  68. data/docs/manual/16-rbs-extended-annotations.md +190 -0
  69. data/docs/manual/17-driving-improvement.md +160 -0
  70. data/docs/manual/README.md +87 -0
  71. data/docs/manual/ci-templates/README.md +58 -0
  72. data/docs/manual/plugins/README.md +86 -0
  73. data/docs/manual/plugins/rigor-actioncable.md +78 -0
  74. data/docs/manual/plugins/rigor-actionmailer.md +74 -0
  75. data/docs/manual/plugins/rigor-actionpack.md +80 -0
  76. data/docs/manual/plugins/rigor-activejob.md +58 -0
  77. data/docs/manual/plugins/rigor-activerecord.md +102 -0
  78. data/docs/manual/plugins/rigor-activestorage.md +74 -0
  79. data/docs/manual/plugins/rigor-activesupport-core-ext.md +86 -0
  80. data/docs/manual/plugins/rigor-devise.md +70 -0
  81. data/docs/manual/plugins/rigor-dry-schema.md +56 -0
  82. data/docs/manual/plugins/rigor-dry-struct.md +60 -0
  83. data/docs/manual/plugins/rigor-dry-types.md +59 -0
  84. data/docs/manual/plugins/rigor-dry-validation.md +62 -0
  85. data/docs/manual/plugins/rigor-factorybot.md +76 -0
  86. data/docs/manual/plugins/rigor-graphql.md +89 -0
  87. data/docs/manual/plugins/rigor-hanami.md +83 -0
  88. data/docs/manual/plugins/rigor-mangrove.md +73 -0
  89. data/docs/manual/plugins/rigor-minitest.md +86 -0
  90. data/docs/manual/plugins/rigor-pundit.md +72 -0
  91. data/docs/manual/plugins/rigor-rails-i18n.md +92 -0
  92. data/docs/manual/plugins/rigor-rails-routes.md +94 -0
  93. data/docs/manual/plugins/rigor-rails.md +44 -0
  94. data/docs/manual/plugins/rigor-rbs-inline.md +83 -0
  95. data/docs/manual/plugins/rigor-rspec-rails.md +72 -0
  96. data/docs/manual/plugins/rigor-rspec.md +86 -0
  97. data/docs/manual/plugins/rigor-shoulda-matchers.md +78 -0
  98. data/docs/manual/plugins/rigor-sidekiq.md +78 -0
  99. data/docs/manual/plugins/rigor-sinatra.md +61 -0
  100. data/docs/manual/plugins/rigor-sorbet.md +63 -0
  101. data/docs/manual/plugins/rigor-statesman.md +75 -0
  102. data/docs/manual/plugins/rigor-typescript-utility-types.md +71 -0
  103. data/exe/rigor +1 -1
  104. data/lib/rigor/analysis/incremental_session.rb +4 -2
  105. data/lib/rigor/analysis/run_stats.rb +13 -1
  106. data/lib/rigor/analysis/runner.rb +54 -12
  107. data/lib/rigor/cli/check_command.rb +26 -3
  108. data/lib/rigor/cli/coverage_command.rb +67 -92
  109. data/lib/rigor/cli/coverage_mutation.rb +149 -0
  110. data/lib/rigor/cli/docs_command.rb +248 -0
  111. data/lib/rigor/cli/fused_protection_renderer.rb +67 -0
  112. data/lib/rigor/cli/fused_protection_report.rb +76 -0
  113. data/lib/rigor/cli/skill_command.rb +103 -41
  114. data/lib/rigor/cli/skill_describe.rb +346 -0
  115. data/lib/rigor/cli.rb +25 -3
  116. data/lib/rigor/config_audit.rb +152 -0
  117. data/lib/rigor/configuration.rb +12 -0
  118. data/lib/rigor/environment/rbs_loader.rb +27 -0
  119. data/lib/rigor/environment.rb +49 -1
  120. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +140 -38
  121. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +37 -6
  122. data/lib/rigor/inference/scope_indexer.rb +87 -89
  123. data/lib/rigor/inference/statement_evaluator.rb +27 -0
  124. data/lib/rigor/plugin/isolation.rb +5 -5
  125. data/lib/rigor/plugin/loader.rb +4 -2
  126. data/lib/rigor/protection/diagnostic_oracle.rb +51 -0
  127. data/lib/rigor/protection/mutation_scanner.rb +98 -38
  128. data/lib/rigor/protection/mutator.rb +21 -0
  129. data/lib/rigor/protection/test_suite_oracle.rb +68 -0
  130. data/lib/rigor/signature_path_audit.rb +92 -0
  131. data/lib/rigor/version.rb +1 -1
  132. data/skills/rigor-ask/SKILL.md +172 -0
  133. data/skills/rigor-doctor/SKILL.md +87 -0
  134. data/skills/rigor-editor-setup/SKILL.md +114 -0
  135. data/skills/rigor-mcp-setup/SKILL.md +117 -0
  136. data/skills/rigor-monkeypatch-resolve/SKILL.md +79 -0
  137. data/skills/rigor-next-steps/SKILL.md +113 -0
  138. data/skills/rigor-plugin-tune/SKILL.md +79 -0
  139. data/skills/rigor-protection-uplift/SKILL.md +133 -0
  140. data/skills/rigor-rbs-setup/SKILL.md +128 -0
  141. data/skills/rigor-upgrade/SKILL.md +79 -0
  142. metadata +120 -1
@@ -2,8 +2,8 @@
2
2
 
3
3
  require "prism"
4
4
 
5
- require_relative "../analysis/runner"
6
5
  require_relative "mutator"
6
+ require_relative "diagnostic_oracle"
7
7
 
8
8
  module Rigor
9
9
  module Protection
@@ -15,17 +15,16 @@ module Rigor
15
15
  # Mechanism (the ADR-62 warm loop, narrowed to per-file measurement):
16
16
  # generate the type-visible mutations ({Mutator}), keep only those whose
17
17
  # receiver Rigor holds a concrete type for (the type-aware filter — the
18
- # FP-safe meaning-maker; an unresolved receiver is kept), then for each:
19
- # re-analyse the mutated SOURCE against a clean baseline and read whether a
20
- # NEW diagnostic appears. A *killed* mutation is a caught breakage; a
21
- # *survived* one is a breakage Rigor missed an "add a type here" site.
18
+ # FP-safe meaning-maker; an unresolved receiver is kept), then for each ask
19
+ # the **kill oracle** whether the mutant is caught. The oracle is the ADR-69
20
+ # seam: {#scan_file} uses the {DiagnosticOracle} (a *new Rigor diagnostic* =
21
+ # a kill); {#scan_file_fused} additionally consults a {TestSuiteOracle} on the
22
+ # type-survivors (ADR-70 — the dynamic protection axis).
22
23
  #
23
24
  # The expensive builds (RBS environment + the whole-project pre-pass scan)
24
- # are paid ONCE by the caller and threaded in via `environment:` /
25
- # `project_scan:`; each mutant reuses them through
26
- # `Runner.new(prebuilt:)#run_source` (in-memory overlay, no disk write).
27
- # Passing `prebuilt:` disables the run-result cache (whose key digests the
28
- # *disk* file), so a mutant is never served a stale clean hit.
25
+ # are paid ONCE by the caller and threaded into the {DiagnosticOracle}; each
26
+ # mutant reuses them through `Runner.new(prebuilt:)#run_source` (in-memory
27
+ # overlay, no disk write).
29
28
  class MutationScanner
30
29
  # A surviving mutation site — a breakage Rigor did not catch.
31
30
  SurvivingSite = Data.define(:line, :receiver, :method_name, :operator)
@@ -39,18 +38,46 @@ module Rigor
39
38
  def ratio = total.zero? ? 1.0 : killed.to_f / total
40
39
  end
41
40
 
41
+ # ADR-70 — one type-survivor classified by the dynamic (test) axis.
42
+ # `protection` is `:test` (a test caught it) or `:none` (unprotected — the
43
+ # "add a type OR a test here" sites).
44
+ FusedSite = Data.define(:line, :receiver, :method_name, :operator, :protection)
45
+
46
+ # ADR-70 — the per-file fused classification. The gradual short-circuit
47
+ # collapses the conceptual "doubly-protected" bucket into `type_killed`:
48
+ # a mutant the type checker already kills never reaches the suite, because
49
+ # the static net already suffices and re-running the suite to learn a test
50
+ # *would also* catch it is wasted work. So the observed buckets are three.
51
+ FusedFileResult = Data.define(:path, :type_killed, :test_killed, :sites) do
52
+ # The unprotected sites (neither a type nor a test caught the breakage).
53
+ def unprotected = sites.size
54
+ def total = type_killed + test_killed + unprotected
55
+
56
+ # Fused protected ratio — caught by *either* axis.
57
+ def ratio = total.zero? ? 1.0 : (type_killed + test_killed).to_f / total
58
+ end
59
+
42
60
  # @param configuration [Rigor::Configuration]
43
61
  # @param environment [Rigor::Environment] pre-built once by the caller
44
62
  # @param project_scan [Rigor::Analysis::ProjectScan] pre-built once
45
63
  # @param limit [Integer, nil] optional per-file mutation cap (sampled with
46
64
  # `seed`); nil analyses every type-relevant mutation (deterministic).
47
65
  # @param seed [Integer] RNG seed for the optional sample.
48
- def initialize(configuration:, environment:, project_scan:, limit: nil, seed: 1)
49
- @configuration = configuration
66
+ # @param oracle [#baseline, #killed?, nil] the kill oracle (ADR-69 Seam 1);
67
+ # defaults to the {DiagnosticOracle} (the ADR-62/63 behaviour).
68
+ # @param site_selector [:biteable, :all] which sites to mutate (ADR-69
69
+ # Seam 2). `:biteable` (default) keeps only concrete-type sites Rigor can
70
+ # bite; `:all` also mutates Dynamic-receiver dispatch sites — use only
71
+ # with a {TestSuiteOracle} (the fused overlay), never the diagnostic path.
72
+ def initialize(configuration:, environment:, project_scan:, limit: nil, seed: 1, oracle: nil,
73
+ site_selector: :biteable)
50
74
  @environment = environment
51
- @project_scan = project_scan
52
75
  @limit = limit
53
76
  @seed = seed
77
+ @site_selector = site_selector
78
+ @oracle = oracle || DiagnosticOracle.new(
79
+ configuration: configuration, environment: environment, project_scan: project_scan
80
+ )
54
81
  end
55
82
 
56
83
  # @param path [String] the file to measure (used as the in-memory bind path)
@@ -58,21 +85,13 @@ module Rigor
58
85
  # @return [FileResult]
59
86
  def scan_file(path, source: nil)
60
87
  source ||= File.read(path, encoding: Encoding::UTF_8)
61
- mutator = Mutator.new(source)
62
- kept, = mutator.filter_by_type(mutator.mutations, environment: @environment, path: path)
63
- kept = sample(kept)
88
+ kept = kept_mutations(source, path)
64
89
  return FileResult.new(path: path, killed: 0, survived: 0, sites: []) if kept.empty?
65
90
 
66
- baseline = signatures(analyse(source, path))
67
- measure(source, path, kept, baseline)
68
- end
69
-
70
- private
71
-
72
- def measure(source, path, mutations, baseline)
91
+ baseline = @oracle.baseline(source: source, path: path)
73
92
  killed = 0
74
93
  sites = []
75
- mutations.each do |mut|
94
+ kept.each do |mut|
76
95
  case classify(source, path, mut, baseline)
77
96
  when :killed then killed += 1
78
97
  when :survived then sites << surviving_site(mut)
@@ -82,39 +101,80 @@ module Rigor
82
101
  FileResult.new(path: path, killed: killed, survived: sites.size, sites: sites)
83
102
  end
84
103
 
104
+ # ADR-70 — the fused static∪dynamic measurement. Runs the type pass
105
+ # (the {DiagnosticOracle}); for every mutant the type checker did **not**
106
+ # kill, asks `test_oracle` whether the project's test suite catches it.
107
+ # The expensive suite run is paid only for type-survivors (the gradual
108
+ # short-circuit), so the cost is proportional to the protection hole.
109
+ # @param test_oracle [TestSuiteOracle]
110
+ # @return [FusedFileResult]
111
+ def scan_file_fused(path, test_oracle:, source: nil)
112
+ source ||= File.read(path, encoding: Encoding::UTF_8)
113
+ kept = kept_mutations(source, path)
114
+ return FusedFileResult.new(path: path, type_killed: 0, test_killed: 0, sites: []) if kept.empty?
115
+
116
+ baseline = @oracle.baseline(source: source, path: path)
117
+ type_killed = 0
118
+ test_killed = 0
119
+ sites = []
120
+ kept.each do |mut|
121
+ case classify(source, path, mut, baseline)
122
+ when :killed then type_killed += 1
123
+ when :survived
124
+ if test_oracle.killed?(path: path, original: source, mutant_source: mut.apply(source))
125
+ test_killed += 1
126
+ else
127
+ sites << fused_site(mut, :none)
128
+ end
129
+ # :invalid — a parse-broken mutant; not a measurement, skip it.
130
+ end
131
+ end
132
+ FusedFileResult.new(path: path, type_killed: type_killed, test_killed: test_killed, sites: sites)
133
+ end
134
+
135
+ private
136
+
137
+ # The mutations to measure: the biteable filter (concrete-type sites only;
138
+ # the FP-safe default — an unresolved receiver is kept) or, under the
139
+ # `:all` selector (ADR-69 Seam 2), every dispatch site including Dynamic
140
+ # receivers. Optionally sampled.
141
+ def kept_mutations(source, path)
142
+ mutator = Mutator.new(source)
143
+ muts = mutator.mutations
144
+ kept =
145
+ if @site_selector == :all
146
+ mutator.dispatch_site_mutations(muts, environment: @environment, path: path)
147
+ else
148
+ mutator.filter_by_type(muts, environment: @environment, path: path).first
149
+ end
150
+ sample(kept)
151
+ end
152
+
85
153
  def classify(source, path, mut, baseline)
86
154
  mutant_source = mut.apply(source)
87
155
  return :invalid unless Prism.parse(mutant_source).success?
88
156
 
89
- new_diagnostics = analyse(mutant_source, path).reject { |d| baseline.include?(sig(d)) }
90
- new_diagnostics.empty? ? :survived : :killed
157
+ @oracle.killed?(mutant_source: mutant_source, path: path, baseline: baseline) ? :killed : :survived
91
158
  rescue StandardError
92
159
  # A harness-level failure on one mutant must not abort the file.
93
160
  :invalid
94
161
  end
95
162
 
96
- # cache_store: nil + prebuilt: scan ⇒ the run cache is bypassed and the
97
- # mutant is always re-analysed against the in-memory bytes.
98
- def analyse(source, path)
99
- Rigor::Analysis::Runner.new(
100
- configuration: @configuration, environment: @environment, prebuilt: @project_scan,
101
- cache_store: nil, collect_stats: false
102
- ).run_source(source: source, path: path).diagnostics
103
- end
104
-
105
163
  def sample(mutations)
106
164
  return mutations unless @limit
107
165
 
108
166
  mutations.sample(@limit, random: Random.new(@seed))
109
167
  end
110
168
 
111
- def signatures(diagnostics) = diagnostics.to_set { |d| sig(d) }
112
- def sig(diagnostic) = [diagnostic.rule, diagnostic.path, diagnostic.line, diagnostic.column, diagnostic.message]
113
-
114
169
  def surviving_site(mut)
115
170
  SurvivingSite.new(line: mut.line, receiver: mut.anchor_type,
116
171
  method_name: mut.method_name, operator: mut.operator.to_s)
117
172
  end
173
+
174
+ def fused_site(mut, protection)
175
+ FusedSite.new(line: mut.line, receiver: mut.anchor_type, method_name: mut.method_name,
176
+ operator: mut.operator.to_s, protection: protection)
177
+ end
118
178
  end
119
179
  end
120
180
  end
@@ -98,6 +98,27 @@ module Rigor
98
98
  [kept, mutations.size - kept.size]
99
99
  end
100
100
 
101
+ # ADR-69 Seam 2 (AllSites) — keep every *dispatch-site* mutation (a method
102
+ # call or a call-argument literal), Dynamic receiver included, annotating
103
+ # the anchor type where Rigor holds one. Drops only non-dispatch literals
104
+ # (a literal outside any call — no receiver contract to violate). The
105
+ # biteable {#filter_by_type} hides exactly the Dynamic sites a test-suite
106
+ # consumer most wants to probe: where Rigor cannot bite, a test is the only
107
+ # protection. Use only with a {TestSuiteOracle} — at a Dynamic site the
108
+ # type pass can never kill, so without the test axis these are all noise.
109
+ def dispatch_site_mutations(mutations, environment:, path:)
110
+ base = Rigor::Scope.empty(environment: environment, source_path: path)
111
+ index = Rigor::Inference::ScopeIndexer.index(@parse.value, default_scope: base)
112
+ cache = {}
113
+ mutations.select do |mut|
114
+ next false if mut.method_name.nil?
115
+
116
+ _keep, type = anchor_decision(mut.anchor, index, cache)
117
+ mut.anchor_type = type
118
+ true
119
+ end
120
+ end
121
+
101
122
  private
102
123
 
103
124
  def walk(node, &blk)
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Protection
5
+ # ADR-70 — the **test-suite** kill oracle, the dynamic sibling of
6
+ # {DiagnosticOracle} on the ADR-69 seam. A mutant is killed iff applying its
7
+ # bytes to the file under test turns the project's test suite **red**. This is
8
+ # the dynamic half of the fused static∪dynamic protection map: a `Dynamic`
9
+ # site Rigor cannot bite (a type survivor) may still be fully guarded by a
10
+ # test.
11
+ #
12
+ # The suite command is the **runner hook** (`--test-command`, e.g.
13
+ # `bundle exec rake`). The runner is injectable so the decision logic is
14
+ # unit-testable without shelling out; the default shells out and reads the
15
+ # process exit status (0 = green / passed).
16
+ #
17
+ # I/O policy: {#killed?} writes the mutant to disk, runs the suite, and
18
+ # **always restores** the original bytes in an `ensure` — a normal exception
19
+ # never leaves a mutant on disk. (A hard interrupt mid-suite is the standard
20
+ # mutation-testing hazard the `ensure` cannot cover; callers running this in
21
+ # CI accept that, as `mutant` / Stryker do.)
22
+ class TestSuiteOracle
23
+ # @param command [Array<String>] the test command (the runner hook)
24
+ # @param runner [#call, nil] `runner.call(command) -> true iff the suite
25
+ # passed`. Defaults to shelling out via `system`.
26
+ def initialize(command:, runner: nil)
27
+ @command = command
28
+ @runner = runner || method(:shell_run)
29
+ end
30
+
31
+ # The baseline: the suite must pass on clean code, else "a mutant survived"
32
+ # is meaningless (every mutant would look killed, or none would). Run once
33
+ # before measuring.
34
+ def green?
35
+ @runner.call(@command)
36
+ end
37
+
38
+ # Killed iff the mutant turns the suite red. Restores `original` afterward.
39
+ # @param path [String] the file to (temporarily) overwrite with the mutant
40
+ # @param original [String] the clean bytes to restore
41
+ # @param mutant_source [String] the mutated bytes to test against
42
+ def killed?(path:, original:, mutant_source:)
43
+ File.write(path, mutant_source)
44
+ !@runner.call(@command)
45
+ ensure
46
+ File.write(path, original)
47
+ end
48
+
49
+ private
50
+
51
+ # Run the suite with Bundler's environment stripped, so a `bundle exec`
52
+ # test command resolves the **target** project's Gemfile — not whatever
53
+ # bundle Rigor itself was launched under. Running Rigor via `bundle exec`
54
+ # leaks `RUBYOPT=-rbundler/setup` + `GEM_HOME` / `BUNDLE_*` into a plain
55
+ # `system` subprocess, which then resolves the target's Gemfile against
56
+ # Rigor's gems and fails — so a green suite looks red and the run aborts.
57
+ # `with_unbundled_env` restores the pre-bundler env (a bare `env -u
58
+ # BUNDLE_GEMFILE` is not enough — the `BUNDLER_ORIG_*` preservers defeat
59
+ # it). Found validating ADR-70 on real projects (2026-06-17).
60
+ def shell_run(command)
61
+ run = -> { system(*command, out: File::NULL, err: File::NULL) }
62
+ return run.call unless defined?(Bundler) && Bundler.respond_to?(:with_unbundled_env)
63
+
64
+ Bundler.with_unbundled_env(&run)
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ # Classifies each configured `signature_paths:` entry by what it
5
+ # actually contributes to the RBS environment, so a caller can warn
6
+ # when a configured path resolves to nothing.
7
+ #
8
+ # The failure this guards against is silent. {Environment::RbsLoader}
9
+ # `add`s a `signature_paths:` entry only when `path.directory?`, and
10
+ # only the `.rbs` files under it carry signatures — so a typo'd or
11
+ # moved path (or a directory holding no `.rbs`) loads zero signatures
12
+ # with no trace on stderr or in the run summary. The downstream symptom
13
+ # is the most authoritative diagnostics: every call into the extensions
14
+ # the missing RBS was meant to describe fires `call.undefined-method` at
15
+ # `evidence_tier: high`. A one-character path typo can manufacture
16
+ # hundreds of plausible-looking false positives; surfacing the empty
17
+ # entry makes the real cause visible.
18
+ #
19
+ # The audit deliberately mirrors the loader's own acceptance test
20
+ # (`path.directory?` + a recursive `**/*.rbs` glob) so a `:ok` verdict
21
+ # means the loader did load from it and a warning means it did not.
22
+ module SignaturePathAudit
23
+ # One configured `signature_paths:` entry's resolution status.
24
+ #
25
+ # `status` is one of:
26
+ # - `:ok` — a directory containing at least one `.rbs`.
27
+ # - `:missing` — the path does not exist.
28
+ # - `:not_directory` — the path exists but is not a directory (the
29
+ # loader only `add`s directories, so a `.rbs` file passed directly
30
+ # is silently ignored).
31
+ # - `:empty` — a directory with no `.rbs` file (recursive).
32
+ Entry = Data.define(:path, :status, :rbs_file_count) do
33
+ def ok?
34
+ status == :ok
35
+ end
36
+
37
+ def warning?
38
+ !ok?
39
+ end
40
+
41
+ # One-line, human-facing reason. The wording matches the loader's
42
+ # actual behaviour ("loaded nothing from it") rather than the
43
+ # filesystem error, so the message points at the consequence.
44
+ def message
45
+ case status
46
+ when :missing
47
+ "signature_paths: #{path.inspect} does not exist (no signatures loaded from it)"
48
+ when :not_directory
49
+ "signature_paths: #{path.inspect} is not a directory (no signatures loaded from it)"
50
+ when :empty
51
+ "signature_paths: #{path.inspect} matched 0 signature files"
52
+ else
53
+ "signature_paths: #{path.inspect} loaded #{rbs_file_count} signature file(s)"
54
+ end
55
+ end
56
+
57
+ def to_h
58
+ { "path" => path, "status" => status.to_s, "rbs_file_count" => rbs_file_count, "message" => message }
59
+ end
60
+ end
61
+
62
+ # Audits each configured entry. `signature_paths` is the
63
+ # {Configuration#signature_paths} array (absolute paths, already
64
+ # resolved against the config file's directory). Pass `nil` — the
65
+ # unset default, where Rigor auto-detects `<root>/sig` — to get an
66
+ # empty result: an absent auto-detected `sig/` is a normal setup, not
67
+ # a misconfiguration, so it is never audited.
68
+ #
69
+ # @param signature_paths [Array<String, Pathname>, nil]
70
+ # @return [Array<Entry>]
71
+ def self.audit(signature_paths)
72
+ Array(signature_paths).map { |path| classify(path.to_s) }
73
+ end
74
+
75
+ # The subset of {audit} that resolved to nothing — the entries worth
76
+ # warning about.
77
+ #
78
+ # @param signature_paths [Array<String, Pathname>, nil]
79
+ # @return [Array<Entry>]
80
+ def self.warnings(signature_paths)
81
+ audit(signature_paths).select(&:warning?)
82
+ end
83
+
84
+ def self.classify(path)
85
+ return Entry.new(path: path, status: :missing, rbs_file_count: 0) unless File.exist?(path)
86
+ return Entry.new(path: path, status: :not_directory, rbs_file_count: 0) unless File.directory?(path)
87
+
88
+ count = Dir.glob(File.join(path, "**", "*.rbs")).size
89
+ Entry.new(path: path, status: count.zero? ? :empty : :ok, rbs_file_count: count)
90
+ end
91
+ end
92
+ end
data/lib/rigor/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rigor
4
- VERSION = "0.2.0"
4
+ VERSION = "0.2.2"
5
5
  end
@@ -0,0 +1,172 @@
1
+ ---
2
+ name: rigor-ask
3
+ description: |
4
+ Rigor is a niche, fast-moving Ruby type checker; its rules, flags, and type behaviour are version-specific, so what you "remember" about it is likely wrong or stale — do NOT answer from memory or guess. For ANY question about Rigor, use this skill and investigate procedurally: run `rigor docs` (handbook + manual, bundled OFFLINE and version-matched), `rigor explain` for a diagnostic id, and for the user's own code `rigor check` / `annotate` / `type-of`, then answer only from what you read. Covers: why a line is flagged or if it's a false positive; the type model (narrowing, refinements, `Dynamic`, RBS); config keys, flags, baselines; comparisons to Sorbet, Steep, mypy, PHPStan; whether it handles Rails, RSpec, or a gem; writing an RBS signature; "what is Rigor / why use it / is it right for us?". Trigger on any Rigor mention seeking understanding — even casual, comparative, or grumbling. Skip only when Rigor isn't mentioned, or it's purely "set it up / fix / reduce it for me" (→ rigor-next-steps).
5
+ license: MPL-2.0
6
+ metadata:
7
+ version: 0.2.0
8
+ homepage: https://github.com/rigortype/rigor
9
+ ---
10
+
11
+ # Ask Rigor anything
12
+
13
+ Someone has a question about Rigor. It might be about a diagnostic, the
14
+ type model, a flag, how Rigor stacks up against another type checker,
15
+ whether Rigor can handle their framework, how to type a method — or just
16
+ "what is this, and should I use it?" Whatever it is, **answer from the
17
+ source, not from memory.**
18
+
19
+ Two things make that easy, and you have both offline:
20
+
21
+ - **Rigor's own docs ship inside the gem.** `rigor docs` serves the full
22
+ handbook and manual, always matching the user's installed version, no
23
+ network. This is the authoritative copy: a rule's exact firing
24
+ condition, a flag's spelling, a config default — all drift release to
25
+ release, and `rigor docs` is the copy that shipped with *this* install,
26
+ so an answer drawn from it cannot disagree with the binary they run.
27
+ - **Rigor can read the user's actual code.** When the question is about
28
+ *their* program — "why is this flagged?", "what type does Rigor see
29
+ here?", "how well-typed is my project?" — `rigor check` / `annotate` /
30
+ `type-of` / `triage` / `coverage` answer from what Rigor inferred. A
31
+ concrete inferred type beats any abstract explanation.
32
+
33
+ This is the user's shortcut: they only ever need to remember two skills —
34
+ **`rigor-next-steps`** ("what should we do next?") and **`rigor-ask`**
35
+ ("answer this about Rigor"). They ask in plain language; *you* turn it
36
+ into the right lookup or analysis so they never have to remember the
37
+ command.
38
+
39
+ ## The toolbox
40
+
41
+ Everything here is read-only and needs no network.
42
+
43
+ ### Reading the docs
44
+
45
+ | Command | Use |
46
+ | --- | --- |
47
+ | `rigor docs` | The offline doc index (`llms.txt`) — the map. Start here when you don't know which page. |
48
+ | `rigor docs --list [manual\|handbook]` | List every bundled page with its path (optionally one category). |
49
+ | `rigor docs <name>` | Print a page. `<name>` is a category-qualified path (`handbook/03-narrowing`), a prefixed basename (`03-narrowing`), or a unique short name (`narrowing`). Pages that exist in **both** trees (e.g. `plugins`) must be qualified — `manual/07-plugins` vs `handbook/09-plugins`. |
50
+ | `rigor explain <rule>` | The catalogue entry for a diagnostic id (`rigor explain call.undefined-method`) — what it means, why it fires, how to address it. |
51
+
52
+ ### Grounding the answer in the user's code
53
+
54
+ | Command | Use |
55
+ | --- | --- |
56
+ | `rigor check <path>` | Run the analysis. Scope it to a file or directory for a quick answer — don't analyse the whole project just to settle one question. `--format json` exposes structured fields (`receiver_type`, `method_name`, `evidence_tier`, …). |
57
+ | `rigor annotate <file>` | Reprint the file with the inferred type of each line in the margin — *what Rigor actually sees*. |
58
+ | `rigor type-of <file>:<line>:<col>` | The inferred type at one position. |
59
+ | `rigor triage` | Cluster the project's diagnostics by rule / receiver / method — for "what's the shape of my errors?". |
60
+ | `rigor coverage [--protection]` | Type / type-protection coverage — for "how well-typed is this?" and "where are the holes?". |
61
+ | `rigor plugins` | Which plugins are installed and enabled *here* — the honest answer to "does Rigor support <gem/framework>?". |
62
+ | `rigor sig-gen <path>` | Generate RBS for code — for "how do I type this?". Offer it and show the result; this project prefers sig-gen over hand-written RBS. |
63
+
64
+ ## Where the answer lives
65
+
66
+ Classify the question, then go to the page(s) — and, for anything about
67
+ *their* code, the command(s) — that own it. When unsure where a page is,
68
+ `rigor docs` (the index) or `rigor docs --list handbook` routes you.
69
+
70
+ | The question is about… | Go to |
71
+ | --- | --- |
72
+ | **A specific diagnostic** — "why is this flagged?", "what does this error mean?", "is this a false positive?" | `rigor explain <rule>`, then `rigor docs diagnostics`. If it's *their* code, also `rigor annotate <file>` / `rigor type-of` to see the inferred types the rule fired on. |
73
+ | **The type model / a concept** — narrowing, refinements, tuple & hash shapes, `Dynamic`, RBS interop, lightweight HKT | The handbook: `rigor docs --list handbook`, then the chapter — `handbook/03-narrowing`, `04-tuples-and-shapes`, `07-rbs-and-extended`, `12-lightweight-hkt`, … |
74
+ | **Operating Rigor** — a config key, CLI flag, baseline, plugins, CI, caching | The manual: `rigor docs configuration`, `cli-reference`, `baseline`, `manual/07-plugins`, `ci`, `caching`, `troubleshooting`. |
75
+ | **How Rigor compares to another tool** — Sorbet, Steep, RBS, TypeScript, mypy, PHPStan, TypeProf, Go, Rust, Java/C# | The chapter/appendix written for exactly that: `handbook/10-sorbet`, `appendix-steep`, `appendix-typescript`, `appendix-mypy`, `appendix-phpstan`, `appendix-typeprof`, `appendix-rust`, `appendix-go`, `appendix-java-csharp` (`rigor docs --list handbook` shows them all). |
76
+ | **Whether Rigor can do X** — generics, Rails, RSpec, a specific gem, concurrency | The handbook for the language feature; for framework/gem support, **`rigor plugins`** (what's actually available in *this* install) plus the per-plugin page `rigor docs rigor-<gem>` (e.g. `rigor docs rigor-sidekiq`) and the catalogue `rigor docs --list manual`. |
77
+ | **Writing a type / RBS** — "how do I type this?", an annotation, a signature | Handbook `07-rbs-and-extended` + `11-sig-gen`; manual `rbs-extended-annotations`. Then offer `rigor sig-gen <path>` to generate it (preferred over hand-RBS) and show the output. |
78
+ | **What Rigor is / why use it / is it right for me** | Handbook `01-getting-started` for the pitch, `handbook/02-everyday-types` for a quick mental model of the type zoo. Ground "is it right for *my* project" in a scoped `rigor check` / `rigor coverage` so they see Rigor on their real code. |
79
+
80
+ ## Answer from the page, name the page
81
+
82
+ Quote or paraphrase the relevant passage and **say which page you drew
83
+ from** (e.g. "per `rigor docs handbook/03-narrowing` …"), so the user can
84
+ re-read it with the same command. Prefer the doc's own wording over a
85
+ remembered approximation. When you ran a command against their code, show
86
+ the relevant line of output — a concrete inferred type is more convincing
87
+ than prose, and it proves the answer rather than asserting it.
88
+
89
+ ## When the question is really "do X for me"
90
+
91
+ Some questions are a task in disguise: *"how do I get Rigor into CI?"*,
92
+ *"how do I shrink this baseline?"*, *"how do I set Rigor up here?"* The
93
+ useful reply is **short**: orient the user — what the thing is, the one
94
+ decision that actually matters, the rough shape of it — then **hand the
95
+ doing to the skill built for it.** Resist pasting the full procedure
96
+ inline (the entire CI workflow YAML, the whole baseline-reduction loop):
97
+ that skill owns the steps, keeps them correct, and updates as the tool
98
+ moves, so duplicating them here only bloats the answer and drifts out of
99
+ date. The line is *explaining* the thing (yours) versus *wiring it in*
100
+ (the setup skill's).
101
+
102
+ A good hand-off is two or three sentences of orientation plus the pointer:
103
+
104
+ - setup / "what next?" → **`rigor-next-steps`** (it probes the project and routes)
105
+ - CI → **`rigor-ci-setup`** · editor → **`rigor-editor-setup`** · MCP agent → **`rigor-mcp-setup`**
106
+ - baseline reduction → **`rigor-baseline-reduce`** · coverage holes → **`rigor-protection-uplift`**
107
+ - a missing gem/DSL → **`rigor-plugin-author`** · monkey-patch clusters → **`rigor-monkeypatch-resolve`**
108
+
109
+ When in doubt, give less and point — it respects the user's "two skills
110
+ to remember" promise and keeps each answer to the part only `rigor-ask`
111
+ can give.
112
+
113
+ ## If the docs don't cover it
114
+
115
+ The bundled set is the **drive-Rigor** corpus (manual + handbook). The
116
+ normative **type specification**, the internal spec, and the ADRs are
117
+ contributor-facing and stay web-only — they are *not* in `rigor docs`; if
118
+ a question genuinely needs them, say so and point at
119
+ <https://rigor.typedduck.fail/llms.txt> rather than guessing.
120
+
121
+ But before you defer, remember Rigor installs from RubyGems **with its
122
+ full source** — the per-plugin pages under the gem's `docs/manual/plugins/`,
123
+ the analyzer and plugin code under `lib/`. For a detail no doc page spells
124
+ out (a plugin's exact rule, a default baked into the code), reading the
125
+ bundled file directly is a perfectly good way to ground the answer, and
126
+ beats a guess. The rule that never bends: **never invent a flag, rule id,
127
+ config key, behaviour, or command output** — read the page, read the
128
+ source, or run the command, and quote only output you actually saw. A
129
+ confident wrong answer about a type checker is worse than "let me check."
130
+
131
+ ## Examples
132
+
133
+ **A diagnostic on their code** — *"Why is Rigor flagging `s.lenght`?"*
134
+
135
+ ```sh
136
+ rigor explain call.undefined-method # what the rule means and why it fires
137
+ rigor annotate demo.rb # the inferred type of `s` on that line
138
+ ```
139
+
140
+ Answer from both: Rigor inferred a concrete `String` receiver for `s`,
141
+ and `String` has no `lenght` (a typo for `length`) — grounded in what
142
+ `annotate` showed, not in a guess.
143
+
144
+ **A comparison** — *"How is Rigor different from Sorbet?"*
145
+
146
+ ```sh
147
+ rigor docs handbook/10-sorbet # the chapter written for this
148
+ ```
149
+
150
+ Answer from the chapter's framing (RBS-superset, gradual `Dynamic`,
151
+ inference-first) rather than a remembered summary, and name it so they
152
+ can read on.
153
+
154
+ **A capability** — *"Does Rigor understand our Sidekiq workers?"*
155
+
156
+ ```sh
157
+ rigor plugins # is rigor-sidekiq enabled in THIS project?
158
+ rigor docs rigor-sidekiq # the per-plugin page: what it teaches Rigor
159
+ ```
160
+
161
+ Answer from what's actually installed, plus — if useful — a scoped
162
+ `rigor check app/workers` so they see Rigor on their real workers.
163
+
164
+ **Authoring** — *"How do I type this method?"*
165
+
166
+ ```sh
167
+ rigor sig-gen path/to/file.rb # generate the RBS, show it
168
+ rigor docs handbook/07-rbs-and-extended
169
+ ```
170
+
171
+ Generate it, show the signature, and explain it from the handbook —
172
+ preferring sig-gen's output over hand-written RBS.
@@ -0,0 +1,87 @@
1
+ ---
2
+ name: rigor-doctor
3
+ description: |
4
+ Validate that a project's Rigor setup is actually healthy — config parses with no silently-inert values, every configured plugin loads, the baseline is not stale, and the bundled paths resolve — by running Rigor's existing validators and interpreting them. Triggers: "is my Rigor setup correct?", "check my rigor config", "rigor diagnostics look wrong / suspicious", "validate rigor setup", "why is rigor reporting nothing / everything?". NOT for first-time setup (use rigor-project-init) or for working real diagnostics down (use rigor-baseline-reduce).
5
+ license: MPL-2.0
6
+ metadata:
7
+ version: 0.1.0
8
+ homepage: https://github.com/rigortype/rigor
9
+ ---
10
+
11
+ # Rigor Doctor
12
+
13
+ `rigor skill describe` reports what *exists* (presence checks). This skill
14
+ goes a level deeper: it *runs* Rigor's validators to confirm the setup is
15
+ actually working — the difference between "a `.rigor.yml` is present" and
16
+ "it parses, loads its plugins, and analyses the right files." Use it when
17
+ the diagnostics look wrong (suspiciously zero, or suspiciously many) or
18
+ after editing the config.
19
+
20
+ It needs **no special command** — it orchestrates checks Rigor already
21
+ ships and interprets the results.
22
+
23
+ ## Checks
24
+
25
+ ### 1. Config resolves with nothing silently inert
26
+
27
+ ```sh
28
+ rigor check --format json # read the `config_warnings` array
29
+ ```
30
+
31
+ `rigor check` audits the config and emits `config_warnings` for the typo
32
+ class whose only symptom is a confusing downstream error: a
33
+ `signature_paths:` that is missing / not a directory / holds no `.rbs`
34
+ (which would turn every covered call into a false `call.undefined-method`
35
+ at `evidence_tier: high`), a `libraries:` name RBS does not recognise, a
36
+ `disable:` / `severity_overrides:` id naming no real rule, or a missing
37
+ `bundler` / `rbs_collection` path. **Each warning here is a real
38
+ misconfiguration — fix it.** None appearing is the healthy state.
39
+
40
+ ### 2. Every configured plugin loads
41
+
42
+ ```sh
43
+ rigor plugins --strict
44
+ ```
45
+
46
+ Reports the activation status of each plugin in `plugins:`; `--strict`
47
+ exits non-zero on any failure. A failure is usually a misspelled id or a
48
+ plugin whose `signature_paths:` did not resolve. Fix it, or the plugin's
49
+ type knowledge is silently absent.
50
+
51
+ ### 3. The baseline is not stale (if one exists)
52
+
53
+ ```sh
54
+ rigor baseline drift
55
+ ```
56
+
57
+ Shows whether the live diagnostics have drifted from `.rigor-baseline.yml`
58
+ — entries the baseline ignores that no longer occur (safe to prune) and
59
+ new diagnostics outside the envelope. A large drift means the baseline
60
+ needs regenerating (often after an upgrade — see `rigor-upgrade`).
61
+
62
+ ### 4. The analysis is actually seeing your code
63
+
64
+ ```sh
65
+ rigor check --format json # check the "Ruby source files" count
66
+ ```
67
+
68
+ If the file count is `0` or far below your project size, `paths:` /
69
+ `exclude:` are mis-scoped, or the command is running from the wrong
70
+ directory. The analysis is only as good as the files it reads.
71
+
72
+ ## Interpreting the result
73
+
74
+ - **All clean** → the setup is healthy; any diagnostics are about the
75
+ code, not the configuration. Move on to `rigor-baseline-reduce` or
76
+ `rigor-protection-uplift`.
77
+ - **A `config_warning` or a plugin failure** → that is the real problem;
78
+ fixing it usually resolves a whole cluster of confusing downstream
79
+ diagnostics at once.
80
+
81
+ For deeper symptoms (hover shows `untyped` everywhere, completion empty,
82
+ LSP silent) see the manual's troubleshooting:
83
+ <https://github.com/rigortype/rigor/blob/master/docs/manual/13-troubleshooting.md>
84
+
85
+ ## Next step
86
+
87
+ Re-run `rigor skill describe` for the recommended next move.