rigortype 0.2.5 → 0.2.7
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 +4 -3
- 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 +60 -2
- data/docs/manual/06-baseline.md +12 -0
- data/docs/manual/08-skills.md +21 -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/lib/rigor/cli/check_command.rb +4 -33
- data/lib/rigor/cli/check_runner_factory.rb +63 -0
- data/lib/rigor/cli/coverage_command.rb +42 -10
- 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/skill_command.rb +52 -1
- data/lib/rigor/cli/upgrade_command.rb +25 -0
- data/lib/rigor/cli.rb +17 -1
- data/lib/rigor/environment/rbs_loader.rb +28 -0
- 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 -8
- 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/sig_gen/generator.rb +25 -0
- data/lib/rigor/sig_gen/method_candidate.rb +7 -2
- data/lib/rigor/sig_gen/writer.rb +60 -13
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +1 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +63 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +2 -3
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +14 -24
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +10 -3
- 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-rspec/lib/rigor/plugin/rspec.rb +1 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +36 -79
- data/sig/rigor/plugin/base.rbs +2 -0
- data/sig/rigor/scope.rbs +3 -1
- data/skills/rigor-ask/SKILL.md +21 -1
- data/skills/rigor-baseline-reduce/SKILL.md +16 -0
- data/skills/rigor-ci-setup/SKILL.md +96 -249
- data/skills/rigor-doctor/SKILL.md +39 -49
- data/skills/rigor-doctor/references/01-checks.md +52 -0
- data/skills/rigor-editor-setup/SKILL.md +14 -0
- data/skills/rigor-mcp-setup/SKILL.md +14 -0
- data/skills/rigor-monkeypatch-resolve/SKILL.md +15 -0
- data/skills/rigor-plugin-author/SKILL.md +24 -5
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +8 -4
- data/skills/rigor-plugin-review/SKILL.md +174 -0
- data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +214 -0
- data/skills/rigor-plugin-tune/SKILL.md +21 -2
- data/skills/rigor-project-init/SKILL.md +16 -0
- data/skills/rigor-protection-uplift/SKILL.md +15 -0
- data/skills/rigor-rbs-setup/SKILL.md +15 -0
- data/skills/rigor-upgrade/SKILL.md +16 -0
- metadata +11 -4
|
@@ -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
|
|
@@ -25,6 +25,12 @@ module Rigor
|
|
|
25
25
|
# inline body plus the header's absolute
|
|
26
26
|
# paths let the agent act with or without
|
|
27
27
|
# a file-reading tool.
|
|
28
|
+
# - `rigor skill --full <name>`— the body AND every `references/*.md`
|
|
29
|
+
# inline: the complete, version-current
|
|
30
|
+
# procedure in one call. The thinned SKILL
|
|
31
|
+
# bodies point a *frozen* (vendored) copy
|
|
32
|
+
# at this so the reader follows the gem's
|
|
33
|
+
# current steps, not a stale local copy.
|
|
28
34
|
# - `rigor skill --path <name>`— one-line absolute path, for a Read tool.
|
|
29
35
|
# - `rigor skill --list` — table of name + absolute path.
|
|
30
36
|
# - `rigor skill --describe` — ADR-73's live entry point: a cheap
|
|
@@ -43,12 +49,14 @@ module Rigor
|
|
|
43
49
|
# verb, so it stays first-class alongside `--describe`.
|
|
44
50
|
class SkillCommand < Command
|
|
45
51
|
USAGE = <<~USAGE
|
|
46
|
-
Usage: rigor skill [<name>] [--path <name>] [--list] [--describe]
|
|
52
|
+
Usage: rigor skill [<name>] [--full <name>] [--path <name>] [--list] [--describe]
|
|
47
53
|
|
|
48
54
|
With no argument, lists the bundled skills.
|
|
49
55
|
|
|
50
56
|
rigor skill List bundled skills
|
|
51
57
|
rigor skill <name> Print the SKILL.md body for <name> (with a header)
|
|
58
|
+
rigor skill --full <name> Print the SKILL.md body AND its references/ inline
|
|
59
|
+
(the complete, version-current procedure in one call)
|
|
52
60
|
rigor skill --path <name> Print the absolute path of the SKILL.md file for <name>
|
|
53
61
|
rigor skill --list List bundled skills (name + absolute path)
|
|
54
62
|
rigor skill --describe Report project state + recommend the next skill to run
|
|
@@ -56,6 +64,7 @@ module Rigor
|
|
|
56
64
|
Examples:
|
|
57
65
|
rigor skill
|
|
58
66
|
rigor skill rigor-project-init
|
|
67
|
+
rigor skill --full rigor-baseline-reduce
|
|
59
68
|
rigor skill --path rigor-baseline-reduce
|
|
60
69
|
rigor skill --describe (also: rigor describe)
|
|
61
70
|
|
|
@@ -95,6 +104,9 @@ module Rigor
|
|
|
95
104
|
when "--list"
|
|
96
105
|
@argv.shift
|
|
97
106
|
run_list
|
|
107
|
+
when "--full"
|
|
108
|
+
@argv.shift
|
|
109
|
+
run_full(@argv.shift)
|
|
98
110
|
when "--path"
|
|
99
111
|
@argv.shift
|
|
100
112
|
run_path(@argv.shift)
|
|
@@ -147,6 +159,34 @@ module Rigor
|
|
|
147
159
|
0
|
|
148
160
|
end
|
|
149
161
|
|
|
162
|
+
# `rigor skill --full <name>` — the whole current procedure in one
|
|
163
|
+
# call: the SKILL.md body followed by each `references/*.md` inline.
|
|
164
|
+
# This is what the thinned SKILL bodies point a *frozen* copy at — a
|
|
165
|
+
# vendored copy of a skill (installed via `npx skills`) may lag the
|
|
166
|
+
# gem, so re-fetching the complete body here guarantees the reader
|
|
167
|
+
# follows the version that shipped with the installed Rigor, without
|
|
168
|
+
# needing a file-reading tool or reading a possibly-stale co-located
|
|
169
|
+
# `references/`.
|
|
170
|
+
def run_full(name)
|
|
171
|
+
return usage_error("`--full` requires a skill name") if name.nil?
|
|
172
|
+
|
|
173
|
+
skill = find_skill(name)
|
|
174
|
+
return name_error(name) if skill.nil?
|
|
175
|
+
|
|
176
|
+
@out.puts(render_print_header(skill))
|
|
177
|
+
@out.puts
|
|
178
|
+
@out.write(File.read(skill.fetch(:path)))
|
|
179
|
+
|
|
180
|
+
reference_files(skill).each do |ref|
|
|
181
|
+
@out.puts
|
|
182
|
+
@out.puts
|
|
183
|
+
@out.puts("<!-- ===== references/#{File.basename(ref)} (bundled with rigortype #{Rigor::VERSION}) ===== -->")
|
|
184
|
+
@out.puts
|
|
185
|
+
@out.write(File.read(ref))
|
|
186
|
+
end
|
|
187
|
+
0
|
|
188
|
+
end
|
|
189
|
+
|
|
150
190
|
def run_path(name)
|
|
151
191
|
return usage_error("`--path` requires a skill name") if name.nil?
|
|
152
192
|
|
|
@@ -207,6 +247,17 @@ module Rigor
|
|
|
207
247
|
discover_skills.find { |s| s.fetch(:name) == name }
|
|
208
248
|
end
|
|
209
249
|
|
|
250
|
+
# The `references/*.md` files bundled alongside a skill, sorted so the
|
|
251
|
+
# `NN-` prefixes drive read order.
|
|
252
|
+
def reference_files(skill)
|
|
253
|
+
dir = File.join(File.dirname(skill.fetch(:path)), "references")
|
|
254
|
+
return [] unless File.directory?(dir)
|
|
255
|
+
|
|
256
|
+
# `Dir.glob` returns lexicographically sorted paths (Ruby 3.0+),
|
|
257
|
+
# so the `NN-` prefixes already drive read order.
|
|
258
|
+
Dir.glob(File.join(dir, "*.md"))
|
|
259
|
+
end
|
|
260
|
+
|
|
210
261
|
def name_error(name)
|
|
211
262
|
@err.puts("Unknown skill: #{name}")
|
|
212
263
|
@err.puts("Available skills (try `rigor skill --list`):")
|
|
@@ -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
|
|
@@ -291,12 +291,29 @@ module Rigor
|
|
|
291
291
|
# prefix of another name is declared `module` (it is a
|
|
292
292
|
# namespace); a leaf is declared `class` (referenced types
|
|
293
293
|
# appear in instance position far more often than as mixins).
|
|
294
|
+
#
|
|
295
|
+
# Names already declared in `base_env` are skipped — exactly the
|
|
296
|
+
# `declared.include?` guard {.collect_missing_namespaces} applies.
|
|
297
|
+
# Without it, stubbing a nested reference (`Foo::Bar::Baz`) re-emits
|
|
298
|
+
# its enclosing prefix (`Foo::Bar`) as a `module`, and when that
|
|
299
|
+
# prefix is already a `class` in the project's own `sig/` the kind
|
|
300
|
+
# mismatch makes `resolve_type_names` raise
|
|
301
|
+
# `RBS::DuplicatedDeclarationError`, collapsing the WHOLE env to nil
|
|
302
|
+
# (every type-of query then degrades to `Dynamic[Top]`). One
|
|
303
|
+
# malformed `.rbs` must not disproportionately blind the analysis:
|
|
304
|
+
# a subclass sig that references an inherited nested type
|
|
305
|
+
# (`class GitAdapter; def x: () -> GitAdapter::Revision`) was the
|
|
306
|
+
# real-world trigger — see the 2026-07-04 redmine onboarding note.
|
|
294
307
|
def append_stub_declarations(base_env, missing)
|
|
308
|
+
declared = declared_type_names(base_env)
|
|
295
309
|
names = missing.to_set
|
|
296
310
|
missing.each do |name|
|
|
297
311
|
parts = name.split("::")
|
|
298
312
|
(1...parts.length).each { |i| names << parts[0, i].join("::") }
|
|
299
313
|
end
|
|
314
|
+
names = names.reject { |name| declared.include?(name) }.to_set
|
|
315
|
+
return if names.empty?
|
|
316
|
+
|
|
300
317
|
source = names.sort_by { |n| n.count(":") }.map do |name|
|
|
301
318
|
keyword = names.any? { |other| other != name && other.start_with?("#{name}::") } ? "module" : "class"
|
|
302
319
|
"#{keyword} #{name}\nend\n"
|
|
@@ -308,6 +325,17 @@ module Rigor
|
|
|
308
325
|
nil
|
|
309
326
|
end
|
|
310
327
|
|
|
328
|
+
# The `::`-stripped names of every class / module / class-alias
|
|
329
|
+
# declaration already present in `env`, so the synthesis paths
|
|
330
|
+
# never re-declare (and thereby duplicate) a real declaration.
|
|
331
|
+
def declared_type_names(env)
|
|
332
|
+
names = env.class_decls.keys.map { |n| n.to_s.sub(/\A::/, "") }
|
|
333
|
+
if env.respond_to?(:class_alias_decls)
|
|
334
|
+
names.concat(env.class_alias_decls.keys.map { |n| n.to_s.sub(/\A::/, "") })
|
|
335
|
+
end
|
|
336
|
+
names.to_set
|
|
337
|
+
end
|
|
338
|
+
|
|
311
339
|
# ADR-32 WD4 — merge synthesised-from-source RBS strings
|
|
312
340
|
# into the freshly-built environment. Each entry is a
|
|
313
341
|
# `[virtual_filename, rbs_source]` pair. `virtual_filename`
|