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.
- checksums.yaml +4 -4
- data/README.md +41 -14
- data/docs/handbook/01-getting-started.md +311 -0
- data/docs/handbook/02-everyday-types.md +337 -0
- data/docs/handbook/03-narrowing.md +359 -0
- data/docs/handbook/04-tuples-and-shapes.md +321 -0
- data/docs/handbook/05-methods-and-blocks.md +339 -0
- data/docs/handbook/06-classes.md +305 -0
- data/docs/handbook/07-rbs-and-extended.md +427 -0
- data/docs/handbook/08-understanding-errors.md +373 -0
- data/docs/handbook/09-plugins.md +241 -0
- data/docs/handbook/10-sorbet.md +347 -0
- data/docs/handbook/11-sig-gen.md +312 -0
- data/docs/handbook/12-lightweight-hkt.md +333 -0
- data/docs/handbook/README.md +275 -0
- data/docs/handbook/appendix-elixir.md +370 -0
- data/docs/handbook/appendix-go.md +399 -0
- data/docs/handbook/appendix-java-csharp.md +470 -0
- data/docs/handbook/appendix-liskov.md +580 -0
- data/docs/handbook/appendix-mypy.md +370 -0
- data/docs/handbook/appendix-phpstan.md +338 -0
- data/docs/handbook/appendix-protocols-and-structural-typing.md +292 -0
- data/docs/handbook/appendix-rust.md +446 -0
- data/docs/handbook/appendix-steep.md +336 -0
- data/docs/handbook/appendix-type-theory.md +1662 -0
- data/docs/handbook/appendix-typeprof.md +416 -0
- data/docs/handbook/appendix-typescript.md +332 -0
- data/docs/install.md +189 -0
- data/docs/llms.txt +72 -0
- data/docs/manual/01-installation.md +342 -0
- data/docs/manual/02-cli-reference.md +557 -0
- data/docs/manual/03-configuration.md +152 -0
- data/docs/manual/04-diagnostics.md +206 -0
- data/docs/manual/05-inspecting-types.md +109 -0
- data/docs/manual/06-baseline.md +104 -0
- data/docs/manual/07-plugins.md +92 -0
- data/docs/manual/08-skills.md +143 -0
- data/docs/manual/09-editor-integration.md +245 -0
- data/docs/manual/10-mcp-server.md +532 -0
- data/docs/manual/11-ci.md +274 -0
- data/docs/manual/12-caching.md +116 -0
- data/docs/manual/13-troubleshooting.md +120 -0
- data/docs/manual/14-rails-quickstart.md +332 -0
- data/docs/manual/15-type-protection-coverage.md +204 -0
- data/docs/manual/16-rbs-extended-annotations.md +190 -0
- data/docs/manual/17-driving-improvement.md +160 -0
- data/docs/manual/README.md +87 -0
- data/docs/manual/ci-templates/README.md +58 -0
- data/docs/manual/plugins/README.md +86 -0
- data/docs/manual/plugins/rigor-actioncable.md +78 -0
- data/docs/manual/plugins/rigor-actionmailer.md +74 -0
- data/docs/manual/plugins/rigor-actionpack.md +80 -0
- data/docs/manual/plugins/rigor-activejob.md +58 -0
- data/docs/manual/plugins/rigor-activerecord.md +102 -0
- data/docs/manual/plugins/rigor-activestorage.md +74 -0
- data/docs/manual/plugins/rigor-activesupport-core-ext.md +86 -0
- data/docs/manual/plugins/rigor-devise.md +70 -0
- data/docs/manual/plugins/rigor-dry-schema.md +56 -0
- data/docs/manual/plugins/rigor-dry-struct.md +60 -0
- data/docs/manual/plugins/rigor-dry-types.md +59 -0
- data/docs/manual/plugins/rigor-dry-validation.md +62 -0
- data/docs/manual/plugins/rigor-factorybot.md +76 -0
- data/docs/manual/plugins/rigor-graphql.md +89 -0
- data/docs/manual/plugins/rigor-hanami.md +83 -0
- data/docs/manual/plugins/rigor-mangrove.md +73 -0
- data/docs/manual/plugins/rigor-minitest.md +86 -0
- data/docs/manual/plugins/rigor-pundit.md +72 -0
- data/docs/manual/plugins/rigor-rails-i18n.md +92 -0
- data/docs/manual/plugins/rigor-rails-routes.md +94 -0
- data/docs/manual/plugins/rigor-rails.md +44 -0
- data/docs/manual/plugins/rigor-rbs-inline.md +83 -0
- data/docs/manual/plugins/rigor-rspec-rails.md +72 -0
- data/docs/manual/plugins/rigor-rspec.md +86 -0
- data/docs/manual/plugins/rigor-shoulda-matchers.md +78 -0
- data/docs/manual/plugins/rigor-sidekiq.md +78 -0
- data/docs/manual/plugins/rigor-sinatra.md +61 -0
- data/docs/manual/plugins/rigor-sorbet.md +63 -0
- data/docs/manual/plugins/rigor-statesman.md +75 -0
- data/docs/manual/plugins/rigor-typescript-utility-types.md +71 -0
- data/exe/rigor +1 -1
- data/lib/rigor/analysis/incremental_session.rb +4 -2
- data/lib/rigor/analysis/run_stats.rb +13 -1
- data/lib/rigor/analysis/runner.rb +54 -12
- data/lib/rigor/cli/check_command.rb +1 -1
- data/lib/rigor/cli/docs_command.rb +248 -0
- data/lib/rigor/cli/skill_command.rb +103 -41
- data/lib/rigor/cli/skill_describe.rb +346 -0
- data/lib/rigor/cli.rb +25 -3
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +124 -32
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +37 -6
- data/lib/rigor/inference/scope_indexer.rb +87 -89
- data/lib/rigor/plugin/isolation.rb +5 -5
- data/lib/rigor/plugin/loader.rb +4 -2
- data/lib/rigor/version.rb +1 -1
- data/skills/rigor-ask/SKILL.md +172 -0
- data/skills/rigor-doctor/SKILL.md +87 -0
- data/skills/rigor-editor-setup/SKILL.md +114 -0
- data/skills/rigor-mcp-setup/SKILL.md +117 -0
- data/skills/rigor-monkeypatch-resolve/SKILL.md +79 -0
- data/skills/rigor-next-steps/SKILL.md +113 -0
- data/skills/rigor-plugin-tune/SKILL.md +79 -0
- data/skills/rigor-protection-uplift/SKILL.md +133 -0
- data/skills/rigor-rbs-setup/SKILL.md +128 -0
- data/skills/rigor-upgrade/SKILL.md +79 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
390
|
-
return
|
|
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
|
-
#
|
|
441
|
-
#
|
|
442
|
-
|
|
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
|
|
522
|
-
# elements; cap that count so a huge `n` (or
|
|
523
|
-
# the Constant. `Range#size`
|
|
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 =
|
|
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
|
-
|
|
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
|