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.
- checksums.yaml +4 -4
- data/README.md +82 -20
- data/data/core_overlay/numeric.rbs +33 -0
- data/data/core_overlay/pathname.rbs +25 -0
- data/data/core_overlay/string_scanner.rbs +28 -0
- data/data/gem_overlay/activesupport/core_ext.rbs +473 -0
- data/data/vendored_gem_sigs/ast/ast.rbs +130 -0
- data/data/vendored_gem_sigs/bcrypt/bcrypt.rbs +47 -0
- data/data/vendored_gem_sigs/bundler/bundler.rbs +238 -0
- data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +34 -0
- data/data/vendored_gem_sigs/did_you_mean/did_you_mean_extras.rbs +34 -0
- data/data/vendored_gem_sigs/idn-ruby/idn.rbs +54 -0
- data/data/vendored_gem_sigs/mysql2/client.rbs +55 -0
- data/data/vendored_gem_sigs/mysql2/error.rbs +5 -0
- data/data/vendored_gem_sigs/mysql2/result.rbs +31 -0
- data/data/vendored_gem_sigs/mysql2/statement.rbs +5 -0
- data/data/vendored_gem_sigs/nokogiri/nokogiri.rbs +2332 -0
- data/data/vendored_gem_sigs/nokogiri/nokogiri_html5.rbs +47 -0
- data/data/vendored_gem_sigs/pg/pg.rbs +212 -0
- data/data/vendored_gem_sigs/prism/prism_supplement.rbs +44 -0
- data/data/vendored_gem_sigs/redis/errors.rbs +50 -0
- data/data/vendored_gem_sigs/redis/future.rbs +5 -0
- data/data/vendored_gem_sigs/redis/redis.rbs +348 -0
- data/data/vendored_gem_sigs/redis/redis_extras.rbs +130 -0
- data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +226 -0
- 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 +26 -3
- data/lib/rigor/cli/coverage_command.rb +67 -92
- data/lib/rigor/cli/coverage_mutation.rb +149 -0
- data/lib/rigor/cli/docs_command.rb +248 -0
- data/lib/rigor/cli/fused_protection_renderer.rb +67 -0
- data/lib/rigor/cli/fused_protection_report.rb +76 -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/config_audit.rb +152 -0
- data/lib/rigor/configuration.rb +12 -0
- data/lib/rigor/environment/rbs_loader.rb +27 -0
- data/lib/rigor/environment.rb +49 -1
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +140 -38
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +37 -6
- data/lib/rigor/inference/scope_indexer.rb +87 -89
- data/lib/rigor/inference/statement_evaluator.rb +27 -0
- data/lib/rigor/plugin/isolation.rb +5 -5
- data/lib/rigor/plugin/loader.rb +4 -2
- data/lib/rigor/protection/diagnostic_oracle.rb +51 -0
- data/lib/rigor/protection/mutation_scanner.rb +98 -38
- data/lib/rigor/protection/mutator.rb +21 -0
- data/lib/rigor/protection/test_suite_oracle.rb +68 -0
- data/lib/rigor/signature_path_audit.rb +92 -0
- 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 +120 -1
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
class CLI
|
|
5
|
+
# ADR-70 — aggregates per-file {Protection::MutationScanner::FusedFileResult}
|
|
6
|
+
# into a project-level **fused** protection report: how many type-visible
|
|
7
|
+
# breakages were caught by the type checker, how many of the *type-survivors*
|
|
8
|
+
# were caught by the test suite, and which sites neither axis caught — the
|
|
9
|
+
# ranked "add a type OR a test here" list.
|
|
10
|
+
#
|
|
11
|
+
# Framing (ADR-63 / ADR-62 Criterion A, extended): the payload is the
|
|
12
|
+
# **attribution** — which protection axis is missing — never raw survival.
|
|
13
|
+
# An unprotected site is "add protection here", never "your code is broken".
|
|
14
|
+
FusedFileProtection = Data.define(:path, :type_killed, :test_killed, :unprotected, :ratio)
|
|
15
|
+
UnprotectedBreakage = Data.define(:method_name, :count, :examples)
|
|
16
|
+
|
|
17
|
+
FusedProtectionReport = Data.define(:files, :unprotected, :parse_errors) do
|
|
18
|
+
def total_type_killed = files.sum(&:type_killed)
|
|
19
|
+
def total_test_killed = files.sum(&:test_killed)
|
|
20
|
+
def total_unprotected = files.sum(&:unprotected)
|
|
21
|
+
def grand_total = total_type_killed + total_test_killed + total_unprotected
|
|
22
|
+
def protected_total = total_type_killed + total_test_killed
|
|
23
|
+
def ratio = grand_total.zero? ? 1.0 : protected_total.to_f / grand_total
|
|
24
|
+
|
|
25
|
+
def to_h
|
|
26
|
+
{
|
|
27
|
+
"mode" => "protection-fused",
|
|
28
|
+
"type_killed" => total_type_killed,
|
|
29
|
+
"test_killed" => total_test_killed,
|
|
30
|
+
"unprotected" => total_unprotected,
|
|
31
|
+
"protected_ratio" => ratio.round(4),
|
|
32
|
+
"files" => files.map do |f|
|
|
33
|
+
{ "path" => f.path, "type_killed" => f.type_killed, "test_killed" => f.test_killed,
|
|
34
|
+
"unprotected" => f.unprotected, "ratio" => f.ratio.round(4) }
|
|
35
|
+
end,
|
|
36
|
+
"add_protection_here" => unprotected.map do |m|
|
|
37
|
+
{ "method" => m.method_name, "count" => m.count, "examples" => m.examples }
|
|
38
|
+
end,
|
|
39
|
+
"parse_errors" => parse_errors
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
class FusedProtectionAccumulator
|
|
45
|
+
def initialize
|
|
46
|
+
@files = []
|
|
47
|
+
@unprotected = Hash.new { |h, k| h[k] = { count: 0, examples: [] } }
|
|
48
|
+
@parse_errors = []
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def absorb(file_result)
|
|
52
|
+
@files << FusedFileProtection.new(
|
|
53
|
+
path: file_result.path, type_killed: file_result.type_killed,
|
|
54
|
+
test_killed: file_result.test_killed, unprotected: file_result.unprotected,
|
|
55
|
+
ratio: file_result.ratio
|
|
56
|
+
)
|
|
57
|
+
file_result.sites.each do |site|
|
|
58
|
+
bucket = @unprotected[site.method_name]
|
|
59
|
+
bucket[:count] += 1
|
|
60
|
+
bucket[:examples] << "#{file_result.path}:#{site.line}" if bucket[:examples].size < 3
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def record_parse_error(path, errors)
|
|
65
|
+
@parse_errors << { "path" => path, "errors" => errors.size }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def to_report
|
|
69
|
+
unprotected = @unprotected
|
|
70
|
+
.map { |m, v| UnprotectedBreakage.new(method_name: m, count: v[:count], examples: v[:examples]) }
|
|
71
|
+
.sort_by { |m| [-m.count, m.method_name] }
|
|
72
|
+
FusedProtectionReport.new(files: @files, unprotected: unprotected, parse_errors: @parse_errors)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "command"
|
|
4
4
|
|
|
5
|
-
require "optparse"
|
|
6
|
-
|
|
7
5
|
module Rigor
|
|
8
6
|
class CLI
|
|
9
7
|
# `rigor skill` — discover and print the SKILL.md files
|
|
@@ -17,59 +15,112 @@ module Rigor
|
|
|
17
15
|
# gem checkout — the project being analysed has no copy, so an
|
|
18
16
|
# AI agent has no a priori way to find them.
|
|
19
17
|
#
|
|
20
|
-
#
|
|
18
|
+
# Grammar (mirrors `rigor docs`): the positional slot is always a
|
|
19
|
+
# skill *name*; alternative outputs are flags, so a skill named
|
|
20
|
+
# `list` or `path` can never be shadowed by a verb.
|
|
21
21
|
#
|
|
22
|
-
# - `rigor skill
|
|
23
|
-
# - `rigor skill
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
# the
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
# - `rigor skill
|
|
31
|
-
#
|
|
22
|
+
# - `rigor skill` — list bundled skills (the default).
|
|
23
|
+
# - `rigor skill <name>` — print the SKILL.md body (header + body).
|
|
24
|
+
# This is the form AI agents call; the
|
|
25
|
+
# inline body plus the header's absolute
|
|
26
|
+
# paths let the agent act with or without
|
|
27
|
+
# a file-reading tool.
|
|
28
|
+
# - `rigor skill --path <name>`— one-line absolute path, for a Read tool.
|
|
29
|
+
# - `rigor skill --list` — table of name + absolute path.
|
|
30
|
+
# - `rigor skill --describe` — ADR-73's live entry point: a cheap
|
|
31
|
+
# project-state probe + the recommended
|
|
32
|
+
# next skill + every skill's current
|
|
33
|
+
# description. The `rigor-next-steps`
|
|
34
|
+
# SKILL routes off this so no
|
|
35
|
+
# version-coupled guidance is frozen into
|
|
36
|
+
# the SKILL. Also spelled `describe`, and
|
|
37
|
+
# surfaced top-level as `rigor describe`.
|
|
32
38
|
#
|
|
33
|
-
# `rigor skill`
|
|
39
|
+
# The pre-v0.3.0 verb spellings `rigor skill list` / `print <name>` /
|
|
40
|
+
# `path <name>` still work but emit a stderr deprecation notice; they
|
|
41
|
+
# are removed in v0.3.0 (see docs/ROADMAP.md § "Scheduled CLI
|
|
42
|
+
# deprecations"). `describe` is a no-argument action, not a name-slot
|
|
43
|
+
# verb, so it stays first-class alongside `--describe`.
|
|
34
44
|
class SkillCommand < Command
|
|
35
45
|
USAGE = <<~USAGE
|
|
36
|
-
Usage: rigor skill <
|
|
46
|
+
Usage: rigor skill [<name>] [--path <name>] [--list] [--describe]
|
|
47
|
+
|
|
48
|
+
With no argument, lists the bundled skills.
|
|
37
49
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
50
|
+
rigor skill List bundled skills
|
|
51
|
+
rigor skill <name> Print the SKILL.md body for <name> (with a header)
|
|
52
|
+
rigor skill --path <name> Print the absolute path of the SKILL.md file for <name>
|
|
53
|
+
rigor skill --list List bundled skills (name + absolute path)
|
|
54
|
+
rigor skill --describe Report project state + recommend the next skill to run
|
|
42
55
|
|
|
43
56
|
Examples:
|
|
44
|
-
rigor skill
|
|
45
|
-
rigor skill
|
|
46
|
-
rigor skill path
|
|
57
|
+
rigor skill
|
|
58
|
+
rigor skill rigor-project-init
|
|
59
|
+
rigor skill --path rigor-baseline-reduce
|
|
60
|
+
rigor skill --describe (also: rigor describe)
|
|
61
|
+
|
|
62
|
+
Deprecated (removed in v0.3.0) — use the forms above:
|
|
63
|
+
rigor skill list -> rigor skill --list
|
|
64
|
+
rigor skill print <name> -> rigor skill <name>
|
|
65
|
+
rigor skill path <name> -> rigor skill --path <name>
|
|
47
66
|
USAGE
|
|
48
67
|
|
|
49
68
|
# The bundled skills live at `<gem_root>/skills/`. From
|
|
50
69
|
# `lib/rigor/cli/skill_command.rb` that is three directories up.
|
|
51
70
|
SKILLS_ROOT = File.expand_path("../../../skills", __dir__)
|
|
52
71
|
|
|
72
|
+
# The verb subcommands the flags superseded keep working with a
|
|
73
|
+
# stderr deprecation notice until this version drops them. Each maps
|
|
74
|
+
# to the canonical advice printed and the flag it rewrites to.
|
|
75
|
+
LEGACY_VERB_REMOVAL = "v0.3.0"
|
|
76
|
+
LEGACY_VERBS = {
|
|
77
|
+
"list" => { old: "list", advice: "--list", flag: "--list" },
|
|
78
|
+
"print" => { old: "print <name>", advice: "<name>", flag: "--print" },
|
|
79
|
+
"path" => { old: "path <name>", advice: "--path <name>", flag: "--path" }
|
|
80
|
+
}.freeze
|
|
81
|
+
|
|
53
82
|
# @return [Integer] CLI exit status.
|
|
54
83
|
def run
|
|
55
|
-
|
|
84
|
+
rewrite_legacy_verb!
|
|
56
85
|
|
|
57
|
-
case
|
|
58
|
-
when
|
|
59
|
-
|
|
60
|
-
when "path" then run_path
|
|
86
|
+
case @argv.first
|
|
87
|
+
when nil
|
|
88
|
+
run_list
|
|
61
89
|
when "-h", "--help", "help"
|
|
62
90
|
print_usage(@out)
|
|
63
91
|
0
|
|
92
|
+
when "describe", "--describe"
|
|
93
|
+
@argv.shift
|
|
94
|
+
run_describe
|
|
95
|
+
when "--list"
|
|
96
|
+
@argv.shift
|
|
97
|
+
run_list
|
|
98
|
+
when "--path"
|
|
99
|
+
@argv.shift
|
|
100
|
+
run_path(@argv.shift)
|
|
101
|
+
when "--print"
|
|
102
|
+
@argv.shift
|
|
103
|
+
run_print(@argv.shift)
|
|
64
104
|
else
|
|
65
|
-
@
|
|
66
|
-
print_usage(@err)
|
|
67
|
-
Rigor::CLI::EXIT_USAGE
|
|
105
|
+
run_print(@argv.shift)
|
|
68
106
|
end
|
|
69
107
|
end
|
|
70
108
|
|
|
71
109
|
private
|
|
72
110
|
|
|
111
|
+
# Translate a deprecated verb spelling into its flag form, warning
|
|
112
|
+
# once on stderr, so the dispatch above only handles canonical forms.
|
|
113
|
+
def rewrite_legacy_verb!
|
|
114
|
+
spec = LEGACY_VERBS[@argv.first]
|
|
115
|
+
return unless spec
|
|
116
|
+
|
|
117
|
+
@err.puts(
|
|
118
|
+
"rigor skill: `#{spec.fetch(:old)}` is deprecated and will be removed in " \
|
|
119
|
+
"#{LEGACY_VERB_REMOVAL}; use `rigor skill #{spec.fetch(:advice)}` instead."
|
|
120
|
+
)
|
|
121
|
+
@argv[0] = spec.fetch(:flag)
|
|
122
|
+
end
|
|
123
|
+
|
|
73
124
|
def run_list
|
|
74
125
|
skills = discover_skills
|
|
75
126
|
if skills.empty?
|
|
@@ -84,9 +135,8 @@ module Rigor
|
|
|
84
135
|
0
|
|
85
136
|
end
|
|
86
137
|
|
|
87
|
-
def run_print
|
|
88
|
-
name
|
|
89
|
-
return usage_error("`print` requires a skill name") if name.nil?
|
|
138
|
+
def run_print(name)
|
|
139
|
+
return usage_error("a skill name is required") if name.nil?
|
|
90
140
|
|
|
91
141
|
skill = find_skill(name)
|
|
92
142
|
return name_error(name) if skill.nil?
|
|
@@ -97,9 +147,8 @@ module Rigor
|
|
|
97
147
|
0
|
|
98
148
|
end
|
|
99
149
|
|
|
100
|
-
def run_path
|
|
101
|
-
name
|
|
102
|
-
return usage_error("`path` requires a skill name") if name.nil?
|
|
150
|
+
def run_path(name)
|
|
151
|
+
return usage_error("`--path` requires a skill name") if name.nil?
|
|
103
152
|
|
|
104
153
|
skill = find_skill(name)
|
|
105
154
|
return name_error(name) if skill.nil?
|
|
@@ -108,11 +157,24 @@ module Rigor
|
|
|
108
157
|
0
|
|
109
158
|
end
|
|
110
159
|
|
|
160
|
+
# `rigor skill --describe` (also spelled `describe`) — ADR-73's
|
|
161
|
+
# live "brain", delegated to {SkillDescribe}: it probes the current
|
|
162
|
+
# project's state with cheap presence checks (it never runs `rigor
|
|
163
|
+
# check`), recommends the next skill to run, and prints every
|
|
164
|
+
# bundled skill's current frontmatter description, so the
|
|
165
|
+
# `rigor-next-steps` SKILL can route without copying any
|
|
166
|
+
# version-coupled guidance into itself.
|
|
167
|
+
def run_describe
|
|
168
|
+
require_relative "skill_describe"
|
|
169
|
+
|
|
170
|
+
@out.puts(SkillDescribe.new(skills: discover_skills).render)
|
|
171
|
+
0
|
|
172
|
+
end
|
|
173
|
+
|
|
111
174
|
# The header that precedes the SKILL.md body when an agent
|
|
112
|
-
# runs `rigor skill
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
# marker) is unchanged.
|
|
175
|
+
# runs `rigor skill <name>`. Kept as `# `-prefixed comment lines
|
|
176
|
+
# so the combined output remains parseable as markdown — anything
|
|
177
|
+
# below `---` (the SKILL frontmatter marker) is unchanged.
|
|
116
178
|
def render_print_header(skill)
|
|
117
179
|
references_dir = File.join(File.dirname(skill.fetch(:path)), "references")
|
|
118
180
|
ref_line = if File.directory?(references_dir)
|
|
@@ -147,7 +209,7 @@ module Rigor
|
|
|
147
209
|
|
|
148
210
|
def name_error(name)
|
|
149
211
|
@err.puts("Unknown skill: #{name}")
|
|
150
|
-
@err.puts("Available skills:")
|
|
212
|
+
@err.puts("Available skills (try `rigor skill --list`):")
|
|
151
213
|
discover_skills.each { |s| @err.puts(" #{s.fetch(:name)}") }
|
|
152
214
|
1
|
|
153
215
|
end
|
|
@@ -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
|