rigortype 0.1.15 → 0.1.16
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/exe/rigor +19 -0
- data/lib/rigor/analysis/check_rules.rb +25 -1
- data/lib/rigor/analysis/diagnostic.rb +40 -0
- data/lib/rigor/analysis/runner.rb +61 -2
- data/lib/rigor/analysis/worker_session.rb +3 -2
- data/lib/rigor/cache/descriptor.rb +6 -2
- data/lib/rigor/cli/plugins_command.rb +51 -4
- data/lib/rigor/cli/plugins_renderer.rb +86 -1
- data/lib/rigor/cli.rb +135 -5
- data/lib/rigor/environment/rbs_loader.rb +259 -1
- data/lib/rigor/environment.rb +8 -2
- data/lib/rigor/inference/budget_trace.rb +137 -0
- data/lib/rigor/inference/expression_typer.rb +9 -2
- data/lib/rigor/inference/hkt_reducer.rb +2 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +23 -6
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +81 -14
- data/lib/rigor/inference/method_dispatcher.rb +57 -10
- data/lib/rigor/inference/precision_scanner.rb +60 -1
- data/lib/rigor/inference/scope_indexer.rb +127 -8
- data/lib/rigor/inference/statement_evaluator.rb +13 -8
- data/lib/rigor/inference/synthetic_method_index.rb +23 -4
- data/lib/rigor/inference/synthetic_method_scanner.rb +148 -14
- data/lib/rigor/plugin/additional_initializer.rb +108 -0
- data/lib/rigor/plugin/base.rb +321 -2
- data/lib/rigor/plugin/box.rb +64 -0
- data/lib/rigor/plugin/inflector.rb +121 -0
- data/lib/rigor/plugin/isolation.rb +191 -0
- data/lib/rigor/plugin/macro/nested_class_template.rb +140 -0
- data/lib/rigor/plugin/macro.rb +1 -0
- data/lib/rigor/plugin/manifest.rb +120 -23
- data/lib/rigor/plugin/node_context.rb +62 -0
- data/lib/rigor/plugin/registry.rb +10 -0
- data/lib/rigor/plugin.rb +3 -0
- data/lib/rigor/sig_gen/generator.rb +2 -3
- data/lib/rigor/sig_gen/observation_collector.rb +2 -2
- data/lib/rigor/source/literals.rb +118 -0
- data/lib/rigor/source/node_walker.rb +26 -0
- data/lib/rigor/source.rb +1 -0
- data/lib/rigor/type/combinator.rb +6 -1
- data/lib/rigor/type/union.rb +65 -1
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +1 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +31 -53
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +21 -23
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +38 -59
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +7 -13
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +22 -33
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +298 -413
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +69 -71
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +24 -34
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +18 -16
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +4 -46
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +1 -1
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +17 -12
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -8
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +2 -7
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +2 -6
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +4 -3
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +40 -45
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +7 -17
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +20 -42
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +7 -4
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +4 -8
- data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +188 -0
- data/plugins/rigor-mangrove/lib/rigor-mangrove.rb +3 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +4 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +24 -8
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +31 -48
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +21 -23
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +54 -82
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +25 -25
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +63 -147
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -17
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +23 -114
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +36 -31
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +6 -3
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +4 -2
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +13 -12
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +28 -40
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +44 -47
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +11 -10
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +45 -87
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +11 -12
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +29 -42
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +20 -19
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +73 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +43 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +21 -29
- data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +36 -96
- data/sig/rigor/plugin/access_denied_error.rbs +3 -1
- data/sig/rigor/plugin/base.rbs +58 -3
- data/sig/rigor/plugin/io_boundary.rbs +3 -0
- data/sig/rigor/plugin/manifest.rbs +31 -1
- data/sig/rigor/source.rbs +12 -0
- data/sig/rigor.rbs +5 -0
- data/skills/rigor-plugin-author/SKILL.md +13 -9
- data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +6 -5
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +159 -75
- data/skills/rigor-plugin-author/references/03-test-and-ship.md +3 -3
- metadata +52 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +0 -114
data/lib/rigor/cli.rb
CHANGED
|
@@ -78,11 +78,7 @@ module Rigor
|
|
|
78
78
|
end
|
|
79
79
|
|
|
80
80
|
def run_check
|
|
81
|
-
|
|
82
|
-
require_relative "analysis/buffer_binding"
|
|
83
|
-
require_relative "analysis/baseline"
|
|
84
|
-
require_relative "cache/store"
|
|
85
|
-
|
|
81
|
+
load_check_dependencies
|
|
86
82
|
options = parse_check_options
|
|
87
83
|
buffer = resolve_buffer_binding(options)
|
|
88
84
|
return EXIT_USAGE if buffer == :usage_error
|
|
@@ -100,6 +96,7 @@ module Rigor
|
|
|
100
96
|
|
|
101
97
|
write_result(result, options.fetch(:format))
|
|
102
98
|
write_run_stats(result.stats) if result.stats
|
|
99
|
+
write_trace_appendices
|
|
103
100
|
write_cache_stats(cache_root, runner.cache_store) if options.fetch(:cache_stats)
|
|
104
101
|
|
|
105
102
|
exit_code = result.success? ? 0 : 1
|
|
@@ -402,6 +399,139 @@ module Rigor
|
|
|
402
399
|
stats.format(@err)
|
|
403
400
|
end
|
|
404
401
|
|
|
402
|
+
# Opt-in developer diagnostics printed after the run: the
|
|
403
|
+
# inference-cutoff trace (RIGOR_BUDGET_TRACE) and the heap-attribution
|
|
404
|
+
# profile (RIGOR_HEAP_PROFILE). Each gates itself, so this is a no-op
|
|
405
|
+
# on a normal run.
|
|
406
|
+
def write_trace_appendices
|
|
407
|
+
write_budget_trace
|
|
408
|
+
write_heap_profile
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Dumps the opt-in inference-cutoff counters (RIGOR_BUDGET_TRACE).
|
|
412
|
+
# These are the hard-coded "budget" guards that silently degrade
|
|
413
|
+
# to `Dynamic[top]` / a fallback bound — counting them shows where
|
|
414
|
+
# inference actually stopped. Process-global counters: meaningful
|
|
415
|
+
# only on a single-process run (`--workers 0`), since they do not
|
|
416
|
+
# cross fork boundaries.
|
|
417
|
+
def write_budget_trace
|
|
418
|
+
return unless Inference::BudgetTrace.enabled?
|
|
419
|
+
|
|
420
|
+
counts = Inference::BudgetTrace.snapshot
|
|
421
|
+
@err.puts("")
|
|
422
|
+
@err.puts("Inference cutoffs (RIGOR_BUDGET_TRACE; --workers 0 for an exact count)")
|
|
423
|
+
@err.puts(" recursion-guard hits: #{counts[Inference::BudgetTrace::RECURSION_GUARD]}")
|
|
424
|
+
@err.puts(" ancestor-walk-limit hits: #{counts[Inference::BudgetTrace::ANCESTOR_WALK_LIMIT]}")
|
|
425
|
+
@err.puts(" hkt-fuel-exhausted hits: #{counts[Inference::BudgetTrace::HKT_FUEL_EXHAUSTED]}")
|
|
426
|
+
write_budget_distributions
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# Dumps the read-only size distributions (ADR-41 Slice 2a). These
|
|
430
|
+
# observe how large unions actually get, with no cap enforced — the
|
|
431
|
+
# data the `union_size` budget default should be chosen from. The
|
|
432
|
+
# `over` thresholds bracket the TypeProf prior (10) and Rigor's spec
|
|
433
|
+
# default (24).
|
|
434
|
+
def write_budget_distributions
|
|
435
|
+
summary = Inference::BudgetTrace.summarize(Inference::BudgetTrace::UNION_ARITY, over: [10, 24, 40])
|
|
436
|
+
pct = summary[:percentiles]
|
|
437
|
+
@err.puts(" union arity: n=#{summary[:count]} max=#{summary[:max]} " \
|
|
438
|
+
"p50=#{pct[:p50]} p90=#{pct[:p90]} p99=#{pct[:p99]}")
|
|
439
|
+
over = summary[:over]
|
|
440
|
+
@err.puts(" unions ≥10: #{over[10]} ≥24: #{over[24]} ≥40: #{over[40]}")
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# Dumps a live-heap class breakdown (RIGOR_HEAP_PROFILE) — retained
|
|
444
|
+
# objects by class after a forced GC, ranked by total memsize. The
|
|
445
|
+
# tool for attributing where the analyzer's resident memory goes
|
|
446
|
+
# (ADR-41 Slice 2b): it answers whether the heap is type carriers,
|
|
447
|
+
# RBS objects, Prism nodes, or fact-store Hashes/Strings. Walking the
|
|
448
|
+
# whole heap is slow — a dev probe, not a normal diagnostic. Run
|
|
449
|
+
# single-process (`--workers 0`) so the parent heap is the analysis
|
|
450
|
+
# heap; the gem is required lazily so a normal run never loads it.
|
|
451
|
+
def write_heap_profile
|
|
452
|
+
return if ENV["RIGOR_HEAP_PROFILE"].to_s.empty?
|
|
453
|
+
|
|
454
|
+
by_class, total = tally_live_heap
|
|
455
|
+
@err.puts("")
|
|
456
|
+
@err.puts("Heap profile (RIGOR_HEAP_PROFILE; live objects after GC, by class)")
|
|
457
|
+
@err.puts(" total tracked: #{heap_mb(total)} across #{by_class.size} classes")
|
|
458
|
+
by_class.sort_by { |_, (_, bytes)| -bytes }.first(30).each do |name, (count, bytes)|
|
|
459
|
+
@err.puts(" #{heap_mb(bytes).rjust(10)} #{count.to_s.rjust(9)} obj #{name}")
|
|
460
|
+
end
|
|
461
|
+
write_string_allocation_sites
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# Loads the analysis-path dependencies lazily (so non-check commands
|
|
465
|
+
# stay light) and starts heap-allocation tracing if requested, before
|
|
466
|
+
# any analysis object is allocated.
|
|
467
|
+
def load_check_dependencies
|
|
468
|
+
require_relative "analysis/runner"
|
|
469
|
+
require_relative "analysis/buffer_binding"
|
|
470
|
+
require_relative "analysis/baseline"
|
|
471
|
+
require_relative "cache/store"
|
|
472
|
+
start_heap_trace_if_requested
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# Starts allocation tracing (RIGOR_HEAP_TRACE) as early as possible so
|
|
476
|
+
# the heap profile can attribute retained Strings to their allocation
|
|
477
|
+
# `file:line`. Very high overhead — run on a small file subset only.
|
|
478
|
+
def start_heap_trace_if_requested
|
|
479
|
+
return if ENV["RIGOR_HEAP_TRACE"].to_s.empty?
|
|
480
|
+
|
|
481
|
+
require "objspace"
|
|
482
|
+
ObjectSpace.trace_object_allocations_start
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# When RIGOR_HEAP_TRACE is on, groups the live String objects by their
|
|
486
|
+
# allocation site (`sourcefile:sourceline`) and prints the top sites by
|
|
487
|
+
# count — pinpointing which engine code retains the millions of strings
|
|
488
|
+
# that dominate the large-app heap (ADR-41 Slice 2b). Strings allocated
|
|
489
|
+
# before tracing started report `(pre-trace)`.
|
|
490
|
+
def write_string_allocation_sites
|
|
491
|
+
return if ENV["RIGOR_HEAP_TRACE"].to_s.empty?
|
|
492
|
+
|
|
493
|
+
by_site = Hash.new(0)
|
|
494
|
+
ObjectSpace.each_object(String) do |str|
|
|
495
|
+
file = ObjectSpace.allocation_sourcefile(str)
|
|
496
|
+
line = ObjectSpace.allocation_sourceline(str)
|
|
497
|
+
by_site[file ? "#{file}:#{line}" : "(pre-trace)"] += 1
|
|
498
|
+
end
|
|
499
|
+
@err.puts("")
|
|
500
|
+
@err.puts(" String allocation sites (top 25 by live count)")
|
|
501
|
+
by_site.sort_by { |_, n| -n }.first(25).each do |site, n|
|
|
502
|
+
@err.puts(" #{n.to_s.rjust(9)} #{site}")
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# Walks the whole live heap (after a forced GC) and tallies
|
|
507
|
+
# `{class_name => [count, memsize]}` plus the grand total. Returns
|
|
508
|
+
# `[by_class, total]`. Slow — a dev probe only.
|
|
509
|
+
def tally_live_heap
|
|
510
|
+
require "objspace"
|
|
511
|
+
GC.start
|
|
512
|
+
by_class = Hash.new { |h, k| h[k] = [0, 0] }
|
|
513
|
+
total = 0
|
|
514
|
+
ObjectSpace.each_object do |obj|
|
|
515
|
+
size = ObjectSpace.memsize_of(obj)
|
|
516
|
+
entry = by_class[heap_class_name(obj)]
|
|
517
|
+
entry[0] += 1
|
|
518
|
+
entry[1] += size
|
|
519
|
+
total += size
|
|
520
|
+
end
|
|
521
|
+
[by_class, total]
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
def heap_class_name(obj)
|
|
525
|
+
klass = Object.instance_method(:class).bind_call(obj)
|
|
526
|
+
klass.name || klass.inspect
|
|
527
|
+
rescue StandardError
|
|
528
|
+
"(unknown)"
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
def heap_mb(bytes)
|
|
532
|
+
Kernel.format("%.1f MB", bytes / 1_048_576.0)
|
|
533
|
+
end
|
|
534
|
+
|
|
405
535
|
def write_cache_stats(cache_root, runtime_store)
|
|
406
536
|
inv = Cache::Store.disk_inventory(root: cache_root)
|
|
407
537
|
|
|
@@ -30,6 +30,24 @@ module Rigor
|
|
|
30
30
|
# See docs/internal-spec/inference-engine.md for the binding contract.
|
|
31
31
|
# rubocop:disable Metrics/ClassLength
|
|
32
32
|
class RbsLoader
|
|
33
|
+
# Buffer name stamped on the `module` declarations synthesized by
|
|
34
|
+
# {.synthesize_missing_namespaces}. Re-read off the built env by
|
|
35
|
+
# {#synthesized_namespaces} so the analysis layer can surface an
|
|
36
|
+
# `:info` diagnostic naming the project's malformed-RBS namespaces
|
|
37
|
+
# — robust across the marshalled env cache, since the sentinel
|
|
38
|
+
# rides along on each synthetic declaration's location.
|
|
39
|
+
SYNTHETIC_NAMESPACE_BUFFER = "(rigor: synthesized namespaces)"
|
|
40
|
+
|
|
41
|
+
# Buffer name stamped on the stub `class` / `module` declarations
|
|
42
|
+
# synthesized by {.stub_missing_referenced_types} for types the
|
|
43
|
+
# project's RBS references but no loaded signature declares.
|
|
44
|
+
# {#synthesized_stub_types} reads them back off the built env (so
|
|
45
|
+
# the answer survives the marshalled env cache), and
|
|
46
|
+
# {#synthesized_type_names} folds them together with the
|
|
47
|
+
# namespace stubs into the set {MethodDispatcher} resolves to
|
|
48
|
+
# `Dynamic[Top]` (no false `call.undefined-method`).
|
|
49
|
+
SYNTHETIC_STUB_BUFFER = "(rigor: synthesized stub types)"
|
|
50
|
+
|
|
33
51
|
class << self
|
|
34
52
|
def default
|
|
35
53
|
@default ||= new.freeze
|
|
@@ -72,7 +90,172 @@ module Rigor
|
|
|
72
90
|
end
|
|
73
91
|
env = RBS::Environment.from_loader(rbs_loader)
|
|
74
92
|
add_virtual_rbs(env, virtual_rbs)
|
|
75
|
-
env
|
|
93
|
+
synthesize_missing_namespaces(env)
|
|
94
|
+
resolved = env.resolve_type_names
|
|
95
|
+
stub_missing_referenced_types(env, resolved, project_sig_files(signature_paths))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# ADR-5 robustness, second tier. A project `signature_paths:`
|
|
99
|
+
# RBS that *references* a type no loaded signature declares —
|
|
100
|
+
# `def x: () -> DRb::DRbServer` when the `drb` RBS is not
|
|
101
|
+
# available, or a stale reference to its own removed
|
|
102
|
+
# `Textbringer::EditorError` — makes
|
|
103
|
+
# `RBS::DefinitionBuilder#build_instance` raise
|
|
104
|
+
# `NoTypeFoundError`, and (per RBS's all-or-nothing per-class
|
|
105
|
+
# build) that single unresolved reference takes down EVERY
|
|
106
|
+
# method on the class, not just the one signature. Observed on
|
|
107
|
+
# shugo/textbringer: one `DRb::DRbServer` reference left the
|
|
108
|
+
# whole `Textbringer::Commands` module — including its
|
|
109
|
+
# 186-call-site `define_command` DSL — resolving as
|
|
110
|
+
# `Dynamic[Top]`.
|
|
111
|
+
#
|
|
112
|
+
# We synthesize an empty stub for each such referenced-but-
|
|
113
|
+
# undeclared type so the rest of the class builds. A leaf type
|
|
114
|
+
# is stubbed as `class`, its enclosing namespaces as `module`.
|
|
115
|
+
# Stubbed types carry no methods, so a call against a value of
|
|
116
|
+
# a stubbed type would otherwise mis-fire `call.undefined-method`;
|
|
117
|
+
# {MethodDispatcher} consults {#synthesized_type_names} and
|
|
118
|
+
# resolves such calls to `Dynamic[Top]` instead (the same
|
|
119
|
+
# no-false-positive contract as the dependency-source tier).
|
|
120
|
+
#
|
|
121
|
+
# Detection re-uses RBS's own builder (correct by construction):
|
|
122
|
+
# build every PROJECT class and read the missing name out of the
|
|
123
|
+
# raised error. Bounded to `signature_paths` classes (stdlib /
|
|
124
|
+
# vendored RBS is well-formed) and to {MAX_STUB_PASSES}
|
|
125
|
+
# iterations — a fresh stub can expose a deeper reference the
|
|
126
|
+
# first build error hid, but empty stubs reference nothing, so
|
|
127
|
+
# the fixpoint converges quickly.
|
|
128
|
+
MAX_STUB_PASSES = 5
|
|
129
|
+
|
|
130
|
+
def stub_missing_referenced_types(base_env, resolved, project_files)
|
|
131
|
+
return resolved if project_files.empty?
|
|
132
|
+
|
|
133
|
+
MAX_STUB_PASSES.times do
|
|
134
|
+
missing = unresolved_referenced_types(resolved, project_files)
|
|
135
|
+
break if missing.empty?
|
|
136
|
+
|
|
137
|
+
append_stub_declarations(base_env, missing)
|
|
138
|
+
resolved = base_env.resolve_type_names
|
|
139
|
+
end
|
|
140
|
+
resolved
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Robustness (ADR-5): a project whose RBS declares qualified
|
|
144
|
+
# names (`class Foo::Bar`) without ever declaring the enclosing
|
|
145
|
+
# namespace (`module Foo`) is invalid by upstream RBS rules —
|
|
146
|
+
# `RBS::DefinitionBuilder#build_instance` raises
|
|
147
|
+
# `NoTypeFoundError: Could not find ::Foo`, which the loader's
|
|
148
|
+
# fail-soft rescue turns into a silent dispatch miss (every
|
|
149
|
+
# method on every such class degrades to `Dynamic[Top]`). This
|
|
150
|
+
# is a common authoring mistake (e.g. shugo/textbringer ships a
|
|
151
|
+
# `sig/` that `rbs validate` itself rejects). Rather than let an
|
|
152
|
+
# otherwise-usable signature set contribute nothing, synthesize
|
|
153
|
+
# an empty `module` declaration for each undeclared enclosing
|
|
154
|
+
# namespace so the definitions build. We only ever add names
|
|
155
|
+
# that are absent — a genuinely-declared namespace (module or
|
|
156
|
+
# class, here or in a loaded gem) is left untouched.
|
|
157
|
+
def synthesize_missing_namespaces(env)
|
|
158
|
+
missing = collect_missing_namespaces(env)
|
|
159
|
+
return if missing.empty?
|
|
160
|
+
|
|
161
|
+
source = missing.map { |name| "module #{name}\nend\n" }.join
|
|
162
|
+
buffer = ::RBS::Buffer.new(name: SYNTHETIC_NAMESPACE_BUFFER, content: source)
|
|
163
|
+
_, directives, decls = ::RBS::Parser.parse_signature(buffer)
|
|
164
|
+
env.add_source(::RBS::Source::RBS.new(buffer, directives || [], decls || []))
|
|
165
|
+
rescue ::RBS::BaseError
|
|
166
|
+
# Fail-soft: synthesis is an opportunistic uplift, never a
|
|
167
|
+
# hard requirement. A parse failure here just leaves the env
|
|
168
|
+
# as it was (dispatch misses on the affected classes).
|
|
169
|
+
nil
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Returns the `::`-stripped names of every enclosing namespace
|
|
173
|
+
# that some declaration references but no declaration defines,
|
|
174
|
+
# shallowest-first so the synthesized source declares `Foo`
|
|
175
|
+
# before `Foo::Bar`.
|
|
176
|
+
def collect_missing_namespaces(env)
|
|
177
|
+
declared = env.class_decls.keys.to_set
|
|
178
|
+
missing = {}
|
|
179
|
+
env.class_decls.each_key do |type_name|
|
|
180
|
+
path = type_name.namespace.path
|
|
181
|
+
path.each_index do |i|
|
|
182
|
+
prefix = path[0..i]
|
|
183
|
+
full = ::RBS::TypeName.parse("::#{prefix.join('::')}")
|
|
184
|
+
missing[prefix.join("::")] = prefix.length unless declared.include?(full)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
missing.sort_by { |_name, depth| depth }.map(&:first)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# The absolute paths of every `.rbs` file under the project's
|
|
191
|
+
# `signature_paths:` (NOT vendored / stdlib RBS — those are
|
|
192
|
+
# well-formed, so attempting to build them would only waste
|
|
193
|
+
# time). Used to scope the referenced-type build sweep.
|
|
194
|
+
def project_sig_files(signature_paths)
|
|
195
|
+
signature_paths.flat_map do |path|
|
|
196
|
+
path = Pathname(path) unless path.is_a?(Pathname)
|
|
197
|
+
next [] unless path.directory?
|
|
198
|
+
|
|
199
|
+
Dir.glob(path.join("**", "*.rbs")).map { |p| File.expand_path(p) }
|
|
200
|
+
end.to_set
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Builds every project class (instance + singleton side) and
|
|
204
|
+
# returns the `::`-stripped names of the types whose absence
|
|
205
|
+
# raised `NoTypeFoundError`. Only the FIRST missing reference
|
|
206
|
+
# per class surfaces per build, which is why the caller loops.
|
|
207
|
+
def unresolved_referenced_types(env, project_files)
|
|
208
|
+
builder = ::RBS::DefinitionBuilder.new(env: env)
|
|
209
|
+
missing = []
|
|
210
|
+
env.class_decls.each do |type_name, entry|
|
|
211
|
+
next unless project_entry?(entry, project_files)
|
|
212
|
+
|
|
213
|
+
%i[build_instance build_singleton].each do |build|
|
|
214
|
+
builder.public_send(build, type_name)
|
|
215
|
+
rescue ::RBS::NoTypeFoundError => e
|
|
216
|
+
name = e.message[/Could not find (\S+)/, 1]
|
|
217
|
+
missing << name.sub(/\A::/, "") if name
|
|
218
|
+
rescue ::RBS::BaseError
|
|
219
|
+
# Other build failures (duplicate decl, mixin cycle, ...)
|
|
220
|
+
# are not ours to repair here — leave them fail-soft.
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
missing.uniq
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# True when a `class_decls` entry was declared in one of the
|
|
227
|
+
# project's own signature files (by declaration location), so
|
|
228
|
+
# the sweep skips the bundled stdlib / vendored universe.
|
|
229
|
+
def project_entry?(entry, project_files)
|
|
230
|
+
decl = entry.respond_to?(:primary_decl) ? entry.primary_decl : nil
|
|
231
|
+
location = decl&.location
|
|
232
|
+
buffer_name = location&.buffer&.name
|
|
233
|
+
return false unless buffer_name
|
|
234
|
+
|
|
235
|
+
project_files.include?(File.expand_path(buffer_name.to_s))
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Adds empty stub declarations for the missing referenced types
|
|
239
|
+
# (and any enclosing namespace they need) to the pre-resolve
|
|
240
|
+
# env, tagged with {SYNTHETIC_STUB_BUFFER}. A name that is a
|
|
241
|
+
# prefix of another name is declared `module` (it is a
|
|
242
|
+
# namespace); a leaf is declared `class` (referenced types
|
|
243
|
+
# appear in instance position far more often than as mixins).
|
|
244
|
+
def append_stub_declarations(base_env, missing)
|
|
245
|
+
names = missing.to_set
|
|
246
|
+
missing.each do |name|
|
|
247
|
+
parts = name.split("::")
|
|
248
|
+
(1...parts.length).each { |i| names << parts[0, i].join("::") }
|
|
249
|
+
end
|
|
250
|
+
source = names.sort_by { |n| n.count(":") }.map do |name|
|
|
251
|
+
keyword = names.any? { |other| other != name && other.start_with?("#{name}::") } ? "module" : "class"
|
|
252
|
+
"#{keyword} #{name}\nend\n"
|
|
253
|
+
end.join
|
|
254
|
+
buffer = ::RBS::Buffer.new(name: SYNTHETIC_STUB_BUFFER, content: source)
|
|
255
|
+
_, directives, decls = ::RBS::Parser.parse_signature(buffer)
|
|
256
|
+
base_env.add_source(::RBS::Source::RBS.new(buffer, directives || [], decls || []))
|
|
257
|
+
rescue ::RBS::BaseError
|
|
258
|
+
nil
|
|
76
259
|
end
|
|
77
260
|
|
|
78
261
|
# ADR-32 WD4 — merge synthesised-from-source RBS strings
|
|
@@ -192,6 +375,40 @@ module Rigor
|
|
|
192
375
|
@hierarchy = RbsHierarchy.new(self)
|
|
193
376
|
end
|
|
194
377
|
|
|
378
|
+
# The enclosing namespaces {.synthesize_missing_namespaces} had to
|
|
379
|
+
# invent because the project's `signature_paths:` RBS declared
|
|
380
|
+
# qualified names (`class Foo::Bar`) without ever declaring `Foo`.
|
|
381
|
+
# Recovered by scanning the built env for class/module entries
|
|
382
|
+
# whose every declaration originated from the synthetic buffer, so
|
|
383
|
+
# the answer survives the marshalled-env cache (where no build-time
|
|
384
|
+
# collector would). Returns `::`-stripped names, shallowest-first.
|
|
385
|
+
# Empty for a well-formed sig set (the common case) and whenever
|
|
386
|
+
# the env failed to build.
|
|
387
|
+
def synthesized_namespaces
|
|
388
|
+
names_synthesized_in(SYNTHETIC_NAMESPACE_BUFFER)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# The referenced-but-undeclared types
|
|
392
|
+
# {.stub_missing_referenced_types} stubbed so the project classes
|
|
393
|
+
# that mention them could build (e.g. an unavailable
|
|
394
|
+
# `DRb::DRbServer`, or a stale `Textbringer::EditorError`).
|
|
395
|
+
# Recovered off the built env like {#synthesized_namespaces}, so
|
|
396
|
+
# it survives the marshalled-env cache.
|
|
397
|
+
def synthesized_stub_types
|
|
398
|
+
names_synthesized_in(SYNTHETIC_STUB_BUFFER)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# Every type name Rigor invented to make an otherwise-inert /
|
|
402
|
+
# unbuildable project signature set resolve — both the namespace
|
|
403
|
+
# stubs and the referenced-type stubs. {MethodDispatcher} resolves
|
|
404
|
+
# a call whose receiver is one of these (and that no real
|
|
405
|
+
# signature answered) to `Dynamic[Top]`, so the empty stub never
|
|
406
|
+
# mis-fires `call.undefined-method`. Memoised; empty (and cheap)
|
|
407
|
+
# for the common well-formed sig set.
|
|
408
|
+
def synthesized_type_names
|
|
409
|
+
@state[:synthesized_type_names] ||= (synthesized_namespaces + synthesized_stub_types).to_set
|
|
410
|
+
end
|
|
411
|
+
|
|
195
412
|
# Returns true when an RBS class or module declaration with the given
|
|
196
413
|
# name is loaded. Accepts unprefixed or top-level-prefixed names
|
|
197
414
|
# ("Integer" or "::Integer"). Memoized per-name (positive and
|
|
@@ -546,6 +763,47 @@ module Rigor
|
|
|
546
763
|
|
|
547
764
|
private
|
|
548
765
|
|
|
766
|
+
# The `::`-stripped names of every class/module entry whose
|
|
767
|
+
# declarations ALL originated from the given sentinel buffer —
|
|
768
|
+
# i.e. names Rigor synthesized, not names the project declared.
|
|
769
|
+
# Reads off the built env so the answer survives the marshalled
|
|
770
|
+
# env cache; shallowest-first. Empty when the env failed to build.
|
|
771
|
+
def names_synthesized_in(buffer_name)
|
|
772
|
+
e = env
|
|
773
|
+
return [] if e.nil?
|
|
774
|
+
|
|
775
|
+
names = e.class_decls.filter_map do |type_name, entry|
|
|
776
|
+
decls = entry_declarations(entry)
|
|
777
|
+
next if decls.empty?
|
|
778
|
+
next unless decls.all? { |decl| synthetic_decl?(decl, buffer_name) }
|
|
779
|
+
|
|
780
|
+
type_name.to_s.sub(/\A::/, "")
|
|
781
|
+
end
|
|
782
|
+
names.sort_by { |name| name.count("::") }
|
|
783
|
+
end
|
|
784
|
+
|
|
785
|
+
# Collects the AST declaration nodes behind a `class_decls`
|
|
786
|
+
# entry. RBS 4's `ModuleEntry` / `ClassEntry` expose `each_decl`;
|
|
787
|
+
# the older single-`decl` shape is handled defensively so the
|
|
788
|
+
# loader survives an rbs-gem minor bump.
|
|
789
|
+
def entry_declarations(entry)
|
|
790
|
+
if entry.respond_to?(:each_decl)
|
|
791
|
+
[].tap { |acc| entry.each_decl { |decl| acc << decl } }
|
|
792
|
+
elsif entry.respond_to?(:decl)
|
|
793
|
+
[entry.decl]
|
|
794
|
+
else
|
|
795
|
+
[]
|
|
796
|
+
end
|
|
797
|
+
end
|
|
798
|
+
|
|
799
|
+
# True when an AST declaration was emitted into `buffer_name`
|
|
800
|
+
# (one of the synthetic-source sentinels) — identified by the
|
|
801
|
+
# buffer name on its location.
|
|
802
|
+
def synthetic_decl?(decl, buffer_name)
|
|
803
|
+
location = decl.respond_to?(:location) ? decl.location : nil
|
|
804
|
+
location&.buffer&.name.to_s == buffer_name
|
|
805
|
+
end
|
|
806
|
+
|
|
549
807
|
def constant_type_table
|
|
550
808
|
@constant_type_table ||= begin
|
|
551
809
|
require_relative "../cache/rbs_constant_table"
|
data/lib/rigor/environment.rb
CHANGED
|
@@ -512,8 +512,14 @@ module Rigor
|
|
|
512
512
|
# presence check without materialising a type carrier.
|
|
513
513
|
def class_known?(name)
|
|
514
514
|
return true if class_registry.nominal_for_name(name)
|
|
515
|
-
|
|
516
|
-
|
|
515
|
+
return true if class_known_in_rbs?(name)
|
|
516
|
+
|
|
517
|
+
# ADR-36 nested-class emission — a variant subclass the
|
|
518
|
+
# substrate synthesised (e.g. `Shape::Circle` from a
|
|
519
|
+
# `variants do variant Circle, Float end` block) is a real
|
|
520
|
+
# class for resolution purposes even though no RBS / source
|
|
521
|
+
# declares it.
|
|
522
|
+
@synthetic_method_index&.knows_class?(name) || false
|
|
517
523
|
end
|
|
518
524
|
|
|
519
525
|
# ADR-15 Phase 2b — returns the loader's read-only,
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Inference
|
|
5
|
+
# Opt-in counters for the hard-coded inference cutoffs — the
|
|
6
|
+
# "budget" guards that silently return `Dynamic[top]` / `nil` /
|
|
7
|
+
# a fallback bound rather than emitting a diagnostic. These are
|
|
8
|
+
# the *operative* cutoffs in the engine today (the configurable
|
|
9
|
+
# `budgets:` table in docs/type-specification/inference-budgets.md
|
|
10
|
+
# is not yet wired); counting how often each fires on a real
|
|
11
|
+
# project is the only way to see where inference actually stops.
|
|
12
|
+
#
|
|
13
|
+
# Three categories, one per guard site:
|
|
14
|
+
#
|
|
15
|
+
# - {RECURSION_GUARD} — `ExpressionTyper#infer_user_method_return`
|
|
16
|
+
# detected a `(receiver, method)` cycle and returned
|
|
17
|
+
# `Dynamic[top]` (the de-facto recursion-depth budget, effective
|
|
18
|
+
# depth 1).
|
|
19
|
+
# - {ANCESTOR_WALK_LIMIT} — `resolve_user_def_through_ancestors`
|
|
20
|
+
# hit the 100-node BFS cap and gave up resolving the self-call.
|
|
21
|
+
# - {HKT_FUEL_EXHAUSTED} — `HktReducer` ran out of its reduction
|
|
22
|
+
# fuel budget and unwound to `app.bound`.
|
|
23
|
+
#
|
|
24
|
+
# Enabled only when `RIGOR_BUDGET_TRACE` is set (to any non-empty
|
|
25
|
+
# value) in the environment, or via {enable!} in tests. When
|
|
26
|
+
# disabled, {hit} is a single boolean check and returns
|
|
27
|
+
# immediately, so normal runs pay nothing.
|
|
28
|
+
#
|
|
29
|
+
# Counters are process-global (Mutex-guarded) so they aggregate
|
|
30
|
+
# across threads, but they do NOT cross `fork` boundaries — run
|
|
31
|
+
# `rigor check --workers 0` to keep all inference in one process
|
|
32
|
+
# when collecting a trace.
|
|
33
|
+
module BudgetTrace
|
|
34
|
+
RECURSION_GUARD = :recursion_guard
|
|
35
|
+
ANCESTOR_WALK_LIMIT = :ancestor_walk_limit
|
|
36
|
+
HKT_FUEL_EXHAUSTED = :hkt_fuel_exhausted
|
|
37
|
+
|
|
38
|
+
CATEGORIES = [RECURSION_GUARD, ANCESTOR_WALK_LIMIT, HKT_FUEL_EXHAUSTED].freeze
|
|
39
|
+
|
|
40
|
+
# Distribution (histogram) categories — read-only observations of
|
|
41
|
+
# a value's size at a site, used to choose budget defaults from an
|
|
42
|
+
# observed tail rather than a guess (ADR-41 WD3 / Slice 2a). No cap
|
|
43
|
+
# is enforced; these only record. `UNION_ARITY` is the member count
|
|
44
|
+
# of every `Type::Union` that `Combinator.union` produces — the
|
|
45
|
+
# distribution the `union_size` budget default should be set from.
|
|
46
|
+
UNION_ARITY = :union_arity
|
|
47
|
+
|
|
48
|
+
DISTRIBUTION_CATEGORIES = [UNION_ARITY].freeze
|
|
49
|
+
|
|
50
|
+
@enabled = !ENV["RIGOR_BUDGET_TRACE"].to_s.empty?
|
|
51
|
+
@mutex = Mutex.new
|
|
52
|
+
@counts = Hash.new(0)
|
|
53
|
+
@distributions = Hash.new { |h, k| h[k] = Hash.new(0) }
|
|
54
|
+
|
|
55
|
+
module_function
|
|
56
|
+
|
|
57
|
+
def enabled?
|
|
58
|
+
@enabled
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Test / programmatic toggles. Production enablement is the
|
|
62
|
+
# `RIGOR_BUDGET_TRACE` env var read once at load time.
|
|
63
|
+
def enable!
|
|
64
|
+
@enabled = true
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def disable!
|
|
68
|
+
@enabled = false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Records one firing of `category`. No-op (one boolean check)
|
|
72
|
+
# when tracing is disabled.
|
|
73
|
+
def hit(category)
|
|
74
|
+
return unless @enabled
|
|
75
|
+
|
|
76
|
+
@mutex.synchronize { @counts[category] += 1 }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Frozen snapshot of the current counts, every known category
|
|
80
|
+
# present (zero-filled) so consumers can render a stable table.
|
|
81
|
+
def snapshot
|
|
82
|
+
@mutex.synchronize do
|
|
83
|
+
CATEGORIES.to_h { |category| [category, @counts[category]] }.freeze
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Records one observation of `value` (an Integer size) into
|
|
88
|
+
# `category`'s histogram. No-op (one boolean check) when disabled.
|
|
89
|
+
def observe(category, value)
|
|
90
|
+
return unless @enabled
|
|
91
|
+
|
|
92
|
+
@mutex.synchronize { @distributions[category][value] += 1 }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Frozen `{value => count}` histogram for a distribution category.
|
|
96
|
+
def distribution(category)
|
|
97
|
+
@mutex.synchronize { @distributions[category].dup.freeze }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Summary of a distribution category: total observation count, max
|
|
101
|
+
# observed value, selected percentiles, and how many observations
|
|
102
|
+
# met or exceeded each threshold in `over`. Percentiles use the
|
|
103
|
+
# nearest-rank method over the expanded sample.
|
|
104
|
+
def summarize(category, over: [])
|
|
105
|
+
hist = distribution(category)
|
|
106
|
+
total = hist.values.sum
|
|
107
|
+
return { count: 0, max: 0, percentiles: {}, over: over.to_h { |t| [t, 0] } } if total.zero?
|
|
108
|
+
|
|
109
|
+
sorted = hist.keys.sort
|
|
110
|
+
{ count: total,
|
|
111
|
+
max: sorted.last,
|
|
112
|
+
percentiles: { p50: percentile(hist, total, 0.50), p90: percentile(hist, total, 0.90),
|
|
113
|
+
p99: percentile(hist, total, 0.99) },
|
|
114
|
+
over: over.to_h { |t| [t, hist.sum { |value, n| value >= t ? n : 0 }] } }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Nearest-rank percentile over a `{value => count}` histogram
|
|
118
|
+
# without materialising the full sample.
|
|
119
|
+
def percentile(hist, total, fraction)
|
|
120
|
+
rank = (fraction * total).ceil
|
|
121
|
+
cumulative = 0
|
|
122
|
+
hist.keys.sort.each do |value|
|
|
123
|
+
cumulative += hist[value]
|
|
124
|
+
return value if cumulative >= rank
|
|
125
|
+
end
|
|
126
|
+
hist.keys.max
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def reset
|
|
130
|
+
@mutex.synchronize do
|
|
131
|
+
@counts.clear
|
|
132
|
+
@distributions.clear
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -5,6 +5,7 @@ require "prism"
|
|
|
5
5
|
require_relative "../type"
|
|
6
6
|
require_relative "../ast"
|
|
7
7
|
require_relative "block_parameter_binder"
|
|
8
|
+
require_relative "budget_trace"
|
|
8
9
|
require_relative "fallback"
|
|
9
10
|
require_relative "indexed_narrowing"
|
|
10
11
|
require_relative "macro_block_self_type"
|
|
@@ -1358,7 +1359,10 @@ module Rigor
|
|
|
1358
1359
|
|
|
1359
1360
|
seen[current] = true
|
|
1360
1361
|
visited += 1
|
|
1361
|
-
|
|
1362
|
+
if visited > ANCESTOR_WALK_LIMIT
|
|
1363
|
+
BudgetTrace.hit(BudgetTrace::ANCESTOR_WALK_LIMIT)
|
|
1364
|
+
return nil
|
|
1365
|
+
end
|
|
1362
1366
|
|
|
1363
1367
|
found = scope.user_def_for(current, method_name)
|
|
1364
1368
|
return found if found
|
|
@@ -1432,7 +1436,10 @@ module Rigor
|
|
|
1432
1436
|
# carrier for top-level / DSL-block defs) printable.
|
|
1433
1437
|
signature = [receiver.describe(:short), def_node.name]
|
|
1434
1438
|
stack = (Thread.current[INFERENCE_GUARD_KEY] ||= [])
|
|
1435
|
-
|
|
1439
|
+
if stack.include?(signature)
|
|
1440
|
+
BudgetTrace.hit(BudgetTrace::RECURSION_GUARD)
|
|
1441
|
+
return Type::Combinator.untyped
|
|
1442
|
+
end
|
|
1436
1443
|
|
|
1437
1444
|
stack.push(signature)
|
|
1438
1445
|
begin
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "hkt_body"
|
|
4
|
+
require_relative "budget_trace"
|
|
4
5
|
|
|
5
6
|
module Rigor
|
|
6
7
|
module Inference
|
|
@@ -71,6 +72,7 @@ module Rigor
|
|
|
71
72
|
walk(definition.body_tree, bindings: bindings_for(definition, app.args), state: state) || app.bound
|
|
72
73
|
end
|
|
73
74
|
rescue FuelExhausted
|
|
75
|
+
BudgetTrace.hit(BudgetTrace::HKT_FUEL_EXHAUSTED)
|
|
74
76
|
app.bound
|
|
75
77
|
end
|
|
76
78
|
end
|