rigortype 0.1.17 → 0.1.18
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 -2
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +18 -1
- data/lib/rigor/analysis/check_rules/rule_walk.rb +67 -0
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +18 -1
- data/lib/rigor/analysis/check_rules.rb +34 -6
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +580 -0
- data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
- data/lib/rigor/analysis/runner/project_pre_passes.rb +318 -0
- data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
- data/lib/rigor/analysis/runner.rb +160 -1190
- data/lib/rigor/analysis/worker_session.rb +47 -8
- data/lib/rigor/cache/incremental_snapshot.rb +10 -4
- data/lib/rigor/cache/rbs_cache_producer.rb +5 -1
- data/lib/rigor/cache/store.rb +46 -13
- data/lib/rigor/cli/check_command.rb +705 -0
- data/lib/rigor/cli/ci_detector.rb +94 -0
- data/lib/rigor/cli/diagnostic_formats.rb +345 -0
- data/lib/rigor/cli/prism_colorizer.rb +10 -3
- data/lib/rigor/cli/trace_command.rb +143 -0
- data/lib/rigor/cli/trace_renderer.rb +310 -0
- data/lib/rigor/cli.rb +15 -614
- data/lib/rigor/configuration.rb +9 -6
- data/lib/rigor/environment/rbs_loader.rb +53 -68
- data/lib/rigor/environment.rb +1 -1
- data/lib/rigor/inference/acceptance.rb +10 -0
- data/lib/rigor/inference/expression_typer.rb +28 -62
- data/lib/rigor/inference/flow_tracer.rb +180 -0
- data/lib/rigor/inference/macro_block_self_type.rb +10 -11
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
- data/lib/rigor/inference/method_dispatcher.rb +115 -54
- data/lib/rigor/inference/narrowing.rb +60 -0
- data/lib/rigor/inference/scope_indexer.rb +75 -15
- data/lib/rigor/inference/statement_evaluator.rb +35 -52
- data/lib/rigor/plugin/additional_initializer.rb +61 -38
- data/lib/rigor/plugin/base.rb +282 -41
- data/lib/rigor/plugin/node_rule_walk.rb +147 -0
- data/lib/rigor/plugin/registry.rb +263 -35
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/rbs_extended/conformance_checker.rb +86 -1
- data/lib/rigor/scope/discovery_index.rb +58 -0
- data/lib/rigor/scope.rb +67 -198
- data/lib/rigor/sig_gen/observation_collector.rb +6 -6
- data/lib/rigor/source/literals.rb +14 -0
- data/lib/rigor/type/combinator.rb +5 -0
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +0 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +70 -32
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +15 -21
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +35 -18
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +83 -36
- data/sig/rigor/environment.rbs +0 -2
- data/sig/rigor/inference.rbs +5 -0
- data/sig/rigor/plugin/base.rbs +1 -2
- data/sig/rigor/scope.rbs +41 -29
- data/sig/rigor/source.rbs +1 -0
- data/skills/rigor-ci-setup/SKILL.md +319 -0
- metadata +15 -2
- data/lib/rigor/cache/rbs_instance_definitions.rb +0 -66
data/lib/rigor/cli.rb
CHANGED
|
@@ -10,13 +10,14 @@ require_relative "version"
|
|
|
10
10
|
require_relative "analysis/diagnostic"
|
|
11
11
|
require_relative "analysis/result"
|
|
12
12
|
require_relative "cli/options"
|
|
13
|
+
require_relative "cli/diagnostic_formats"
|
|
14
|
+
require_relative "cli/ci_detector"
|
|
13
15
|
|
|
14
16
|
module Rigor
|
|
15
17
|
# The CLI class is a dispatcher: each `run_*` method delegates to a
|
|
16
18
|
# command-specific class once the command grows beyond a few lines (see
|
|
17
|
-
# {CLI::TypeOfCommand}).
|
|
18
|
-
|
|
19
|
-
class CLI # rubocop:disable Metrics/ClassLength
|
|
19
|
+
# {CLI::TypeOfCommand} and {CLI::CheckCommand}).
|
|
20
|
+
class CLI
|
|
20
21
|
EXIT_USAGE = 64
|
|
21
22
|
|
|
22
23
|
HANDLERS = {
|
|
@@ -24,6 +25,7 @@ module Rigor
|
|
|
24
25
|
"init" => :run_init,
|
|
25
26
|
"annotate" => :run_annotate,
|
|
26
27
|
"type-of" => :run_type_of,
|
|
28
|
+
"trace" => :run_trace,
|
|
27
29
|
"type-scan" => :run_type_scan,
|
|
28
30
|
"explain" => :run_explain,
|
|
29
31
|
"diff" => :run_diff,
|
|
@@ -78,590 +80,10 @@ module Rigor
|
|
|
78
80
|
EXIT_USAGE
|
|
79
81
|
end
|
|
80
82
|
|
|
81
|
-
def run_check
|
|
82
|
-
|
|
83
|
-
options = parse_check_options
|
|
84
|
-
buffer = Options.resolve_buffer_binding(options, err: @err)
|
|
85
|
-
return EXIT_USAGE if buffer == :usage_error
|
|
86
|
-
|
|
87
|
-
configuration = load_check_configuration(options)
|
|
88
|
-
cache_root = configuration.cache_path
|
|
89
|
-
handle_clear_cache(cache_root) if options.fetch(:clear_cache)
|
|
90
|
-
|
|
91
|
-
special = dispatch_special_check_mode(configuration, options, cache_root)
|
|
92
|
-
return special unless special.nil?
|
|
93
|
-
|
|
94
|
-
runner = build_check_runner(
|
|
95
|
-
configuration: configuration, options: options,
|
|
96
|
-
buffer: buffer, cache_root: cache_root
|
|
97
|
-
)
|
|
98
|
-
raw_result = runner.run(@argv.empty? ? configuration.paths : @argv)
|
|
99
|
-
result = apply_baseline_filter(raw_result, configuration, options)
|
|
100
|
-
|
|
101
|
-
write_result(result, options.fetch(:format))
|
|
102
|
-
write_run_stats(result.stats) if result.stats
|
|
103
|
-
write_trace_appendices
|
|
104
|
-
runner.cache_store&.evict!
|
|
105
|
-
write_cache_stats(cache_root, runner.cache_store) if options.fetch(:cache_stats)
|
|
106
|
-
|
|
107
|
-
exit_code = result.success? ? 0 : 1
|
|
108
|
-
exit_code = 1 if baseline_strict_violation?(raw_result.diagnostics, configuration, options)
|
|
109
|
-
exit_code
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
# ADR-46 — the two incremental-analysis check modes both fully handle
|
|
113
|
-
# the run and return an exit code (so `run_check` short-circuits);
|
|
114
|
-
# returns nil for an ordinary check.
|
|
115
|
-
def dispatch_special_check_mode(configuration, options, cache_root)
|
|
116
|
-
return run_verify_incremental(configuration) if options.fetch(:verify_incremental)
|
|
117
|
-
return run_incremental_check(configuration, options, cache_root) if options.fetch(:incremental)
|
|
118
|
-
|
|
119
|
-
nil
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
# ADR-46 — the incremental-analysis acceptance gate. Runs a baseline
|
|
123
|
-
# analysis (recording cross-file dependencies), then re-analyzes a
|
|
124
|
-
# representative subset of files and serves the rest from the per-file
|
|
125
|
-
# cache (the body tier), and asserts the merged diagnostics are
|
|
126
|
-
# byte-identical to a full `--no-cache` analysis. A mismatch means the
|
|
127
|
-
# incremental machinery would serve a stale — manufactured —
|
|
128
|
-
# diagnostic, the soundness failure this gate exists to catch. Prints a
|
|
129
|
-
# one-line PASS (exit 0) or the differing diagnostics (exit 1).
|
|
130
|
-
def run_verify_incremental(configuration)
|
|
131
|
-
paths = @argv.empty? ? nil : @argv
|
|
132
|
-
session = Analysis::IncrementalSession.new(configuration: configuration, paths: paths)
|
|
133
|
-
session.baseline
|
|
134
|
-
analyzed = session.analyzed_files
|
|
135
|
-
|
|
136
|
-
# Every other file forms the re-analyzed subset, so the run exercises
|
|
137
|
-
# BOTH the subset-analysis path and the cache-serving path.
|
|
138
|
-
subset = analyzed.each_with_index.select { |_, index| index.even? }.map(&:first)
|
|
139
|
-
incremental = normalize_diagnostics(session.reanalyze_subset(subset))
|
|
140
|
-
full = normalize_diagnostics(verify_full_diagnostics(configuration, paths))
|
|
141
|
-
|
|
142
|
-
report_verify_incremental(incremental, full, subset_size: subset.size, total: analyzed.size)
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
# ADR-46 — cross-process incremental analysis (`--incremental`). Derives
|
|
146
|
-
# the global fingerprint cheaply (no RBS env build), loads the disk
|
|
147
|
-
# snapshot, and on a fingerprint hit re-analyzes only the files changed
|
|
148
|
-
# since the last run (plus their dependents), serving the rest from the
|
|
149
|
-
# snapshot; on a miss runs a full baseline. Persists the updated
|
|
150
|
-
# snapshot for the next invocation. Diagnostics are identical to a full
|
|
151
|
-
# run (the `--verify-incremental` gate enforces this); the win is
|
|
152
|
-
# skipping per-file inference for unchanged files.
|
|
153
|
-
def run_incremental_check(configuration, options, cache_root)
|
|
154
|
-
paths = @argv.empty? ? nil : @argv
|
|
155
|
-
probe = Analysis::Runner.new(configuration: configuration, cache_store: nil)
|
|
156
|
-
files = paths ? probe.analysis_file_set(paths) : probe.analysis_file_set
|
|
157
|
-
fingerprint = Cache::IncrementalSnapshot.fingerprint(
|
|
158
|
-
configuration: configuration, roots: paths || configuration.paths
|
|
159
|
-
)
|
|
160
|
-
snapshot = Cache::IncrementalSnapshot.new(root: cache_root)
|
|
161
|
-
session = Analysis::IncrementalSession.new(configuration: configuration, paths: paths)
|
|
162
|
-
|
|
163
|
-
diagnostics, warm = session.run_incremental(snapshot: snapshot, fingerprint: fingerprint)
|
|
164
|
-
@err.puts("rigor: --incremental #{warm ? 'warm — reused cached diagnostics' : 'cold — full analysis'} " \
|
|
165
|
-
"(#{files.size} files)")
|
|
166
|
-
|
|
167
|
-
result = apply_baseline_filter(Analysis::Result.new(diagnostics: diagnostics, stats: nil), configuration, options)
|
|
168
|
-
write_result(result, options.fetch(:format))
|
|
169
|
-
result.success? ? 0 : 1
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
def verify_full_diagnostics(configuration, paths)
|
|
173
|
-
runner = Analysis::Runner.new(configuration: configuration, cache_store: nil)
|
|
174
|
-
(paths ? runner.run(paths) : runner.run).diagnostics
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
def normalize_diagnostics(diagnostics)
|
|
178
|
-
diagnostics.map(&:to_h).sort_by do |hash|
|
|
179
|
-
[hash["path"].to_s, hash["line"].to_i, hash["column"].to_i, hash["rule"].to_s, hash["message"].to_s]
|
|
180
|
-
end
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
def report_verify_incremental(incremental, full, subset_size:, total:)
|
|
184
|
-
if incremental == full
|
|
185
|
-
@out.puts("rigor: --verify-incremental OK — incremental " \
|
|
186
|
-
"(#{subset_size}/#{total} files re-analyzed, rest from cache) " \
|
|
187
|
-
"matches full (#{full.size} diagnostics)")
|
|
188
|
-
return 0
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
only_incremental = incremental - full
|
|
192
|
-
only_full = full - incremental
|
|
193
|
-
@err.puts("rigor: --verify-incremental FAILED — incremental and full diagnostics differ.")
|
|
194
|
-
@err.puts(" incremental-only: #{only_incremental.size}, full-only: #{only_full.size}")
|
|
195
|
-
(only_incremental + only_full).first(10).each do |hash|
|
|
196
|
-
@err.puts(" #{hash['path']}:#{hash['line']}:#{hash['column']}: [#{hash['rule']}] #{hash['message']}")
|
|
197
|
-
end
|
|
198
|
-
1
|
|
199
|
-
end
|
|
200
|
-
|
|
201
|
-
# ADR-22 slice 5 — the `--baseline-strict` CI gate. When the
|
|
202
|
-
# flag is set, ANY baseline drift fails the run — not only
|
|
203
|
-
# excess drift (a bucket over threshold, which already fails
|
|
204
|
-
# via the surfaced diagnostics) but also DEFICIT drift
|
|
205
|
-
# (`actual < count`: the baseline has grown looser than the
|
|
206
|
-
# code and should be regenerated). A no-op, with a stderr
|
|
207
|
-
# note, when no baseline is active — the flag never
|
|
208
|
-
# implicitly loads a baseline the config did not name (WD2).
|
|
209
|
-
def baseline_strict_violation?(raw_diagnostics, configuration, options)
|
|
210
|
-
return false unless options.fetch(:baseline_strict)
|
|
211
|
-
|
|
212
|
-
path = resolve_baseline_path(configuration, options)
|
|
213
|
-
if path.nil?
|
|
214
|
-
@err.puts("rigor: --baseline-strict given but no baseline is active; nothing to gate.")
|
|
215
|
-
return false
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
baseline = Analysis::Baseline.load(path, project_root: Dir.pwd)
|
|
219
|
-
return false if baseline.nil? || baseline.empty?
|
|
83
|
+
def run_check
|
|
84
|
+
require_relative "cli/check_command"
|
|
220
85
|
|
|
221
|
-
|
|
222
|
-
return false if drifted.empty?
|
|
223
|
-
|
|
224
|
-
report_strict_drift(drifted, path)
|
|
225
|
-
true
|
|
226
|
-
rescue Analysis::Baseline::LoadError => e
|
|
227
|
-
@err.puts("rigor: baseline load failed: #{e.message} (--baseline-strict gate skipped)")
|
|
228
|
-
false
|
|
229
|
-
end
|
|
230
|
-
|
|
231
|
-
def report_strict_drift(rows, path)
|
|
232
|
-
@err.puts("rigor: --baseline-strict — #{rows.size} bucket(s) drifted from #{path}:")
|
|
233
|
-
rows.sort_by { |r| [r.bucket.file, r.bucket.rule] }.each do |row|
|
|
234
|
-
delta = row.delta.positive? ? "+#{row.delta}" : row.delta.to_s
|
|
235
|
-
@err.puts(" #{row.bucket.file} [#{row.bucket.rule}] " \
|
|
236
|
-
"#{row.bucket.count} → #{row.actual_count} (Δ#{delta}, #{row.status})")
|
|
237
|
-
end
|
|
238
|
-
@err.puts("rigor: run `rigor baseline regenerate` to refresh the baseline.")
|
|
239
|
-
end
|
|
240
|
-
|
|
241
|
-
# ADR-22 — apply the baseline filter as the LAST step of
|
|
242
|
-
# the diagnostic pipeline (after `# rigor:disable`,
|
|
243
|
-
# `severity_profile`, etc. — WD6). Resolution order
|
|
244
|
-
# follows WD2 (b):
|
|
245
|
-
#
|
|
246
|
-
# 1. --no-baseline on the CLI → no baseline.
|
|
247
|
-
# 2. --baseline=PATH on the CLI → load that path.
|
|
248
|
-
# 3. .rigor.yml's `baseline: <path>` → load that path.
|
|
249
|
-
# 4. otherwise → no baseline.
|
|
250
|
-
#
|
|
251
|
-
# When the path resolves and loads successfully, the filter
|
|
252
|
-
# replaces `result.diagnostics` with the surfaced set and
|
|
253
|
-
# writes a one-line summary to stderr (WD7) when any
|
|
254
|
-
# diagnostics were silenced. Load failures emit a warning
|
|
255
|
-
# to stderr and fall through to "no baseline" (graceful
|
|
256
|
-
# degradation).
|
|
257
|
-
def apply_baseline_filter(result, configuration, options)
|
|
258
|
-
path = resolve_baseline_path(configuration, options)
|
|
259
|
-
return result if path.nil?
|
|
260
|
-
|
|
261
|
-
baseline = Analysis::Baseline.load(path, project_root: Dir.pwd)
|
|
262
|
-
return result if baseline.nil?
|
|
263
|
-
|
|
264
|
-
surfaced, silenced_count = baseline.filter(result.diagnostics)
|
|
265
|
-
report_baseline_summary(silenced_count, path) if silenced_count.positive?
|
|
266
|
-
Analysis::Result.new(diagnostics: surfaced, stats: result.stats)
|
|
267
|
-
rescue Analysis::Baseline::LoadError => e
|
|
268
|
-
@err.puts("rigor: baseline load failed: #{e.message} (continuing without baseline)")
|
|
269
|
-
result
|
|
270
|
-
end
|
|
271
|
-
|
|
272
|
-
# WD2 (b) — resolve effective baseline path.
|
|
273
|
-
def resolve_baseline_path(configuration, options)
|
|
274
|
-
cli_value = options.fetch(:baseline)
|
|
275
|
-
case cli_value
|
|
276
|
-
when false then nil # --no-baseline
|
|
277
|
-
when :unset then configuration.baseline_path # fall through to config
|
|
278
|
-
else cli_value # --baseline=PATH
|
|
279
|
-
end
|
|
280
|
-
end
|
|
281
|
-
|
|
282
|
-
def report_baseline_summary(silenced_count, baseline_path)
|
|
283
|
-
@err.puts("rigor: #{silenced_count} diagnostic(s) silenced by baseline #{baseline_path}")
|
|
284
|
-
end
|
|
285
|
-
|
|
286
|
-
def build_check_runner(configuration:, options:, buffer:, cache_root:)
|
|
287
|
-
cache_store = if options.fetch(:no_cache)
|
|
288
|
-
nil
|
|
289
|
-
else
|
|
290
|
-
Cache::Store.new(
|
|
291
|
-
root: cache_root,
|
|
292
|
-
max_bytes: configuration.cache_max_bytes
|
|
293
|
-
)
|
|
294
|
-
end
|
|
295
|
-
Analysis::Runner.new(
|
|
296
|
-
configuration: configuration,
|
|
297
|
-
explain: options.fetch(:explain),
|
|
298
|
-
cache_store: cache_store,
|
|
299
|
-
collect_stats: options.fetch(:stats),
|
|
300
|
-
workers: resolve_workers(options, configuration),
|
|
301
|
-
buffer: buffer
|
|
302
|
-
)
|
|
303
|
-
end
|
|
304
|
-
|
|
305
|
-
# ADR-15 Phase 4c — resolves the worker count by
|
|
306
|
-
# precedence: CLI `--workers=N` (most explicit) > env
|
|
307
|
-
# `RIGOR_RACTOR_WORKERS` > config `.rigor.yml`
|
|
308
|
-
# `parallel.workers:` > 0 (sequential default). Returns
|
|
309
|
-
# an Integer; non-numeric values raise so typos fail
|
|
310
|
-
# loudly. CLI / env may pass a negative value — clamped
|
|
311
|
-
# to 0 (sequential) so a stray `-1` doesn't crash the
|
|
312
|
-
# pool spawn loop.
|
|
313
|
-
def resolve_workers(options, configuration)
|
|
314
|
-
cli_value = options[:workers]
|
|
315
|
-
return [Integer(cli_value), 0].max if cli_value
|
|
316
|
-
|
|
317
|
-
env_value = ENV.fetch("RIGOR_RACTOR_WORKERS", nil)
|
|
318
|
-
return [Integer(env_value), 0].max if env_value && !env_value.empty?
|
|
319
|
-
|
|
320
|
-
configuration.parallel_workers
|
|
321
|
-
end
|
|
322
|
-
|
|
323
|
-
def parse_check_options # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
|
|
324
|
-
options = {
|
|
325
|
-
# `nil` triggers `Configuration.discover` (`.rigor.yml` then
|
|
326
|
-
# `.rigor.dist.yml`); an explicit `--config=PATH` overrides.
|
|
327
|
-
config: nil,
|
|
328
|
-
format: "text",
|
|
329
|
-
explain: false,
|
|
330
|
-
cache_stats: false,
|
|
331
|
-
clear_cache: false,
|
|
332
|
-
no_cache: false,
|
|
333
|
-
# Run-stats summary (target files, RBS class universe
|
|
334
|
-
# breakdown, wall time, peak RSS) is on by default
|
|
335
|
-
# because collection is ~free (single syscall for RSS,
|
|
336
|
-
# one walk of `class_decl_paths` for the breakdown).
|
|
337
|
-
# `--no-stats` suppresses it for callers that want a
|
|
338
|
-
# diagnostic-only output stream.
|
|
339
|
-
stats: true,
|
|
340
|
-
# ADR-15 Phase 4c — when nil, falls back to
|
|
341
|
-
# `RIGOR_RACTOR_WORKERS` then `.rigor.yml`
|
|
342
|
-
# `parallel.workers:` then 0 (sequential). See
|
|
343
|
-
# `resolve_workers` for the precedence chain.
|
|
344
|
-
workers: nil,
|
|
345
|
-
# Editor mode (`docs/design/20260516-editor-mode.md`).
|
|
346
|
-
# Both must appear together; the runner uses the pair
|
|
347
|
-
# to bind an in-flight buffer file to its logical path.
|
|
348
|
-
tmp_file: nil,
|
|
349
|
-
instead_of: nil,
|
|
350
|
-
# ADR-22 — baseline filter. `:unset` means "fall through
|
|
351
|
-
# to `.rigor.yml`'s `baseline:` key"; a String overrides
|
|
352
|
-
# the config; `false` (from `--no-baseline`) suppresses
|
|
353
|
-
# any baseline that the config might name.
|
|
354
|
-
baseline: :unset,
|
|
355
|
-
# ADR-22 slice 5 — `--baseline-strict` CI gate: fail the
|
|
356
|
-
# run on any baseline drift, in either direction.
|
|
357
|
-
baseline_strict: false,
|
|
358
|
-
# ADR-32 WD10 carry-over — `--treat-all-as-inline-rbs`
|
|
359
|
-
# forces the `rigor-rbs-inline` plugin into the loaded
|
|
360
|
-
# plugin set with `require_magic_comment: false` so a
|
|
361
|
-
# single ad-hoc `rigor check` invocation treats every
|
|
362
|
-
# analysed file as inline-RBS without the user editing
|
|
363
|
-
# `.rigor.yml`. Intended for single-file / ad-hoc CI use;
|
|
364
|
-
# ordinary projects should configure the plugin in
|
|
365
|
-
# `.rigor.yml`.
|
|
366
|
-
treat_all_as_inline_rbs: false,
|
|
367
|
-
# ADR-46 — the incremental-analysis acceptance gate. Runs a
|
|
368
|
-
# baseline analysis, re-analyzes a subset and serves the rest from
|
|
369
|
-
# the per-file cache, and asserts the merged diagnostics are
|
|
370
|
-
# byte-identical to a full `--no-cache` run. Exits non-zero on any
|
|
371
|
-
# mismatch. Off by default.
|
|
372
|
-
verify_incremental: false,
|
|
373
|
-
# ADR-46 — cross-process incremental analysis. With a disk snapshot
|
|
374
|
-
# of the prior run's per-file diagnostics + dependency graph,
|
|
375
|
-
# re-analyzes only the changed closure and serves the rest from the
|
|
376
|
-
# snapshot. Off by default.
|
|
377
|
-
incremental: false
|
|
378
|
-
}
|
|
379
|
-
parser = OptionParser.new do |opts| # rubocop:disable Metrics/BlockLength
|
|
380
|
-
opts.banner = "Usage: rigor check [options] [paths]"
|
|
381
|
-
opts.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
|
|
382
|
-
opts.on("--format=FORMAT", "Output format: text or json") { |value| options[:format] = value }
|
|
383
|
-
opts.on("--explain", "Surface fail-soft fallback events as :info diagnostics") { options[:explain] = true }
|
|
384
|
-
opts.on("--cache-stats", "Print on-disk cache inventory at end of run") { options[:cache_stats] = true }
|
|
385
|
-
opts.on("--clear-cache", "Remove the .rigor/cache directory before running") { options[:clear_cache] = true }
|
|
386
|
-
opts.on("--no-cache", "Disable the persistent cache for this run") { options[:no_cache] = true }
|
|
387
|
-
opts.on("--[no-]stats",
|
|
388
|
-
"Print run summary (files, classes, memory, wall time) to stderr (default: on)") do |value|
|
|
389
|
-
options[:stats] = value
|
|
390
|
-
end
|
|
391
|
-
opts.on("--workers=N", Integer,
|
|
392
|
-
"Dispatch per-file analysis across N Ractor workers (default: 0; sequential)") do |value|
|
|
393
|
-
options[:workers] = value
|
|
394
|
-
end
|
|
395
|
-
Options.add_editor_mode(opts, options)
|
|
396
|
-
opts.on("--baseline=PATH",
|
|
397
|
-
"ADR-22: load baseline from PATH (overrides .rigor.yml `baseline:`)") do |value|
|
|
398
|
-
options[:baseline] = value
|
|
399
|
-
end
|
|
400
|
-
opts.on("--no-baseline",
|
|
401
|
-
"ADR-22: ignore any configured baseline for this run") do
|
|
402
|
-
options[:baseline] = false
|
|
403
|
-
end
|
|
404
|
-
opts.on("--baseline-strict",
|
|
405
|
-
"ADR-22: fail the run on any baseline drift (CI gate)") do
|
|
406
|
-
options[:baseline_strict] = true
|
|
407
|
-
end
|
|
408
|
-
opts.on("--treat-all-as-inline-rbs",
|
|
409
|
-
"ADR-32: force-load rigor-rbs-inline with require_magic_comment: false") do
|
|
410
|
-
options[:treat_all_as_inline_rbs] = true
|
|
411
|
-
end
|
|
412
|
-
opts.on("--verify-incremental",
|
|
413
|
-
"ADR-46: assert incremental analysis matches a full run, then exit") do
|
|
414
|
-
options[:verify_incremental] = true
|
|
415
|
-
end
|
|
416
|
-
opts.on("--incremental",
|
|
417
|
-
"ADR-46: re-analyze only files changed since the last run (cross-process cache)") do
|
|
418
|
-
options[:incremental] = true
|
|
419
|
-
end
|
|
420
|
-
end
|
|
421
|
-
parser.parse!(@argv)
|
|
422
|
-
options
|
|
423
|
-
end
|
|
424
|
-
|
|
425
|
-
# ADR-32 WD10 carry-over — wraps `Configuration.load` so the
|
|
426
|
-
# CLI's `--treat-all-as-inline-rbs` flag can inject a
|
|
427
|
-
# `rigor-rbs-inline` plugin entry with
|
|
428
|
-
# `require_magic_comment: false` into the loaded plugin
|
|
429
|
-
# set. Re-runs the include-aware YAML load and applies the
|
|
430
|
-
# injection before `Configuration.new` so the new entry
|
|
431
|
-
# follows the normal coercion path. A pre-existing
|
|
432
|
-
# `rigor-rbs-inline` entry (by gem name or `id: rbs-inline`)
|
|
433
|
-
# is removed first so the synthesised entry's
|
|
434
|
-
# `require_magic_comment: false` wins unconditionally.
|
|
435
|
-
def load_check_configuration(options)
|
|
436
|
-
return Configuration.load(options.fetch(:config)) unless options.fetch(:treat_all_as_inline_rbs)
|
|
437
|
-
|
|
438
|
-
path = options.fetch(:config) || Configuration.discover
|
|
439
|
-
data = path && File.exist?(path) ? Configuration.load_with_includes(path) : {}
|
|
440
|
-
data = data.dup
|
|
441
|
-
data["plugins"] = inject_treat_all_as_inline_rbs(Array(data["plugins"]))
|
|
442
|
-
Configuration.new(Configuration::DEFAULTS.merge(data))
|
|
443
|
-
end
|
|
444
|
-
|
|
445
|
-
def inject_treat_all_as_inline_rbs(entries)
|
|
446
|
-
filtered = entries.reject { |entry| rigor_rbs_inline_entry?(entry) }
|
|
447
|
-
filtered + [{
|
|
448
|
-
"gem" => "rigor-rbs-inline",
|
|
449
|
-
"id" => "rbs-inline",
|
|
450
|
-
"config" => { "require_magic_comment" => false }
|
|
451
|
-
}]
|
|
452
|
-
end
|
|
453
|
-
|
|
454
|
-
def rigor_rbs_inline_entry?(entry)
|
|
455
|
-
case entry
|
|
456
|
-
when String
|
|
457
|
-
entry == "rigor-rbs-inline"
|
|
458
|
-
when Hash
|
|
459
|
-
string_keyed = entry.to_h { |k, v| [k.to_s, v] }
|
|
460
|
-
string_keyed["gem"] == "rigor-rbs-inline" || string_keyed["id"] == "rbs-inline"
|
|
461
|
-
else
|
|
462
|
-
false
|
|
463
|
-
end
|
|
464
|
-
end
|
|
465
|
-
|
|
466
|
-
def handle_clear_cache(cache_root)
|
|
467
|
-
if File.directory?(cache_root)
|
|
468
|
-
FileUtils.rm_rf(cache_root)
|
|
469
|
-
@out.puts("Cleared cache: #{cache_root}")
|
|
470
|
-
else
|
|
471
|
-
@out.puts("Cache already empty: #{cache_root}")
|
|
472
|
-
end
|
|
473
|
-
end
|
|
474
|
-
|
|
475
|
-
# Emits the {Analysis::RunStats} summary to STDERR so it
|
|
476
|
-
# doesn't interleave with the diagnostic stream (text or
|
|
477
|
-
# JSON) on STDOUT. JSON consumers can pipe stdout cleanly;
|
|
478
|
-
# interactive users still see the summary on their tty.
|
|
479
|
-
def write_run_stats(stats)
|
|
480
|
-
@err.puts("")
|
|
481
|
-
stats.format(@err)
|
|
482
|
-
end
|
|
483
|
-
|
|
484
|
-
# Opt-in developer diagnostics printed after the run: the
|
|
485
|
-
# inference-cutoff trace (RIGOR_BUDGET_TRACE) and the heap-attribution
|
|
486
|
-
# profile (RIGOR_HEAP_PROFILE). Each gates itself, so this is a no-op
|
|
487
|
-
# on a normal run.
|
|
488
|
-
def write_trace_appendices
|
|
489
|
-
write_budget_trace
|
|
490
|
-
write_heap_profile
|
|
491
|
-
end
|
|
492
|
-
|
|
493
|
-
# Dumps the opt-in inference-cutoff counters (RIGOR_BUDGET_TRACE).
|
|
494
|
-
# These are the hard-coded "budget" guards that silently degrade
|
|
495
|
-
# to `Dynamic[top]` / a fallback bound — counting them shows where
|
|
496
|
-
# inference actually stopped. Process-global counters: meaningful
|
|
497
|
-
# only on a single-process run (`--workers 0`), since they do not
|
|
498
|
-
# cross fork boundaries.
|
|
499
|
-
def write_budget_trace
|
|
500
|
-
return unless Inference::BudgetTrace.enabled?
|
|
501
|
-
|
|
502
|
-
counts = Inference::BudgetTrace.snapshot
|
|
503
|
-
@err.puts("")
|
|
504
|
-
@err.puts("Inference cutoffs (RIGOR_BUDGET_TRACE; --workers 0 for an exact count)")
|
|
505
|
-
@err.puts(" recursion-guard hits: #{counts[Inference::BudgetTrace::RECURSION_GUARD]}")
|
|
506
|
-
@err.puts(" ancestor-walk-limit hits: #{counts[Inference::BudgetTrace::ANCESTOR_WALK_LIMIT]}")
|
|
507
|
-
@err.puts(" hkt-fuel-exhausted hits: #{counts[Inference::BudgetTrace::HKT_FUEL_EXHAUSTED]}")
|
|
508
|
-
write_budget_distributions
|
|
509
|
-
end
|
|
510
|
-
|
|
511
|
-
# Dumps the read-only size distributions (ADR-41 Slice 2a). These
|
|
512
|
-
# observe how large unions actually get, with no cap enforced — the
|
|
513
|
-
# data the `union_size` budget default should be chosen from. The
|
|
514
|
-
# `over` thresholds bracket the TypeProf prior (10) and Rigor's spec
|
|
515
|
-
# default (24).
|
|
516
|
-
def write_budget_distributions
|
|
517
|
-
summary = Inference::BudgetTrace.summarize(Inference::BudgetTrace::UNION_ARITY, over: [10, 24, 40])
|
|
518
|
-
pct = summary[:percentiles]
|
|
519
|
-
@err.puts(" union arity: n=#{summary[:count]} max=#{summary[:max]} " \
|
|
520
|
-
"p50=#{pct[:p50]} p90=#{pct[:p90]} p99=#{pct[:p99]}")
|
|
521
|
-
over = summary[:over]
|
|
522
|
-
@err.puts(" unions ≥10: #{over[10]} ≥24: #{over[24]} ≥40: #{over[40]}")
|
|
523
|
-
end
|
|
524
|
-
|
|
525
|
-
# Dumps a live-heap class breakdown (RIGOR_HEAP_PROFILE) — retained
|
|
526
|
-
# objects by class after a forced GC, ranked by total memsize. The
|
|
527
|
-
# tool for attributing where the analyzer's resident memory goes
|
|
528
|
-
# (ADR-41 Slice 2b): it answers whether the heap is type carriers,
|
|
529
|
-
# RBS objects, Prism nodes, or fact-store Hashes/Strings. Walking the
|
|
530
|
-
# whole heap is slow — a dev probe, not a normal diagnostic. Run
|
|
531
|
-
# single-process (`--workers 0`) so the parent heap is the analysis
|
|
532
|
-
# heap; the gem is required lazily so a normal run never loads it.
|
|
533
|
-
def write_heap_profile
|
|
534
|
-
return if ENV["RIGOR_HEAP_PROFILE"].to_s.empty?
|
|
535
|
-
|
|
536
|
-
by_class, total = tally_live_heap
|
|
537
|
-
@err.puts("")
|
|
538
|
-
@err.puts("Heap profile (RIGOR_HEAP_PROFILE; live objects after GC, by class)")
|
|
539
|
-
@err.puts(" total tracked: #{heap_mb(total)} across #{by_class.size} classes")
|
|
540
|
-
by_class.sort_by { |_, (_, bytes)| -bytes }.first(30).each do |name, (count, bytes)|
|
|
541
|
-
@err.puts(" #{heap_mb(bytes).rjust(10)} #{count.to_s.rjust(9)} obj #{name}")
|
|
542
|
-
end
|
|
543
|
-
write_string_allocation_sites
|
|
544
|
-
end
|
|
545
|
-
|
|
546
|
-
# Loads the analysis-path dependencies lazily (so non-check commands
|
|
547
|
-
# stay light) and starts heap-allocation tracing if requested, before
|
|
548
|
-
# any analysis object is allocated.
|
|
549
|
-
def load_check_dependencies
|
|
550
|
-
require_relative "analysis/runner"
|
|
551
|
-
require_relative "analysis/buffer_binding"
|
|
552
|
-
require_relative "analysis/baseline"
|
|
553
|
-
require_relative "cache/store"
|
|
554
|
-
start_heap_trace_if_requested
|
|
555
|
-
end
|
|
556
|
-
|
|
557
|
-
# Starts allocation tracing (RIGOR_HEAP_TRACE) as early as possible so
|
|
558
|
-
# the heap profile can attribute retained Strings to their allocation
|
|
559
|
-
# `file:line`. Very high overhead — run on a small file subset only.
|
|
560
|
-
def start_heap_trace_if_requested
|
|
561
|
-
return if ENV["RIGOR_HEAP_TRACE"].to_s.empty?
|
|
562
|
-
|
|
563
|
-
require "objspace"
|
|
564
|
-
ObjectSpace.trace_object_allocations_start
|
|
565
|
-
end
|
|
566
|
-
|
|
567
|
-
# When RIGOR_HEAP_TRACE is on, groups the live String objects by their
|
|
568
|
-
# allocation site (`sourcefile:sourceline`) and prints the top sites by
|
|
569
|
-
# count — pinpointing which engine code retains the millions of strings
|
|
570
|
-
# that dominate the large-app heap (ADR-41 Slice 2b). Strings allocated
|
|
571
|
-
# before tracing started report `(pre-trace)`.
|
|
572
|
-
def write_string_allocation_sites
|
|
573
|
-
return if ENV["RIGOR_HEAP_TRACE"].to_s.empty?
|
|
574
|
-
|
|
575
|
-
by_site = Hash.new(0)
|
|
576
|
-
ObjectSpace.each_object(String) do |str|
|
|
577
|
-
file = ObjectSpace.allocation_sourcefile(str)
|
|
578
|
-
line = ObjectSpace.allocation_sourceline(str)
|
|
579
|
-
by_site[file ? "#{file}:#{line}" : "(pre-trace)"] += 1
|
|
580
|
-
end
|
|
581
|
-
@err.puts("")
|
|
582
|
-
@err.puts(" String allocation sites (top 25 by live count)")
|
|
583
|
-
by_site.sort_by { |_, n| -n }.first(25).each do |site, n|
|
|
584
|
-
@err.puts(" #{n.to_s.rjust(9)} #{site}")
|
|
585
|
-
end
|
|
586
|
-
end
|
|
587
|
-
|
|
588
|
-
# Walks the whole live heap (after a forced GC) and tallies
|
|
589
|
-
# `{class_name => [count, memsize]}` plus the grand total. Returns
|
|
590
|
-
# `[by_class, total]`. Slow — a dev probe only.
|
|
591
|
-
def tally_live_heap
|
|
592
|
-
require "objspace"
|
|
593
|
-
GC.start
|
|
594
|
-
by_class = Hash.new { |h, k| h[k] = [0, 0] }
|
|
595
|
-
total = 0
|
|
596
|
-
ObjectSpace.each_object do |obj|
|
|
597
|
-
size = ObjectSpace.memsize_of(obj)
|
|
598
|
-
entry = by_class[heap_class_name(obj)]
|
|
599
|
-
entry[0] += 1
|
|
600
|
-
entry[1] += size
|
|
601
|
-
total += size
|
|
602
|
-
end
|
|
603
|
-
[by_class, total]
|
|
604
|
-
end
|
|
605
|
-
|
|
606
|
-
def heap_class_name(obj)
|
|
607
|
-
klass = Object.instance_method(:class).bind_call(obj)
|
|
608
|
-
klass.name || klass.inspect
|
|
609
|
-
rescue StandardError
|
|
610
|
-
"(unknown)"
|
|
611
|
-
end
|
|
612
|
-
|
|
613
|
-
def heap_mb(bytes)
|
|
614
|
-
Kernel.format("%.1f MB", bytes / 1_048_576.0)
|
|
615
|
-
end
|
|
616
|
-
|
|
617
|
-
def write_cache_stats(cache_root, runtime_store)
|
|
618
|
-
inv = Cache::Store.disk_inventory(root: cache_root)
|
|
619
|
-
|
|
620
|
-
@out.puts("")
|
|
621
|
-
@out.puts("Cache (root: #{inv.fetch(:root)})")
|
|
622
|
-
schema = inv.fetch(:schema_version)
|
|
623
|
-
@out.puts(" schema_version: #{schema.nil? ? 'absent' : schema}")
|
|
624
|
-
write_disk_inventory(inv)
|
|
625
|
-
write_runtime_stats(runtime_store) if runtime_store
|
|
626
|
-
end
|
|
627
|
-
|
|
628
|
-
def write_disk_inventory(inv)
|
|
629
|
-
if inv.fetch(:total_entries).zero?
|
|
630
|
-
@out.puts(" (empty)")
|
|
631
|
-
return
|
|
632
|
-
end
|
|
633
|
-
|
|
634
|
-
@out.puts(" #{inv.fetch(:total_entries)} entries, #{format_bytes(inv.fetch(:total_bytes))}")
|
|
635
|
-
inv.fetch(:producers).each do |producer|
|
|
636
|
-
bytes = format_bytes(producer.fetch(:bytes))
|
|
637
|
-
@out.puts(" #{producer.fetch(:id)}: #{producer.fetch(:entries)} entries, #{bytes}")
|
|
638
|
-
end
|
|
639
|
-
end
|
|
640
|
-
|
|
641
|
-
def write_runtime_stats(store)
|
|
642
|
-
stats = store.stats
|
|
643
|
-
hits = stats.fetch(:hits)
|
|
644
|
-
misses = stats.fetch(:misses)
|
|
645
|
-
writes = stats.fetch(:writes)
|
|
646
|
-
@out.puts(" this run: #{hits} #{plural(hits, 'hit')}, " \
|
|
647
|
-
"#{misses} #{plural(misses, 'miss', 'misses')}, " \
|
|
648
|
-
"#{writes} #{plural(writes, 'write')}")
|
|
649
|
-
stats.fetch(:by_producer).each do |id, counts|
|
|
650
|
-
@out.puts(" #{id}: #{counts.fetch(:hits)} #{plural(counts.fetch(:hits), 'hit')}, " \
|
|
651
|
-
"#{counts.fetch(:misses)} #{plural(counts.fetch(:misses), 'miss', 'misses')}, " \
|
|
652
|
-
"#{counts.fetch(:writes)} #{plural(counts.fetch(:writes), 'write')}")
|
|
653
|
-
end
|
|
654
|
-
end
|
|
655
|
-
|
|
656
|
-
def plural(count, singular, plural = "#{singular}s")
|
|
657
|
-
count == 1 ? singular : plural
|
|
658
|
-
end
|
|
659
|
-
|
|
660
|
-
def format_bytes(bytes)
|
|
661
|
-
return "#{bytes} B" if bytes < 1024
|
|
662
|
-
return format("%.1f KiB", bytes / 1024.0) if bytes < 1024 * 1024
|
|
663
|
-
|
|
664
|
-
format("%.1f MiB", bytes / (1024.0 * 1024.0))
|
|
86
|
+
CheckCommand.new(argv: @argv, out: @out, err: @err).run
|
|
665
87
|
end
|
|
666
88
|
|
|
667
89
|
def run_init
|
|
@@ -778,6 +200,12 @@ module Rigor
|
|
|
778
200
|
TypeOfCommand.new(argv: @argv, out: @out, err: @err).run
|
|
779
201
|
end
|
|
780
202
|
|
|
203
|
+
def run_trace
|
|
204
|
+
require_relative "cli/trace_command"
|
|
205
|
+
|
|
206
|
+
TraceCommand.new(argv: @argv, out: @out, err: @err).run
|
|
207
|
+
end
|
|
208
|
+
|
|
781
209
|
def run_type_scan
|
|
782
210
|
require_relative "cli/type_scan_command"
|
|
783
211
|
|
|
@@ -861,34 +289,6 @@ module Rigor
|
|
|
861
289
|
CLI::PluginCommand.new(argv: @argv, out: @out, err: @err).run
|
|
862
290
|
end
|
|
863
291
|
|
|
864
|
-
def write_result(result, format)
|
|
865
|
-
case format
|
|
866
|
-
when "json"
|
|
867
|
-
@out.puts(JSON.pretty_generate(result.to_h))
|
|
868
|
-
when "text"
|
|
869
|
-
write_text_result(result)
|
|
870
|
-
else
|
|
871
|
-
raise OptionParser::InvalidArgument, "unsupported format: #{format}"
|
|
872
|
-
end
|
|
873
|
-
end
|
|
874
|
-
|
|
875
|
-
# Text output adds a one-line summary so users see the
|
|
876
|
-
# diagnostic-count immediately. The summary distinguishes
|
|
877
|
-
# the success and failure cases and reports the affected
|
|
878
|
-
# file count for failures.
|
|
879
|
-
def write_text_result(result)
|
|
880
|
-
result.diagnostics.each { |diagnostic| @out.puts(diagnostic) }
|
|
881
|
-
|
|
882
|
-
if result.success?
|
|
883
|
-
@out.puts("No diagnostics") if result.diagnostics.empty?
|
|
884
|
-
return
|
|
885
|
-
end
|
|
886
|
-
|
|
887
|
-
error_files = result.diagnostics.select(&:error?).map(&:path).uniq.size
|
|
888
|
-
@out.puts("")
|
|
889
|
-
@out.puts("#{result.error_count} error(s) in #{error_files} file(s)")
|
|
890
|
-
end
|
|
891
|
-
|
|
892
292
|
def help
|
|
893
293
|
<<~HELP
|
|
894
294
|
Usage: rigor <command> [options]
|
|
@@ -898,6 +298,7 @@ module Rigor
|
|
|
898
298
|
init Create a starter .rigor.yml
|
|
899
299
|
annotate Print FILE with each line's last-expression type
|
|
900
300
|
type-of Print the inferred type at FILE:LINE:COL
|
|
301
|
+
trace Replay how the engine typed FILE as a terminal animation
|
|
901
302
|
type-scan Report Scope#type_of coverage across PATHs
|
|
902
303
|
explain Print the description of one or all CheckRules
|
|
903
304
|
diff Compare current diagnostics to a saved baseline JSON
|