rigortype 0.2.4 → 0.2.6
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/docs/handbook/09-plugins.md +5 -2
- data/docs/handbook/appendix-liskov.md +5 -3
- data/docs/handbook/appendix-phpstan.md +2 -2
- data/docs/install.md +1 -1
- data/docs/manual/02-cli-reference.md +58 -1
- data/docs/manual/06-baseline.md +12 -0
- data/docs/manual/11-ci.md +6 -6
- data/docs/manual/15-type-protection-coverage.md +29 -0
- data/docs/manual/plugins/rigor-minitest.md +1 -1
- data/docs/manual/plugins/rigor-rails-i18n.md +22 -3
- data/lib/rigor/analysis/incremental_session.rb +7 -2
- data/lib/rigor/cli/check_command.rb +4 -33
- data/lib/rigor/cli/check_runner_factory.rb +63 -0
- data/lib/rigor/cli/doctor_command.rb +295 -0
- data/lib/rigor/cli/plugins_command.rb +2 -2
- data/lib/rigor/cli/plugins_renderer.rb +1 -1
- data/lib/rigor/cli/protection_renderer.rb +32 -2
- data/lib/rigor/cli/protection_report.rb +32 -6
- data/lib/rigor/cli/upgrade_command.rb +25 -0
- data/lib/rigor/cli.rb +17 -1
- data/lib/rigor/flow_contribution/fact.rb +1 -1
- data/lib/rigor/inference/dynamic_origin.rb +67 -0
- data/lib/rigor/inference/expression_typer.rb +22 -10
- data/lib/rigor/inference/fallback.rb +2 -2
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +16 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +41 -2
- data/lib/rigor/inference/method_dispatcher.rb +19 -4
- data/lib/rigor/inference/mutation_widening.rb +18 -0
- data/lib/rigor/inference/protection_scanner.rb +6 -3
- data/lib/rigor/inference/statement_evaluator.rb +5 -4
- data/lib/rigor/plugin/base.rb +34 -7
- data/lib/rigor/plugin/registry.rb +1 -1
- data/lib/rigor/scope.rb +16 -5
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +1 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +1 -1
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +1 -1
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +52 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +123 -8
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +1 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +3 -3
- data/sig/rigor/plugin/base.rbs +2 -0
- data/sig/rigor/scope.rbs +3 -1
- data/skills/rigor-plugin-author/SKILL.md +8 -5
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +8 -4
- metadata +27 -3
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optionparser"
|
|
5
|
+
|
|
6
|
+
require_relative "../configuration"
|
|
7
|
+
require_relative "../config_audit"
|
|
8
|
+
require_relative "../analysis/baseline"
|
|
9
|
+
require_relative "../analysis/result"
|
|
10
|
+
require_relative "../plugin"
|
|
11
|
+
require_relative "../plugin/loader"
|
|
12
|
+
require_relative "../plugin/services"
|
|
13
|
+
require_relative "../reflection"
|
|
14
|
+
require_relative "../type/combinator"
|
|
15
|
+
require_relative "check_runner_factory"
|
|
16
|
+
require_relative "command"
|
|
17
|
+
require_relative "options"
|
|
18
|
+
|
|
19
|
+
module Rigor
|
|
20
|
+
class CLI
|
|
21
|
+
# `rigor doctor` — classify existing project findings into setup-problem
|
|
22
|
+
# vs clean-run with a routed next action (ADR-77 WD1).
|
|
23
|
+
#
|
|
24
|
+
# It is a presentation/aggregation layer over data `rigor check` already
|
|
25
|
+
# produces — no new analysis pass and no new diagnostic rule. The
|
|
26
|
+
# command runs a scoped analysis to gather stats, audits the
|
|
27
|
+
# configuration, validates plugins, and checks baseline drift, then
|
|
28
|
+
# reports a small fixed set of checks with a hint for each failure.
|
|
29
|
+
class DoctorCommand < Command # rubocop:disable Metrics/ClassLength
|
|
30
|
+
USAGE = "Usage: rigor doctor [options]"
|
|
31
|
+
|
|
32
|
+
# Check identifiers — the stable JSON contract (`checks[].id`).
|
|
33
|
+
CHECK_CONFIG = "config_audit"
|
|
34
|
+
CHECK_RBS = "rbs_environment"
|
|
35
|
+
CHECK_PLUGINS = "plugins"
|
|
36
|
+
CHECK_BASELINE = "baseline"
|
|
37
|
+
CHECK_RAILS = "rails_plugins"
|
|
38
|
+
|
|
39
|
+
RAILS_LOCK_MARKERS = %w[railties actionpack activerecord actioncable].freeze
|
|
40
|
+
RAILS_PLUGIN_MARKERS = %w[
|
|
41
|
+
rigor-activerecord rigor-actionpack rigor-actionmailer rigor-activejob
|
|
42
|
+
rigor-rails-routes rigor-rails-i18n rigor-actioncable rigor-activestorage rigor-rails
|
|
43
|
+
].freeze
|
|
44
|
+
|
|
45
|
+
# @return [Integer] CLI exit status.
|
|
46
|
+
def run
|
|
47
|
+
options = parse_options
|
|
48
|
+
configuration = Configuration.load(options.fetch(:config))
|
|
49
|
+
findings = []
|
|
50
|
+
|
|
51
|
+
# 1. Config audit (cheap — no analysis needed).
|
|
52
|
+
findings.concat(audit_config(configuration))
|
|
53
|
+
|
|
54
|
+
# 2. Run a scoped analysis to gather stats + diagnostics for the
|
|
55
|
+
# deeper checks. Use no-cache so the probe doesn't churn disk.
|
|
56
|
+
runner = CheckRunnerFactory.build(
|
|
57
|
+
configuration: configuration,
|
|
58
|
+
options: { no_cache: true, explain: false, stats: true, workers: 0 },
|
|
59
|
+
buffer: nil,
|
|
60
|
+
cache_root: configuration.cache_path
|
|
61
|
+
)
|
|
62
|
+
result = runner.run(configuration.paths)
|
|
63
|
+
|
|
64
|
+
# 3. RBS environment check.
|
|
65
|
+
findings.concat(check_rbs_environment(result))
|
|
66
|
+
|
|
67
|
+
# 4. Plugin load check.
|
|
68
|
+
findings.concat(check_plugins(configuration))
|
|
69
|
+
|
|
70
|
+
# 5. Baseline drift check.
|
|
71
|
+
findings.concat(check_baseline(configuration, result))
|
|
72
|
+
|
|
73
|
+
# 6. Rails locked but no Rails plugins.
|
|
74
|
+
findings.concat(check_rails_plugins(configuration))
|
|
75
|
+
|
|
76
|
+
report(findings, options.fetch(:format))
|
|
77
|
+
findings.any? { |f| f[:status] == :fail } ? 1 : 0
|
|
78
|
+
rescue OptionParser::InvalidArgument => e
|
|
79
|
+
@err.puts(e.message)
|
|
80
|
+
CLI::EXIT_USAGE
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def parse_options
|
|
86
|
+
options = { config: nil, format: "text" }
|
|
87
|
+
OptionParser.new do |opts|
|
|
88
|
+
opts.banner = USAGE
|
|
89
|
+
Options.add_config(opts, options)
|
|
90
|
+
opts.on("--format=FORMAT", "Output format: text (default) or json") { |v| options[:format] = v }
|
|
91
|
+
end.parse!(@argv)
|
|
92
|
+
validate!(options)
|
|
93
|
+
options
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def validate!(options)
|
|
97
|
+
return if %w[text json].include?(options.fetch(:format))
|
|
98
|
+
|
|
99
|
+
raise OptionParser::InvalidArgument, "unsupported format: #{options.fetch(:format)}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
# Individual checks — each returns an Array of finding Hashes.
|
|
104
|
+
# A finding has: :check, :status (:pass/:fail/:warn), :message, :hint
|
|
105
|
+
# ------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
def audit_config(configuration)
|
|
108
|
+
warnings = ConfigAudit.warnings(configuration, project_root: Dir.pwd)
|
|
109
|
+
return [] if warnings.empty?
|
|
110
|
+
|
|
111
|
+
messages = warnings.map(&:message)
|
|
112
|
+
[
|
|
113
|
+
{
|
|
114
|
+
check: CHECK_CONFIG,
|
|
115
|
+
status: :fail,
|
|
116
|
+
message: "Configuration warnings: #{messages.size}",
|
|
117
|
+
hint: "Review and fix the flagged entries in your config file."
|
|
118
|
+
}
|
|
119
|
+
]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def check_rbs_environment(result)
|
|
123
|
+
stats = result.stats
|
|
124
|
+
return [] unless stats
|
|
125
|
+
|
|
126
|
+
if stats.rbs_classes_total.zero?
|
|
127
|
+
[
|
|
128
|
+
{
|
|
129
|
+
check: CHECK_RBS,
|
|
130
|
+
status: :fail,
|
|
131
|
+
message: "RBS environment is empty (#{stats.rbs_classes_total} classes available)",
|
|
132
|
+
hint: "The RBS environment failed to build or loaded no signatures. " \
|
|
133
|
+
"Check `signature_paths:` for duplicate declarations, or run " \
|
|
134
|
+
"`rbs collection install` for gem signatures."
|
|
135
|
+
}
|
|
136
|
+
]
|
|
137
|
+
else
|
|
138
|
+
[
|
|
139
|
+
{
|
|
140
|
+
check: CHECK_RBS,
|
|
141
|
+
status: :pass,
|
|
142
|
+
message: "RBS environment healthy (#{stats.rbs_classes_total} classes available)",
|
|
143
|
+
hint: nil
|
|
144
|
+
}
|
|
145
|
+
]
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def check_plugins(configuration)
|
|
150
|
+
services = Plugin::Services.new(
|
|
151
|
+
reflection: Reflection,
|
|
152
|
+
type: Type::Combinator,
|
|
153
|
+
configuration: configuration,
|
|
154
|
+
cache_store: nil
|
|
155
|
+
)
|
|
156
|
+
registry = Plugin::Loader.load(configuration: configuration, services: services)
|
|
157
|
+
errors = registry.load_errors
|
|
158
|
+
return [] if errors.empty?
|
|
159
|
+
|
|
160
|
+
[
|
|
161
|
+
{
|
|
162
|
+
check: CHECK_PLUGINS,
|
|
163
|
+
status: :fail,
|
|
164
|
+
message: "Plugin load errors: #{errors.size}",
|
|
165
|
+
hint: "Run `rigor plugins --strict` for the full per-plugin report."
|
|
166
|
+
}
|
|
167
|
+
]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def check_baseline(configuration, result)
|
|
171
|
+
path = configuration.baseline_path
|
|
172
|
+
return [] if path.nil? || !File.exist?(path)
|
|
173
|
+
|
|
174
|
+
baseline = Analysis::Baseline.load(path, project_root: Dir.pwd)
|
|
175
|
+
return [] if baseline.nil? || baseline.empty?
|
|
176
|
+
|
|
177
|
+
drifted = baseline.audit(result.diagnostics).reject { |row| row.status == :within }
|
|
178
|
+
return [] if drifted.empty?
|
|
179
|
+
|
|
180
|
+
[
|
|
181
|
+
{
|
|
182
|
+
check: CHECK_BASELINE,
|
|
183
|
+
status: :fail,
|
|
184
|
+
message: "Baseline drift detected (#{baseline_drift_summary(drifted)})",
|
|
185
|
+
hint: "Run `rigor baseline regenerate` to refresh the baseline."
|
|
186
|
+
}
|
|
187
|
+
]
|
|
188
|
+
rescue Analysis::Baseline::LoadError, Psych::SyntaxError => e
|
|
189
|
+
[
|
|
190
|
+
{
|
|
191
|
+
check: CHECK_BASELINE,
|
|
192
|
+
status: :warn,
|
|
193
|
+
message: "Baseline load failed: #{e.message}",
|
|
194
|
+
hint: "Check the baseline file path in your config."
|
|
195
|
+
}
|
|
196
|
+
]
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def baseline_drift_summary(drifted)
|
|
200
|
+
counts = drifted.group_by(&:status).transform_values(&:size)
|
|
201
|
+
parts = []
|
|
202
|
+
parts << "#{counts.fetch(:over, 0)} over threshold" if counts.key?(:over)
|
|
203
|
+
parts << "#{counts.fetch(:cleared, 0)} cleared" if counts.key?(:cleared)
|
|
204
|
+
parts << "#{counts.fetch(:reducible, 0)} reducible" if counts.key?(:reducible)
|
|
205
|
+
parts.join(", ")
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def check_rails_plugins(configuration)
|
|
209
|
+
return [] unless rails_unconfigured?(configuration)
|
|
210
|
+
|
|
211
|
+
[
|
|
212
|
+
{
|
|
213
|
+
check: CHECK_RAILS,
|
|
214
|
+
status: :fail,
|
|
215
|
+
message: "Rails detected but no Rails plugins enabled",
|
|
216
|
+
hint: "Add Rails plugins (e.g. rigor-activerecord, rigor-actionpack) to " \
|
|
217
|
+
"`.rigor.yml` `plugins:` so framework calls resolve."
|
|
218
|
+
}
|
|
219
|
+
]
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# True when Rails is in Gemfile.lock but the config enables no
|
|
223
|
+
# Rails plugin — the same probe {ProjectStateProbe} uses.
|
|
224
|
+
def rails_unconfigured?(_configuration)
|
|
225
|
+
config_path = Configuration.discover
|
|
226
|
+
return false if config_path.nil?
|
|
227
|
+
return false unless File.file?(config_path)
|
|
228
|
+
|
|
229
|
+
lock = File.join(Dir.pwd, "Gemfile.lock")
|
|
230
|
+
return false unless File.file?(lock) && file_mentions_any?(lock, RAILS_LOCK_MARKERS)
|
|
231
|
+
|
|
232
|
+
!file_mentions_any?(config_path, RAILS_PLUGIN_MARKERS)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def file_mentions_any?(path, markers)
|
|
236
|
+
content = File.read(path)
|
|
237
|
+
markers.any? { |marker| content.include?(marker) }
|
|
238
|
+
rescue StandardError
|
|
239
|
+
false
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# ------------------------------------------------------------------
|
|
243
|
+
# Reporting
|
|
244
|
+
# ------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
def report(findings, format)
|
|
247
|
+
case format
|
|
248
|
+
when "json" then report_json(findings)
|
|
249
|
+
else report_text(findings)
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def report_json(findings)
|
|
254
|
+
payload = {
|
|
255
|
+
"status" => findings.any? { |f| f[:status] == :fail } ? "issues_found" : "clean",
|
|
256
|
+
"checks" => findings.map do |f|
|
|
257
|
+
{
|
|
258
|
+
"id" => f[:check].to_s,
|
|
259
|
+
"status" => f[:status].to_s,
|
|
260
|
+
"message" => f[:message],
|
|
261
|
+
"hint" => f[:hint]
|
|
262
|
+
}
|
|
263
|
+
end
|
|
264
|
+
}
|
|
265
|
+
@out.puts(JSON.pretty_generate(payload))
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def report_text(findings)
|
|
269
|
+
failures = findings.select { |f| f[:status] == :fail }
|
|
270
|
+
warnings = findings.select { |f| f[:status] == :warn }
|
|
271
|
+
passes = findings.select { |f| f[:status] == :pass }
|
|
272
|
+
|
|
273
|
+
if failures.empty? && warnings.empty?
|
|
274
|
+
@out.puts("rigor doctor: all checks passed — no setup problems detected.")
|
|
275
|
+
else
|
|
276
|
+
@out.puts("rigor doctor: #{failures.size} issue(s) found")
|
|
277
|
+
failures.each { |f| print_finding(f) }
|
|
278
|
+
warnings.each { |f| print_finding(f) } unless warnings.empty?
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
passes.each { |f| print_finding(f) } unless passes.empty?
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def print_finding(finding)
|
|
285
|
+
label = case finding[:status]
|
|
286
|
+
when :fail then "[FAIL]"
|
|
287
|
+
when :warn then "[WARN]"
|
|
288
|
+
else "[PASS]"
|
|
289
|
+
end
|
|
290
|
+
@out.puts("#{label} #{finding[:check]}: #{finding[:message]}")
|
|
291
|
+
@out.puts(" → #{finding[:hint]}") if finding[:hint]
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
|
@@ -37,7 +37,7 @@ module Rigor
|
|
|
37
37
|
# `source_rbs_synthesizer:`);
|
|
38
38
|
# - the ADR-37 narrow extension protocols read off the plugin
|
|
39
39
|
# class — `node_rule` node types, `dynamic_return` receivers,
|
|
40
|
-
# `
|
|
40
|
+
# `narrowing_facts` methods.
|
|
41
41
|
#
|
|
42
42
|
# `--capabilities` switches to a focused catalogue of just the
|
|
43
43
|
# narrow-protocol gate values + produced/consumed facts (ADR-37
|
|
@@ -194,7 +194,7 @@ module Rigor
|
|
|
194
194
|
|
|
195
195
|
# ADR-37 narrow extension protocols. Unlike the 10 declarative
|
|
196
196
|
# manifest fields, these are class-level DSLs (`node_rule` /
|
|
197
|
-
# `dynamic_return` / `
|
|
197
|
+
# `dynamic_return` / `narrowing_facts`), so they are read off the
|
|
198
198
|
# plugin class rather than the manifest. The gate values — node
|
|
199
199
|
# types, receiver class names, specified method names — are the
|
|
200
200
|
# greppable, enumerable surface the capability catalogue exposes.
|
|
@@ -170,7 +170,7 @@ module Rigor
|
|
|
170
170
|
end
|
|
171
171
|
|
|
172
172
|
# ADR-37 narrow extension protocols (node_rule / dynamic_return /
|
|
173
|
-
#
|
|
173
|
+
# narrowing_facts). Surfaced in the full report alongside the
|
|
174
174
|
# declarative surfaces; `--capabilities` is the focused view.
|
|
175
175
|
def narrow_protocol_lines(row)
|
|
176
176
|
lines = []
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
|
|
5
|
+
require_relative "../inference/dynamic_origin"
|
|
6
|
+
|
|
5
7
|
module Rigor
|
|
6
8
|
class CLI
|
|
7
9
|
# Renders an {ProtectionReport} (ADR-63 Tier 1) as text or JSON. The text
|
|
@@ -12,6 +14,15 @@ module Rigor
|
|
|
12
14
|
TOP_CALLS = 15
|
|
13
15
|
TOP_FILES = 10
|
|
14
16
|
|
|
17
|
+
# ADR-73 P6 / ADR-75 WD2 — the actionable axis for each tractability
|
|
18
|
+
# category, shown next to a hole's origin so a user knows whether (and
|
|
19
|
+
# how) a type can close it.
|
|
20
|
+
TRACTABILITY_HINTS = {
|
|
21
|
+
Inference::DynamicOrigin::ADD_RBS => "add RBS",
|
|
22
|
+
Inference::DynamicOrigin::ENABLE_PLUGIN => "enable a plugin / pre_eval",
|
|
23
|
+
Inference::DynamicOrigin::ENGINE_GAP => "engine gap — report it"
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
15
26
|
def initialize(out:)
|
|
16
27
|
@out = out
|
|
17
28
|
end
|
|
@@ -40,13 +51,32 @@ module Rigor
|
|
|
40
51
|
return if calls.empty?
|
|
41
52
|
|
|
42
53
|
@out.puts "\nAdd a type here — methods most often called on an untyped receiver:"
|
|
54
|
+
render_tractability_summary(report)
|
|
43
55
|
calls.first(TOP_CALLS).each do |call|
|
|
44
|
-
|
|
45
|
-
|
|
56
|
+
label = origin_label(call.dynamic_origin)
|
|
57
|
+
@out.puts format(" %<count>-5d #%<method>s e.g. %<sites>s%<label>s",
|
|
58
|
+
count: call.count, method: call.method_name,
|
|
59
|
+
sites: call.examples.join(" "), label: label)
|
|
46
60
|
end
|
|
47
61
|
@out.puts " (#{calls.size - TOP_CALLS} more)" if calls.size > TOP_CALLS
|
|
48
62
|
end
|
|
49
63
|
|
|
64
|
+
def render_tractability_summary(report)
|
|
65
|
+
summary = report.tractability_summary
|
|
66
|
+
return if summary.empty?
|
|
67
|
+
|
|
68
|
+
parts = summary.sort_by { |_, n| -n }.map { |axis, n| "#{n} #{axis.to_s.tr('_', '-')}" }
|
|
69
|
+
@out.puts " by tractability: #{parts.join(' · ')} (add-rbs = closable with a type)"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def origin_label(origin)
|
|
73
|
+
return "" if origin.nil?
|
|
74
|
+
|
|
75
|
+
tractability = Inference::DynamicOrigin.tractability(origin)
|
|
76
|
+
hint = tractability ? " → #{TRACTABILITY_HINTS[tractability]}" : ""
|
|
77
|
+
" [#{origin.to_s.tr('_', '-')}#{hint}]"
|
|
78
|
+
end
|
|
79
|
+
|
|
50
80
|
def render_files(report)
|
|
51
81
|
worst = report.files.reject { |f| f.unprotected_count.zero? }.sort_by(&:ratio).first(TOP_FILES)
|
|
52
82
|
return if worst.empty?
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../inference/dynamic_origin"
|
|
4
|
+
|
|
3
5
|
module Rigor
|
|
4
6
|
class CLI
|
|
5
7
|
# ADR-63 Tier 1 — aggregates per-file {Inference::ProtectionScanner}
|
|
@@ -9,7 +11,7 @@ module Rigor
|
|
|
9
11
|
# untyped dispatches, where a receiver annotation buys the most catching
|
|
10
12
|
# power.
|
|
11
13
|
FileProtection = Data.define(:path, :protected_count, :unprotected_count, :ratio)
|
|
12
|
-
UntypedCall = Data.define(:method_name, :count, :examples)
|
|
14
|
+
UntypedCall = Data.define(:method_name, :count, :examples, :dynamic_origin)
|
|
13
15
|
|
|
14
16
|
ProtectionReport = Data.define(:files, :untyped_calls, :parse_errors) do
|
|
15
17
|
def total_protected = files.sum(&:protected_count)
|
|
@@ -17,17 +19,37 @@ module Rigor
|
|
|
17
19
|
def grand_total = total_protected + total_unprotected
|
|
18
20
|
def ratio = grand_total.zero? ? 1.0 : total_protected.to_f / grand_total
|
|
19
21
|
|
|
22
|
+
# ADR-73 P6 — total dispatch-site count per tractability axis across
|
|
23
|
+
# the classified holes (those with a recorded `dynamic_origin`), so a
|
|
24
|
+
# user sees at a glance how much of the untyped surface a type can
|
|
25
|
+
# actually close (`add_rbs`) vs. needs a plugin (`enable_plugin`) vs.
|
|
26
|
+
# is an engine gap. Keys are omitted when zero; an all-unclassified
|
|
27
|
+
# run yields `{}`.
|
|
28
|
+
def tractability_summary
|
|
29
|
+
untyped_calls.each_with_object(Hash.new(0)) do |c, acc|
|
|
30
|
+
axis = c.dynamic_origin && Inference::DynamicOrigin.tractability(c.dynamic_origin)
|
|
31
|
+
acc[axis] += c.count if axis
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
20
35
|
def to_h
|
|
21
36
|
{
|
|
22
37
|
"protected" => total_protected,
|
|
23
38
|
"unprotected" => total_unprotected,
|
|
24
39
|
"protection_ratio" => ratio.round(4),
|
|
40
|
+
"tractability_summary" => tractability_summary.transform_keys(&:to_s),
|
|
25
41
|
"files" => files.map do |f|
|
|
26
42
|
{ "path" => f.path, "protected" => f.protected_count,
|
|
27
43
|
"unprotected" => f.unprotected_count, "ratio" => f.ratio.round(4) }
|
|
28
44
|
end,
|
|
29
45
|
"add_a_type_here" => untyped_calls.map do |c|
|
|
30
|
-
{ "method" => c.method_name, "count" => c.count, "examples" => c.examples }
|
|
46
|
+
entry = { "method" => c.method_name, "count" => c.count, "examples" => c.examples }
|
|
47
|
+
if c.dynamic_origin
|
|
48
|
+
entry["dynamic_origin"] = c.dynamic_origin
|
|
49
|
+
tractability = Inference::DynamicOrigin.tractability(c.dynamic_origin)
|
|
50
|
+
entry["tractability"] = tractability if tractability
|
|
51
|
+
end
|
|
52
|
+
entry
|
|
31
53
|
end,
|
|
32
54
|
"parse_errors" => parse_errors
|
|
33
55
|
}
|
|
@@ -37,7 +59,7 @@ module Rigor
|
|
|
37
59
|
class ProtectionAccumulator
|
|
38
60
|
def initialize
|
|
39
61
|
@files = []
|
|
40
|
-
@calls = Hash.new { |h, k| h[k] = { count: 0, examples: [] } }
|
|
62
|
+
@calls = Hash.new { |h, k| h[k] = { count: 0, examples: [], origins: Hash.new(0) } }
|
|
41
63
|
@parse_errors = []
|
|
42
64
|
end
|
|
43
65
|
|
|
@@ -50,6 +72,7 @@ module Rigor
|
|
|
50
72
|
bucket = @calls[site.method_name]
|
|
51
73
|
bucket[:count] += 1
|
|
52
74
|
bucket[:examples] << "#{path}:#{site.line}" if bucket[:examples].size < 3
|
|
75
|
+
bucket[:origins][site.dynamic_origin] += 1 if site.dynamic_origin
|
|
53
76
|
end
|
|
54
77
|
end
|
|
55
78
|
|
|
@@ -58,9 +81,12 @@ module Rigor
|
|
|
58
81
|
end
|
|
59
82
|
|
|
60
83
|
def to_report
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
84
|
+
calls = @calls.map do |method, v|
|
|
85
|
+
origin = v[:origins].max_by { |_, count| count }&.first
|
|
86
|
+
UntypedCall.new(method_name: method, count: v[:count], examples: v[:examples],
|
|
87
|
+
dynamic_origin: origin)
|
|
88
|
+
end
|
|
89
|
+
untyped = calls.sort_by { |c| [-c.count, c.method_name] }
|
|
64
90
|
ProtectionReport.new(files: @files, untyped_calls: untyped, parse_errors: @parse_errors)
|
|
65
91
|
end
|
|
66
92
|
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "command"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
class CLI
|
|
7
|
+
# `rigor upgrade` — the ADR-50 WD7 migration command skeleton.
|
|
8
|
+
#
|
|
9
|
+
# The real body lands when a concrete backwards-compatibility break
|
|
10
|
+
# gives it a target (e.g. re-running `baseline regenerate` against a
|
|
11
|
+
# strengthened default profile, surfacing renamed suppression ids,
|
|
12
|
+
# reporting `bleeding_edge:` graduations). Until then it prints the
|
|
13
|
+
# current version and notes that upgrade is queued.
|
|
14
|
+
class UpgradeCommand < Command
|
|
15
|
+
USAGE = "Usage: rigor upgrade [options]"
|
|
16
|
+
|
|
17
|
+
# @return [Integer] CLI exit status.
|
|
18
|
+
def run
|
|
19
|
+
@out.puts("rigor upgrade: No migration target available yet (ADR-50 WD7, queued).")
|
|
20
|
+
@out.puts("Current version: #{Rigor::VERSION}")
|
|
21
|
+
0
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
data/lib/rigor/cli.rb
CHANGED
|
@@ -43,7 +43,9 @@ module Rigor
|
|
|
43
43
|
"skill" => :run_skill,
|
|
44
44
|
"describe" => :run_describe,
|
|
45
45
|
"docs" => :run_docs,
|
|
46
|
-
"show-bleedingedge" => :run_show_bleedingedge
|
|
46
|
+
"show-bleedingedge" => :run_show_bleedingedge,
|
|
47
|
+
"doctor" => :run_doctor,
|
|
48
|
+
"upgrade" => :run_upgrade
|
|
47
49
|
}.freeze
|
|
48
50
|
|
|
49
51
|
def self.start(argv = ARGV, out: $stdout, err: $stderr)
|
|
@@ -317,6 +319,18 @@ module Rigor
|
|
|
317
319
|
CLI::ShowBleedingedgeCommand.new(argv: @argv, out: @out, err: @err).run
|
|
318
320
|
end
|
|
319
321
|
|
|
322
|
+
def run_doctor
|
|
323
|
+
require_relative "cli/doctor_command"
|
|
324
|
+
|
|
325
|
+
CLI::DoctorCommand.new(argv: @argv, out: @out, err: @err).run
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def run_upgrade
|
|
329
|
+
require_relative "cli/upgrade_command"
|
|
330
|
+
|
|
331
|
+
CLI::UpgradeCommand.new(argv: @argv, out: @out, err: @err).run
|
|
332
|
+
end
|
|
333
|
+
|
|
320
334
|
def help
|
|
321
335
|
<<~HELP
|
|
322
336
|
Usage: rigor <command> [options]
|
|
@@ -342,6 +356,8 @@ module Rigor
|
|
|
342
356
|
skill Recommend the next skill + list/print bundled Agent Skills (skill describe, skill <name>)
|
|
343
357
|
docs Print the bundled docs offline (docs <name>, docs --list)
|
|
344
358
|
show-bleedingedge Show the bleeding-edge overlay + what your config adopts (ADR-50)
|
|
359
|
+
doctor Classify setup problems vs clean run with routed next actions (ADR-77)
|
|
360
|
+
upgrade Migration command skeleton (ADR-50 WD7, queued)
|
|
345
361
|
version Print the Rigor version
|
|
346
362
|
help Print this help
|
|
347
363
|
HELP
|
|
@@ -18,7 +18,7 @@ module Rigor
|
|
|
18
18
|
# (`Rigor::RbsExtended::PredicateEffect`).
|
|
19
19
|
# 3. RBS::Extended `assert*` directives
|
|
20
20
|
# (`Rigor::RbsExtended::AssertEffect`).
|
|
21
|
-
# 4. Plugin contributions via `
|
|
21
|
+
# 4. Plugin contributions via `narrowing_facts` DSL (ADR-52).
|
|
22
22
|
#
|
|
23
23
|
# Each of those four carriers translates to / from Fact at
|
|
24
24
|
# its boundary; downstream of {Rigor::FlowContribution#to_element_list}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Inference
|
|
5
|
+
# ADR-75 v1 cause set — precision-additive provenance for Dynamic[T]
|
|
6
|
+
# introduction sites. Each symbol identifies a distinct family of
|
|
7
|
+
# reason that an expression widened to Dynamic, surfaced by
|
|
8
|
+
# `rigor coverage --protection` so users can distinguish tractable
|
|
9
|
+
# holes (e.g. an explicit `untyped` RBS signature) from intractable
|
|
10
|
+
# ones (e.g. unsupported syntax).
|
|
11
|
+
#
|
|
12
|
+
# Provenance is a side-channel: it never participates in subtyping,
|
|
13
|
+
# consistency, normalization, or erasure, and no diagnostic fires
|
|
14
|
+
# from it.
|
|
15
|
+
module DynamicOrigin
|
|
16
|
+
# A gem with no resolvable RBS.
|
|
17
|
+
EXTERNAL_GEM_WITHOUT_RBS = :external_gem_without_rbs
|
|
18
|
+
# Value across macro/DSL expansion or plugin-declared dynamic return.
|
|
19
|
+
FRAMEWORK_DSL_BOUNDARY = :framework_dsl_boundary
|
|
20
|
+
# Budget/fuel guard widened to Dynamic.
|
|
21
|
+
ANALYZER_BUDGET_CUTOFF = :analyzer_budget_cutoff
|
|
22
|
+
# Authored `untyped` contract.
|
|
23
|
+
EXPLICIT_UNTYPED = :explicit_untyped
|
|
24
|
+
# Inference fallback on unmodeled construct.
|
|
25
|
+
UNSUPPORTED_SYNTAX = :unsupported_syntax
|
|
26
|
+
|
|
27
|
+
CAUSES = [
|
|
28
|
+
EXTERNAL_GEM_WITHOUT_RBS,
|
|
29
|
+
FRAMEWORK_DSL_BOUNDARY,
|
|
30
|
+
ANALYZER_BUDGET_CUTOFF,
|
|
31
|
+
EXPLICIT_UNTYPED,
|
|
32
|
+
UNSUPPORTED_SYNTAX
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
# ADR-73 P6 / ADR-75 WD2 — tractability: given the cause, can a user
|
|
36
|
+
# close this `Dynamic` hole, and on which axis. `coverage --protection`
|
|
37
|
+
# surfaces it so a user does not chase a hole a hand-written type
|
|
38
|
+
# cannot fix. Three coarse, action-oriented categories:
|
|
39
|
+
#
|
|
40
|
+
# - `:add_rbs` — write or install RBS (a missing gem signature,
|
|
41
|
+
# or an authored `untyped` to tighten).
|
|
42
|
+
# - `:enable_plugin`— a framework / DSL boundary; needs a plugin or
|
|
43
|
+
# `pre_eval:`, not a hand-written type.
|
|
44
|
+
# - `:engine_gap` — not user-closable (a budget cutoff or unmodeled
|
|
45
|
+
# syntax); report it.
|
|
46
|
+
ADD_RBS = :add_rbs
|
|
47
|
+
ENABLE_PLUGIN = :enable_plugin
|
|
48
|
+
ENGINE_GAP = :engine_gap
|
|
49
|
+
|
|
50
|
+
TRACTABILITY = {
|
|
51
|
+
EXTERNAL_GEM_WITHOUT_RBS => ADD_RBS,
|
|
52
|
+
EXPLICIT_UNTYPED => ADD_RBS,
|
|
53
|
+
FRAMEWORK_DSL_BOUNDARY => ENABLE_PLUGIN,
|
|
54
|
+
ANALYZER_BUDGET_CUTOFF => ENGINE_GAP,
|
|
55
|
+
UNSUPPORTED_SYNTAX => ENGINE_GAP
|
|
56
|
+
}.freeze
|
|
57
|
+
|
|
58
|
+
module_function
|
|
59
|
+
|
|
60
|
+
# @return [Symbol, nil] the tractability category for a cause, or nil
|
|
61
|
+
# when the cause is unknown / absent.
|
|
62
|
+
def tractability(cause)
|
|
63
|
+
TRACTABILITY[cause]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|