rigortype 0.2.9 → 0.3.1

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 (222) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/data/builtins/ruby_core/array.yml +416 -392
  4. data/data/builtins/ruby_core/file.yml +42 -42
  5. data/data/builtins/ruby_core/hash.yml +302 -302
  6. data/data/builtins/ruby_core/io.yml +191 -191
  7. data/data/builtins/ruby_core/numeric.yml +321 -366
  8. data/data/builtins/ruby_core/proc.yml +124 -124
  9. data/data/builtins/ruby_core/range.yml +21 -21
  10. data/data/builtins/ruby_core/rational.yml +39 -39
  11. data/data/builtins/ruby_core/re.yml +65 -65
  12. data/data/builtins/ruby_core/set.yml +106 -106
  13. data/data/builtins/ruby_core/struct.yml +14 -14
  14. data/data/core_overlay/string_scanner.rbs +6 -5
  15. data/docs/handbook/01-getting-started.md +22 -34
  16. data/docs/handbook/03-narrowing.md +2 -1
  17. data/docs/handbook/04-tuples-and-shapes.md +7 -3
  18. data/docs/handbook/06-classes.md +19 -10
  19. data/docs/handbook/07-rbs-and-extended.md +76 -101
  20. data/docs/handbook/08-understanding-errors.md +114 -246
  21. data/docs/handbook/09-plugins.md +54 -144
  22. data/docs/handbook/11-sig-gen.md +11 -1
  23. data/docs/handbook/README.md +5 -3
  24. data/docs/handbook/appendix-liskov.md +4 -2
  25. data/docs/handbook/appendix-mypy.md +4 -3
  26. data/docs/handbook/appendix-phpstan.md +14 -7
  27. data/docs/handbook/appendix-steep.md +4 -2
  28. data/docs/handbook/appendix-type-theory.md +3 -1
  29. data/docs/install.md +14 -6
  30. data/docs/manual/01-installation.md +98 -4
  31. data/docs/manual/02-cli-reference.md +81 -12
  32. data/docs/manual/03-configuration.md +34 -2
  33. data/docs/manual/04-diagnostics.md +59 -4
  34. data/docs/manual/06-baseline.md +35 -1
  35. data/docs/manual/07-plugins.md +4 -4
  36. data/docs/manual/08-skills.md +6 -1
  37. data/docs/manual/09-editor-integration.md +7 -6
  38. data/docs/manual/11-ci.md +51 -0
  39. data/docs/manual/12-caching.md +65 -0
  40. data/docs/manual/plugins/rigor-actioncable.md +32 -0
  41. data/docs/manual/plugins/rigor-devise.md +4 -2
  42. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -1
  43. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +5 -3
  44. data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +128 -0
  45. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +60 -0
  46. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -2
  47. data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +117 -0
  48. data/lib/rigor/analysis/check_rules/rule_ids.rb +125 -0
  49. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  50. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -1
  51. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +283 -0
  52. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +10 -1
  53. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +112 -0
  54. data/lib/rigor/analysis/check_rules.rb +581 -98
  55. data/lib/rigor/analysis/dependency_recorder.rb +93 -9
  56. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  57. data/lib/rigor/analysis/incremental_session.rb +456 -51
  58. data/lib/rigor/analysis/path_expansion.rb +42 -0
  59. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +188 -0
  60. data/lib/rigor/analysis/rule_catalog.rb +226 -1
  61. data/lib/rigor/analysis/run_cache_key.rb +78 -0
  62. data/lib/rigor/analysis/run_cache_probe.rb +72 -0
  63. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +181 -28
  64. data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -15
  65. data/lib/rigor/analysis/runner/project_pre_passes.rb +95 -49
  66. data/lib/rigor/analysis/runner/run_snapshots.rb +9 -2
  67. data/lib/rigor/analysis/runner.rb +344 -68
  68. data/lib/rigor/analysis/severity_stamp.rb +43 -0
  69. data/lib/rigor/analysis/worker_session.rb +30 -2
  70. data/lib/rigor/bleeding_edge.rb +37 -8
  71. data/lib/rigor/builtins/regex_refinement.rb +36 -1
  72. data/lib/rigor/cache/descriptor.rb +88 -28
  73. data/lib/rigor/cache/file_digest.rb +158 -0
  74. data/lib/rigor/cache/incremental_snapshot.rb +41 -5
  75. data/lib/rigor/cache/rbs_cache_producer.rb +11 -1
  76. data/lib/rigor/cache/rbs_descriptor.rb +56 -10
  77. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +38 -0
  78. data/lib/rigor/cache/store.rb +132 -27
  79. data/lib/rigor/ci_detector.rb +90 -0
  80. data/lib/rigor/cli/annotate_command.rb +15 -9
  81. data/lib/rigor/cli/baseline_command.rb +4 -1
  82. data/lib/rigor/cli/check_command.rb +223 -32
  83. data/lib/rigor/cli/check_invocation.rb +84 -0
  84. data/lib/rigor/cli/coverage_command.rb +6 -1
  85. data/lib/rigor/cli/docs_command.rb +0 -30
  86. data/lib/rigor/cli/doctor_command.rb +169 -31
  87. data/lib/rigor/cli/lsp_command.rb +5 -0
  88. data/lib/rigor/cli/mcp_command.rb +5 -0
  89. data/lib/rigor/cli/plugins_command.rb +36 -6
  90. data/lib/rigor/cli/plugins_renderer.rb +44 -12
  91. data/lib/rigor/cli/probe_environment.rb +85 -0
  92. data/lib/rigor/cli/sig_gen_command.rb +36 -9
  93. data/lib/rigor/cli/skill_command.rb +22 -33
  94. data/lib/rigor/cli/skill_deep_probe.rb +172 -0
  95. data/lib/rigor/cli/skill_describe.rb +75 -9
  96. data/lib/rigor/cli/trace_command.rb +5 -6
  97. data/lib/rigor/cli/type_of_command.rb +9 -8
  98. data/lib/rigor/cli/type_scan_command.rb +8 -8
  99. data/lib/rigor/cli.rb +15 -4
  100. data/lib/rigor/config_audit.rb +48 -2
  101. data/lib/rigor/configuration/severity_profile.rb +31 -0
  102. data/lib/rigor/configuration.rb +156 -8
  103. data/lib/rigor/environment/default_libraries.rb +36 -0
  104. data/lib/rigor/environment/rbs_loader.rb +164 -5
  105. data/lib/rigor/environment.rb +29 -28
  106. data/lib/rigor/inference/budget_trace.rb +77 -2
  107. data/lib/rigor/inference/closure_escape_analyzer.rb +14 -1
  108. data/lib/rigor/inference/def_handle.rb +23 -0
  109. data/lib/rigor/inference/def_node_resolver.rb +90 -0
  110. data/lib/rigor/inference/def_return_typer.rb +2 -1
  111. data/lib/rigor/inference/expression_typer.rb +361 -113
  112. data/lib/rigor/inference/method_dispatcher/data_folding.rb +30 -5
  113. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +200 -0
  114. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +22 -16
  115. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +33 -0
  116. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +17 -1
  117. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +42 -9
  118. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +5 -0
  119. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +28 -35
  120. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +40 -8
  121. data/lib/rigor/inference/method_dispatcher.rb +57 -1
  122. data/lib/rigor/inference/mutation_widening.rb +42 -8
  123. data/lib/rigor/inference/narrowing.rb +127 -17
  124. data/lib/rigor/inference/parameter_inference_collector.rb +15 -0
  125. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  126. data/lib/rigor/inference/rbs_type_translator.rb +8 -1
  127. data/lib/rigor/inference/scope_indexer.rb +451 -36
  128. data/lib/rigor/inference/statement_evaluator.rb +64 -15
  129. data/lib/rigor/inference/struct_fold_safety.rb +48 -11
  130. data/lib/rigor/inference/synthetic_method_scanner.rb +15 -14
  131. data/lib/rigor/inference/void_origin.rb +25 -0
  132. data/lib/rigor/inference/void_tail_summary.rb +220 -0
  133. data/lib/rigor/language_server/buffer_resolution.rb +6 -3
  134. data/lib/rigor/language_server/buffer_table.rb +46 -6
  135. data/lib/rigor/language_server/completion_provider.rb +2 -1
  136. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -0
  137. data/lib/rigor/language_server/document_symbol_provider.rb +2 -1
  138. data/lib/rigor/language_server/folding_range_provider.rb +2 -1
  139. data/lib/rigor/language_server/incremental_sync.rb +159 -0
  140. data/lib/rigor/language_server/selection_range_provider.rb +2 -1
  141. data/lib/rigor/language_server/server.rb +19 -9
  142. data/lib/rigor/language_server/signature_help_provider.rb +2 -1
  143. data/lib/rigor/language_server.rb +1 -0
  144. data/lib/rigor/plugin/base.rb +38 -24
  145. data/lib/rigor/plugin/inflector.rb +12 -3
  146. data/lib/rigor/plugin/io_boundary.rb +5 -1
  147. data/lib/rigor/plugin/isolation.rb +81 -11
  148. data/lib/rigor/plugin/load_error.rb +10 -1
  149. data/lib/rigor/plugin/loader.rb +96 -14
  150. data/lib/rigor/plugin/node_rule_walk.rb +5 -3
  151. data/lib/rigor/plugin/registry.rb +32 -23
  152. data/lib/rigor/plugin.rb +26 -0
  153. data/lib/rigor/protection/mutator.rb +3 -2
  154. data/lib/rigor/reflection.rb +64 -0
  155. data/lib/rigor/runtime/jit.rb +128 -0
  156. data/lib/rigor/scope/discovery_index.rb +12 -2
  157. data/lib/rigor/scope.rb +122 -28
  158. data/lib/rigor/sig_gen/classification.rb +5 -1
  159. data/lib/rigor/sig_gen/generator.rb +38 -6
  160. data/lib/rigor/sig_gen/observation_collector.rb +4 -3
  161. data/lib/rigor/sig_gen/rbs_validity.rb +44 -0
  162. data/lib/rigor/sig_gen/renderer.rb +10 -0
  163. data/lib/rigor/sig_gen/write_result.rb +9 -4
  164. data/lib/rigor/sig_gen/writer.rb +207 -49
  165. data/lib/rigor/source/node_children.rb +116 -0
  166. data/lib/rigor/source/node_locator.rb +3 -1
  167. data/lib/rigor/source/node_walker.rb +4 -2
  168. data/lib/rigor/source.rb +1 -0
  169. data/lib/rigor/type/difference.rb +28 -24
  170. data/lib/rigor/type/hash_shape.rb +34 -10
  171. data/lib/rigor/version.rb +1 -1
  172. data/lib/rigortype.rb +24 -0
  173. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +4 -2
  174. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -1
  175. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +6 -4
  176. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +5 -3
  177. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +4 -2
  178. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +3 -1
  179. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -2
  180. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +4 -2
  181. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -1
  182. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +4 -2
  183. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +38 -6
  184. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -1
  185. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +3 -1
  186. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +10 -0
  187. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +11 -0
  188. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +3 -1
  189. data/plugins/rigor-rails/lib/rigor-rails.rb +11 -12
  190. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +3 -1
  191. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +3 -1
  192. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +7 -5
  193. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +83 -9
  194. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +9 -5
  195. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -1
  196. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +3 -1
  197. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +11 -0
  198. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +4 -1
  199. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +4 -2
  200. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +3 -1
  201. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +74 -14
  202. data/sig/prism_node_children.rbs +9 -0
  203. data/sig/rigor/cache.rbs +6 -0
  204. data/sig/rigor/inference/void_origin.rbs +18 -0
  205. data/sig/rigor/inference.rbs +7 -0
  206. data/sig/rigor/plugin/base.rbs +6 -6
  207. data/sig/rigor/reflection.rbs +1 -0
  208. data/sig/rigor/scope.rbs +12 -1
  209. data/skills/rigor-ci-setup/SKILL.md +10 -0
  210. data/skills/rigor-editor-setup/SKILL.md +11 -0
  211. data/skills/rigor-next-steps/SKILL.md +23 -2
  212. data/skills/rigor-plugin-author/SKILL.md +3 -3
  213. data/skills/rigor-plugin-author/references/02-walker-and-types.md +2 -2
  214. data/skills/rigor-plugin-review/SKILL.md +2 -2
  215. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +5 -4
  216. data/skills/rigor-project-init/SKILL.md +29 -3
  217. data/skills/rigor-project-init/references/01-detect.md +1 -1
  218. data/skills/rigor-project-init/references/02-configure.md +39 -5
  219. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +20 -0
  220. data/skills/rigor-project-init/references/05-jit-performance.md +125 -0
  221. metadata +48 -26
  222. data/lib/rigor/cli/ci_detector.rb +0 -89
@@ -42,21 +42,43 @@ module Rigor
42
42
  paths = @argv.empty? ? configuration.paths : @argv
43
43
 
44
44
  observations = collect_observations(configuration, options)
45
- candidates = SigGen::Generator.new(configuration: configuration, paths: paths,
46
- observations: observations,
47
- include_private: options.fetch(:include_private)).run
45
+ generator = SigGen::Generator.new(configuration: configuration, paths: paths,
46
+ observations: observations,
47
+ include_private: options.fetch(:include_private))
48
+ candidates = generator.run
48
49
  mode = options.fetch(:mode).to_sym
49
50
 
50
- if mode == :write
51
- dispatch_write(candidates, configuration, options)
52
- else
53
- dispatch_print_or_diff(candidates, mode, options)
54
- end
55
- 0
51
+ status = if mode == :write
52
+ dispatch_write(candidates, configuration, options)
53
+ else
54
+ dispatch_print_or_diff(candidates, mode, options)
55
+ 0
56
+ end
57
+ report_unrenderable(generator.unrenderable)
58
+ status
56
59
  end
57
60
 
58
61
  private
59
62
 
63
+ # A method whose rendered RBS does not parse is a Rigor rendering defect, not a fact about the user's
64
+ # code — the generator skipped it (so the rest of the signatures are still usable and still valid), but
65
+ # staying silent would leave the user with a quietly incomplete `sig/` and us with an unreported bug.
66
+ # Reported on stderr so it never contaminates `--print` output being piped into a file.
67
+ def report_unrenderable(unrenderable)
68
+ return if unrenderable.empty?
69
+
70
+ @err.puts(
71
+ "rigor sig-gen: skipped #{unrenderable.size} method(s) whose generated RBS does not parse. " \
72
+ "This is a bug in Rigor's RBS rendering, not in your code — please report it. " \
73
+ "The remaining signatures are unaffected."
74
+ )
75
+ unrenderable.each do |method|
76
+ @err.puts(" #{method.path}: #{method.class_name}##{method.method_name}")
77
+ @err.puts(" rendered: #{method.rbs}")
78
+ @err.puts(" #{method.error}")
79
+ end
80
+ end
81
+
60
82
  def dispatch_print_or_diff(candidates, mode, options)
61
83
  SigGen::Renderer.new(out: @out).render(
62
84
  candidates: candidates,
@@ -66,6 +88,7 @@ module Rigor
66
88
  )
67
89
  end
68
90
 
91
+ # @return [Integer] exit status — non-zero when a file the user asked to write could not be written.
69
92
  def dispatch_write(candidates, configuration, options)
70
93
  layout_index = SigGen::LayoutIndex.new(signature_paths: configuration.signature_paths)
71
94
  path_mapper = SigGen::PathMapper.new(configuration: configuration, layout_index: layout_index)
@@ -74,6 +97,10 @@ module Rigor
74
97
  results = writer.write_all(candidates)
75
98
 
76
99
  SigGen::Renderer.new(out: @out).render_write(results: results, format: options.fetch(:format))
100
+ # An assembled file that does not parse is refused rather than written. The user asked for a write and
101
+ # did not get one, so the command must not report success — a green `sig-gen --write` in CI would
102
+ # otherwise mean nothing.
103
+ results.any? { |result| result.action == :skipped_invalid_rbs } ? 1 : 0
77
104
  end
78
105
 
79
106
  # Slice 3 — collect call-site argument observations when `--params=observed` is set. When `--observe=PATH` is not
@@ -36,10 +36,15 @@ module Rigor
36
36
  # version-coupled guidance is frozen into
37
37
  # the SKILL. Also spelled `describe`, and
38
38
  # surfaced top-level as `rigor describe`.
39
+ # - `rigor skill describe --deep`
40
+ # — the same report, except it runs `rigor
41
+ # check` first and lets the result pick the
42
+ # headline. Opt-in because that is slow and
43
+ # writes the cache; the un-flagged form keeps
44
+ # WD2's presence-only, side-effect-free
45
+ # contract exactly (issue #148).
39
46
  #
40
- # The pre-v0.3.0 verb spellings `rigor skill list` / `print <name>` / `path <name>` still work but emit a stderr
41
- # deprecation notice; they are removed in v0.3.0 (see docs/ROADMAP.md § "Scheduled CLI deprecations"). `describe` is
42
- # a no-argument action, not a name-slot verb, so it stays first-class alongside `--describe`.
47
+ # `describe` is a no-argument action, not a name-slot verb, so it stays first-class alongside `--describe`.
43
48
  class SkillCommand < Command
44
49
  USAGE = <<~USAGE
45
50
  Usage: rigor skill [<name>] [--full <name>] [--path <name>] [--list] [--describe]
@@ -53,6 +58,10 @@ module Rigor
53
58
  rigor skill --path <name> Print the absolute path of the SKILL.md file for <name>
54
59
  rigor skill --list List bundled skills (name + absolute path)
55
60
  rigor skill --describe Report project state + recommend the next skill to run
61
+ (presence-only probe — never runs `rigor check`)
62
+ rigor skill describe --deep
63
+ Same, but run `rigor check` first and route the
64
+ recommendation on its result (slow; writes the cache)
56
65
 
57
66
  Examples:
58
67
  rigor skill
@@ -60,30 +69,15 @@ module Rigor
60
69
  rigor skill --full rigor-baseline-reduce
61
70
  rigor skill --path rigor-baseline-reduce
62
71
  rigor skill --describe (also: rigor describe)
63
-
64
- Deprecated (removed in v0.3.0) — use the forms above:
65
- rigor skill list -> rigor skill --list
66
- rigor skill print <name> -> rigor skill <name>
67
- rigor skill path <name> -> rigor skill --path <name>
72
+ rigor skill describe --deep (also: rigor describe --deep)
68
73
  USAGE
69
74
 
70
75
  # The bundled skills live at `<gem_root>/skills/`. From `lib/rigor/cli/skill_command.rb` that is three directories
71
76
  # up.
72
77
  SKILLS_ROOT = File.expand_path("../../../skills", __dir__)
73
78
 
74
- # The verb subcommands the flags superseded keep working with a stderr deprecation notice until this version drops
75
- # them. Each maps to the canonical advice printed and the flag it rewrites to.
76
- LEGACY_VERB_REMOVAL = "v0.3.0"
77
- LEGACY_VERBS = {
78
- "list" => { old: "list", advice: "--list", flag: "--list" },
79
- "print" => { old: "print <name>", advice: "<name>", flag: "--print" },
80
- "path" => { old: "path <name>", advice: "--path <name>", flag: "--path" }
81
- }.freeze
82
-
83
79
  # @return [Integer] CLI exit status.
84
80
  def run
85
- rewrite_legacy_verb!
86
-
87
81
  case @argv.first
88
82
  when nil
89
83
  run_list
@@ -112,19 +106,6 @@ module Rigor
112
106
 
113
107
  private
114
108
 
115
- # Translate a deprecated verb spelling into its flag form, warning once on stderr, so the dispatch above only
116
- # handles canonical forms.
117
- def rewrite_legacy_verb!
118
- spec = LEGACY_VERBS[@argv.first]
119
- return unless spec
120
-
121
- @err.puts(
122
- "rigor skill: `#{spec.fetch(:old)}` is deprecated and will be removed in " \
123
- "#{LEGACY_VERB_REMOVAL}; use `rigor skill #{spec.fetch(:advice)}` instead."
124
- )
125
- @argv[0] = spec.fetch(:flag)
126
- end
127
-
128
109
  def run_list
129
110
  skills = discover_skills
130
111
  if skills.empty?
@@ -190,10 +171,18 @@ module Rigor
190
171
  # probes the current project's state with cheap presence checks (it never runs `rigor check`), recommends the next
191
172
  # skill to run, and prints every bundled skill's current frontmatter description, so the `rigor-next-steps` SKILL
192
173
  # can route without copying any version-coupled guidance into itself.
174
+ #
175
+ # `--deep` is the one opt-in that breaks the "never runs `rigor check`" guarantee — and only for the invocation
176
+ # that asks for it (issue #148 / ADR-73 § "Field-trial follow-ups"). It runs a real check and lets the result
177
+ # pick the headline; the un-flagged command is unchanged, down to loading no analysis code at all.
193
178
  def run_describe
179
+ deep = !@argv.delete("--deep").nil?
180
+ unknown = @argv.first
181
+ return usage_error("unknown option for `describe`: #{unknown}") unless unknown.nil?
182
+
194
183
  require_relative "skill_describe"
195
184
 
196
- @out.puts(SkillDescribe.new(skills: discover_skills).render)
185
+ @out.puts(SkillDescribe.new(skills: discover_skills, deep: deep).render)
197
186
  0
198
187
  end
199
188
 
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "check_invocation"
4
+
5
+ module Rigor
6
+ class CLI
7
+ # `rigor skill describe --deep` — the one part of `describe` that runs an analysis (issue #148; ADR-73
8
+ # § "Field-trial follow-ups", the "headline check-awareness" open decision, shape (b)).
9
+ #
10
+ # **The boundary is this file.** ADR-73 WD2 makes `describe` presence-only and side-effect-free, and that
11
+ # guardrail binds the *un-flagged* command exactly as before: {SkillDescribe} requires this file only when
12
+ # `--deep` was passed, so a default `rigor skill describe` never loads the inference engine, never opens a
13
+ # project source file, and writes nothing. `--deep` is the opt-in that trades that purity away: it runs a real
14
+ # `rigor check` over the configured paths using the configured cache and worker count, so it is as slow as
15
+ # `rigor check` on a cold cache and it WRITES `.rigor/cache` just like `rigor check` does.
16
+ #
17
+ # The routing vocabulary is deliberately the one `describe`'s "For the agent" section already teaches (errors →
18
+ # `rigor-baseline-reduce`, a proven monkey-patch cluster → `rigor-monkeypatch-resolve`, an empty RBS environment
19
+ # or a `configuration-error` → `rigor-doctor`). `--deep` makes the headline compute the call the agent would
20
+ # otherwise make from the same evidence; it does not introduce a second taxonomy.
21
+ class SkillDeepProbe
22
+ # Diagnostics the engine emits when the *setup* is broken rather than the code — the `rigor-doctor` signal.
23
+ CONFIGURATION_ERROR_RULE = "configuration-error"
24
+
25
+ # The share of a run's errors that must be proven monkey-patches before the headline routes to
26
+ # `rigor-monkeypatch-resolve` rather than `rigor-baseline-reduce`. The agent-prompt vocabulary this reuses says
27
+ # "a monkey-patch *cluster*", and a cluster is what makes the workflow the right one: `pre_eval:` clears those
28
+ # sites wholesale, so it is the shortest path only when clearing them materially changes the count. One proven
29
+ # site beside four hundred unrelated errors is a true finding pointing at the wrong workflow — the report says
30
+ # so in the reason line either way, so nothing is lost by leaving the headline on the bigger problem.
31
+ MONKEY_PATCH_CLUSTER_SHARE = Rational(1, 3)
32
+
33
+ # What the deep check concluded.
34
+ #
35
+ # - `status` — `:skipped` (no config to check), `:error` (the check could not run), `:analyzed` (it ran).
36
+ # - `detail` — the human line printed under "## Deep check"; on `:error` it says so outright, because a check
37
+ # that could not run is NOT a clean check and the report must never let it read as one.
38
+ # - `route` / `reason` — the headline override, or nil to leave the presence-only recommendation standing.
39
+ Report = Data.define(:status, :detail, :route, :reason) do
40
+ # @return [Boolean] true when the check did not complete — the caller must then say so rather than let the
41
+ # fallback recommendation read as an all-clear.
42
+ def failed?
43
+ status == :error
44
+ end
45
+ end
46
+
47
+ # @param config [String, nil] the config filename the presence probe found, relative to `root`.
48
+ # @param root [String] project root (the analysis itself resolves paths against the process cwd, as
49
+ # `rigor check` does).
50
+ def initialize(config:, root: Dir.pwd)
51
+ @config = config
52
+ @root = root
53
+ end
54
+
55
+ # @return [Report] never raises — every failure degrades to a `:error`/`:skipped` report whose `route` is nil,
56
+ # leaving the presence-only headline in charge.
57
+ def run
58
+ if @config.nil?
59
+ return Report.new(
60
+ status: :skipped, route: nil, reason: nil,
61
+ detail: "skipped — this project has no Rigor configuration, so there is nothing to check yet."
62
+ )
63
+ end
64
+
65
+ outcome = CheckInvocation.attempt(config_path: File.join(@root, @config))
66
+ return failed(outcome.error) unless outcome.ran?
67
+
68
+ classify(outcome.result)
69
+ end
70
+
71
+ private
72
+
73
+ # A check that could not run at all. Reported as a failure, not as a clean result, and with the raw error text
74
+ # so the user can act on it — the un-run check is itself a finding, but not one precise enough to route on.
75
+ def failed(error)
76
+ Report.new(
77
+ status: :error, route: nil, reason: nil,
78
+ detail: "the check could NOT run: #{error}. This is not a clean result — the recommendation below " \
79
+ "falls back to the presence-only probe. Run `rigor check` (or `rigor doctor`) to see what failed."
80
+ )
81
+ end
82
+
83
+ def classify(result)
84
+ sites = monkey_patch_diagnostics(result)
85
+ if broken_environment?(result)
86
+ doctor_report(result)
87
+ elsif monkey_patch_cluster?(result, sites)
88
+ monkeypatch_report(result, sites)
89
+ elsif result.error_count.positive?
90
+ baseline_report(result, sites)
91
+ else
92
+ Report.new(
93
+ status: :analyzed, route: nil, reason: nil,
94
+ detail: "the check ran clean — 0 error diagnostics. The recommendation below is the presence-only one."
95
+ )
96
+ end
97
+ end
98
+
99
+ # The `rigor-doctor` signal, read exactly as `rigor doctor` itself reads it: an RBS environment that built to
100
+ # zero classes (analysis is hollow, so every other count is meaningless), or a `configuration-error` the run
101
+ # already emitted. Checked first — with a broken setup the remaining diagnostics are not evidence of anything.
102
+ def broken_environment?(result)
103
+ result.stats&.rbs_classes_total&.zero? ||
104
+ result.diagnostics.any? { |diagnostic| diagnostic.rule == CONFIGURATION_ERROR_RULE }
105
+ end
106
+
107
+ # ADR-17 / the `project-monkey-patch-known` triage recogniser: `call.undefined-method` sets
108
+ # `project_definition_site` when the project itself defines the called method on the receiver class somewhere
109
+ # in the analysed file set. That is engine-proven evidence, not a spread heuristic, which is why it routes with
110
+ # no count threshold — and why the *unproven* shapes (a bare `call.unresolved-toplevel` count, "framework calls
111
+ # typing as Dynamic") are deliberately not routed on here. Guessing a workflow from a weak signal is worse than
112
+ # leaving the generic recommendation standing.
113
+ def monkey_patch_diagnostics(result)
114
+ result.diagnostics.select(&:project_definition_site)
115
+ end
116
+
117
+ # Whether the proven sites dominate the run enough for `pre_eval:` to be the shortest path. Any proven site is
118
+ # real evidence — `project_definition_site` is engine-proven, not a spread heuristic — but evidence of a *site*
119
+ # is not evidence that clearing it is the next thing to do. Below the share, `baseline_report` still names the
120
+ # sites, so the finding survives even when the headline does not follow it.
121
+ def monkey_patch_cluster?(result, sites)
122
+ return false if sites.empty?
123
+ return true unless result.error_count.positive?
124
+
125
+ Rational(sites.size, result.error_count) >= MONKEY_PATCH_CLUSTER_SHARE
126
+ end
127
+
128
+ def doctor_report(result)
129
+ classes = result.stats&.rbs_classes_total
130
+ cause = if classes&.zero?
131
+ "the RBS environment built to 0 classes"
132
+ else
133
+ "the run emitted a `#{CONFIGURATION_ERROR_RULE}` diagnostic"
134
+ end
135
+ Report.new(
136
+ status: :analyzed, route: "rigor-doctor",
137
+ reason: "a `--deep` check found a broken setup (#{cause}) — the analysis is hollow until that is fixed, " \
138
+ "so no other finding is trustworthy yet.",
139
+ detail: "the check found a broken setup — #{cause}."
140
+ )
141
+ end
142
+
143
+ def monkeypatch_report(result, sites)
144
+ files = sites.filter_map { |d| d.project_definition_site&.sub(/:\d+\z/, "") }.uniq.sort
145
+ Report.new(
146
+ status: :analyzed, route: "rigor-monkeypatch-resolve",
147
+ reason: "a `--deep` check found #{sites.size} call site(s) that resolve to the project's own definitions " \
148
+ "in #{files.first(3).join(', ')} — reopened classes Rigor does not apply cross-file. Listing " \
149
+ "them in `pre_eval:` clears them wholesale.",
150
+ detail: "the check reported #{result.error_count} error diagnostic(s), #{sites.size} of them proven " \
151
+ "project monkey-patches (#{files.size} file(s))."
152
+ )
153
+ end
154
+
155
+ def baseline_report(result, sites = [])
156
+ aside = if sites.empty?
157
+ ""
158
+ else
159
+ " #{sites.size} of them are proven project monkey-patches — too small a share to make `pre_eval:` " \
160
+ "the shortest path, but `rigor-monkeypatch-resolve` clears them if you want them gone first."
161
+ end
162
+ Report.new(
163
+ status: :analyzed, route: "rigor-baseline-reduce",
164
+ reason: "a `--deep` check reported #{result.error_count} error diagnostic(s) — work them down (or record " \
165
+ "them in a baseline) before adding more surface.#{aside}",
166
+ detail: "the check reported #{result.error_count} error diagnostic(s), " \
167
+ "#{sites.empty? ? 'none' : sites.size} of them proven project monkey-patches."
168
+ )
169
+ end
170
+ end
171
+ end
172
+ end
@@ -142,27 +142,43 @@ module Rigor
142
142
 
143
143
  # @param skills [Array<Hash>] discovered skills, each `{name:, path:}`.
144
144
  # @param root [String] project root to probe (defaults to the cwd).
145
- def initialize(skills:, root: Dir.pwd)
145
+ # @param deep [Boolean] opt into `--deep`: run a real `rigor check` and let its result pick the headline. Off by
146
+ # default, and off is ADR-73 WD2's contract — see {SkillDeepProbe} for why the whole analysis path lives
147
+ # behind this one flag and in its own file.
148
+ def initialize(skills:, root: Dir.pwd, deep: false)
146
149
  @skills = skills
147
150
  @root = root
151
+ @deep = deep
148
152
  end
149
153
 
150
154
  # @return [String] the full describe report.
151
155
  def render
152
156
  catalog = catalog_skills
153
157
  state = ProjectStateProbe.new(@root).to_h
154
- recommendation = recommend(state, catalog)
158
+ deep = deep_report(state)
159
+ recommendation = recommend(state, catalog, deep)
155
160
  [
156
161
  title,
157
162
  state_section(state),
163
+ deep_section(deep),
158
164
  recommendation_section(recommendation),
159
165
  catalog_section(catalog),
160
- agent_prompt(recommendation)
161
- ].join("\n")
166
+ agent_prompt(recommendation, deep)
167
+ ].compact.join("\n")
162
168
  end
163
169
 
164
170
  private
165
171
 
172
+ # The `--deep` analysis, or nil when the flag was not given. `require` sits here rather than at the top of the
173
+ # file so the default, presence-only path never even loads the check-invocation plumbing — WD2's "runs no
174
+ # analysis" guarantee is enforced by what gets loaded, not only by what gets called.
175
+ def deep_report(state)
176
+ return nil unless @deep
177
+
178
+ require_relative "skill_deep_probe"
179
+ SkillDeepProbe.new(config: state.fetch(:config), root: @root).run
180
+ end
181
+
166
182
  # The skills offered as "what to do next", in adoption-journey order. The entry-point skill is excluded, and
167
183
  # unknown skills sort after the known journey, alphabetically.
168
184
  def catalog_skills
@@ -173,8 +189,12 @@ module Rigor
173
189
 
174
190
  # The decision tree (ADR-73 WD2). Returns `{ skill:, reason: }` for the recommended next step, or nil when no
175
191
  # catalogue skill matches.
176
- def recommend(state, catalog)
177
- name, reason = recommended_name_and_reason(state)
192
+ #
193
+ # A `--deep` report overrides the presence-only tree only when it actually routed: a check that was skipped,
194
+ # could not run, or ran clean leaves the tree's answer standing untouched. So the un-flagged behaviour is
195
+ # literally the `deep == nil` branch of this one method.
196
+ def recommend(state, catalog, deep = nil)
197
+ name, reason = deep&.route ? [deep.route, deep.reason] : recommended_name_and_reason(state)
178
198
  skill = catalog.find { |candidate| candidate.fetch(:name) == name }
179
199
  skill.nil? ? nil : { skill: skill, reason: reason }
180
200
  end
@@ -248,6 +268,18 @@ module Rigor
248
268
  STATE
249
269
  end
250
270
 
271
+ # Printed only under `--deep`, so the un-flagged report is byte-identical to what it has always been.
272
+ def deep_section(deep)
273
+ return nil if deep.nil?
274
+
275
+ <<~DEEP
276
+ ## Deep check (--deep)
277
+ A real `rigor check` was run for this recommendation — unlike the probe above it
278
+ is slow and it writes `.rigor/cache`, exactly as `rigor check` does.
279
+ - Result: #{deep.detail}
280
+ DEEP
281
+ end
282
+
251
283
  def recommendation_section(recommendation)
252
284
  return "## Recommended next step\n- (no bundled skill matched the current state)\n" if recommendation.nil?
253
285
 
@@ -267,7 +299,7 @@ module Rigor
267
299
  "## All skills you can run next\n#{lines.join("\n")}\n"
268
300
  end
269
301
 
270
- def agent_prompt(recommendation)
302
+ def agent_prompt(recommendation, deep = nil)
271
303
  opener =
272
304
  if recommendation.nil?
273
305
  "Ask the user what they would like to do next"
@@ -279,6 +311,33 @@ module Rigor
279
311
  #{opener}, then run `rigor skill <name>` for the chosen skill and
280
312
  follow its body top to bottom.
281
313
 
314
+ #{check_awareness(deep)}
315
+
316
+ Re-run `rigor skill describe` whenever you need the next step — it always
317
+ reflects the project's current state.
318
+ PROMPT
319
+ end
320
+
321
+ # The check-aware routing block. Without `--deep` it teaches the agent to make the call itself from a check it
322
+ # has already run (the 2026-06-20 field-trial fix, unchanged); with `--deep` the headline already made that
323
+ # call, so the block reports what happened instead of re-teaching it. The routing vocabulary is one taxonomy
324
+ # shared by both branches and by {SkillDeepProbe}.
325
+ def check_awareness(deep)
326
+ return presence_only_check_awareness if deep.nil?
327
+ return deep_failure_check_awareness if deep.failed?
328
+
329
+ <<~DEEP.chomp
330
+ The recommendation above already factors in the `--deep` check result, using the
331
+ same routing this command teaches without `--deep`: proven project monkey-patches
332
+ → rigor-monkeypatch-resolve; `RBS classes available: 0` or a `configuration-error`
333
+ → rigor-doctor; remaining error diagnostics → rigor-baseline-reduce. It does not
334
+ route on weaker signals (framework calls typing as Dynamic → rigor-plugin-tune is
335
+ still yours to judge from the check output).
336
+ DEEP
337
+ end
338
+
339
+ def presence_only_check_awareness
340
+ <<~PROMPT.chomp
282
341
  The recommendation above is from a presence-only probe — it does not run
283
342
  `rigor check`. If you have run (or now run) `rigor check`, let its findings
284
343
  refine the choice:
@@ -287,9 +346,16 @@ module Rigor
287
346
  - framework calls (ActiveRecord, routes, i18n …) typing as Dynamic with no
288
347
  matching plugins enabled → rigor-plugin-tune
289
348
  - `RBS classes available: 0` or a `configuration-error` diagnostic → rigor-doctor
349
+ (`rigor skill describe --deep` runs that check for you and applies this routing
350
+ to the headline itself — at the cost of a full analysis.)
351
+ PROMPT
352
+ end
290
353
 
291
- Re-run `rigor skill describe` whenever you need the next step — it always
292
- reflects the project's current state.
354
+ def deep_failure_check_awareness
355
+ <<~PROMPT.chomp
356
+ The `--deep` check did NOT complete, so the recommendation above is the
357
+ presence-only one. Do not treat the project as clean: run `rigor doctor`
358
+ (or `rigor check`) and fix what it reports before trusting any routing.
293
359
  PROMPT
294
360
  end
295
361
 
@@ -12,6 +12,7 @@ require_relative "../inference/flow_tracer"
12
12
  require_relative "../inference/scope_indexer"
13
13
  require_relative "command"
14
14
  require_relative "trace_renderer"
15
+ require_relative "probe_environment"
15
16
 
16
17
  module Rigor
17
18
  class CLI
@@ -90,13 +91,11 @@ module Rigor
90
91
  0
91
92
  end
92
93
 
93
- # Mirrors the single-file path `rigor type-of` takes: a project-aware environment, an empty seed scope, one
94
- # statement-level evaluation of the whole programbut recorded under the FlowTracer.
94
+ # Mirrors the single-file path `rigor type-of` takes: the plugin-aware environment (so a trace replays the
95
+ # same dispatch `rigor check` would, including plugin-synthesized RBS see {ProbeEnvironment}), an empty
96
+ # seed scope, one statement-level evaluation of the whole program — but recorded under the FlowTracer.
95
97
  def record_events(root, file, configuration)
96
- environment = Environment.for_project(
97
- libraries: configuration.libraries,
98
- signature_paths: configuration.signature_paths
99
- )
98
+ environment = ProbeEnvironment.build(configuration: configuration, source_files: [file])
100
99
  scope = Scope.empty(environment: environment, source_path: file)
101
100
  Inference::FlowTracer.record { scope.evaluate(root) }
102
101
  end
@@ -13,6 +13,7 @@ require_relative "../inference/scope_indexer"
13
13
  require_relative "type_of_renderer"
14
14
  require_relative "command"
15
15
  require_relative "options"
16
+ require_relative "probe_environment"
16
17
 
17
18
  module Rigor
18
19
  class CLI
@@ -92,14 +93,14 @@ module Rigor
92
93
  0
93
94
  end
94
95
 
95
- # Builds a project-aware environment relative to the probed file. Project-RBS auto-detection roots at CWD today;
96
- # future work will walk parent directories to find the enclosing `Gemfile`/`*.gemspec` so probes against files
97
- # outside the current process's CWD still see the right `sig/` tree.
98
- def project_environment(_file, configuration)
99
- Environment.for_project(
100
- libraries: configuration.libraries,
101
- signature_paths: configuration.signature_paths
102
- )
96
+ # Builds the plugin-aware environment relative to the probed file, so the reported type matches what `rigor
97
+ # check` computes for the same position including types synthesized from inline RBS annotations by the
98
+ # ADR-93 auto-wired `rigor-rbs-inline` plugin (see {ProbeEnvironment} for the #162 misattribution this
99
+ # closes). The probed file is threaded as the synthesizer's `source_files:`. Project-RBS auto-detection
100
+ # roots at CWD today; future work will walk parent directories to find the enclosing `Gemfile`/`*.gemspec`
101
+ # so probes against files outside the current process's CWD still see the right `sig/` tree.
102
+ def project_environment(file, configuration)
103
+ ProbeEnvironment.build(configuration: configuration, source_files: [file])
103
104
  end
104
105
 
105
106
  def file_exists?(file)
@@ -11,6 +11,7 @@ require_relative "../scope"
11
11
  require_relative "type_scan_renderer"
12
12
  require_relative "type_scan_report"
13
13
  require_relative "command"
14
+ require_relative "probe_environment"
14
15
 
15
16
  module Rigor
16
17
  class CLI
@@ -69,20 +70,19 @@ module Rigor
69
70
 
70
71
  def scan_paths(paths, options)
71
72
  configuration = Configuration.load(options.fetch(:config))
72
- scope = Scope.empty(environment: project_environment(configuration))
73
+ scope = Scope.empty(environment: project_environment(configuration, paths))
73
74
  scanner = Inference::CoverageScanner.new(scope: scope)
74
75
  accumulator = ScanAccumulator.new
75
76
  paths.each { |path| scan_one(path, scanner, accumulator, configuration) }
76
77
  accumulator.to_report(paths, options)
77
78
  end
78
79
 
79
- # Builds a project-aware environment that auto-detects `<cwd>/sig` by default and honours the configuration's
80
- # `libraries:` / `signature_paths:` keys when present.
81
- def project_environment(configuration)
82
- Environment.for_project(
83
- libraries: configuration.libraries,
84
- signature_paths: configuration.signature_paths
85
- )
80
+ # Builds the plugin-aware environment that auto-detects `<cwd>/sig` by default and honours the
81
+ # configuration's `libraries:` / `signature_paths:` keys when present. The scanned `paths` are threaded as
82
+ # the plugin `source_rbs_synthesizer` inputs so coverage reflects the same synthesized RBS `rigor check`
83
+ # sees (see {ProbeEnvironment}).
84
+ def project_environment(configuration, source_files)
85
+ ProbeEnvironment.build(configuration: configuration, source_files: source_files)
86
86
  end
87
87
 
88
88
  def scan_one(path, scanner, accumulator, configuration)
data/lib/rigor/cli.rb CHANGED
@@ -11,7 +11,7 @@ require_relative "analysis/diagnostic"
11
11
  require_relative "analysis/result"
12
12
  require_relative "cli/options"
13
13
  require_relative "cli/diagnostic_formats"
14
- require_relative "cli/ci_detector"
14
+ require_relative "ci_detector"
15
15
 
16
16
  module Rigor
17
17
  # The CLI class is a dispatcher: each `run_*` method delegates to a command-specific class once the command grows
@@ -20,6 +20,16 @@ module Rigor
20
20
  class CLI # rubocop:disable Metrics/ClassLength
21
21
  EXIT_USAGE = 64
22
22
 
23
+ # The published location of `schemas/rigor-config.schema.json`, written into the `.rigor.yml` that
24
+ # `rigor init` generates so an editor validates the file as the user types.
25
+ #
26
+ # This MUST equal the schema's own `$id`, and it MUST resolve. Both are gated
27
+ # (`spec/rigor/config_schema_spec.rb`) because neither failure has a symptom: an unreachable schema
28
+ # is indistinguishable from a schema with no complaints, so a stale URL here silently disables
29
+ # validation for every project that ran `rigor init`. It pointed at a nonexistent org until
30
+ # 2026-07-17 and nothing noticed. See `docs/internal-spec/config.md`.
31
+ CONFIG_SCHEMA_URL = "https://github.com/rigortype/rigor/raw/master/schemas/rigor-config.schema.json"
32
+
23
33
  HANDLERS = {
24
34
  "check" => :run_check,
25
35
  "init" => :run_init,
@@ -138,9 +148,10 @@ module Rigor
138
148
  # most likely to want to edit.
139
149
  def init_template
140
150
  <<~YAML
141
- # yaml-language-server: $schema=https://github.com/zenwerk/rigor/raw/master/schemas/rigor-config.schema.json
142
- # Rigor configuration. See docs/CURRENT_WORK.md for the
143
- # full set of features the analyzer ships in this preview.
151
+ # yaml-language-server: $schema=#{CONFIG_SCHEMA_URL}
152
+ # Rigor configuration. The full key reference is
153
+ # https://github.com/rigortype/rigor/blob/master/docs/manual/03-configuration.md
154
+ # (or run `rigor docs configuration`).
144
155
  #
145
156
  # Keys you may want to edit:
146
157
  # - target_ruby: minimum Ruby version your project targets.