rigortype 0.1.8 → 0.1.10
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 +186 -513
- data/lib/rigor/analysis/check_rules.rb +20 -0
- data/lib/rigor/analysis/runner.rb +67 -9
- data/lib/rigor/analysis/worker_session.rb +13 -4
- data/lib/rigor/cache/rbs_descriptor.rb +21 -2
- data/lib/rigor/cache/rbs_environment.rb +2 -1
- data/lib/rigor/cli/annotate_command.rb +274 -0
- data/lib/rigor/cli/baseline_command.rb +36 -16
- data/lib/rigor/cli/coverage_command.rb +126 -0
- data/lib/rigor/cli/coverage_renderer.rb +162 -0
- data/lib/rigor/cli/coverage_report.rb +75 -0
- data/lib/rigor/cli/mcp_command.rb +70 -0
- data/lib/rigor/cli/prism_colorizer.rb +111 -0
- data/lib/rigor/cli.rb +134 -6
- data/lib/rigor/environment/rbs_loader.rb +46 -5
- data/lib/rigor/environment/reporters.rb +3 -2
- data/lib/rigor/environment.rb +168 -5
- data/lib/rigor/inference/builtins/method_catalog.rb +17 -1
- data/lib/rigor/inference/builtins/time_catalog.rb +10 -1
- data/lib/rigor/inference/def_return_typer.rb +98 -0
- data/lib/rigor/inference/expression_typer.rb +308 -18
- data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +109 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +178 -10
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +53 -1
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
- data/lib/rigor/inference/method_dispatcher/math_folding.rb +149 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +20 -1
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +81 -0
- data/lib/rigor/inference/method_dispatcher/set_folding.rb +81 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +431 -9
- data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +126 -0
- data/lib/rigor/inference/method_dispatcher/time_folding.rb +56 -0
- data/lib/rigor/inference/method_dispatcher/uri_folding.rb +67 -0
- data/lib/rigor/inference/method_dispatcher.rb +148 -1
- data/lib/rigor/inference/method_parameter_binder.rb +67 -10
- data/lib/rigor/inference/narrowing.rb +29 -10
- data/lib/rigor/inference/precision_scanner.rb +131 -0
- data/lib/rigor/inference/statement_evaluator.rb +29 -3
- data/lib/rigor/mcp/loop.rb +43 -0
- data/lib/rigor/mcp/server.rb +263 -0
- data/lib/rigor/mcp.rb +16 -0
- data/lib/rigor/plugin/base.rb +67 -5
- data/lib/rigor/plugin/loader.rb +22 -1
- data/lib/rigor/plugin/manifest.rb +101 -10
- data/lib/rigor/plugin/protocol_contract.rb +185 -0
- data/lib/rigor/plugin/registry.rb +87 -0
- data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
- data/lib/rigor/sig_gen/generator.rb +150 -75
- data/lib/rigor/triage/catalogue.rb +2 -2
- data/lib/rigor/type/combinator.rb +57 -0
- data/lib/rigor/type/constant.rb +29 -2
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/analysis/baseline.rbs +39 -0
- data/sig/rigor/environment.rbs +3 -2
- data/sig/rigor/type.rbs +4 -0
- data/sig/rigor.rbs +2 -0
- metadata +42 -1
data/lib/rigor/cli.rb
CHANGED
|
@@ -21,14 +21,17 @@ module Rigor
|
|
|
21
21
|
HANDLERS = {
|
|
22
22
|
"check" => :run_check,
|
|
23
23
|
"init" => :run_init,
|
|
24
|
+
"annotate" => :run_annotate,
|
|
24
25
|
"type-of" => :run_type_of,
|
|
25
26
|
"type-scan" => :run_type_scan,
|
|
26
27
|
"explain" => :run_explain,
|
|
27
28
|
"diff" => :run_diff,
|
|
28
29
|
"sig-gen" => :run_sig_gen,
|
|
29
30
|
"lsp" => :run_lsp,
|
|
31
|
+
"mcp" => :run_mcp,
|
|
30
32
|
"baseline" => :run_baseline,
|
|
31
|
-
"triage" => :run_triage
|
|
33
|
+
"triage" => :run_triage,
|
|
34
|
+
"coverage" => :run_coverage
|
|
32
35
|
}.freeze
|
|
33
36
|
|
|
34
37
|
def self.start(argv = ARGV, out: $stdout, err: $stderr)
|
|
@@ -80,7 +83,7 @@ module Rigor
|
|
|
80
83
|
buffer = resolve_buffer_binding(options)
|
|
81
84
|
return EXIT_USAGE if buffer == :usage_error
|
|
82
85
|
|
|
83
|
-
configuration =
|
|
86
|
+
configuration = load_check_configuration(options)
|
|
84
87
|
cache_root = configuration.cache_path
|
|
85
88
|
handle_clear_cache(cache_root) if options.fetch(:clear_cache)
|
|
86
89
|
|
|
@@ -88,13 +91,56 @@ module Rigor
|
|
|
88
91
|
configuration: configuration, options: options,
|
|
89
92
|
buffer: buffer, cache_root: cache_root
|
|
90
93
|
)
|
|
91
|
-
|
|
92
|
-
result = apply_baseline_filter(
|
|
94
|
+
raw_result = runner.run(@argv.empty? ? configuration.paths : @argv)
|
|
95
|
+
result = apply_baseline_filter(raw_result, configuration, options)
|
|
93
96
|
|
|
94
97
|
write_result(result, options.fetch(:format))
|
|
95
98
|
write_run_stats(result.stats) if result.stats
|
|
96
99
|
write_cache_stats(cache_root, runner.cache_store) if options.fetch(:cache_stats)
|
|
97
|
-
|
|
100
|
+
|
|
101
|
+
exit_code = result.success? ? 0 : 1
|
|
102
|
+
exit_code = 1 if baseline_strict_violation?(raw_result.diagnostics, configuration, options)
|
|
103
|
+
exit_code
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# ADR-22 slice 5 — the `--baseline-strict` CI gate. When the
|
|
107
|
+
# flag is set, ANY baseline drift fails the run — not only
|
|
108
|
+
# excess drift (a bucket over threshold, which already fails
|
|
109
|
+
# via the surfaced diagnostics) but also DEFICIT drift
|
|
110
|
+
# (`actual < count`: the baseline has grown looser than the
|
|
111
|
+
# code and should be regenerated). A no-op, with a stderr
|
|
112
|
+
# note, when no baseline is active — the flag never
|
|
113
|
+
# implicitly loads a baseline the config did not name (WD2).
|
|
114
|
+
def baseline_strict_violation?(raw_diagnostics, configuration, options)
|
|
115
|
+
return false unless options.fetch(:baseline_strict)
|
|
116
|
+
|
|
117
|
+
path = resolve_baseline_path(configuration, options)
|
|
118
|
+
if path.nil?
|
|
119
|
+
@err.puts("rigor: --baseline-strict given but no baseline is active; nothing to gate.")
|
|
120
|
+
return false
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
baseline = Analysis::Baseline.load(path)
|
|
124
|
+
return false if baseline.nil? || baseline.empty?
|
|
125
|
+
|
|
126
|
+
drifted = baseline.audit(raw_diagnostics).reject { |row| row.status == :within }
|
|
127
|
+
return false if drifted.empty?
|
|
128
|
+
|
|
129
|
+
report_strict_drift(drifted, path)
|
|
130
|
+
true
|
|
131
|
+
rescue Analysis::Baseline::LoadError => e
|
|
132
|
+
@err.puts("rigor: baseline load failed: #{e.message} (--baseline-strict gate skipped)")
|
|
133
|
+
false
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def report_strict_drift(rows, path)
|
|
137
|
+
@err.puts("rigor: --baseline-strict — #{rows.size} bucket(s) drifted from #{path}:")
|
|
138
|
+
rows.sort_by { |r| [r.bucket.file, r.bucket.rule] }.each do |row|
|
|
139
|
+
delta = row.delta.positive? ? "+#{row.delta}" : row.delta.to_s
|
|
140
|
+
@err.puts(" #{row.bucket.file} [#{row.bucket.rule}] " \
|
|
141
|
+
"#{row.bucket.count} → #{row.actual_count} (Δ#{delta}, #{row.status})")
|
|
142
|
+
end
|
|
143
|
+
@err.puts("rigor: run `rigor baseline regenerate` to refresh the baseline.")
|
|
98
144
|
end
|
|
99
145
|
|
|
100
146
|
# ADR-22 — apply the baseline filter as the LAST step of
|
|
@@ -234,7 +280,19 @@ module Rigor
|
|
|
234
280
|
# to `.rigor.yml`'s `baseline:` key"; a String overrides
|
|
235
281
|
# the config; `false` (from `--no-baseline`) suppresses
|
|
236
282
|
# any baseline that the config might name.
|
|
237
|
-
baseline: :unset
|
|
283
|
+
baseline: :unset,
|
|
284
|
+
# ADR-22 slice 5 — `--baseline-strict` CI gate: fail the
|
|
285
|
+
# run on any baseline drift, in either direction.
|
|
286
|
+
baseline_strict: false,
|
|
287
|
+
# ADR-32 WD10 carry-over — `--treat-all-as-inline-rbs`
|
|
288
|
+
# forces the `rigor-rbs-inline` plugin into the loaded
|
|
289
|
+
# plugin set with `require_magic_comment: false` so a
|
|
290
|
+
# single ad-hoc `rigor check` invocation treats every
|
|
291
|
+
# analysed file as inline-RBS without the user editing
|
|
292
|
+
# `.rigor.yml`. Intended for single-file / ad-hoc CI use;
|
|
293
|
+
# ordinary projects should configure the plugin in
|
|
294
|
+
# `.rigor.yml`.
|
|
295
|
+
treat_all_as_inline_rbs: false
|
|
238
296
|
}
|
|
239
297
|
parser = OptionParser.new do |opts| # rubocop:disable Metrics/BlockLength
|
|
240
298
|
opts.banner = "Usage: rigor check [options] [paths]"
|
|
@@ -268,11 +326,60 @@ module Rigor
|
|
|
268
326
|
"ADR-22: ignore any configured baseline for this run") do
|
|
269
327
|
options[:baseline] = false
|
|
270
328
|
end
|
|
329
|
+
opts.on("--baseline-strict",
|
|
330
|
+
"ADR-22: fail the run on any baseline drift (CI gate)") do
|
|
331
|
+
options[:baseline_strict] = true
|
|
332
|
+
end
|
|
333
|
+
opts.on("--treat-all-as-inline-rbs",
|
|
334
|
+
"ADR-32: force-load rigor-rbs-inline with require_magic_comment: false") do
|
|
335
|
+
options[:treat_all_as_inline_rbs] = true
|
|
336
|
+
end
|
|
271
337
|
end
|
|
272
338
|
parser.parse!(@argv)
|
|
273
339
|
options
|
|
274
340
|
end
|
|
275
341
|
|
|
342
|
+
# ADR-32 WD10 carry-over — wraps `Configuration.load` so the
|
|
343
|
+
# CLI's `--treat-all-as-inline-rbs` flag can inject a
|
|
344
|
+
# `rigor-rbs-inline` plugin entry with
|
|
345
|
+
# `require_magic_comment: false` into the loaded plugin
|
|
346
|
+
# set. Re-runs the include-aware YAML load and applies the
|
|
347
|
+
# injection before `Configuration.new` so the new entry
|
|
348
|
+
# follows the normal coercion path. A pre-existing
|
|
349
|
+
# `rigor-rbs-inline` entry (by gem name or `id: rbs-inline`)
|
|
350
|
+
# is removed first so the synthesised entry's
|
|
351
|
+
# `require_magic_comment: false` wins unconditionally.
|
|
352
|
+
def load_check_configuration(options)
|
|
353
|
+
return Configuration.load(options.fetch(:config)) unless options.fetch(:treat_all_as_inline_rbs)
|
|
354
|
+
|
|
355
|
+
path = options.fetch(:config) || Configuration.discover
|
|
356
|
+
data = path && File.exist?(path) ? Configuration.load_with_includes(path) : {}
|
|
357
|
+
data = data.dup
|
|
358
|
+
data["plugins"] = inject_treat_all_as_inline_rbs(Array(data["plugins"]))
|
|
359
|
+
Configuration.new(Configuration::DEFAULTS.merge(data))
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def inject_treat_all_as_inline_rbs(entries)
|
|
363
|
+
filtered = entries.reject { |entry| rigor_rbs_inline_entry?(entry) }
|
|
364
|
+
filtered + [{
|
|
365
|
+
"gem" => "rigor-rbs-inline",
|
|
366
|
+
"id" => "rbs-inline",
|
|
367
|
+
"config" => { "require_magic_comment" => false }
|
|
368
|
+
}]
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def rigor_rbs_inline_entry?(entry)
|
|
372
|
+
case entry
|
|
373
|
+
when String
|
|
374
|
+
entry == "rigor-rbs-inline"
|
|
375
|
+
when Hash
|
|
376
|
+
string_keyed = entry.to_h { |k, v| [k.to_s, v] }
|
|
377
|
+
string_keyed["gem"] == "rigor-rbs-inline" || string_keyed["id"] == "rbs-inline"
|
|
378
|
+
else
|
|
379
|
+
false
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
|
|
276
383
|
def handle_clear_cache(cache_root)
|
|
277
384
|
if File.directory?(cache_root)
|
|
278
385
|
FileUtils.rm_rf(cache_root)
|
|
@@ -423,6 +530,12 @@ module Rigor
|
|
|
423
530
|
YAML
|
|
424
531
|
end
|
|
425
532
|
|
|
533
|
+
def run_annotate
|
|
534
|
+
require_relative "cli/annotate_command"
|
|
535
|
+
|
|
536
|
+
AnnotateCommand.new(argv: @argv, out: @out, err: @err).run
|
|
537
|
+
end
|
|
538
|
+
|
|
426
539
|
def run_type_of
|
|
427
540
|
require_relative "cli/type_of_command"
|
|
428
541
|
|
|
@@ -459,6 +572,12 @@ module Rigor
|
|
|
459
572
|
LspCommand.new(argv: @argv, out: @out, err: @err).run
|
|
460
573
|
end
|
|
461
574
|
|
|
575
|
+
def run_mcp
|
|
576
|
+
require_relative "cli/mcp_command"
|
|
577
|
+
|
|
578
|
+
McpCommand.new(argv: @argv, out: @out, err: @err).run
|
|
579
|
+
end
|
|
580
|
+
|
|
462
581
|
def run_baseline
|
|
463
582
|
require_relative "cli/baseline_command"
|
|
464
583
|
|
|
@@ -471,6 +590,12 @@ module Rigor
|
|
|
471
590
|
CLI::TriageCommand.new(argv: @argv, out: @out, err: @err).run
|
|
472
591
|
end
|
|
473
592
|
|
|
593
|
+
def run_coverage
|
|
594
|
+
require_relative "cli/coverage_command"
|
|
595
|
+
|
|
596
|
+
CLI::CoverageCommand.new(argv: @argv, out: @out, err: @err).run
|
|
597
|
+
end
|
|
598
|
+
|
|
474
599
|
def write_result(result, format)
|
|
475
600
|
case format
|
|
476
601
|
when "json"
|
|
@@ -506,13 +631,16 @@ module Rigor
|
|
|
506
631
|
Commands:
|
|
507
632
|
check Analyze Ruby source files
|
|
508
633
|
init Create a starter .rigor.yml
|
|
634
|
+
annotate Print FILE with each line's last-expression type
|
|
509
635
|
type-of Print the inferred type at FILE:LINE:COL
|
|
510
636
|
type-scan Report Scope#type_of coverage across PATHs
|
|
511
637
|
explain Print the description of one or all CheckRules
|
|
512
638
|
diff Compare current diagnostics to a saved baseline JSON
|
|
513
639
|
sig-gen Emit RBS skeletons inferred from .rb sources (ADR-14)
|
|
514
640
|
lsp Run the Rigor Language Server (LSP) over stdio
|
|
641
|
+
mcp Run the Rigor MCP server over stdio (ADR-33)
|
|
515
642
|
triage Summarise diagnostics: distribution, hotspots, hints (ADR-23)
|
|
643
|
+
coverage Report type-precision coverage (precise vs Dynamic ratio)
|
|
516
644
|
version Print the Rigor version
|
|
517
645
|
help Print this help
|
|
518
646
|
HELP
|
|
@@ -56,7 +56,7 @@ module Rigor
|
|
|
56
56
|
# run. The gem stubs are intentionally read-only and
|
|
57
57
|
# appended LAST so user-supplied `signature_paths` win on
|
|
58
58
|
# name conflicts.
|
|
59
|
-
def build_env_for(libraries:, signature_paths:)
|
|
59
|
+
def build_env_for(libraries:, signature_paths:, virtual_rbs: [])
|
|
60
60
|
rbs_loader = RBS::EnvironmentLoader.new
|
|
61
61
|
libraries.each do |library|
|
|
62
62
|
next unless rbs_loader.has_library?(library: library, version: nil)
|
|
@@ -70,7 +70,36 @@ module Rigor
|
|
|
70
70
|
vendored_gem_sig_paths.each do |path|
|
|
71
71
|
rbs_loader.add(path: path) if path.directory?
|
|
72
72
|
end
|
|
73
|
-
RBS::Environment.from_loader(rbs_loader)
|
|
73
|
+
env = RBS::Environment.from_loader(rbs_loader)
|
|
74
|
+
add_virtual_rbs(env, virtual_rbs)
|
|
75
|
+
env.resolve_type_names
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# ADR-32 WD4 — merge synthesised-from-source RBS strings
|
|
79
|
+
# into the freshly-built environment. Each entry is a
|
|
80
|
+
# `[virtual_filename, rbs_source]` pair. `virtual_filename`
|
|
81
|
+
# is purely for diagnostic provenance (RBS parse errors
|
|
82
|
+
# cite it) — it is not a real file path. Per WD6 the
|
|
83
|
+
# synthesizer-emit path is responsible for catching its
|
|
84
|
+
# own parse errors and returning `nil` rather than
|
|
85
|
+
# garbage; this method assumes its input is parseable
|
|
86
|
+
# and only rescues `RBS::ParsingError` as a fail-soft.
|
|
87
|
+
def add_virtual_rbs(env, virtual_rbs)
|
|
88
|
+
return if virtual_rbs.nil? || virtual_rbs.empty?
|
|
89
|
+
|
|
90
|
+
virtual_rbs.each do |filename, content|
|
|
91
|
+
next if content.nil? || content.empty?
|
|
92
|
+
|
|
93
|
+
buffer = ::RBS::Buffer.new(name: filename.to_s, content: content.to_s)
|
|
94
|
+
_, directives, decls = ::RBS::Parser.parse_signature(buffer)
|
|
95
|
+
source = ::RBS::Source::RBS.new(buffer, directives || [], decls || [])
|
|
96
|
+
env.add_source(source)
|
|
97
|
+
rescue ::RBS::BaseError
|
|
98
|
+
# WD6 fail-soft: a single broken virtual RBS contribution
|
|
99
|
+
# does not pull the whole env down. The plugin layer
|
|
100
|
+
# records a `source-rbs-synthesis-failed` info diagnostic
|
|
101
|
+
# in slice 2; here we just skip the entry.
|
|
102
|
+
end
|
|
74
103
|
end
|
|
75
104
|
|
|
76
105
|
# Per-gem `data/vendored_gem_sigs/<gem>/` directories that
|
|
@@ -95,7 +124,7 @@ module Rigor
|
|
|
95
124
|
end
|
|
96
125
|
end
|
|
97
126
|
|
|
98
|
-
attr_reader :libraries, :signature_paths, :cache_store
|
|
127
|
+
attr_reader :libraries, :signature_paths, :cache_store, :virtual_rbs
|
|
99
128
|
|
|
100
129
|
# @param libraries [Array<String, Symbol>] stdlib library names to
|
|
101
130
|
# load on top of core (e.g., `["pathname", "json"]`). Empty by
|
|
@@ -114,10 +143,18 @@ module Rigor
|
|
|
114
143
|
# reflection artefacts). Pass `nil` (the default) to skip
|
|
115
144
|
# the cache entirely; the runner threads its own Store
|
|
116
145
|
# through here when caching is enabled.
|
|
117
|
-
|
|
146
|
+
# @param virtual_rbs [Array<[String, String]>] ADR-32 WD4 —
|
|
147
|
+
# `[virtual_filename, rbs_source]` pairs synthesised from
|
|
148
|
+
# project source by a plugin's
|
|
149
|
+
# `Manifest#source_rbs_synthesizer`. Merged into the env
|
|
150
|
+
# after `signature_paths:` and the vendored stubs. Pass
|
|
151
|
+
# `[]` (the default) when no synthesizer-emitting plugin
|
|
152
|
+
# is loaded.
|
|
153
|
+
def initialize(libraries: [], signature_paths: [], cache_store: nil, virtual_rbs: [])
|
|
118
154
|
@libraries = libraries.map(&:to_s).freeze
|
|
119
155
|
@signature_paths = signature_paths.map { |p| Pathname(p) }.freeze
|
|
120
156
|
@cache_store = cache_store
|
|
157
|
+
@virtual_rbs = virtual_rbs.map { |name, content| [name.to_s.dup.freeze, content.to_s.dup.freeze].freeze }.freeze
|
|
121
158
|
# Per-loader memoization bucket. Held as a single
|
|
122
159
|
# mutable Hash so the loader instance itself can be
|
|
123
160
|
# `.freeze`d (per ADR-15 reflection-facade contract)
|
|
@@ -642,7 +679,11 @@ module Rigor
|
|
|
642
679
|
end
|
|
643
680
|
|
|
644
681
|
def build_env
|
|
645
|
-
self.class.build_env_for(
|
|
682
|
+
self.class.build_env_for(
|
|
683
|
+
libraries: @libraries,
|
|
684
|
+
signature_paths: @signature_paths,
|
|
685
|
+
virtual_rbs: @virtual_rbs
|
|
686
|
+
)
|
|
646
687
|
end
|
|
647
688
|
|
|
648
689
|
def build_instance_definition(class_name)
|
|
@@ -29,11 +29,12 @@ module Rigor
|
|
|
29
29
|
# return nil, and the consumer sites short-circuit on
|
|
30
30
|
# `reporter.nil?`.
|
|
31
31
|
class Reporters
|
|
32
|
-
attr_accessor :rbs_extended, :boundary_cross
|
|
32
|
+
attr_accessor :rbs_extended, :boundary_cross, :source_rbs_synthesis
|
|
33
33
|
|
|
34
|
-
def initialize(rbs_extended: nil, boundary_cross: nil)
|
|
34
|
+
def initialize(rbs_extended: nil, boundary_cross: nil, source_rbs_synthesis: nil)
|
|
35
35
|
@rbs_extended = rbs_extended
|
|
36
36
|
@boundary_cross = boundary_cross
|
|
37
|
+
@source_rbs_synthesis = source_rbs_synthesis
|
|
37
38
|
end
|
|
38
39
|
end
|
|
39
40
|
end
|
data/lib/rigor/environment.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
3
5
|
require_relative "environment/class_registry"
|
|
4
6
|
require_relative "environment/rbs_loader"
|
|
5
7
|
require_relative "environment/reflection"
|
|
@@ -24,7 +26,7 @@ module Rigor
|
|
|
24
26
|
# constant-folding tiers cannot answer.
|
|
25
27
|
#
|
|
26
28
|
# See docs/internal-spec/inference-engine.md for the binding contract.
|
|
27
|
-
class Environment
|
|
29
|
+
class Environment # rubocop:disable Metrics/ClassLength
|
|
28
30
|
DEFAULT_PROJECT_SIG_DIR = "sig"
|
|
29
31
|
private_constant :DEFAULT_PROJECT_SIG_DIR
|
|
30
32
|
|
|
@@ -87,6 +89,7 @@ module Rigor
|
|
|
87
89
|
def initialize(class_registry: ClassRegistry.default, rbs_loader: nil, # rubocop:disable Metrics/ParameterLists
|
|
88
90
|
plugin_registry: nil, dependency_source_index: nil,
|
|
89
91
|
rbs_extended_reporter: nil, boundary_cross_reporter: nil,
|
|
92
|
+
source_rbs_synthesis_reporter: nil,
|
|
90
93
|
synthetic_method_index: nil, project_patched_methods: nil,
|
|
91
94
|
hkt_registry: nil)
|
|
92
95
|
@class_registry = class_registry
|
|
@@ -100,7 +103,8 @@ module Rigor
|
|
|
100
103
|
# accessors below preserve the public lookup shape.
|
|
101
104
|
@reporters = Reporters.new(
|
|
102
105
|
rbs_extended: rbs_extended_reporter,
|
|
103
|
-
boundary_cross: boundary_cross_reporter
|
|
106
|
+
boundary_cross: boundary_cross_reporter,
|
|
107
|
+
source_rbs_synthesis: source_rbs_synthesis_reporter
|
|
104
108
|
)
|
|
105
109
|
@synthetic_method_index = synthetic_method_index || Inference::SyntheticMethodIndex::EMPTY
|
|
106
110
|
@project_patched_methods = project_patched_methods || Inference::ProjectPatchedMethods::EMPTY
|
|
@@ -159,6 +163,17 @@ module Rigor
|
|
|
159
163
|
@reporters.boundary_cross
|
|
160
164
|
end
|
|
161
165
|
|
|
166
|
+
# ADR-32 WD6 — the per-run accumulator for synthesizer
|
|
167
|
+
# failure events. `Environment.for_project` records
|
|
168
|
+
# `[:error, message]` returns from a plugin's
|
|
169
|
+
# `source_rbs_synthesizer` here so the Runner can emit
|
|
170
|
+
# `source-rbs-synthesis-failed` `:info` diagnostics after
|
|
171
|
+
# analysis completes. Nil when no plugin contributes a
|
|
172
|
+
# synthesizer.
|
|
173
|
+
def source_rbs_synthesis_reporter
|
|
174
|
+
@reporters.source_rbs_synthesis
|
|
175
|
+
end
|
|
176
|
+
|
|
162
177
|
# Replaces the env's per-run reporter slots. Intended for
|
|
163
178
|
# long-lived integrations (LSP `ProjectContext`) that share one
|
|
164
179
|
# Environment instance across many `Runner.run` calls: each call
|
|
@@ -210,10 +225,12 @@ module Rigor
|
|
|
210
225
|
def for_project(root: Dir.pwd, libraries: [], signature_paths: nil, cache_store: nil,
|
|
211
226
|
plugin_registry: nil, dependency_source_index: nil,
|
|
212
227
|
rbs_extended_reporter: nil, boundary_cross_reporter: nil,
|
|
228
|
+
source_rbs_synthesis_reporter: nil,
|
|
213
229
|
bundler_bundle_path: nil, bundler_auto_detect: false,
|
|
214
230
|
bundler_lockfile: nil,
|
|
215
231
|
rbs_collection_lockfile: nil, rbs_collection_auto_detect: false,
|
|
216
|
-
synthetic_method_index: nil, project_patched_methods: nil
|
|
232
|
+
synthetic_method_index: nil, project_patched_methods: nil,
|
|
233
|
+
source_files: [])
|
|
217
234
|
resolved_paths = signature_paths || default_signature_paths(root)
|
|
218
235
|
# O4 MVP — append per-gem `sig/` directories discovered
|
|
219
236
|
# under the target project's bundler install root. Empty
|
|
@@ -252,12 +269,39 @@ module Rigor
|
|
|
252
269
|
project_root: root,
|
|
253
270
|
auto_detect: rbs_collection_auto_detect
|
|
254
271
|
).map(&:to_s)
|
|
255
|
-
|
|
272
|
+
# ADR-25 — RBS signature directories contributed by loaded
|
|
273
|
+
# plugins via their manifest `signature_paths:`. Resolved
|
|
274
|
+
# to absolute dirs by `Plugin::Base#signature_paths`;
|
|
275
|
+
# additive, ranked below the user's explicit
|
|
276
|
+
# `signature_paths:` and above the opportunistic bundle /
|
|
277
|
+
# collection discovery. A duplicate-declaration conflict
|
|
278
|
+
# degrades through the same O7 failure-memo path.
|
|
279
|
+
plugin_sig_paths = plugin_registry ? plugin_registry.signature_paths.map(&:to_s) : []
|
|
280
|
+
loader_signature_paths = resolved_paths + plugin_sig_paths + gem_sig_paths + collection_paths
|
|
256
281
|
merged_libraries = (DEFAULT_LIBRARIES + libraries.map(&:to_s)).uniq
|
|
282
|
+
# ADR-32 WD4 + WD5 — invoke each loaded plugin's
|
|
283
|
+
# `source_rbs_synthesizer` once per project source file
|
|
284
|
+
# and collect non-nil `[filename, rbs_source]` pairs.
|
|
285
|
+
# The synthesizer-emitting plugin (currently only
|
|
286
|
+
# `rigor-rbs-inline`) is responsible for its own
|
|
287
|
+
# fail-soft on parse errors per WD6; this loop only
|
|
288
|
+
# filters `nil` returns.
|
|
289
|
+
#
|
|
290
|
+
# When a `cache_store` is supplied, each synthesizer
|
|
291
|
+
# invocation is memoised per
|
|
292
|
+
# `(file path + content SHA, plugin id + version + config_hash)`
|
|
293
|
+
# — WD5's cache key — so a second run with unchanged
|
|
294
|
+
# source skips the rbs-inline parse cost. The empty
|
|
295
|
+
# string is the sentinel for "no contribution" so the
|
|
296
|
+
# Store (which treats `nil` as cache miss) can persist
|
|
297
|
+
# the no-contribution decision too.
|
|
298
|
+
virtual_rbs = collect_virtual_rbs(plugin_registry, source_files, cache_store,
|
|
299
|
+
source_rbs_synthesis_reporter)
|
|
257
300
|
loader = RbsLoader.new(
|
|
258
301
|
libraries: merged_libraries,
|
|
259
302
|
signature_paths: loader_signature_paths,
|
|
260
|
-
cache_store: cache_store
|
|
303
|
+
cache_store: cache_store,
|
|
304
|
+
virtual_rbs: virtual_rbs
|
|
261
305
|
)
|
|
262
306
|
# ADR-20 slice 2c + 2e — seed hkt_registry with the
|
|
263
307
|
# bundled builtins. The Environment's `#hkt_registry`
|
|
@@ -274,6 +318,7 @@ module Rigor
|
|
|
274
318
|
dependency_source_index: dependency_source_index,
|
|
275
319
|
rbs_extended_reporter: rbs_extended_reporter,
|
|
276
320
|
boundary_cross_reporter: boundary_cross_reporter,
|
|
321
|
+
source_rbs_synthesis_reporter: source_rbs_synthesis_reporter,
|
|
277
322
|
synthetic_method_index: synthetic_method_index,
|
|
278
323
|
project_patched_methods: project_patched_methods,
|
|
279
324
|
hkt_registry: Builtins::HktBuiltins.registry
|
|
@@ -287,6 +332,124 @@ module Rigor
|
|
|
287
332
|
sig = Pathname(root) / DEFAULT_PROJECT_SIG_DIR
|
|
288
333
|
sig.directory? ? [sig] : []
|
|
289
334
|
end
|
|
335
|
+
|
|
336
|
+
# ADR-32 WD4 + WD5 — for each project source file, invoke
|
|
337
|
+
# every plugin-registered synthesizer once and collect
|
|
338
|
+
# non-nil returns. The returned array is `[[virtual_filename,
|
|
339
|
+
# rbs_source], ...]`; the loader threads it through to
|
|
340
|
+
# `RbsLoader.new(virtual_rbs: ...)`.
|
|
341
|
+
#
|
|
342
|
+
# `virtual_filename` is the source file path prefixed with
|
|
343
|
+
# the plugin id so RBS parse errors point back to the
|
|
344
|
+
# contributing plugin in their diagnostic location string.
|
|
345
|
+
#
|
|
346
|
+
# When no plugin declares a synthesizer (the common case),
|
|
347
|
+
# the registry's `source_rbs_synthesizers` is empty and
|
|
348
|
+
# this method short-circuits to `[]` without walking the
|
|
349
|
+
# file list.
|
|
350
|
+
#
|
|
351
|
+
# WD5 — when `cache_store` is supplied, each (file, plugin)
|
|
352
|
+
# synthesizer call is memoised through `Cache::Store`. The
|
|
353
|
+
# cache key composes the file's content SHA with the
|
|
354
|
+
# plugin's `PluginEntry` (id + version + config_hash) so a
|
|
355
|
+
# config change or content change invalidates the entry
|
|
356
|
+
# automatically.
|
|
357
|
+
def collect_virtual_rbs(plugin_registry, source_files, cache_store, reporter)
|
|
358
|
+
return [] if plugin_registry.nil?
|
|
359
|
+
|
|
360
|
+
synthesizers = plugin_registry.source_rbs_synthesizers
|
|
361
|
+
return [] if synthesizers.empty?
|
|
362
|
+
return [] if source_files.nil? || source_files.empty?
|
|
363
|
+
|
|
364
|
+
result = []
|
|
365
|
+
source_files.each do |path|
|
|
366
|
+
synthesizers.each do |plugin, callable|
|
|
367
|
+
outcome = synthesizer_output_for(plugin, callable, path, cache_store)
|
|
368
|
+
outcome = interpret_synthesizer_outcome(outcome, plugin, path, reporter)
|
|
369
|
+
next if outcome.nil? || outcome.empty?
|
|
370
|
+
|
|
371
|
+
virtual_name = "virtual:#{plugin.manifest.id}:#{path}"
|
|
372
|
+
result << [virtual_name, outcome]
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
result
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# ADR-32 WD5 — cache wrapper around a single (plugin,
|
|
379
|
+
# file) invocation. The cache stores the empty string
|
|
380
|
+
# `""` as the "no contribution" sentinel because
|
|
381
|
+
# `Cache::Store` treats `nil` as a cache miss. Error
|
|
382
|
+
# tuples are stored as the canonical
|
|
383
|
+
# `[:error, message_string]` Array so the same wrapper
|
|
384
|
+
# short-circuits subsequent runs against unchanged broken
|
|
385
|
+
# input.
|
|
386
|
+
def synthesizer_output_for(plugin, callable, path, cache_store)
|
|
387
|
+
return invoke_synthesizer_safely(callable, path) if cache_store.nil?
|
|
388
|
+
return invoke_synthesizer_safely(callable, path) unless File.file?(path)
|
|
389
|
+
|
|
390
|
+
descriptor = build_synthesizer_cache_descriptor(plugin, path)
|
|
391
|
+
cache_store.fetch_or_compute(
|
|
392
|
+
producer_id: SYNTHESIZER_CACHE_PRODUCER_ID,
|
|
393
|
+
params: {},
|
|
394
|
+
descriptor: descriptor
|
|
395
|
+
) { invoke_synthesizer_safely(callable, path) || "" }
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# ADR-32 WD6 — route a synthesizer return value through
|
|
399
|
+
# the per-run failure reporter. The synthesizer's contract
|
|
400
|
+
# (declared in `plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb`)
|
|
401
|
+
# admits three return shapes:
|
|
402
|
+
# - `String` (non-empty) → successful RBS source
|
|
403
|
+
# - `nil` / `""` → no contribution
|
|
404
|
+
# - `[:error, message]` → parse failed
|
|
405
|
+
# The error tuple is converted into a reporter entry +
|
|
406
|
+
# treated as "no contribution" so the analysis pipeline
|
|
407
|
+
# continues. Reporter is `nil` for callers that don't care
|
|
408
|
+
# (legacy Environment.new, tests).
|
|
409
|
+
def interpret_synthesizer_outcome(outcome, plugin, path, reporter)
|
|
410
|
+
return outcome unless outcome.is_a?(Array) && outcome[0] == :error
|
|
411
|
+
|
|
412
|
+
reporter&.record(
|
|
413
|
+
plugin_id: plugin.manifest.id,
|
|
414
|
+
path: path,
|
|
415
|
+
message: outcome[1].to_s
|
|
416
|
+
)
|
|
417
|
+
nil
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
SYNTHESIZER_CACHE_PRODUCER_ID = "plugin.source_rbs_synthesizer"
|
|
421
|
+
private_constant :SYNTHESIZER_CACHE_PRODUCER_ID
|
|
422
|
+
|
|
423
|
+
def build_synthesizer_cache_descriptor(plugin, path)
|
|
424
|
+
Cache::Descriptor.new(
|
|
425
|
+
files: [Cache::Descriptor::FileEntry.new(
|
|
426
|
+
path: path.to_s,
|
|
427
|
+
comparator: :digest,
|
|
428
|
+
value: synthesizer_input_digest(path)
|
|
429
|
+
)],
|
|
430
|
+
plugins: [plugin.plugin_entry]
|
|
431
|
+
)
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def synthesizer_input_digest(path)
|
|
435
|
+
Digest::SHA256.hexdigest(File.binread(path))
|
|
436
|
+
rescue ::SystemCallError
|
|
437
|
+
# Unreadable file → key on the path alone; the
|
|
438
|
+
# synthesizer's File.file?/File.read will see the same
|
|
439
|
+
# failure and return nil.
|
|
440
|
+
Digest::SHA256.hexdigest(path.to_s)
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def invoke_synthesizer_safely(callable, path)
|
|
444
|
+
callable.call(path.to_s)
|
|
445
|
+
rescue StandardError
|
|
446
|
+
# WD6 fail-soft — a synthesizer that raises does NOT
|
|
447
|
+
# crash analysis. Slice 2b will turn this into a
|
|
448
|
+
# `source-rbs-synthesis-failed` info diagnostic; for now
|
|
449
|
+
# the contract is "no analysis crash on a misbehaving
|
|
450
|
+
# synthesizer".
|
|
451
|
+
nil
|
|
452
|
+
end
|
|
290
453
|
end
|
|
291
454
|
|
|
292
455
|
# Resolves a constant name to a Rigor::Type::Nominal (the *instance*
|
|
@@ -57,7 +57,8 @@ module Rigor
|
|
|
57
57
|
return nil unless klass
|
|
58
58
|
|
|
59
59
|
bucket_key = kind == :singleton ? "singleton_methods" : "instance_methods"
|
|
60
|
-
klass.dig(bucket_key, selector.to_s)
|
|
60
|
+
klass.dig(bucket_key, selector.to_s) ||
|
|
61
|
+
resolve_alias_entry(klass, selector, bucket_key)
|
|
61
62
|
end
|
|
62
63
|
|
|
63
64
|
def reset!
|
|
@@ -66,6 +67,21 @@ module Rigor
|
|
|
66
67
|
|
|
67
68
|
private
|
|
68
69
|
|
|
70
|
+
def resolve_alias_entry(klass, selector, bucket_key)
|
|
71
|
+
return nil unless bucket_key == "instance_methods"
|
|
72
|
+
|
|
73
|
+
aliases = klass["aliases"]
|
|
74
|
+
return nil unless aliases
|
|
75
|
+
|
|
76
|
+
alias_entry = aliases[selector.to_s]
|
|
77
|
+
return nil unless alias_entry
|
|
78
|
+
|
|
79
|
+
target = alias_entry["old"]
|
|
80
|
+
return nil unless target
|
|
81
|
+
|
|
82
|
+
klass.dig(bucket_key, target)
|
|
83
|
+
end
|
|
84
|
+
|
|
69
85
|
def blocked?(class_name, selector)
|
|
70
86
|
# Bang-suffixed selectors are mutating by Ruby convention
|
|
71
87
|
# (`upcase!`, `concat`, etc. are listed explicitly below;
|
|
@@ -55,7 +55,16 @@ module Rigor
|
|
|
55
55
|
# as `time_localtime`: `time_modify(time)` then a
|
|
56
56
|
# `time_set_vtm` write and `TZMODE_SET_UTC`. Both
|
|
57
57
|
# selectors share the cfunc, so both must be blocked.
|
|
58
|
-
:gmtime, :utc
|
|
58
|
+
:gmtime, :utc,
|
|
59
|
+
# `getlocal` is not a mutator — it returns a fresh Time —
|
|
60
|
+
# but the fresh Time is pinned to the *analysis machine's*
|
|
61
|
+
# timezone. Folding it through a `Constant[Time]` carrier
|
|
62
|
+
# (which only ever holds a UTC literal from `Time.utc`)
|
|
63
|
+
# would bake a host-dependent wall clock / `utc_offset`
|
|
64
|
+
# into the inferred type. Blocked so the fold stays
|
|
65
|
+
# machine-independent; the RBS tier answers `Nominal[Time]`.
|
|
66
|
+
# `getutc` / `getgm` stay foldable — their result is UTC.
|
|
67
|
+
:getlocal
|
|
59
68
|
]
|
|
60
69
|
}
|
|
61
70
|
)
|