rigortype 0.2.1 → 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 (105) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +41 -14
  3. data/docs/handbook/01-getting-started.md +311 -0
  4. data/docs/handbook/02-everyday-types.md +337 -0
  5. data/docs/handbook/03-narrowing.md +359 -0
  6. data/docs/handbook/04-tuples-and-shapes.md +321 -0
  7. data/docs/handbook/05-methods-and-blocks.md +339 -0
  8. data/docs/handbook/06-classes.md +305 -0
  9. data/docs/handbook/07-rbs-and-extended.md +427 -0
  10. data/docs/handbook/08-understanding-errors.md +373 -0
  11. data/docs/handbook/09-plugins.md +241 -0
  12. data/docs/handbook/10-sorbet.md +347 -0
  13. data/docs/handbook/11-sig-gen.md +312 -0
  14. data/docs/handbook/12-lightweight-hkt.md +333 -0
  15. data/docs/handbook/README.md +275 -0
  16. data/docs/handbook/appendix-elixir.md +370 -0
  17. data/docs/handbook/appendix-go.md +399 -0
  18. data/docs/handbook/appendix-java-csharp.md +470 -0
  19. data/docs/handbook/appendix-liskov.md +580 -0
  20. data/docs/handbook/appendix-mypy.md +370 -0
  21. data/docs/handbook/appendix-phpstan.md +338 -0
  22. data/docs/handbook/appendix-protocols-and-structural-typing.md +292 -0
  23. data/docs/handbook/appendix-rust.md +446 -0
  24. data/docs/handbook/appendix-steep.md +336 -0
  25. data/docs/handbook/appendix-type-theory.md +1662 -0
  26. data/docs/handbook/appendix-typeprof.md +416 -0
  27. data/docs/handbook/appendix-typescript.md +332 -0
  28. data/docs/install.md +189 -0
  29. data/docs/llms.txt +72 -0
  30. data/docs/manual/01-installation.md +342 -0
  31. data/docs/manual/02-cli-reference.md +557 -0
  32. data/docs/manual/03-configuration.md +152 -0
  33. data/docs/manual/04-diagnostics.md +206 -0
  34. data/docs/manual/05-inspecting-types.md +109 -0
  35. data/docs/manual/06-baseline.md +104 -0
  36. data/docs/manual/07-plugins.md +92 -0
  37. data/docs/manual/08-skills.md +143 -0
  38. data/docs/manual/09-editor-integration.md +245 -0
  39. data/docs/manual/10-mcp-server.md +532 -0
  40. data/docs/manual/11-ci.md +274 -0
  41. data/docs/manual/12-caching.md +116 -0
  42. data/docs/manual/13-troubleshooting.md +120 -0
  43. data/docs/manual/14-rails-quickstart.md +332 -0
  44. data/docs/manual/15-type-protection-coverage.md +204 -0
  45. data/docs/manual/16-rbs-extended-annotations.md +190 -0
  46. data/docs/manual/17-driving-improvement.md +160 -0
  47. data/docs/manual/README.md +87 -0
  48. data/docs/manual/ci-templates/README.md +58 -0
  49. data/docs/manual/plugins/README.md +86 -0
  50. data/docs/manual/plugins/rigor-actioncable.md +78 -0
  51. data/docs/manual/plugins/rigor-actionmailer.md +74 -0
  52. data/docs/manual/plugins/rigor-actionpack.md +80 -0
  53. data/docs/manual/plugins/rigor-activejob.md +58 -0
  54. data/docs/manual/plugins/rigor-activerecord.md +102 -0
  55. data/docs/manual/plugins/rigor-activestorage.md +74 -0
  56. data/docs/manual/plugins/rigor-activesupport-core-ext.md +86 -0
  57. data/docs/manual/plugins/rigor-devise.md +70 -0
  58. data/docs/manual/plugins/rigor-dry-schema.md +56 -0
  59. data/docs/manual/plugins/rigor-dry-struct.md +60 -0
  60. data/docs/manual/plugins/rigor-dry-types.md +59 -0
  61. data/docs/manual/plugins/rigor-dry-validation.md +62 -0
  62. data/docs/manual/plugins/rigor-factorybot.md +76 -0
  63. data/docs/manual/plugins/rigor-graphql.md +89 -0
  64. data/docs/manual/plugins/rigor-hanami.md +83 -0
  65. data/docs/manual/plugins/rigor-mangrove.md +73 -0
  66. data/docs/manual/plugins/rigor-minitest.md +86 -0
  67. data/docs/manual/plugins/rigor-pundit.md +72 -0
  68. data/docs/manual/plugins/rigor-rails-i18n.md +92 -0
  69. data/docs/manual/plugins/rigor-rails-routes.md +94 -0
  70. data/docs/manual/plugins/rigor-rails.md +44 -0
  71. data/docs/manual/plugins/rigor-rbs-inline.md +83 -0
  72. data/docs/manual/plugins/rigor-rspec-rails.md +72 -0
  73. data/docs/manual/plugins/rigor-rspec.md +86 -0
  74. data/docs/manual/plugins/rigor-shoulda-matchers.md +78 -0
  75. data/docs/manual/plugins/rigor-sidekiq.md +78 -0
  76. data/docs/manual/plugins/rigor-sinatra.md +61 -0
  77. data/docs/manual/plugins/rigor-sorbet.md +63 -0
  78. data/docs/manual/plugins/rigor-statesman.md +75 -0
  79. data/docs/manual/plugins/rigor-typescript-utility-types.md +71 -0
  80. data/exe/rigor +1 -1
  81. data/lib/rigor/analysis/incremental_session.rb +4 -2
  82. data/lib/rigor/analysis/run_stats.rb +13 -1
  83. data/lib/rigor/analysis/runner.rb +54 -12
  84. data/lib/rigor/cli/check_command.rb +1 -1
  85. data/lib/rigor/cli/docs_command.rb +248 -0
  86. data/lib/rigor/cli/skill_command.rb +103 -41
  87. data/lib/rigor/cli/skill_describe.rb +346 -0
  88. data/lib/rigor/cli.rb +25 -3
  89. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +124 -32
  90. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +37 -6
  91. data/lib/rigor/inference/scope_indexer.rb +87 -89
  92. data/lib/rigor/plugin/isolation.rb +5 -5
  93. data/lib/rigor/plugin/loader.rb +4 -2
  94. data/lib/rigor/version.rb +1 -1
  95. data/skills/rigor-ask/SKILL.md +172 -0
  96. data/skills/rigor-doctor/SKILL.md +87 -0
  97. data/skills/rigor-editor-setup/SKILL.md +114 -0
  98. data/skills/rigor-mcp-setup/SKILL.md +117 -0
  99. data/skills/rigor-monkeypatch-resolve/SKILL.md +79 -0
  100. data/skills/rigor-next-steps/SKILL.md +113 -0
  101. data/skills/rigor-plugin-tune/SKILL.md +79 -0
  102. data/skills/rigor-protection-uplift/SKILL.md +133 -0
  103. data/skills/rigor-rbs-setup/SKILL.md +128 -0
  104. data/skills/rigor-upgrade/SKILL.md +79 -0
  105. metadata +90 -1
@@ -0,0 +1,346 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Rigor
6
+ class CLI
7
+ # The presence-only project-state probe behind `rigor skill describe`
8
+ # (ADR-73 WD2). It stats files and opens only config / CI / lockfiles
9
+ # — it runs no analysis — so it is cheap and side-effect-free, and an
10
+ # agent can run it freely at any point. Split from {SkillDescribe} so
11
+ # the routing/rendering class stays focused.
12
+ class ProjectStateProbe
13
+ # Config / baseline filenames the probe stats for. `CONFIG_FILENAMES`
14
+ # mirrors `Configuration::DISCOVERY_ORDER` (developer-local override
15
+ # first, committed default second) so the probe agrees with what
16
+ # `rigor check` would auto-discover.
17
+ CONFIG_FILENAMES = %w[.rigor.yml .rigor.dist.yml].freeze
18
+ BASELINE_FILENAME = ".rigor-baseline.yml"
19
+ # `rbs collection install`'s lockfile — Rigor auto-detects it at the
20
+ # project root and feeds each gem's community RBS into the signature
21
+ # paths, so its presence is the signal that the gem-RBS gap has been
22
+ # addressed.
23
+ RBS_COLLECTION_LOCKFILE = "rbs_collection.lock.yaml"
24
+
25
+ # `Gemfile.lock` substrings that mark a Rails app, and the bundled
26
+ # Rails-family plugin ids — used to spot a configured Rails project
27
+ # that has not enabled any Rails plugin (a `rigor-plugin-tune` cue).
28
+ RAILS_LOCK_MARKERS = %w[railties actionpack activerecord actioncable].freeze
29
+ RAILS_PLUGIN_MARKERS = %w[
30
+ rigor-activerecord rigor-actionpack rigor-actionmailer rigor-activejob
31
+ rigor-rails-routes rigor-rails-i18n rigor-actioncable rigor-activestorage rigor-rails
32
+ ].freeze
33
+
34
+ def initialize(root)
35
+ @root = root
36
+ end
37
+
38
+ # @return [Hash] the presence-only state the recommendation routes on.
39
+ def to_h
40
+ config = CONFIG_FILENAMES.find { |name| File.file?(File.join(@root, name)) }
41
+ {
42
+ config: config,
43
+ baseline: File.file?(File.join(@root, BASELINE_FILENAME)),
44
+ sig: File.directory?(File.join(@root, "sig")),
45
+ gems: File.file?(File.join(@root, "Gemfile.lock")),
46
+ rbs_collection: File.file?(File.join(@root, RBS_COLLECTION_LOCKFILE)),
47
+ rails_unconfigured: rails_unconfigured?(config),
48
+ ci: ci_state,
49
+ editor: editor_state,
50
+ mcp: mcp_state
51
+ }
52
+ end
53
+
54
+ private
55
+
56
+ # True when Rails is in `Gemfile.lock` but the (present) config
57
+ # enables no Rails plugin — so `rigor-plugin-tune` (wiring the
58
+ # ActiveRecord / routes / i18n plugins) buys more than community RBS
59
+ # would (the 20260620 field trial's strap case). Only fires on an
60
+ # already-configured project; an un-configured Rails app routes to
61
+ # `rigor-project-init`, which selects the plugins itself.
62
+ def rails_unconfigured?(config)
63
+ return false if config.nil?
64
+
65
+ lock = File.join(@root, "Gemfile.lock")
66
+ return false unless File.file?(lock) && file_mentions_any?(lock, RAILS_LOCK_MARKERS)
67
+
68
+ !file_mentions_any?(File.join(@root, config), RAILS_PLUGIN_MARKERS)
69
+ end
70
+
71
+ def file_mentions_any?(path, markers)
72
+ content = File.read(path)
73
+ markers.any? { |marker| content.include?(marker) }
74
+ rescue StandardError
75
+ false
76
+ end
77
+
78
+ # `:wired` (a CI config mentions `rigor`), `:unwired` (a CI config
79
+ # exists but does not), or `:none`.
80
+ def ci_state
81
+ files = ci_config_files
82
+ return :none if files.empty?
83
+
84
+ files.any? { |path| file_mentions_rigor?(path) } ? :wired : :unwired
85
+ end
86
+
87
+ # Editor-LSP signal — `:wired` (a committed `.vscode/` config names
88
+ # `rigor`), `:unwired` (a `.vscode/` is present but does not), or
89
+ # `:none`. Only VS Code is detectable here: Neovim / Emacs / Helix
90
+ # configs live in the user's home, not the repo, so editor-setup is
91
+ # otherwise a catalogue-only destination.
92
+ def editor_state
93
+ vscode = File.join(@root, ".vscode")
94
+ return :none unless File.directory?(vscode)
95
+
96
+ files = Dir.glob(File.join(vscode, "*.json"))
97
+ return :unwired if files.empty?
98
+
99
+ files.any? { |path| file_mentions_rigor?(path) } ? :wired : :unwired
100
+ end
101
+
102
+ # MCP-client signal — `:wired` (a committed project MCP config names
103
+ # `rigor`), `:unwired` (one is present but does not), or `:none`.
104
+ # Only the project-scoped configs are detectable (`.mcp.json`,
105
+ # `.cursor/mcp.json`); user-level client configs live in $HOME, so
106
+ # mcp-setup is otherwise a catalogue-only destination.
107
+ def mcp_state
108
+ files = [".mcp.json", File.join(".cursor", "mcp.json")]
109
+ .map { |rel| File.join(@root, rel) }
110
+ .select { |path| File.file?(path) }
111
+ return :none if files.empty?
112
+
113
+ files.any? { |path| file_mentions_rigor?(path) } ? :wired : :unwired
114
+ end
115
+
116
+ def file_mentions_rigor?(path)
117
+ File.read(path).include?("rigor")
118
+ rescue StandardError
119
+ false
120
+ end
121
+
122
+ def ci_config_files
123
+ files = Dir.glob(File.join(@root, ".github", "workflows", "*.{yml,yaml}"))
124
+ gitlab = File.join(@root, ".gitlab-ci.yml")
125
+ files << gitlab if File.file?(gitlab)
126
+ files
127
+ end
128
+ end
129
+
130
+ # Builds the `rigor skill describe` report (ADR-73): a {ProjectStateProbe}
131
+ # snapshot, a recommended next skill, and the live catalogue of bundled
132
+ # skills with their current frontmatter descriptions. Extracted from
133
+ # {SkillCommand} so the command stays a thin dispatcher and this — the
134
+ # "live brain" — owns the routing.
135
+ class SkillDescribe
136
+ # The entry-point SKILL itself — excluded from the catalogue because
137
+ # it is the skill being run, not a destination.
138
+ ENTRY_POINT_SKILL = "rigor-next-steps"
139
+
140
+ # Adoption-journey order for the catalogue and the order the
141
+ # recommendation decision tree walks. `rigor-ask` sits last: it is
142
+ # the journey-agnostic "answer a question about Rigor" companion the
143
+ # agent can offer at any point, never a presence-recommended step.
144
+ CATALOG_ORDER = %w[
145
+ rigor-project-init
146
+ rigor-rbs-setup
147
+ rigor-ci-setup
148
+ rigor-baseline-reduce
149
+ rigor-monkeypatch-resolve
150
+ rigor-editor-setup
151
+ rigor-mcp-setup
152
+ rigor-protection-uplift
153
+ rigor-plugin-tune
154
+ rigor-plugin-author
155
+ rigor-upgrade
156
+ rigor-doctor
157
+ rigor-ask
158
+ ].freeze
159
+
160
+ # @param skills [Array<Hash>] discovered skills, each `{name:, path:}`.
161
+ # @param root [String] project root to probe (defaults to the cwd).
162
+ def initialize(skills:, root: Dir.pwd)
163
+ @skills = skills
164
+ @root = root
165
+ end
166
+
167
+ # @return [String] the full describe report.
168
+ def render
169
+ catalog = catalog_skills
170
+ state = ProjectStateProbe.new(@root).to_h
171
+ recommendation = recommend(state, catalog)
172
+ [
173
+ title,
174
+ state_section(state),
175
+ recommendation_section(recommendation),
176
+ catalog_section(catalog),
177
+ agent_prompt(recommendation)
178
+ ].join("\n")
179
+ end
180
+
181
+ private
182
+
183
+ # The skills offered as "what to do next", in adoption-journey order.
184
+ # The entry-point skill is excluded, and unknown skills sort after
185
+ # the known journey, alphabetically.
186
+ def catalog_skills
187
+ @skills
188
+ .reject { |skill| skill.fetch(:name) == ENTRY_POINT_SKILL }
189
+ .sort_by { |skill| [CATALOG_ORDER.index(skill.fetch(:name)) || CATALOG_ORDER.size, skill.fetch(:name)] }
190
+ end
191
+
192
+ # The decision tree (ADR-73 WD2). Returns `{ skill:, reason: }` for
193
+ # the recommended next step, or nil when no catalogue skill matches.
194
+ def recommend(state, catalog)
195
+ name, reason = recommended_name_and_reason(state)
196
+ skill = catalog.find { |candidate| candidate.fetch(:name) == name }
197
+ skill.nil? ? nil : { skill: skill, reason: reason }
198
+ end
199
+
200
+ def recommended_name_and_reason(state)
201
+ if state.fetch(:config).nil?
202
+ ["rigor-project-init", "this project has no Rigor configuration yet — start here."]
203
+ elsif state.fetch(:rails_unconfigured)
204
+ ["rigor-plugin-tune",
205
+ "Rails is in your Gemfile.lock but no Rails plugins are enabled — wire them so " \
206
+ "ActiveRecord / routes / i18n calls resolve (a bigger win here than community RBS)."]
207
+ elsif state.fetch(:gems) && !state.fetch(:rbs_collection)
208
+ ["rigor-rbs-setup", "your gems ship no community RBS yet — install it so Rigor stops typing them as Dynamic."]
209
+ elsif state.fetch(:ci) != :wired
210
+ ["rigor-ci-setup", "Rigor is configured but not wired into CI — lock in the regression guard."]
211
+ elsif state.fetch(:baseline)
212
+ ["rigor-baseline-reduce", "a baseline is in place — work it down rule by rule."]
213
+ elsif state.fetch(:editor) == :unwired
214
+ ["rigor-editor-setup",
215
+ "you have an editor config but no Rigor LSP — wire `rigor lsp` for live diagnostics and hover types."]
216
+ elsif state.fetch(:mcp) == :unwired
217
+ ["rigor-mcp-setup",
218
+ "an MCP client config is present without Rigor — wire `rigor mcp` so your AI agent can call Rigor's tools."]
219
+ else
220
+ ["rigor-protection-uplift", "the basics are in place — raise how much of your code Rigor can catch bugs in."]
221
+ end
222
+ end
223
+
224
+ def title
225
+ <<~TITLE
226
+ # Rigor — next steps for this project
227
+ #
228
+ # Generated live by rigortype #{Rigor::VERSION}; this guidance always
229
+ # reflects your installed version and the project's current state.
230
+ TITLE
231
+ end
232
+
233
+ def state_section(state)
234
+ ci = case state.fetch(:ci)
235
+ when :wired then "Rigor wired in"
236
+ when :unwired then "CI present, Rigor not wired"
237
+ else "not detected"
238
+ end
239
+ rbs = if state.fetch(:gems)
240
+ state.fetch(:rbs_collection) ? "collection installed" : "gems present, no collection"
241
+ else
242
+ "no Gemfile.lock"
243
+ end
244
+ editor = case state.fetch(:editor)
245
+ when :wired then "Rigor LSP wired (.vscode)"
246
+ when :unwired then ".vscode present, Rigor LSP not wired"
247
+ else "not detected"
248
+ end
249
+ mcp = case state.fetch(:mcp)
250
+ when :wired then "Rigor MCP wired"
251
+ when :unwired then "MCP config present, Rigor not wired"
252
+ else "not detected"
253
+ end
254
+ <<~STATE
255
+ ## Project state
256
+ - Config file: #{state.fetch(:config) || 'none (no .rigor.yml / .rigor.dist.yml)'}
257
+ - Baseline: #{state.fetch(:baseline) ? '.rigor-baseline.yml' : 'none'}
258
+ - Project sig/: #{state.fetch(:sig) ? 'present' : 'none'}
259
+ - Community RBS: #{rbs}
260
+ - CI integration: #{ci}
261
+ - Editor LSP: #{editor}
262
+ - MCP server: #{mcp}
263
+ STATE
264
+ end
265
+
266
+ def recommendation_section(recommendation)
267
+ return "## Recommended next step\n- (no bundled skill matched the current state)\n" if recommendation.nil?
268
+
269
+ name = recommendation.fetch(:skill).fetch(:name)
270
+ <<~REC
271
+ ## Recommended next step
272
+ → #{name} — #{recommendation.fetch(:reason)}
273
+ Load it: rigor skill #{name}
274
+ REC
275
+ end
276
+
277
+ def catalog_section(catalog)
278
+ lines = catalog.map do |skill|
279
+ name = skill.fetch(:name)
280
+ "- #{name} — #{catalog_blurb(skill.fetch(:path))}\n rigor skill #{name}"
281
+ end
282
+ "## All skills you can run next\n#{lines.join("\n")}\n"
283
+ end
284
+
285
+ def agent_prompt(recommendation)
286
+ opener =
287
+ if recommendation.nil?
288
+ "Ask the user what they would like to do next"
289
+ else
290
+ "Present the recommended step above (or, if the user has a different goal, ask which they want)"
291
+ end
292
+ <<~PROMPT
293
+ ## For the agent
294
+ #{opener}, then run `rigor skill <name>` for the chosen skill and
295
+ follow its body top to bottom.
296
+
297
+ The recommendation above is from a presence-only probe — it does not run
298
+ `rigor check`. If you have run (or now run) `rigor check`, let its findings
299
+ refine the choice:
300
+ - errors present and no baseline yet → rigor-baseline-reduce
301
+ - a `call.unresolved-toplevel` / `call.undefined-method` cluster on the
302
+ project's own monkey-patches → rigor-monkeypatch-resolve
303
+ - framework calls (ActiveRecord, routes, i18n …) typing as Dynamic with no
304
+ matching plugins enabled → rigor-plugin-tune
305
+ - `RBS classes available: 0` or a `configuration-error` diagnostic → rigor-doctor
306
+
307
+ Re-run `rigor skill describe` whenever you need the next step — it always
308
+ reflects the project's current state.
309
+ PROMPT
310
+ end
311
+
312
+ # The one-line essence of a skill's frontmatter `description`, read
313
+ # live from the SKILL.md so the catalogue can never go stale relative
314
+ # to the shipped skill. Drops the `Triggers:` / `NOT for` tail and
315
+ # caps the length.
316
+ def catalog_blurb(path)
317
+ text = frontmatter(path).fetch("description", "").to_s.strip.tr("\n", " ").squeeze(" ")
318
+ head = text.split(/\s*Triggers?:/, 2).first.to_s.strip
319
+ head = text if head.empty?
320
+ truncate(head, 200)
321
+ end
322
+
323
+ def truncate(text, limit)
324
+ return text if text.length <= limit
325
+
326
+ "#{(text[0, limit] || '').rstrip}…"
327
+ end
328
+
329
+ # Parse a SKILL.md's leading `---`-delimited YAML frontmatter. Returns
330
+ # {} on any missing or malformed block so the catalogue degrades to a
331
+ # name-only entry rather than raising.
332
+ def frontmatter(path)
333
+ text = File.read(path)
334
+ return {} unless text.start_with?("---\n")
335
+
336
+ closing = text.index("\n---\n", 4)
337
+ return {} if closing.nil?
338
+
339
+ data = YAML.safe_load(text[4...closing])
340
+ data.is_a?(Hash) ? data : {}
341
+ rescue StandardError
342
+ {}
343
+ end
344
+ end
345
+ end
346
+ end
data/lib/rigor/cli.rb CHANGED
@@ -16,8 +16,10 @@ require_relative "cli/ci_detector"
16
16
  module Rigor
17
17
  # The CLI class is a dispatcher: each `run_*` method delegates to a
18
18
  # command-specific class once the command grows beyond a few lines (see
19
- # {CLI::TypeOfCommand} and {CLI::CheckCommand}).
20
- class CLI
19
+ # {CLI::TypeOfCommand} and {CLI::CheckCommand}). It necessarily grows by
20
+ # one delegator + one help line per command, so — like the command
21
+ # classes it fans out to — it carries an explicit ClassLength exemption.
22
+ class CLI # rubocop:disable Metrics/ClassLength
21
23
  EXIT_USAGE = 64
22
24
 
23
25
  HANDLERS = {
@@ -39,6 +41,8 @@ module Rigor
39
41
  "plugin" => :run_plugin,
40
42
  "playground" => :run_playground,
41
43
  "skill" => :run_skill,
44
+ "describe" => :run_describe,
45
+ "docs" => :run_docs,
42
46
  "show-bleedingedge" => :run_show_bleedingedge
43
47
  }.freeze
44
48
 
@@ -285,6 +289,22 @@ module Rigor
285
289
  CLI::SkillCommand.new(argv: @argv, out: @out, err: @err).run
286
290
  end
287
291
 
292
+ # `rigor describe` — a top-level alias for `rigor skill describe`,
293
+ # the entry point most users reach for first. Surfaced because a
294
+ # bare `rigor describe` is the intuitive guess (the onboarding field
295
+ # trial saw it tried and met "Unknown command").
296
+ def run_describe
297
+ require_relative "cli/skill_command"
298
+
299
+ CLI::SkillCommand.new(argv: ["describe", *@argv], out: @out, err: @err).run
300
+ end
301
+
302
+ def run_docs
303
+ require_relative "cli/docs_command"
304
+
305
+ CLI::DocsCommand.new(argv: @argv, out: @out, err: @err).run
306
+ end
307
+
288
308
  def run_plugin
289
309
  require_relative "cli/plugin_command"
290
310
 
@@ -318,7 +338,9 @@ module Rigor
318
338
  plugins Report activation status of every configured plugin
319
339
  plugin Browse bundled plugin source as worked examples (list/path/print/root)
320
340
  playground Start the browser playground (requires rigor-playground gem)
321
- skill List or print bundled Agent Skills (rigor-project-init, ...)
341
+ describe Recommend the next skill for this project (alias for `skill describe`)
342
+ skill Recommend the next skill + list/print bundled Agent Skills (skill describe, skill <name>)
343
+ docs Print the bundled docs offline (docs <name>, docs --list)
322
344
  show-bleedingedge Show the bleeding-edge overlay + what your config adopts (ADR-50)
323
345
  version Print the Rigor version
324
346
  help Print this help
@@ -51,7 +51,15 @@ module Rigor
51
51
  NUMERIC_BINARY = Set[
52
52
  :+, :-, :*, :/, :%, :**, :&, :|, :^, :<<, :>>,
53
53
  :<, :<=, :>, :>=, :==, :!=, :<=>,
54
- :gcd, :lcm, :fdiv, :quo, :ceildiv, :[]
54
+ :gcd, :lcm, :fdiv, :quo, :ceildiv, :[],
55
+ # Integer bit-test predicates (`(self & mask) <=> mask|0`). The
56
+ # catalog marks them `:dispatch` only because a non-Integer mask
57
+ # would route through `to_int`; a concrete Integer literal never
58
+ # does, so the fold is pure here — the sibling of the already-folded
59
+ # bit-reference `:[]`. Integer-only, but Float-safe to list: a Float
60
+ # receiver has no such method, so `invoke_binary` rescues the
61
+ # `NoMethodError` to nil and the RBS tier answers.
62
+ :allbits?, :anybits?, :nobits?
55
63
  ].freeze
56
64
  STRING_BINARY = Set[
57
65
  :+, :*, :==, :!=, :<, :<=, :>, :>=, :<=>,
@@ -86,6 +94,17 @@ module Rigor
86
94
  # delegates to operand `==`); ordering is undefined for Complex.
87
95
  COMPLEX_BINARY = Set[:+, :-, :*, :/, :**].freeze
88
96
 
97
+ # `Set#&` and its alias `Set#intersection` are leaf-pure for a
98
+ # concrete Set operand exactly like their siblings `|` / `-` / `^`
99
+ # (all `:leaf` in the catalog), but the catalog flags
100
+ # `set_i_intersection`'s C body `block_dependent` — it drives Set's
101
+ # own internal iterator — so the catalog tier declines and the
102
+ # intersection alone fails to fold. A concrete Set argument's `each`
103
+ # is the pure core method, so the fold is sound; the hand-rolled
104
+ # allow-list is the right tool, mirroring the bit-test predicates.
105
+ # (The other binary set ops keep folding through the catalog.)
106
+ SET_BINARY = Set[:&, :intersection].freeze
107
+
89
108
  # v0.0.3 C — pure unary catalogue. Each method must:
90
109
  # - take zero arguments,
91
110
  # - have no side effects,
@@ -130,6 +149,19 @@ module Rigor
130
149
  :abs, :magnitude, :floor, :ceil, :round, :truncate,
131
150
  :next_float, :prev_float,
132
151
  :to_s, :to_i, :to_int, :to_f, :to_r, :rationalize,
152
+ # `numerator` / `denominator` expose the rational
153
+ # decomposition of the float (`2.5.numerator → 5`,
154
+ # `.denominator → 2`) — pure arithmetic, the Float siblings
155
+ # of the already-folded Rational accessors. The non-finite
156
+ # edges stay sound: `Infinity.numerator → Infinity` /
157
+ # `.denominator → 1` fold to the same value Ruby returns, and
158
+ # `NaN.numerator → NaN` is declined by `foldable_constant_value?`.
159
+ :numerator, :denominator,
160
+ # `arg` / `angle` / `phase` (aliases) return the complex
161
+ # argument of the real number: `0` for `self >= 0`, `Math::PI`
162
+ # for `self < 0`. Pure sign test, deterministic; a NaN
163
+ # receiver yields NaN which `foldable_constant_value?` declines.
164
+ :arg, :angle, :phase,
133
165
  :inspect, :-@, :+@
134
166
  ].freeze
135
167
  STRING_UNARY = Set[
@@ -138,7 +170,12 @@ module Rigor
138
170
  :empty?, :strip, :lstrip, :rstrip, :chomp, :chop, :squeeze,
139
171
  :to_s, :to_str, :to_sym, :intern,
140
172
  :to_i, :to_f, :ord, :chr, :hex, :oct, :succ, :next,
141
- :sum, :inspect
173
+ :sum, :inspect,
174
+ # `shellescape` is the String-receiver twin of the already-folded
175
+ # `Shellwords.escape` — deterministic shell-quoting, no global
176
+ # state. The `shellwords` library is loaded process-wide via
177
+ # `shellwords_folding`, so the method is always defined here.
178
+ :shellescape
142
179
  ].freeze
143
180
  SYMBOL_UNARY = Set[
144
181
  :to_s, :to_sym, :to_proc, :length, :size,
@@ -386,26 +423,8 @@ module Rigor
386
423
  end
387
424
 
388
425
  def try_fold_unary_set(receiver_values, method_name)
389
- range_lift = try_fold_range_constant_unary(receiver_values, method_name)
390
- return range_lift if range_lift
391
-
392
- string_lift = try_fold_string_array_unary(receiver_values, method_name)
393
- return string_lift if string_lift
394
-
395
- pathname_lift = try_fold_pathname_unary(receiver_values, method_name)
396
- return pathname_lift if pathname_lift
397
-
398
- regexp_lift = try_fold_regexp_array_unary(receiver_values, method_name)
399
- return regexp_lift if regexp_lift
400
-
401
- set_lift = try_fold_set_array_unary(receiver_values, method_name)
402
- return set_lift if set_lift
403
-
404
- integer_lift = try_fold_integer_array_unary(receiver_values, method_name)
405
- return integer_lift if integer_lift
406
-
407
- numeric_lift = try_fold_numeric_array_unary(receiver_values, method_name)
408
- return numeric_lift if numeric_lift
426
+ special = try_fold_unary_special(receiver_values, method_name)
427
+ return special if special
409
428
 
410
429
  # Type-level allow check on every receiver. If one member's
411
430
  # type does not have the method in its allow list (e.g.
@@ -420,6 +439,23 @@ module Rigor
420
439
  end
421
440
  build_constant_type(results, source: receiver_values)
422
441
  end
442
+
443
+ # The carrier-specific unary lifts — Range-to-Tuple, the
444
+ # Array-returning String / Pathname / Regexp / Set / Integer /
445
+ # Numeric folds — that produce a precise structural type before
446
+ # the generic scalar `invoke_unary` path. The first match wins;
447
+ # nil means none applied and the caller falls through to the
448
+ # scalar allow-list path.
449
+ def try_fold_unary_special(receiver_values, method_name)
450
+ try_fold_range_constant_unary(receiver_values, method_name) ||
451
+ try_fold_string_array_unary(receiver_values, method_name) ||
452
+ try_fold_pathname_unary(receiver_values, method_name) ||
453
+ try_fold_pathname_array_unary(receiver_values, method_name) ||
454
+ try_fold_regexp_array_unary(receiver_values, method_name) ||
455
+ try_fold_set_array_unary(receiver_values, method_name) ||
456
+ try_fold_integer_array_unary(receiver_values, method_name) ||
457
+ try_fold_numeric_array_unary(receiver_values, method_name)
458
+ end
423
459
  # v0.0.7 — `Constant<Range>#to_a` and the no-arg
424
460
  # `first` / `last` / `min` / `max` short-circuit through a
425
461
  # Range-specific arm that catalog dispatch cannot reach:
@@ -436,10 +472,13 @@ module Rigor
436
472
  RANGE_FOLD_METHODS = Set[:to_a, :first, :last, :min, :max, :count, :size, :length, :entries, :minmax,
437
473
  :sum].freeze
438
474
  # 1-arg head/tail projections on a `Constant<Range>`. `first(n)` /
439
- # `take(n)` return the first `n` elements, `last(n)` the final `n` —
440
- # each lifts to a per-position `Tuple[Constant[Integer]…]`. The
441
- # no-arg `first` / `last` stay on the unary path (single Integer).
442
- RANGE_FOLD_BINARY_METHODS = Set[:first, :last, :take].freeze
475
+ # `take(n)` return the first `n` elements, `last(n)` the final `n`,
476
+ # and `min(n)` / `max(n)` the n smallest / largest (for an ascending
477
+ # integer range `min(n) == first(n)` and `max(n) == last(n).reverse`)
478
+ # each lifts to a per-position `Tuple[Constant[Integer]…]`. The
479
+ # no-arg `first` / `last` / `min` / `max` stay on the unary path
480
+ # (single Integer endpoint).
481
+ RANGE_FOLD_BINARY_METHODS = Set[:first, :last, :take, :min, :max].freeze
443
482
  RANGE_TO_A_LIMIT = 16
444
483
  private_constant :RANGE_FOLD_METHODS, :RANGE_FOLD_BINARY_METHODS, :RANGE_TO_A_LIMIT
445
484
 
@@ -518,17 +557,31 @@ module Rigor
518
557
 
519
558
  def range_take_tuple(range, method_name, count)
520
559
  return nil unless count.is_a?(Integer) && !count.negative?
521
- # `first(n)`/`last(n)`/`take(n)` materialise at most `min(n, size)`
522
- # elements; cap that count so a huge `n` (or range) never blows up
523
- # the Constant. `Range#size` is O(1) for integer endpoints.
560
+ # `first(n)`/`last(n)`/`take(n)`/`min(n)`/`max(n)` materialise at
561
+ # most `min(n, size)` elements; cap that count so a huge `n` (or
562
+ # range) never blows up the Constant. `Range#size` and the head/
563
+ # tail projections are O(n) for integer endpoints (no full
564
+ # materialisation).
524
565
  return nil if [count, range.size].min > RANGE_TO_A_LIMIT
525
566
 
526
- values = method_name == :last ? range.last(count) : range.first(count)
567
+ values = range_head_tail(range, method_name, count)
527
568
  return Type::Combinator.tuple_of if values.empty?
528
569
 
529
570
  Type::Combinator.tuple_of(*values.map { |v| Type::Combinator.constant_of(v) })
530
571
  end
531
572
 
573
+ # The n elements a head/tail projection selects, in Ruby's order.
574
+ # For an ascending integer range `min(n)` is the leading `n`
575
+ # (`first(n)`) and `max(n)` the trailing `n` reversed (descending),
576
+ # so neither needs the full sort `Array#min`/`#max` would do.
577
+ def range_head_tail(range, method_name, count)
578
+ case method_name
579
+ when :last then range.last(count)
580
+ when :max then range.last(count).reverse
581
+ else range.first(count) # :first, :take, :min
582
+ end
583
+ end
584
+
532
585
  def try_fold_binary_set(receiver_values, method_name, arg_values)
533
586
  range_lift = try_fold_range_constant_binary(receiver_values, method_name, arg_values)
534
587
  return range_lift if range_lift
@@ -559,7 +612,13 @@ module Rigor
559
612
  # bounded for long strings. (`codepoints` yields per-character
560
613
  # Integer codepoints, the sibling of the byte-valued `bytes`;
561
614
  # `grapheme_clusters` is the extended-grapheme sibling of `chars`.)
562
- STRING_ARRAY_UNARY_METHODS = Set[:chars, :bytes, :codepoints, :grapheme_clusters, :lines, :split].freeze
615
+ # `shellsplit` is the String-receiver twin of the already-folded
616
+ # `Shellwords.split` — lifts the token Array to a Tuple. Raises
617
+ # `ArgumentError` on unmatched quotes, which `try_fold_string_array_unary`
618
+ # rescues to nil (RBS tier widens). `shellwords` is loaded process-wide
619
+ # via `shellwords_folding`.
620
+ STRING_ARRAY_UNARY_METHODS = Set[:chars, :bytes, :codepoints, :grapheme_clusters,
621
+ :lines, :split, :shellsplit].freeze
563
622
  # `partition` / `rpartition` always return a fixed 3-element
564
623
  # `[head, separator, tail]` Array whose members are substrings of
565
624
  # the receiver (bounded by the input), so they lift to a precise
@@ -623,10 +682,26 @@ module Rigor
623
682
  ].freeze
624
683
  PATHNAME_PURE_BINARY = Set[
625
684
  :+, :join, :sub_ext, :<=>, :==, :eql?, :===,
626
- :relative_path_from
685
+ :relative_path_from,
686
+ # `/` is the exact alias of `+` (`def /(other) = self + other`),
687
+ # the idiomatic path-join operator (`dir / "file"`). `basename`'s
688
+ # 1-arg suffix-stripping form (`path.basename(".rb")` → the stem)
689
+ # is the binary sibling of the already-folded no-arg `basename` —
690
+ # both are pure `@path` string manipulation, no filesystem read.
691
+ :/, :basename
627
692
  ].freeze
628
693
  private_constant :PATHNAME_PURE_UNARY, :PATHNAME_PURE_BINARY
629
694
 
695
+ # `Constant<Pathname>#split` returns the fixed 2-element
696
+ # `[dirname, basename]` pair (both Pathname), the path-string
697
+ # split of `File.split`. Lifted to `Tuple[Constant[Pathname],
698
+ # Constant[Pathname]]`. Filesystem-independent — reads only
699
+ # `@path` — so it is deterministic at fold time, the
700
+ # Array-returning sibling of the scalar `basename` / `dirname`
701
+ # folds (which `try_fold_pathname_unary` already covers).
702
+ PATHNAME_ARRAY_UNARY_METHODS = Set[:split].freeze
703
+ private_constant :PATHNAME_ARRAY_UNARY_METHODS
704
+
630
705
  def try_fold_pathname_unary(receiver_values, method_name)
631
706
  return nil unless PATHNAME_PURE_UNARY.include?(method_name)
632
707
  return nil unless receiver_values.size == 1
@@ -659,6 +734,22 @@ module Rigor
659
734
  nil
660
735
  end
661
736
 
737
+ # `Constant<Pathname>#split` — lift the `[dirname, basename]`
738
+ # Pathname pair to a Tuple[Constant[Pathname], Constant[Pathname]].
739
+ # Pure path-string manipulation (no filesystem read); both
740
+ # elements are Pathname, a foldable Constant class.
741
+ def try_fold_pathname_array_unary(receiver_values, method_name)
742
+ return nil unless PATHNAME_ARRAY_UNARY_METHODS.include?(method_name)
743
+ return nil unless receiver_values.size == 1
744
+
745
+ receiver = receiver_values.first
746
+ return nil unless receiver.is_a?(Pathname)
747
+
748
+ lift_array_result(receiver.split)
749
+ rescue StandardError
750
+ nil
751
+ end
752
+
662
753
  def try_fold_string_array_unary(receiver_values, method_name)
663
754
  return nil unless STRING_ARRAY_UNARY_METHODS.include?(method_name)
664
755
  return nil unless receiver_values.size == 1
@@ -1500,6 +1591,7 @@ module Rigor
1500
1591
  when nil then NIL_BINARY
1501
1592
  when Rational then RATIONAL_BINARY
1502
1593
  when Complex then COMPLEX_BINARY
1594
+ when ::Set then SET_BINARY
1503
1595
  else Set.new
1504
1596
  end
1505
1597
  end