rigortype 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +82 -20
  3. data/data/core_overlay/numeric.rbs +33 -0
  4. data/data/core_overlay/pathname.rbs +25 -0
  5. data/data/core_overlay/string_scanner.rbs +28 -0
  6. data/data/gem_overlay/activesupport/core_ext.rbs +473 -0
  7. data/data/vendored_gem_sigs/ast/ast.rbs +130 -0
  8. data/data/vendored_gem_sigs/bcrypt/bcrypt.rbs +47 -0
  9. data/data/vendored_gem_sigs/bundler/bundler.rbs +238 -0
  10. data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +34 -0
  11. data/data/vendored_gem_sigs/did_you_mean/did_you_mean_extras.rbs +34 -0
  12. data/data/vendored_gem_sigs/idn-ruby/idn.rbs +54 -0
  13. data/data/vendored_gem_sigs/mysql2/client.rbs +55 -0
  14. data/data/vendored_gem_sigs/mysql2/error.rbs +5 -0
  15. data/data/vendored_gem_sigs/mysql2/result.rbs +31 -0
  16. data/data/vendored_gem_sigs/mysql2/statement.rbs +5 -0
  17. data/data/vendored_gem_sigs/nokogiri/nokogiri.rbs +2332 -0
  18. data/data/vendored_gem_sigs/nokogiri/nokogiri_html5.rbs +47 -0
  19. data/data/vendored_gem_sigs/pg/pg.rbs +212 -0
  20. data/data/vendored_gem_sigs/prism/prism_supplement.rbs +44 -0
  21. data/data/vendored_gem_sigs/redis/errors.rbs +50 -0
  22. data/data/vendored_gem_sigs/redis/future.rbs +5 -0
  23. data/data/vendored_gem_sigs/redis/redis.rbs +348 -0
  24. data/data/vendored_gem_sigs/redis/redis_extras.rbs +130 -0
  25. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +226 -0
  26. data/docs/handbook/01-getting-started.md +311 -0
  27. data/docs/handbook/02-everyday-types.md +337 -0
  28. data/docs/handbook/03-narrowing.md +359 -0
  29. data/docs/handbook/04-tuples-and-shapes.md +321 -0
  30. data/docs/handbook/05-methods-and-blocks.md +339 -0
  31. data/docs/handbook/06-classes.md +305 -0
  32. data/docs/handbook/07-rbs-and-extended.md +427 -0
  33. data/docs/handbook/08-understanding-errors.md +373 -0
  34. data/docs/handbook/09-plugins.md +241 -0
  35. data/docs/handbook/10-sorbet.md +347 -0
  36. data/docs/handbook/11-sig-gen.md +312 -0
  37. data/docs/handbook/12-lightweight-hkt.md +333 -0
  38. data/docs/handbook/README.md +275 -0
  39. data/docs/handbook/appendix-elixir.md +370 -0
  40. data/docs/handbook/appendix-go.md +399 -0
  41. data/docs/handbook/appendix-java-csharp.md +470 -0
  42. data/docs/handbook/appendix-liskov.md +580 -0
  43. data/docs/handbook/appendix-mypy.md +370 -0
  44. data/docs/handbook/appendix-phpstan.md +338 -0
  45. data/docs/handbook/appendix-protocols-and-structural-typing.md +292 -0
  46. data/docs/handbook/appendix-rust.md +446 -0
  47. data/docs/handbook/appendix-steep.md +336 -0
  48. data/docs/handbook/appendix-type-theory.md +1662 -0
  49. data/docs/handbook/appendix-typeprof.md +416 -0
  50. data/docs/handbook/appendix-typescript.md +332 -0
  51. data/docs/install.md +189 -0
  52. data/docs/llms.txt +72 -0
  53. data/docs/manual/01-installation.md +342 -0
  54. data/docs/manual/02-cli-reference.md +557 -0
  55. data/docs/manual/03-configuration.md +152 -0
  56. data/docs/manual/04-diagnostics.md +206 -0
  57. data/docs/manual/05-inspecting-types.md +109 -0
  58. data/docs/manual/06-baseline.md +104 -0
  59. data/docs/manual/07-plugins.md +92 -0
  60. data/docs/manual/08-skills.md +143 -0
  61. data/docs/manual/09-editor-integration.md +245 -0
  62. data/docs/manual/10-mcp-server.md +532 -0
  63. data/docs/manual/11-ci.md +274 -0
  64. data/docs/manual/12-caching.md +116 -0
  65. data/docs/manual/13-troubleshooting.md +120 -0
  66. data/docs/manual/14-rails-quickstart.md +332 -0
  67. data/docs/manual/15-type-protection-coverage.md +204 -0
  68. data/docs/manual/16-rbs-extended-annotations.md +190 -0
  69. data/docs/manual/17-driving-improvement.md +160 -0
  70. data/docs/manual/README.md +87 -0
  71. data/docs/manual/ci-templates/README.md +58 -0
  72. data/docs/manual/plugins/README.md +86 -0
  73. data/docs/manual/plugins/rigor-actioncable.md +78 -0
  74. data/docs/manual/plugins/rigor-actionmailer.md +74 -0
  75. data/docs/manual/plugins/rigor-actionpack.md +80 -0
  76. data/docs/manual/plugins/rigor-activejob.md +58 -0
  77. data/docs/manual/plugins/rigor-activerecord.md +102 -0
  78. data/docs/manual/plugins/rigor-activestorage.md +74 -0
  79. data/docs/manual/plugins/rigor-activesupport-core-ext.md +86 -0
  80. data/docs/manual/plugins/rigor-devise.md +70 -0
  81. data/docs/manual/plugins/rigor-dry-schema.md +56 -0
  82. data/docs/manual/plugins/rigor-dry-struct.md +60 -0
  83. data/docs/manual/plugins/rigor-dry-types.md +59 -0
  84. data/docs/manual/plugins/rigor-dry-validation.md +62 -0
  85. data/docs/manual/plugins/rigor-factorybot.md +76 -0
  86. data/docs/manual/plugins/rigor-graphql.md +89 -0
  87. data/docs/manual/plugins/rigor-hanami.md +83 -0
  88. data/docs/manual/plugins/rigor-mangrove.md +73 -0
  89. data/docs/manual/plugins/rigor-minitest.md +86 -0
  90. data/docs/manual/plugins/rigor-pundit.md +72 -0
  91. data/docs/manual/plugins/rigor-rails-i18n.md +92 -0
  92. data/docs/manual/plugins/rigor-rails-routes.md +94 -0
  93. data/docs/manual/plugins/rigor-rails.md +44 -0
  94. data/docs/manual/plugins/rigor-rbs-inline.md +83 -0
  95. data/docs/manual/plugins/rigor-rspec-rails.md +72 -0
  96. data/docs/manual/plugins/rigor-rspec.md +86 -0
  97. data/docs/manual/plugins/rigor-shoulda-matchers.md +78 -0
  98. data/docs/manual/plugins/rigor-sidekiq.md +78 -0
  99. data/docs/manual/plugins/rigor-sinatra.md +61 -0
  100. data/docs/manual/plugins/rigor-sorbet.md +63 -0
  101. data/docs/manual/plugins/rigor-statesman.md +75 -0
  102. data/docs/manual/plugins/rigor-typescript-utility-types.md +71 -0
  103. data/exe/rigor +1 -1
  104. data/lib/rigor/analysis/incremental_session.rb +4 -2
  105. data/lib/rigor/analysis/run_stats.rb +13 -1
  106. data/lib/rigor/analysis/runner.rb +54 -12
  107. data/lib/rigor/cli/check_command.rb +26 -3
  108. data/lib/rigor/cli/coverage_command.rb +67 -92
  109. data/lib/rigor/cli/coverage_mutation.rb +149 -0
  110. data/lib/rigor/cli/docs_command.rb +248 -0
  111. data/lib/rigor/cli/fused_protection_renderer.rb +67 -0
  112. data/lib/rigor/cli/fused_protection_report.rb +76 -0
  113. data/lib/rigor/cli/skill_command.rb +103 -41
  114. data/lib/rigor/cli/skill_describe.rb +346 -0
  115. data/lib/rigor/cli.rb +25 -3
  116. data/lib/rigor/config_audit.rb +152 -0
  117. data/lib/rigor/configuration.rb +12 -0
  118. data/lib/rigor/environment/rbs_loader.rb +27 -0
  119. data/lib/rigor/environment.rb +49 -1
  120. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +140 -38
  121. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +37 -6
  122. data/lib/rigor/inference/scope_indexer.rb +87 -89
  123. data/lib/rigor/inference/statement_evaluator.rb +27 -0
  124. data/lib/rigor/plugin/isolation.rb +5 -5
  125. data/lib/rigor/plugin/loader.rb +4 -2
  126. data/lib/rigor/protection/diagnostic_oracle.rb +51 -0
  127. data/lib/rigor/protection/mutation_scanner.rb +98 -38
  128. data/lib/rigor/protection/mutator.rb +21 -0
  129. data/lib/rigor/protection/test_suite_oracle.rb +68 -0
  130. data/lib/rigor/signature_path_audit.rb +92 -0
  131. data/lib/rigor/version.rb +1 -1
  132. data/skills/rigor-ask/SKILL.md +172 -0
  133. data/skills/rigor-doctor/SKILL.md +87 -0
  134. data/skills/rigor-editor-setup/SKILL.md +114 -0
  135. data/skills/rigor-mcp-setup/SKILL.md +117 -0
  136. data/skills/rigor-monkeypatch-resolve/SKILL.md +79 -0
  137. data/skills/rigor-next-steps/SKILL.md +113 -0
  138. data/skills/rigor-plugin-tune/SKILL.md +79 -0
  139. data/skills/rigor-protection-uplift/SKILL.md +133 -0
  140. data/skills/rigor-rbs-setup/SKILL.md +128 -0
  141. data/skills/rigor-upgrade/SKILL.md +79 -0
  142. metadata +120 -1
@@ -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
- # This command exposes the bundled skills via three subcommands:
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 list` table of name + absolute path.
23
- # - `rigor skill print <name>` short header (paths + how to use)
24
- # followed by the SKILL.md body. This
25
- # is the form AI agents should call;
26
- # the inline body plus the header's
27
- # absolute paths together let the
28
- # agent act with or without a file
29
- # reading tool.
30
- # - `rigor skill path <name>` one-line absolute path, suitable
31
- # as input to a Read tool.
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` with no subcommand is an alias for `list`.
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 <subcommand> [args]
46
+ Usage: rigor skill [<name>] [--path <name>] [--list] [--describe]
47
+
48
+ With no argument, lists the bundled skills.
37
49
 
38
- Subcommands:
39
- list List bundled skills (default when no subcommand given)
40
- print <name> Print the SKILL.md body for <name> to stdout, with a header
41
- path <name> Print the absolute path of the SKILL.md file for <name>
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 list
45
- rigor skill print rigor-project-init
46
- rigor skill path rigor-baseline-reduce
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
- subcommand = @argv.shift || "list"
84
+ rewrite_legacy_verb!
56
85
 
57
- case subcommand
58
- when "list" then run_list
59
- when "print" then run_print
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
- @err.puts("Unknown subcommand: #{subcommand}")
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 = @argv.shift
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 = @argv.shift
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 print <name>`. Kept as `# `-prefixed
113
- # comment lines so the combined output remains parseable as
114
- # markdown — anything below `---` (the SKILL frontmatter
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