rigortype 0.1.14 → 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 (114) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -2
  3. data/exe/rigor +19 -0
  4. data/lib/rigor/analysis/check_rules.rb +428 -6
  5. data/lib/rigor/analysis/diagnostic.rb +55 -3
  6. data/lib/rigor/analysis/rule_catalog.rb +80 -0
  7. data/lib/rigor/analysis/runner.rb +71 -2
  8. data/lib/rigor/analysis/worker_session.rb +3 -2
  9. data/lib/rigor/cache/descriptor.rb +6 -2
  10. data/lib/rigor/cli/plugin_command.rb +245 -0
  11. data/lib/rigor/cli/plugins_command.rb +51 -4
  12. data/lib/rigor/cli/plugins_renderer.rb +86 -1
  13. data/lib/rigor/cli.rb +143 -5
  14. data/lib/rigor/configuration/severity_profile.rb +9 -0
  15. data/lib/rigor/environment/rbs_loader.rb +259 -1
  16. data/lib/rigor/environment.rb +8 -2
  17. data/lib/rigor/inference/budget_trace.rb +137 -0
  18. data/lib/rigor/inference/expression_typer.rb +9 -2
  19. data/lib/rigor/inference/hkt_reducer.rb +2 -0
  20. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +23 -6
  21. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +81 -14
  22. data/lib/rigor/inference/method_dispatcher.rb +57 -10
  23. data/lib/rigor/inference/precision_scanner.rb +60 -1
  24. data/lib/rigor/inference/scope_indexer.rb +184 -27
  25. data/lib/rigor/inference/statement_evaluator.rb +13 -8
  26. data/lib/rigor/inference/synthetic_method_index.rb +23 -4
  27. data/lib/rigor/inference/synthetic_method_scanner.rb +148 -14
  28. data/lib/rigor/plugin/additional_initializer.rb +108 -0
  29. data/lib/rigor/plugin/base.rb +321 -2
  30. data/lib/rigor/plugin/box.rb +64 -0
  31. data/lib/rigor/plugin/inflector.rb +121 -0
  32. data/lib/rigor/plugin/isolation.rb +191 -0
  33. data/lib/rigor/plugin/macro/nested_class_template.rb +140 -0
  34. data/lib/rigor/plugin/macro.rb +1 -0
  35. data/lib/rigor/plugin/manifest.rb +120 -23
  36. data/lib/rigor/plugin/node_context.rb +62 -0
  37. data/lib/rigor/plugin/registry.rb +10 -0
  38. data/lib/rigor/plugin.rb +3 -0
  39. data/lib/rigor/scope.rb +27 -1
  40. data/lib/rigor/sig_gen/generator.rb +2 -3
  41. data/lib/rigor/sig_gen/observation_collector.rb +2 -2
  42. data/lib/rigor/source/literals.rb +118 -0
  43. data/lib/rigor/source/node_walker.rb +26 -0
  44. data/lib/rigor/source.rb +1 -0
  45. data/lib/rigor/triage/catalogue.rb +71 -5
  46. data/lib/rigor/type/combinator.rb +6 -1
  47. data/lib/rigor/type/union.rb +65 -1
  48. data/lib/rigor/version.rb +1 -1
  49. data/lib/rigor.rb +1 -0
  50. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +31 -53
  51. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +21 -23
  52. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +38 -59
  53. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +7 -13
  54. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +22 -33
  55. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +298 -413
  56. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +69 -71
  57. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +24 -34
  58. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +18 -16
  59. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +4 -46
  60. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  61. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +1 -1
  62. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +17 -12
  63. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -8
  64. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +2 -7
  65. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +2 -6
  66. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +4 -3
  67. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  68. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +40 -45
  69. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +7 -17
  70. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +20 -42
  71. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +7 -4
  72. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +4 -8
  73. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +188 -0
  74. data/plugins/rigor-mangrove/lib/rigor-mangrove.rb +3 -0
  75. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +4 -0
  76. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +24 -8
  77. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +31 -48
  78. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +21 -23
  79. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +54 -82
  80. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +25 -25
  81. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +63 -147
  82. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -17
  83. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +23 -114
  84. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +36 -31
  85. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  86. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +6 -3
  87. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +4 -2
  88. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +13 -12
  89. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +28 -40
  90. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +44 -47
  91. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +11 -10
  92. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +45 -87
  93. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +11 -12
  94. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +29 -42
  95. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +20 -19
  96. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +73 -0
  97. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +43 -1
  98. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +21 -29
  99. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +36 -96
  100. data/sig/rigor/plugin/access_denied_error.rbs +3 -1
  101. data/sig/rigor/plugin/base.rbs +58 -3
  102. data/sig/rigor/plugin/io_boundary.rbs +3 -0
  103. data/sig/rigor/plugin/manifest.rbs +31 -1
  104. data/sig/rigor/scope.rbs +3 -0
  105. data/sig/rigor/source.rbs +12 -0
  106. data/sig/rigor.rbs +5 -0
  107. data/skills/rigor-plugin-author/SKILL.md +33 -9
  108. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +65 -26
  109. data/skills/rigor-plugin-author/references/02-walker-and-types.md +213 -80
  110. data/skills/rigor-plugin-author/references/03-test-and-ship.md +3 -3
  111. data/skills/rigor-project-init/SKILL.md +72 -7
  112. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +233 -19
  113. metadata +53 -2
  114. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +0 -114
@@ -13,7 +13,7 @@ module Rigor
13
13
  # tooling (SKILLs, CI, editor integrations) while text is
14
14
  # for interactive inspection. Rows are printed in the order
15
15
  # the loader resolved them.
16
- class PluginsRenderer
16
+ class PluginsRenderer # rubocop:disable Metrics/ClassLength
17
17
  def initialize(rows:, configuration_path:)
18
18
  @rows = rows
19
19
  @configuration_path = configuration_path
@@ -42,8 +42,74 @@ module Rigor
42
42
  )
43
43
  end
44
44
 
45
+ # ADR-37 § "Machine-readable capability catalogue" — the focused
46
+ # per-plugin extension-protocol dump. Only loaded plugins appear
47
+ # (a plugin that failed to load contributes no capabilities), and
48
+ # each carries only the gate values an agent enumerates to learn
49
+ # what the plugin does: node-rule node types, dynamic-return
50
+ # receivers, type-specifier methods, and produced / consumed facts.
51
+ def capabilities_json
52
+ JSON.pretty_generate(
53
+ {
54
+ "configuration" => @configuration_path,
55
+ "capabilities" => loaded_rows.map { |row| capabilities_json_for(row) }
56
+ }
57
+ )
58
+ end
59
+
60
+ def capabilities_text
61
+ lines = ["Plugin capability catalogue (ADR-37 narrow extension protocols)", ""]
62
+ loaded = loaded_rows
63
+ if loaded.empty?
64
+ lines << " (no plugins loaded)"
65
+ else
66
+ loaded.each_with_index do |row, index|
67
+ lines.concat(capability_lines(row))
68
+ lines << "" unless index == loaded.size - 1
69
+ end
70
+ end
71
+ lines.join("\n")
72
+ end
73
+
45
74
  private
46
75
 
76
+ def loaded_rows
77
+ @rows.select { |r| r[:status] == :loaded }
78
+ end
79
+
80
+ def capability_lines(row)
81
+ lines = [" #{row[:id]} v#{row[:version]} (#{row[:gem]})"]
82
+ capability_surfaces(row).each { |surface| lines << " #{surface}" }
83
+ lines << " (no narrow extension protocols declared)" if lines.size == 1
84
+ lines
85
+ end
86
+
87
+ # The non-empty capability surfaces for a plugin, each as a
88
+ # `label: a, b, c` string. Data-driven so the catalogue stays a
89
+ # single source of truth shared between the text and JSON views.
90
+ def capability_surfaces(row)
91
+ [
92
+ ["node_rule", row[:node_rule_types]],
93
+ ["dynamic_return receivers", row[:dynamic_return_receivers]],
94
+ ["type_specifier methods", row[:type_specifier_methods]],
95
+ ["produces", row[:produces]],
96
+ ["consumes", row[:consumes]]
97
+ ].filter_map { |label, values| "#{label}: #{values.join(', ')}" if values.any? }
98
+ end
99
+
100
+ def capabilities_json_for(row)
101
+ {
102
+ "id" => row[:id],
103
+ "gem" => row[:gem],
104
+ "version" => row[:version],
105
+ "node_rule_types" => row[:node_rule_types],
106
+ "dynamic_return_receivers" => row[:dynamic_return_receivers],
107
+ "type_specifier_methods" => row[:type_specifier_methods],
108
+ "produces" => row[:produces],
109
+ "consumes" => row[:consumes]
110
+ }
111
+ end
112
+
47
113
  def header
48
114
  loaded = @rows.count { |r| r[:status] == :loaded }
49
115
  errored = @rows.count { |r| r[:status] == :load_error }
@@ -99,6 +165,22 @@ module Rigor
99
165
  lines << " owns_receivers: #{row[:owns_receivers].join(', ')}" if row[:owns_receivers].any?
100
166
  lines << " produces: #{row[:produces].join(', ')}" if row[:produces].any?
101
167
  lines << " consumes: #{row[:consumes].join(', ')}" if row[:consumes].any?
168
+ lines.concat(narrow_protocol_lines(row))
169
+ lines
170
+ end
171
+
172
+ # ADR-37 narrow extension protocols (node_rule / dynamic_return /
173
+ # type_specifier). Surfaced in the full report alongside the
174
+ # declarative surfaces; `--capabilities` is the focused view.
175
+ def narrow_protocol_lines(row)
176
+ lines = []
177
+ lines << " node_rule: #{row[:node_rule_types].join(', ')}" if row[:node_rule_types].any?
178
+ if row[:dynamic_return_receivers].any?
179
+ lines << " dynamic_return receivers: #{row[:dynamic_return_receivers].join(', ')}"
180
+ end
181
+ if row[:type_specifier_methods].any?
182
+ lines << " type_specifier methods: #{row[:type_specifier_methods].join(', ')}"
183
+ end
102
184
  lines
103
185
  end
104
186
 
@@ -157,6 +239,9 @@ module Rigor
157
239
  "hkt_definitions" => row[:hkt_definitions],
158
240
  "protocol_contracts" => row[:protocol_contracts],
159
241
  "source_rbs_synthesizer" => row[:source_rbs_synthesizer],
242
+ "node_rule_types" => row[:node_rule_types],
243
+ "dynamic_return_receivers" => row[:dynamic_return_receivers],
244
+ "type_specifier_methods" => row[:type_specifier_methods],
160
245
  "load_error" => row[:load_error]
161
246
  }
162
247
  end
data/lib/rigor/cli.rb CHANGED
@@ -33,6 +33,7 @@ module Rigor
33
33
  "triage" => :run_triage,
34
34
  "coverage" => :run_coverage,
35
35
  "plugins" => :run_plugins,
36
+ "plugin" => :run_plugin,
36
37
  "playground" => :run_playground,
37
38
  "skill" => :run_skill
38
39
  }.freeze
@@ -77,11 +78,7 @@ module Rigor
77
78
  end
78
79
 
79
80
  def run_check
80
- require_relative "analysis/runner"
81
- require_relative "analysis/buffer_binding"
82
- require_relative "analysis/baseline"
83
- require_relative "cache/store"
84
-
81
+ load_check_dependencies
85
82
  options = parse_check_options
86
83
  buffer = resolve_buffer_binding(options)
87
84
  return EXIT_USAGE if buffer == :usage_error
@@ -99,6 +96,7 @@ module Rigor
99
96
 
100
97
  write_result(result, options.fetch(:format))
101
98
  write_run_stats(result.stats) if result.stats
99
+ write_trace_appendices
102
100
  write_cache_stats(cache_root, runner.cache_store) if options.fetch(:cache_stats)
103
101
 
104
102
  exit_code = result.success? ? 0 : 1
@@ -401,6 +399,139 @@ module Rigor
401
399
  stats.format(@err)
402
400
  end
403
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
+
404
535
  def write_cache_stats(cache_root, runtime_store)
405
536
  inv = Cache::Store.disk_inventory(root: cache_root)
406
537
 
@@ -642,6 +773,12 @@ module Rigor
642
773
  CLI::SkillCommand.new(argv: @argv, out: @out, err: @err).run
643
774
  end
644
775
 
776
+ def run_plugin
777
+ require_relative "cli/plugin_command"
778
+
779
+ CLI::PluginCommand.new(argv: @argv, out: @out, err: @err).run
780
+ end
781
+
645
782
  def write_result(result, format)
646
783
  case format
647
784
  when "json"
@@ -688,6 +825,7 @@ module Rigor
688
825
  triage Summarise diagnostics: distribution, hotspots, hints (ADR-23)
689
826
  coverage Report type-precision coverage (precise vs Dynamic ratio)
690
827
  plugins Report activation status of every configured plugin
828
+ plugin Browse bundled plugin source as worked examples (list/path/print/root)
691
829
  playground Start the browser playground (requires rigor-playground gem)
692
830
  skill List or print bundled Agent Skills (rigor-project-init, ...)
693
831
  version Print the Rigor version
@@ -51,6 +51,9 @@ module Rigor
51
51
  "dump.type" => :info,
52
52
  "def.return-type-mismatch" => :warning,
53
53
  "def.method-visibility-mismatch" => :warning,
54
+ "def.override-visibility-reduced" => :off,
55
+ "def.override-return-widened" => :off,
56
+ "def.override-param-narrowed" => :off,
54
57
  "def.ivar-write-mismatch" => :warning
55
58
  }.freeze,
56
59
  balanced: {
@@ -67,6 +70,9 @@ module Rigor
67
70
  "dump.type" => :info,
68
71
  "def.return-type-mismatch" => :warning,
69
72
  "def.method-visibility-mismatch" => :error,
73
+ "def.override-visibility-reduced" => :warning,
74
+ "def.override-return-widened" => :warning,
75
+ "def.override-param-narrowed" => :warning,
70
76
  "def.ivar-write-mismatch" => :warning
71
77
  }.freeze,
72
78
  strict: {
@@ -83,6 +89,9 @@ module Rigor
83
89
  "dump.type" => :error,
84
90
  "def.return-type-mismatch" => :error,
85
91
  "def.method-visibility-mismatch" => :error,
92
+ "def.override-visibility-reduced" => :error,
93
+ "def.override-return-widened" => :error,
94
+ "def.override-param-narrowed" => :error,
86
95
  "def.ivar-write-mismatch" => :error
87
96
  }.freeze
88
97
  }.freeze
@@ -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,