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.
- checksums.yaml +4 -4
- data/README.md +10 -10
- data/data/builtins/ruby_core/array.yml +416 -392
- data/data/builtins/ruby_core/file.yml +42 -42
- data/data/builtins/ruby_core/hash.yml +302 -302
- data/data/builtins/ruby_core/io.yml +191 -191
- data/data/builtins/ruby_core/numeric.yml +321 -366
- data/data/builtins/ruby_core/proc.yml +124 -124
- data/data/builtins/ruby_core/range.yml +21 -21
- data/data/builtins/ruby_core/rational.yml +39 -39
- data/data/builtins/ruby_core/re.yml +65 -65
- data/data/builtins/ruby_core/set.yml +106 -106
- data/data/builtins/ruby_core/struct.yml +14 -14
- data/data/core_overlay/pathname.rbs +5 -0
- data/data/core_overlay/resolv.rbs +31 -0
- data/data/core_overlay/string_scanner.rbs +11 -5
- data/data/gem_overlay/activesupport/core_ext.rbs +10 -6
- data/data/vendored_gem_sigs/bundler/bundler.rbs +39 -27
- data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +6 -0
- data/data/vendored_gem_sigs/prism/prism_supplement.rbs +7 -0
- data/data/vendored_gem_sigs/racc/racc.rbs +67 -0
- data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +20 -9
- data/docs/handbook/01-getting-started.md +22 -34
- data/docs/handbook/06-classes.md +1 -1
- data/docs/handbook/07-rbs-and-extended.md +76 -101
- data/docs/handbook/08-understanding-errors.md +114 -247
- data/docs/handbook/09-plugins.md +54 -144
- data/docs/handbook/README.md +5 -3
- data/docs/handbook/appendix-liskov.md +4 -2
- data/docs/handbook/appendix-phpstan.md +14 -7
- data/docs/handbook/appendix-steep.md +4 -2
- data/docs/handbook/appendix-type-theory.md +3 -1
- data/docs/manual/02-cli-reference.md +103 -11
- data/docs/manual/03-configuration.md +16 -1
- data/docs/manual/04-diagnostics.md +36 -4
- data/docs/manual/06-baseline.md +35 -1
- data/docs/manual/08-skills.md +6 -1
- data/docs/manual/09-editor-integration.md +3 -2
- data/docs/manual/12-caching.md +17 -5
- data/docs/manual/15-type-protection-coverage.md +122 -1
- data/docs/manual/plugins/rigor-actioncable.md +32 -0
- data/docs/manual/plugins/rigor-devise.md +4 -2
- data/docs/manual/plugins/rigor-rbs-inline.md +22 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
- data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +21 -2
- data/lib/rigor/analysis/check_rules.rb +79 -19
- data/lib/rigor/analysis/incremental_session.rb +156 -11
- data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
- data/lib/rigor/analysis/run_cache_key.rb +27 -1
- data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +201 -0
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +28 -10
- data/lib/rigor/analysis/runner/pool_coordinator.rb +7 -10
- data/lib/rigor/analysis/runner/project_pre_passes.rb +3 -1
- data/lib/rigor/analysis/runner.rb +80 -8
- data/lib/rigor/bleeding_edge.rb +132 -7
- data/lib/rigor/cache/descriptor.rb +6 -1
- data/lib/rigor/cache/engine_source.rb +162 -0
- data/lib/rigor/cache/incremental_snapshot.rb +87 -18
- data/lib/rigor/cache/rbs_cache_producer.rb +11 -1
- data/lib/rigor/cache/rbs_environment_marshal_patch.rb +38 -0
- data/lib/rigor/cache/store.rb +99 -24
- data/lib/rigor/cli/check_command.rb +61 -34
- data/lib/rigor/cli/check_invocation.rb +84 -0
- data/lib/rigor/cli/coverage_command.rb +15 -2
- data/lib/rigor/cli/coverage_mutation.rb +242 -11
- data/lib/rigor/cli/doctor_command.rb +6 -8
- data/lib/rigor/cli/fused_protection_renderer.rb +10 -0
- data/lib/rigor/cli/fused_protection_report.rb +12 -3
- data/lib/rigor/cli/mutation_fork_scan.rb +64 -0
- data/lib/rigor/cli/mutation_protection_renderer.rb +12 -0
- data/lib/rigor/cli/mutation_protection_report.rb +26 -5
- data/lib/rigor/cli/protection_renderer.rb +13 -0
- data/lib/rigor/cli/protection_report.rb +11 -3
- data/lib/rigor/cli/show_bleedingedge_command.rb +17 -4
- data/lib/rigor/cli/sig_gen_command.rb +5 -4
- data/lib/rigor/cli/skill_command.rb +21 -1
- data/lib/rigor/cli/skill_deep_probe.rb +172 -0
- data/lib/rigor/cli/skill_describe.rb +75 -9
- data/lib/rigor/configuration.rb +45 -6
- data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
- data/lib/rigor/environment/default_libraries.rb +5 -4
- data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
- data/lib/rigor/environment/rbs_loader.rb +434 -75
- data/lib/rigor/environment.rb +38 -14
- data/lib/rigor/inference/expression_typer.rb +15 -0
- data/lib/rigor/inference/fork_map.rb +9 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +50 -2
- data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
- data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +35 -1
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +6 -1
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +16 -0
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +137 -9
- data/lib/rigor/inference/method_dispatcher/struct_folding.rb +3 -2
- data/lib/rigor/inference/method_dispatcher/uri_folding.rb +124 -1
- data/lib/rigor/inference/mutation_widening.rb +30 -25
- data/lib/rigor/inference/narrowing.rb +3 -1
- data/lib/rigor/inference/optimistic_origin.rb +58 -0
- data/lib/rigor/inference/protection_scanner.rb +18 -2
- data/lib/rigor/inference/receiver_alias.rb +57 -0
- data/lib/rigor/inference/scope_indexer.rb +78 -27
- data/lib/rigor/inference/statement_evaluator.rb +40 -2
- data/lib/rigor/language_server/buffer_resolution.rb +6 -3
- data/lib/rigor/language_server/buffer_table.rb +68 -6
- data/lib/rigor/language_server/diagnostic_publisher.rb +207 -4
- data/lib/rigor/language_server/incremental_sync.rb +159 -0
- data/lib/rigor/language_server/project_context.rb +45 -0
- data/lib/rigor/language_server/publish_batcher.rb +82 -0
- data/lib/rigor/language_server/server.rb +38 -9
- data/lib/rigor/language_server.rb +2 -0
- data/lib/rigor/plugin/base.rb +29 -2
- data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
- data/lib/rigor/protection/closure_kill_oracle.rb +171 -0
- data/lib/rigor/protection/dependency_closure.rb +59 -0
- data/lib/rigor/protection/diagnostic_oracle.rb +12 -8
- data/lib/rigor/protection/discovery_seed.rb +116 -0
- data/lib/rigor/protection/kill_signature.rb +31 -0
- data/lib/rigor/protection/mutation_cache.rb +355 -0
- data/lib/rigor/protection/mutation_scanner.rb +55 -11
- data/lib/rigor/protection/mutator.rb +26 -4
- data/lib/rigor/runtime/jit.rb +63 -1
- data/lib/rigor/scope/discovery_index.rb +9 -0
- data/lib/rigor/scope.rb +52 -8
- data/lib/rigor/sig_gen/generator.rb +130 -31
- data/lib/rigor/sig_gen/layout_index.rb +6 -0
- data/lib/rigor/sig_gen/meta_class_shape.rb +84 -0
- data/lib/rigor/sig_gen/renderer.rb +20 -1
- data/lib/rigor/sig_gen/write_result.rb +6 -4
- data/lib/rigor/sig_gen/writer.rb +195 -47
- data/lib/rigor/sig_gen.rb +1 -0
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -1
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +6 -6
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/result_shape.rb +146 -0
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +177 -12
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +74 -7
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +257 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +69 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +87 -11
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +34 -4
- data/sig/rigor/cache.rbs +6 -0
- data/sig/rigor/inference/void_origin.rbs +21 -0
- data/sig/rigor/plugin/base.rbs +4 -3
- data/sig/rigor/scope.rbs +8 -0
- 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
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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.
|
|
17
|
-
#
|
|
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
|
-
#
|
|
101
|
-
# did not get one, so the command must not report
|
|
102
|
-
# otherwise mean nothing.
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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
|
-
|
|
292
|
-
|
|
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
|
|