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.
Files changed (106) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/exe/rigor +19 -0
  4. data/lib/rigor/analysis/check_rules.rb +25 -1
  5. data/lib/rigor/analysis/diagnostic.rb +40 -0
  6. data/lib/rigor/analysis/runner.rb +61 -2
  7. data/lib/rigor/analysis/worker_session.rb +3 -2
  8. data/lib/rigor/cache/descriptor.rb +6 -2
  9. data/lib/rigor/cli/plugins_command.rb +51 -4
  10. data/lib/rigor/cli/plugins_renderer.rb +86 -1
  11. data/lib/rigor/cli.rb +135 -5
  12. data/lib/rigor/environment/rbs_loader.rb +259 -1
  13. data/lib/rigor/environment.rb +8 -2
  14. data/lib/rigor/inference/budget_trace.rb +137 -0
  15. data/lib/rigor/inference/expression_typer.rb +9 -2
  16. data/lib/rigor/inference/hkt_reducer.rb +2 -0
  17. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +23 -6
  18. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +81 -14
  19. data/lib/rigor/inference/method_dispatcher.rb +57 -10
  20. data/lib/rigor/inference/precision_scanner.rb +60 -1
  21. data/lib/rigor/inference/scope_indexer.rb +127 -8
  22. data/lib/rigor/inference/statement_evaluator.rb +13 -8
  23. data/lib/rigor/inference/synthetic_method_index.rb +23 -4
  24. data/lib/rigor/inference/synthetic_method_scanner.rb +148 -14
  25. data/lib/rigor/plugin/additional_initializer.rb +108 -0
  26. data/lib/rigor/plugin/base.rb +321 -2
  27. data/lib/rigor/plugin/box.rb +64 -0
  28. data/lib/rigor/plugin/inflector.rb +121 -0
  29. data/lib/rigor/plugin/isolation.rb +191 -0
  30. data/lib/rigor/plugin/macro/nested_class_template.rb +140 -0
  31. data/lib/rigor/plugin/macro.rb +1 -0
  32. data/lib/rigor/plugin/manifest.rb +120 -23
  33. data/lib/rigor/plugin/node_context.rb +62 -0
  34. data/lib/rigor/plugin/registry.rb +10 -0
  35. data/lib/rigor/plugin.rb +3 -0
  36. data/lib/rigor/sig_gen/generator.rb +2 -3
  37. data/lib/rigor/sig_gen/observation_collector.rb +2 -2
  38. data/lib/rigor/source/literals.rb +118 -0
  39. data/lib/rigor/source/node_walker.rb +26 -0
  40. data/lib/rigor/source.rb +1 -0
  41. data/lib/rigor/type/combinator.rb +6 -1
  42. data/lib/rigor/type/union.rb +65 -1
  43. data/lib/rigor/version.rb +1 -1
  44. data/lib/rigor.rb +1 -0
  45. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +31 -53
  46. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +21 -23
  47. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +38 -59
  48. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +7 -13
  49. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +22 -33
  50. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +298 -413
  51. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +69 -71
  52. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +24 -34
  53. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +18 -16
  54. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +4 -46
  55. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  56. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +1 -1
  57. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +17 -12
  58. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -8
  59. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +2 -7
  60. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +2 -6
  61. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +4 -3
  62. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  63. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +40 -45
  64. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +7 -17
  65. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +20 -42
  66. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +7 -4
  67. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +4 -8
  68. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +188 -0
  69. data/plugins/rigor-mangrove/lib/rigor-mangrove.rb +3 -0
  70. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +4 -0
  71. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +24 -8
  72. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +31 -48
  73. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +21 -23
  74. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +54 -82
  75. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +25 -25
  76. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +63 -147
  77. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -17
  78. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +23 -114
  79. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +36 -31
  80. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  81. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +6 -3
  82. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +4 -2
  83. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +13 -12
  84. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +28 -40
  85. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +44 -47
  86. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +11 -10
  87. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +45 -87
  88. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +11 -12
  89. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +29 -42
  90. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +20 -19
  91. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +73 -0
  92. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +43 -1
  93. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +21 -29
  94. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +36 -96
  95. data/sig/rigor/plugin/access_denied_error.rbs +3 -1
  96. data/sig/rigor/plugin/base.rbs +58 -3
  97. data/sig/rigor/plugin/io_boundary.rbs +3 -0
  98. data/sig/rigor/plugin/manifest.rbs +31 -1
  99. data/sig/rigor/source.rbs +12 -0
  100. data/sig/rigor.rbs +5 -0
  101. data/skills/rigor-plugin-author/SKILL.md +13 -9
  102. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +6 -5
  103. data/skills/rigor-plugin-author/references/02-walker-and-types.md +159 -75
  104. data/skills/rigor-plugin-author/references/03-test-and-ship.md +3 -3
  105. metadata +52 -2
  106. 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
- require_relative "analysis/runner"
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.resolve_type_names
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"
@@ -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
- class_known_in_rbs?(name)
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
- return nil if visited > ANCESTOR_WALK_LIMIT
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
- return Type::Combinator.untyped if stack.include?(signature)
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