rigortype 0.3.0 → 0.3.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 (145) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -10
  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/pathname.rbs +5 -0
  15. data/data/core_overlay/resolv.rbs +31 -0
  16. data/data/core_overlay/string_scanner.rbs +11 -5
  17. data/data/gem_overlay/activesupport/core_ext.rbs +10 -6
  18. data/data/vendored_gem_sigs/bundler/bundler.rbs +39 -27
  19. data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +6 -0
  20. data/data/vendored_gem_sigs/prism/prism_supplement.rbs +7 -0
  21. data/data/vendored_gem_sigs/racc/racc.rbs +67 -0
  22. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +20 -9
  23. data/docs/handbook/01-getting-started.md +22 -34
  24. data/docs/handbook/06-classes.md +1 -1
  25. data/docs/handbook/07-rbs-and-extended.md +76 -101
  26. data/docs/handbook/08-understanding-errors.md +114 -247
  27. data/docs/handbook/09-plugins.md +54 -144
  28. data/docs/handbook/README.md +5 -3
  29. data/docs/handbook/appendix-liskov.md +4 -2
  30. data/docs/handbook/appendix-phpstan.md +14 -7
  31. data/docs/handbook/appendix-steep.md +4 -2
  32. data/docs/handbook/appendix-type-theory.md +3 -1
  33. data/docs/manual/02-cli-reference.md +103 -11
  34. data/docs/manual/03-configuration.md +16 -1
  35. data/docs/manual/04-diagnostics.md +36 -4
  36. data/docs/manual/06-baseline.md +35 -1
  37. data/docs/manual/08-skills.md +6 -1
  38. data/docs/manual/09-editor-integration.md +3 -2
  39. data/docs/manual/12-caching.md +17 -5
  40. data/docs/manual/15-type-protection-coverage.md +122 -1
  41. data/docs/manual/plugins/rigor-actioncable.md +32 -0
  42. data/docs/manual/plugins/rigor-devise.md +4 -2
  43. data/docs/manual/plugins/rigor-rbs-inline.md +22 -0
  44. data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
  45. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +21 -2
  46. data/lib/rigor/analysis/check_rules.rb +79 -19
  47. data/lib/rigor/analysis/incremental_session.rb +156 -11
  48. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
  49. data/lib/rigor/analysis/run_cache_key.rb +27 -1
  50. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +201 -0
  51. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +28 -10
  52. data/lib/rigor/analysis/runner/pool_coordinator.rb +7 -10
  53. data/lib/rigor/analysis/runner/project_pre_passes.rb +3 -1
  54. data/lib/rigor/analysis/runner.rb +80 -8
  55. data/lib/rigor/bleeding_edge.rb +132 -7
  56. data/lib/rigor/cache/descriptor.rb +6 -1
  57. data/lib/rigor/cache/engine_source.rb +162 -0
  58. data/lib/rigor/cache/incremental_snapshot.rb +87 -18
  59. data/lib/rigor/cache/rbs_cache_producer.rb +11 -1
  60. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +38 -0
  61. data/lib/rigor/cache/store.rb +99 -24
  62. data/lib/rigor/cli/check_command.rb +61 -34
  63. data/lib/rigor/cli/check_invocation.rb +84 -0
  64. data/lib/rigor/cli/coverage_command.rb +15 -2
  65. data/lib/rigor/cli/coverage_mutation.rb +242 -11
  66. data/lib/rigor/cli/doctor_command.rb +6 -8
  67. data/lib/rigor/cli/fused_protection_renderer.rb +10 -0
  68. data/lib/rigor/cli/fused_protection_report.rb +12 -3
  69. data/lib/rigor/cli/mutation_fork_scan.rb +64 -0
  70. data/lib/rigor/cli/mutation_protection_renderer.rb +12 -0
  71. data/lib/rigor/cli/mutation_protection_report.rb +26 -5
  72. data/lib/rigor/cli/protection_renderer.rb +13 -0
  73. data/lib/rigor/cli/protection_report.rb +11 -3
  74. data/lib/rigor/cli/show_bleedingedge_command.rb +17 -4
  75. data/lib/rigor/cli/sig_gen_command.rb +5 -4
  76. data/lib/rigor/cli/skill_command.rb +21 -1
  77. data/lib/rigor/cli/skill_deep_probe.rb +172 -0
  78. data/lib/rigor/cli/skill_describe.rb +75 -9
  79. data/lib/rigor/configuration.rb +45 -6
  80. data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
  81. data/lib/rigor/environment/default_libraries.rb +5 -4
  82. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  83. data/lib/rigor/environment/rbs_loader.rb +434 -75
  84. data/lib/rigor/environment.rb +38 -14
  85. data/lib/rigor/inference/expression_typer.rb +15 -0
  86. data/lib/rigor/inference/fork_map.rb +9 -0
  87. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +50 -2
  88. data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
  89. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +35 -1
  90. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +6 -1
  91. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +16 -0
  92. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
  93. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +137 -9
  94. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +3 -2
  95. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +124 -1
  96. data/lib/rigor/inference/mutation_widening.rb +30 -25
  97. data/lib/rigor/inference/narrowing.rb +3 -1
  98. data/lib/rigor/inference/optimistic_origin.rb +58 -0
  99. data/lib/rigor/inference/protection_scanner.rb +18 -2
  100. data/lib/rigor/inference/receiver_alias.rb +57 -0
  101. data/lib/rigor/inference/scope_indexer.rb +78 -27
  102. data/lib/rigor/inference/statement_evaluator.rb +40 -2
  103. data/lib/rigor/language_server/buffer_resolution.rb +6 -3
  104. data/lib/rigor/language_server/buffer_table.rb +68 -6
  105. data/lib/rigor/language_server/diagnostic_publisher.rb +207 -4
  106. data/lib/rigor/language_server/incremental_sync.rb +159 -0
  107. data/lib/rigor/language_server/project_context.rb +45 -0
  108. data/lib/rigor/language_server/publish_batcher.rb +82 -0
  109. data/lib/rigor/language_server/server.rb +38 -9
  110. data/lib/rigor/language_server.rb +2 -0
  111. data/lib/rigor/plugin/base.rb +29 -2
  112. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
  113. data/lib/rigor/protection/closure_kill_oracle.rb +171 -0
  114. data/lib/rigor/protection/dependency_closure.rb +59 -0
  115. data/lib/rigor/protection/diagnostic_oracle.rb +12 -8
  116. data/lib/rigor/protection/discovery_seed.rb +116 -0
  117. data/lib/rigor/protection/kill_signature.rb +31 -0
  118. data/lib/rigor/protection/mutation_cache.rb +355 -0
  119. data/lib/rigor/protection/mutation_scanner.rb +55 -11
  120. data/lib/rigor/protection/mutator.rb +26 -4
  121. data/lib/rigor/runtime/jit.rb +63 -1
  122. data/lib/rigor/scope/discovery_index.rb +9 -0
  123. data/lib/rigor/scope.rb +52 -8
  124. data/lib/rigor/sig_gen/generator.rb +130 -31
  125. data/lib/rigor/sig_gen/layout_index.rb +6 -0
  126. data/lib/rigor/sig_gen/meta_class_shape.rb +84 -0
  127. data/lib/rigor/sig_gen/renderer.rb +20 -1
  128. data/lib/rigor/sig_gen/write_result.rb +6 -4
  129. data/lib/rigor/sig_gen/writer.rb +195 -47
  130. data/lib/rigor/sig_gen.rb +1 -0
  131. data/lib/rigor/version.rb +1 -1
  132. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -1
  133. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +6 -6
  134. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/result_shape.rb +146 -0
  135. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +177 -12
  136. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +74 -7
  137. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +257 -0
  138. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +69 -0
  139. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +87 -11
  140. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +34 -4
  141. data/sig/rigor/cache.rbs +6 -0
  142. data/sig/rigor/inference/void_origin.rbs +21 -0
  143. data/sig/rigor/plugin/base.rbs +4 -3
  144. data/sig/rigor/scope.rbs +8 -0
  145. metadata +23 -3
@@ -9,7 +9,14 @@ module Rigor
9
9
  #
10
10
  # The framing is load-bearing (ADR-63 Criterion A / ADR-62 Criterion A): the number is *effectiveness*, the
11
11
  # survivors are *missed breakages / where to add a type*, never "your code is broken".
12
- FileEffectiveness = Data.define(:path, :killed, :survived, :ratio)
12
+ # `harness_errors` (#264) mutants where the harness itself raised (a rescued failure), distinguished from
13
+ # a parse-invalid mutant. Defaults to 0 so existing call sites keep constructing a `FileEffectiveness`
14
+ # without the new keyword.
15
+ FileEffectiveness = Data.define(:path, :killed, :survived, :ratio, :harness_errors) do
16
+ def initialize(path:, killed:, survived:, ratio:, harness_errors: 0)
17
+ super
18
+ end
19
+ end
13
20
  MissedBreakage = Data.define(:method_name, :count, :examples)
14
21
 
15
22
  MutationProtectionReport = Data.define(:files, :missed, :parse_errors) do
@@ -18,15 +25,22 @@ module Rigor
18
25
  def grand_total = total_killed + total_survived
19
26
  def ratio = grand_total.zero? ? 1.0 : total_killed.to_f / grand_total
20
27
 
28
+ # #264 — a harness-level failure count, summed across files. Stays OUT of `grand_total`/`ratio` exactly
29
+ # like a parse error: it is not a measurement of the code.
30
+ def total_harness_errors = files.sum(&:harness_errors)
31
+
21
32
  def to_h
22
33
  {
23
34
  "mode" => "mutation",
24
35
  "killed" => total_killed,
25
36
  "survived" => total_survived,
26
37
  "effectiveness_ratio" => ratio.round(4),
38
+ # #264 — unconditional: a JSON consumer (e.g. a CI gate) must be able to check this every run, not
39
+ # only when a text renderer decided it was worth a line.
40
+ "harness_errors" => total_harness_errors,
27
41
  "files" => files.map do |f|
28
- { "path" => f.path, "killed" => f.killed,
29
- "survived" => f.survived, "ratio" => f.ratio.round(4) }
42
+ { "path" => f.path, "killed" => f.killed, "survived" => f.survived,
43
+ "ratio" => f.ratio.round(4), "harness_errors" => f.harness_errors }
30
44
  end,
31
45
  "add_a_type_here" => missed.map do |m|
32
46
  { "method" => m.method_name, "count" => m.count, "examples" => m.examples }
@@ -46,7 +60,8 @@ module Rigor
46
60
  def absorb(file_result)
47
61
  @files << FileEffectiveness.new(
48
62
  path: file_result.path, killed: file_result.killed,
49
- survived: file_result.survived, ratio: file_result.ratio
63
+ survived: file_result.survived, ratio: file_result.ratio,
64
+ harness_errors: file_result.harness_errors
50
65
  )
51
66
  file_result.sites.each do |site|
52
67
  bucket = @missed[site.method_name]
@@ -56,7 +71,13 @@ module Rigor
56
71
  end
57
72
 
58
73
  def record_parse_error(path, errors)
59
- @parse_errors << { "path" => path, "errors" => errors.size }
74
+ record_parse_error_count(path, errors.size)
75
+ end
76
+
77
+ # Count-based variant for the fork-pool path, where a worker carries only the marshalable error count
78
+ # (see {MutationForkScan::ParseError}), not the Prism error objects.
79
+ def record_parse_error_count(path, count)
80
+ @parse_errors << { "path" => path, "errors" => count }
60
81
  end
61
82
 
62
83
  def to_report
@@ -40,10 +40,23 @@ module Rigor
40
40
  @out.puts "Type-protection coverage (Tier 1 — dispatch-site receiver concreteness)"
41
41
  @out.puts " protected dispatch sites: #{report.total_protected} / #{report.grand_total} (#{pct}%)"
42
42
  @out.puts " (protected = Rigor can catch a wrong call here; an upper bound on real protection)"
43
+ render_lower_bound_typed(report)
43
44
  render_untyped_calls(report)
44
45
  render_files(report)
45
46
  end
46
47
 
48
+ # ADR-67 WD6b (issue #263) — a sub-bucket WITHIN `protected`, never a move out of it: these sites'
49
+ # receiver types are call-site lower bounds (an undeclared parameter seeded from its callers), so the
50
+ # negative in-body rules decline on them — no diagnostic actually fires here yet. Silent when zero, the
51
+ # normal `check` / unseeded-scope case.
52
+ def render_lower_bound_typed(report)
53
+ m = report.total_lower_bound_typed
54
+ return if m.zero?
55
+
56
+ @out.puts " protected #{report.total_protected} (of which #{m} lower-bound-typed — " \
57
+ "no negative-rule teeth, ADR-67 WD6b)"
58
+ end
59
+
47
60
  def render_untyped_calls(report)
48
61
  calls = report.untyped_calls
49
62
  return if calls.empty?
@@ -8,7 +8,7 @@ module Rigor
8
8
  # the protected ratio, the per-file breakdown, and a ranked "add a type here" list keyed by the method called on an
9
9
  # unprotected (`Dynamic`) receiver — the highest-traffic untyped dispatches, where a receiver annotation buys the
10
10
  # most catching power.
11
- FileProtection = Data.define(:path, :protected_count, :unprotected_count, :ratio)
11
+ FileProtection = Data.define(:path, :protected_count, :unprotected_count, :ratio, :lower_bound_typed)
12
12
  UntypedCall = Data.define(:method_name, :count, :examples, :dynamic_origin)
13
13
 
14
14
  ProtectionReport = Data.define(:files, :untyped_calls, :parse_errors, :cause_site_counts) do
@@ -17,6 +17,11 @@ module Rigor
17
17
  def grand_total = total_protected + total_unprotected
18
18
  def ratio = grand_total.zero? ? 1.0 : total_protected.to_f / grand_total
19
19
 
20
+ # ADR-67 WD6b (issue #263) — total count of `protected` sites that are additionally lower-bound-typed
21
+ # (a call-site-inferred, undeclared parameter): a sub-bucket WITHIN `total_protected`, never a move out
22
+ # of it. `total_protected` / `ratio` are unaffected by this count.
23
+ def total_lower_bound_typed = files.sum(&:lower_bound_typed)
24
+
20
25
  # ADR-73 P6 / ADR-82 — total dispatch-site count per tractability axis across the classified holes (those with a
21
26
  # recorded `dynamic_origin`), so a user sees at a glance how much of the untyped surface a type can actually close
22
27
  # (`add_rbs`) vs. needs a plugin (`enable_plugin`) vs. is an engine gap. Keys are omitted when zero; an
@@ -43,11 +48,13 @@ module Rigor
43
48
  "protected" => total_protected,
44
49
  "unprotected" => total_unprotected,
45
50
  "protection_ratio" => ratio.round(4),
51
+ "lower_bound_typed" => total_lower_bound_typed,
46
52
  "tractability_summary" => tractability_summary.transform_keys(&:to_s),
47
53
  "cause_site_counts" => cause_site_totals,
48
54
  "files" => files.map do |f|
49
55
  { "path" => f.path, "protected" => f.protected_count,
50
- "unprotected" => f.unprotected_count, "ratio" => f.ratio.round(4) }
56
+ "unprotected" => f.unprotected_count, "ratio" => f.ratio.round(4),
57
+ "lower_bound_typed" => f.lower_bound_typed }
51
58
  end,
52
59
  "add_a_type_here" => untyped_calls.map do |c|
53
60
  entry = { "method" => c.method_name, "count" => c.count, "examples" => c.examples }
@@ -74,7 +81,8 @@ module Rigor
74
81
  def absorb(path, file_result)
75
82
  @files << FileProtection.new(
76
83
  path: path, protected_count: file_result.protected_count,
77
- unprotected_count: file_result.unprotected_count, ratio: file_result.ratio
84
+ unprotected_count: file_result.unprotected_count, ratio: file_result.ratio,
85
+ lower_bound_typed: file_result.lower_bound_typed
78
86
  )
79
87
  file_result.sites.each do |site|
80
88
  bucket = @calls[site.method_name]
@@ -13,8 +13,9 @@ module Rigor
13
13
  #
14
14
  # Prints the bleeding-edge overlay — the Rigor-maintained set of the next major's queued changes
15
15
  # ({Rigor::BleedingEdge}) — as an explicit list, and reports which of them the project's `bleeding_edge:`
16
- # configuration adopts. The overlay is empty in this release, so the command currently reports an empty set; it
17
- # becomes the inspection surface ADR-50 describes once a feature is queued.
16
+ # configuration adopts. Each feature is rendered with its kind: a `severity` feature prints the rule severity
17
+ # diff it imposes, and a `behaviour` feature has no such diff to print, so its summary carries the whole
18
+ # explanation. Ids that have already graduated to default-on (ADR-50 § WD7) are listed after the overlay.
18
19
  #
19
20
  # Read-only: it loads `.rigor.yml` to resolve the active selection but runs no analysis.
20
21
  class ShowBleedingedgeCommand < Command
@@ -62,7 +63,8 @@ module Rigor
62
63
  "overlay" => BleedingEdge.features.map(&:to_h),
63
64
  "selector" => configuration.to_h.fetch("bleeding_edge"),
64
65
  "active" => BleedingEdge.active_features(selector).map(&:id),
65
- "unknown_selected" => BleedingEdge.unknown_selected_ids(selector)
66
+ "unknown_selected" => BleedingEdge.unknown_selected_ids(selector),
67
+ "graduated" => BleedingEdge::GRADUATED
66
68
  ))
67
69
  end
68
70
 
@@ -74,6 +76,7 @@ module Rigor
74
76
  else
75
77
  render_overlay
76
78
  end
79
+ render_graduated
77
80
  @out.puts("")
78
81
  render_selection(configuration)
79
82
  end
@@ -88,7 +91,7 @@ module Rigor
88
91
  @out.puts("#{BleedingEdge.features.length} feature(s) queued for the next major:")
89
92
  @out.puts("")
90
93
  BleedingEdge.features.each do |feature|
91
- @out.puts(" #{feature.id}")
94
+ @out.puts(" #{feature.id} [#{feature.kind}]")
92
95
  @out.puts(" #{feature.summary}")
93
96
  feature.severity_overrides.each do |rule, severity|
94
97
  @out.puts(" severity: #{rule} → :#{severity}")
@@ -96,6 +99,16 @@ module Rigor
96
99
  end
97
100
  end
98
101
 
102
+ # ADR-50 § WD7 — only printed when something has graduated, so a release with an empty list reads exactly
103
+ # as it did before graduation existed.
104
+ def render_graduated
105
+ return if BleedingEdge::GRADUATED.empty?
106
+
107
+ @out.puts("")
108
+ @out.puts("Graduated — on by default, no longer selectable:")
109
+ BleedingEdge::GRADUATED.each { |id| @out.puts(" #{id}") }
110
+ end
111
+
99
112
  def render_selection(configuration)
100
113
  selector = configuration.bleeding_edge
101
114
  active = BleedingEdge.active_features(selector).map(&:id)
@@ -97,10 +97,11 @@ module Rigor
97
97
  results = writer.write_all(candidates)
98
98
 
99
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
100
+ # A refused write (an assembled file that does not parse, or an existing target that is not valid
101
+ # UTF-8) means the user asked for a write and did not get one, so the command must not report
102
+ # success — a green `sig-gen --write` in CI would otherwise mean nothing.
103
+ refusals = %i[skipped_invalid_rbs skipped_invalid_encoding]
104
+ results.any? { |result| refusals.include?(result.action) } ? 1 : 0
104
105
  end
105
106
 
106
107
  # Slice 3 — collect call-site argument observations when `--params=observed` is set. When `--observe=PATH` is not
@@ -36,6 +36,13 @@ 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
47
  # `describe` is a no-argument action, not a name-slot verb, so it stays first-class alongside `--describe`.
41
48
  class SkillCommand < Command
@@ -51,6 +58,10 @@ module Rigor
51
58
  rigor skill --path <name> Print the absolute path of the SKILL.md file for <name>
52
59
  rigor skill --list List bundled skills (name + absolute path)
53
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)
54
65
 
55
66
  Examples:
56
67
  rigor skill
@@ -58,6 +69,7 @@ module Rigor
58
69
  rigor skill --full rigor-baseline-reduce
59
70
  rigor skill --path rigor-baseline-reduce
60
71
  rigor skill --describe (also: rigor describe)
72
+ rigor skill describe --deep (also: rigor describe --deep)
61
73
  USAGE
62
74
 
63
75
  # The bundled skills live at `<gem_root>/skills/`. From `lib/rigor/cli/skill_command.rb` that is three directories
@@ -159,10 +171,18 @@ module Rigor
159
171
  # probes the current project's state with cheap presence checks (it never runs `rigor check`), recommends the next
160
172
  # skill to run, and prints every bundled skill's current frontmatter description, so the `rigor-next-steps` SKILL
161
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.
162
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
+
163
183
  require_relative "skill_describe"
164
184
 
165
- @out.puts(SkillDescribe.new(skills: discover_skills).render)
185
+ @out.puts(SkillDescribe.new(skills: discover_skills, deep: deep).render)
166
186
  0
167
187
  end
168
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