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
@@ -107,22 +107,28 @@ module Rigor
107
107
  # introduced method names. `rigor check` consults the
108
108
  # table to suppress false positives for methods the
109
109
  # user has defined but no RBS sig describes.
110
- discovered_methods = build_discovered_methods(root)
110
+ # Merged UNDER any cross-file pre-pass seed (like the def-node
111
+ # / include tables below) so a method `def`/`attr_reader`-
112
+ # declared in one file suppresses a false `undefined-method`
113
+ # for a call in another — `rigor check` seeds the project-wide
114
+ # table via `Runner#seed_project_scope`.
115
+ discovered_methods = deep_merge_class_methods(
116
+ default_scope.discovered_methods, build_discovered_methods(root)
117
+ )
111
118
  seeded_scope = seeded_scope.with_discovered_methods(discovered_methods)
112
119
 
113
120
  # v0.0.2 #5 + ADR-24 slice 2 — record per-instance-method
114
121
  # def nodes, the class -> superclass map, and the
115
122
  # class/module -> included-modules map, each merged under
116
123
  # the cross-file pre-pass seed (see below).
117
- seeded_scope = merge_project_method_indexes(seeded_scope, default_scope, root)
118
-
119
124
  # v0.1.2 — per-class table of method visibilities
120
125
  # (`:public` / `:private` / `:protected`). The
121
- # `def.method-visibility-mismatch` CheckRule consults
122
- # the table to flag explicit-non-self calls to a
123
- # private user method.
124
- discovered_method_visibilities = build_discovered_method_visibilities(root)
125
- seeded_scope = seeded_scope.with_discovered_method_visibilities(discovered_method_visibilities)
126
+ # `def.method-visibility-mismatch` and ADR-35
127
+ # `def.override-visibility-reduced` CheckRules consult the
128
+ # table. Seeded inside `merge_project_method_indexes` so the
129
+ # per-file visibilities merge OVER the cross-file project seed
130
+ # rather than overwriting it.
131
+ seeded_scope = merge_project_method_indexes(seeded_scope, default_scope, root)
126
132
 
127
133
  table = {}.compare_by_identity
128
134
  table.default = seeded_scope
@@ -161,11 +167,18 @@ module Rigor
161
167
  includes = default_scope.discovered_includes.merge(
162
168
  build_discovered_includes(root)
163
169
  ) { |_class, cross_file, per_file| (cross_file + per_file).uniq }
170
+ # ADR-35 — per-file visibilities merged OVER the cross-file
171
+ # seed (the current file is authoritative for its own classes;
172
+ # sibling-file ancestors are preserved from the project seed).
173
+ method_visibilities = default_scope.discovered_method_visibilities.merge(
174
+ build_discovered_method_visibilities(root)
175
+ ) { |_class, cross_file, per_file| cross_file.merge(per_file) }
164
176
 
165
177
  seeded_scope
166
178
  .with_discovered_def_nodes(def_nodes)
167
179
  .with_discovered_superclasses(superclasses)
168
180
  .with_discovered_includes(includes)
181
+ .with_discovered_method_visibilities(method_visibilities)
169
182
  end
170
183
 
171
184
  # Slice 7 phase 2. Builds the class-level ivar accumulator
@@ -377,7 +390,7 @@ module Rigor
377
390
  # class body has been walked, using `init_writes` as
378
391
  # the soundness gate (an ivar written in `initialize`
379
392
  # is initialised before any other method body runs).
380
- collect_read_before_write_evidence(def_node, class_name, read_before_write, init_writes)
393
+ collect_read_before_write_evidence(def_node, class_name, read_before_write, init_writes, default_scope)
381
394
  end
382
395
 
383
396
  # Walks the method body in AST (== execution) order
@@ -388,14 +401,21 @@ module Rigor
388
401
  # `init_writes` instead — used by the finalisation step
389
402
  # to suppress nil contribution for ivars the constructor
390
403
  # guarantees are initialised.
391
- def collect_read_before_write_evidence(def_node, class_name, read_before_write, init_writes)
404
+ def collect_read_before_write_evidence(def_node, class_name, read_before_write, init_writes, default_scope = nil)
392
405
  return if read_before_write.nil? || init_writes.nil?
393
406
 
394
407
  seen_writes = Set.new
395
408
  read_first = Set.new
396
409
  detect_read_before_write(def_node.body, seen_writes, read_first)
397
410
 
398
- if def_node.name == :initialize
411
+ # ADR-38 `initialize` is the built-in initializer gate;
412
+ # a plugin may declare additional `def`-form initializer
413
+ # methods (minitest `setup`, Rails `after_initialize`, DI
414
+ # setters) on a constrained class. Both fold their writes
415
+ # into `init_writes`, suppressing the read-before-write nil
416
+ # contribution for sibling readers.
417
+ if def_node.name == :initialize ||
418
+ additional_initializer?(class_name, def_node.name, default_scope)
399
419
  init_set = (init_writes[class_name] ||= Set.new)
400
420
  seen_writes.each { |name| init_set << name }
401
421
  return
@@ -407,6 +427,43 @@ module Rigor
407
427
  read_first.each { |name| rbw_set << name }
408
428
  end
409
429
 
430
+ # ADR-38 — true when a loaded plugin declares `method_name` an
431
+ # additional initializer for `class_name` (or an ancestor).
432
+ # Reads the plugin registry off the pre-pass scope's
433
+ # environment; the receiver-constraint match reuses
434
+ # `Environment#class_ordering` (the same mechanism ADR-16
435
+ # Tier A's `MacroBlockSelfType` uses). The whole lookup is
436
+ # wrapped so any resolution failure degrades to "no match" —
437
+ # since the gate only ever SUPPRESSES a nil contribution, a
438
+ # missed match is false-positive-safe (it merely leaves the
439
+ # existing nil widening in place).
440
+ def additional_initializer?(class_name, method_name, default_scope)
441
+ return false if class_name.nil? || default_scope.nil?
442
+
443
+ environment = default_scope.environment
444
+ registry = environment&.plugin_registry
445
+ return false if registry.nil?
446
+ return false if registry.respond_to?(:empty?) && registry.empty?
447
+ return false unless registry.respond_to?(:additional_initializers)
448
+
449
+ registry.additional_initializers.any? do |entry|
450
+ entry.covers_method?(method_name) &&
451
+ class_matches_constraint?(class_name, entry.receiver_constraint, environment)
452
+ end
453
+ rescue StandardError
454
+ false
455
+ end
456
+
457
+ def class_matches_constraint?(class_name, constraint, environment)
458
+ return true if class_name == constraint
459
+ return false if environment.nil?
460
+
461
+ ordering = environment.class_ordering(class_name, constraint)
462
+ %i[equal subclass].include?(ordering)
463
+ rescue StandardError
464
+ false
465
+ end
466
+
410
467
  IVAR_WRITE_NODES = [
411
468
  Prism::InstanceVariableWriteNode,
412
469
  Prism::InstanceVariableOrWriteNode,
@@ -796,7 +853,19 @@ module Rigor
796
853
  accumulator.transform_values(&:freeze).freeze
797
854
  end
798
855
 
799
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
856
+ # Merges two `class_name => { method => kind }` tables, unioning
857
+ # the per-class method maps (so a seeded cross-file table and the
858
+ # current file's table combine instead of clobbering).
859
+ def deep_merge_class_methods(base, overlay)
860
+ return overlay if base.nil? || base.empty?
861
+ return base if overlay.empty?
862
+
863
+ base.merge(overlay) do |_class_name, base_methods, overlay_methods|
864
+ base_methods.merge(overlay_methods)
865
+ end
866
+ end
867
+
868
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/AbcSize
800
869
  def walk_methods(node, qualified_prefix, in_singleton_class, accumulator)
801
870
  return unless node.is_a?(Prism::Node)
802
871
 
@@ -830,6 +899,10 @@ module Rigor
830
899
  return
831
900
  when Prism::CallNode
832
901
  record_define_method(node, qualified_prefix, in_singleton_class, accumulator) if node.name == :define_method
902
+ if ATTR_MACROS.include?(node.name)
903
+ record_attr_methods(node, qualified_prefix, in_singleton_class,
904
+ accumulator)
905
+ end
833
906
  end
834
907
 
835
908
  node.compact_child_nodes.each do |child|
@@ -860,7 +933,7 @@ module Rigor
860
933
  end
861
934
  end
862
935
  end
863
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength
936
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/AbcSize
864
937
 
865
938
  # v0.1.2 — when a `Const = Data.define(*sym) do ... end`
866
939
  # / `Const = Struct.new(*sym) do ... end` constant write
@@ -1301,6 +1374,35 @@ module Rigor
1301
1374
  accumulator[class_name][method_name] = in_singleton_class ? :singleton : :instance
1302
1375
  end
1303
1376
 
1377
+ # The `attr_*` accessor macros that introduce methods Rigor must
1378
+ # treat as source-declared. Without this, a class that defines an
1379
+ # accessor with `attr_reader :x` AND carries RBS that omits `x`
1380
+ # (a common gap — the project ships an incomplete `sig/`) fires a
1381
+ # false `call.undefined-method` on `obj.x`, because the
1382
+ # undefined-method rule only suppressed `def` / `define_method` /
1383
+ # `alias_method`-discovered methods. `attr_reader` defines
1384
+ # readers, `attr_writer` writers (`x=`), `attr_accessor` both.
1385
+ ATTR_MACROS = %i[attr_reader attr_writer attr_accessor].freeze
1386
+
1387
+ def record_attr_methods(call_node, qualified_prefix, in_singleton_class, accumulator)
1388
+ return if qualified_prefix.empty?
1389
+ return unless call_node.receiver.nil? # only the implicit-self macro defines on the lexical class
1390
+ return if call_node.arguments.nil?
1391
+
1392
+ kind = in_singleton_class ? :singleton : :instance
1393
+ reader = call_node.name != :attr_writer
1394
+ writer = call_node.name != :attr_reader
1395
+ class_name = qualified_prefix.join("::")
1396
+ call_node.arguments.arguments.each do |arg|
1397
+ base = literal_method_name(arg)
1398
+ next if base.nil?
1399
+
1400
+ accumulator[class_name] ||= {}
1401
+ accumulator[class_name][base] = kind if reader
1402
+ accumulator[class_name][:"#{base}="] = kind if writer
1403
+ end
1404
+ end
1405
+
1304
1406
  def literal_method_name(node)
1305
1407
  return nil unless node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
1306
1408
 
@@ -1365,31 +1467,86 @@ module Rigor
1365
1467
  # (`Mastodon::CLI::Accounts` calling a helper defined in
1366
1468
  # `Mastodon::CLI::Base`).
1367
1469
  #
1470
+ # The returned `def_sources` map mirrors `def_nodes` but stores
1471
+ # a `"path:line"` String per `(class_name, method_name)` instead
1472
+ # of the `Prism::DefNode`. A `Prism::Location` does not expose
1473
+ # its source file through public API, so the source site is
1474
+ # captured here, in the pre-pass loop that still holds `path`.
1475
+ # `CheckRules#undefined_method_diagnostic` consults the seeded
1476
+ # copy to name the defining file when a project monkey-patch on
1477
+ # a core/stdlib/gem class is called cross-file (ADR-17). First
1478
+ # write wins, matching `def_nodes`' own merge order.
1479
+ #
1368
1480
  # @param paths [Array<String>] project file paths.
1369
1481
  # @param buffer [Rigor::Analysis::BufferBinding, nil]
1370
- # @return [Hash{Symbol => Hash}] `{ def_nodes:, superclasses: }`
1482
+ # @return [Hash{Symbol => Hash}]
1483
+ # `{ def_nodes:, def_sources:, superclasses:, includes: }`
1371
1484
  def discovered_def_index_for_paths(paths, buffer: nil)
1372
- def_nodes = {}
1373
- superclasses = {}
1374
- includes = {}
1485
+ acc = { def_nodes: {}, def_sources: {}, superclasses: {}, includes: {}, method_visibilities: {}, methods: {} }
1375
1486
  paths.each do |path|
1376
1487
  physical = buffer ? buffer.resolve(path) : path
1377
1488
  root = Prism.parse(File.read(physical), filepath: path).value
1378
- build_discovered_def_nodes(root).each do |class_name, methods|
1379
- (def_nodes[class_name] ||= {}).merge!(methods)
1380
- end
1381
- superclasses.merge!(build_discovered_superclasses(root))
1382
- build_discovered_includes(root).each do |class_name, mods|
1383
- includes[class_name] = ((includes[class_name] || []) + mods).uniq
1384
- end
1489
+ accumulate_project_index(acc, path, root)
1385
1490
  rescue StandardError
1386
1491
  # Skip files that fail to parse or read; the per-file
1387
1492
  # analyzer surfaces the parse error separately.
1388
1493
  next
1389
1494
  end
1390
- def_nodes.each_value(&:freeze)
1391
- includes.each_value(&:freeze)
1392
- { def_nodes: def_nodes.freeze, superclasses: superclasses.freeze, includes: includes.freeze }
1495
+ # Cross-file method suppression is for the project's OWN
1496
+ # accessors (attr_* / define_method / alias) — NOT for plain
1497
+ # `def`s. A cross-file `def` on a class is exactly the ADR-17
1498
+ # monkey-patch case the undefined-method rule deliberately
1499
+ # surfaces (fire + def-site annotation, nudging `pre_eval:`),
1500
+ # so dropping the `def`-declared names keeps that contract
1501
+ # intact while still letting `attr_reader :x` in one file
1502
+ # suppress a false undefined-method for `obj.x` in another.
1503
+ acc[:methods] = subtract_def_methods(acc[:methods], acc[:def_nodes])
1504
+ %i[def_nodes def_sources includes method_visibilities methods].each { |key| acc[key].each_value(&:freeze) }
1505
+ acc.transform_values(&:freeze)
1506
+ end
1507
+
1508
+ # Removes, per class, the method names that have a project `def`
1509
+ # node, leaving only accessor/alias/define_method-introduced
1510
+ # methods in the cross-file suppression table.
1511
+ def subtract_def_methods(methods, def_nodes)
1512
+ methods.each_with_object({}) do |(class_name, table), out|
1513
+ defs = def_nodes[class_name] || {}
1514
+ kept = table.reject { |method_name, _kind| defs.key?(method_name) }
1515
+ out[class_name] = kept unless kept.empty?
1516
+ end
1517
+ end
1518
+
1519
+ # Folds one file's class-keyed indexes into the cross-file
1520
+ # accumulator. `method_visibilities` (ADR-35) is collected here so
1521
+ # the override-visibility-reduced rule can read an ancestor's
1522
+ # visibility declared in a sibling file.
1523
+ def accumulate_project_index(acc, path, root)
1524
+ merge_discovered_defs(acc[:def_nodes], acc[:def_sources], path, root)
1525
+ acc[:superclasses].merge!(build_discovered_superclasses(root))
1526
+ build_discovered_includes(root).each do |class_name, mods|
1527
+ acc[:includes][class_name] = ((acc[:includes][class_name] || []) + mods).uniq
1528
+ end
1529
+ build_discovered_method_visibilities(root).each do |class_name, table|
1530
+ (acc[:method_visibilities][class_name] ||= {}).merge!(table)
1531
+ end
1532
+ build_discovered_methods(root).each do |class_name, table|
1533
+ (acc[:methods][class_name] ||= {}).merge!(table)
1534
+ end
1535
+ end
1536
+
1537
+ # Merges one file's `class → method → DefNode` map into the
1538
+ # cross-file `def_nodes` index and records each method's first-
1539
+ # seen `"path:line"` definition site in `def_sources` (ADR-17 —
1540
+ # the un-registered-project-patch signal `call.undefined-method`
1541
+ # and `rigor triage` key on).
1542
+ def merge_discovered_defs(def_nodes, def_sources, path, root)
1543
+ build_discovered_def_nodes(root).each do |class_name, methods|
1544
+ (def_nodes[class_name] ||= {}).merge!(methods)
1545
+ sources = (def_sources[class_name] ||= {})
1546
+ methods.each do |method_name, def_node|
1547
+ sources[method_name] ||= "#{path}:#{def_node.location&.start_line || 1}"
1548
+ end
1549
+ end
1393
1550
  end
1394
1551
 
1395
1552
  # Class-only variant of `record_declarations` — descends
@@ -1371,16 +1371,21 @@ module Rigor
1371
1371
  end
1372
1372
  end
1373
1373
 
1374
- # Walks the registry and collects each plugin's
1375
- # `flow_contribution_for` result, swallowing per-plugin
1376
- # exceptions so a buggy plugin can't abort the assertion
1377
- # path. Mirrors `MethodDispatcher.collect_plugin_contributions`
1378
- # exactly the two paths consume the same hook.
1374
+ # ADR-37 slice 2 gathers each plugin's post-return narrowing from
1375
+ # BOTH the narrow `type_specifier` DSL (method-gated, wrapped as a
1376
+ # facts-only `FlowContribution`) and the legacy
1377
+ # `flow_contribution_for` escape valve, swallowing per-plugin
1378
+ # exceptions so a buggy plugin can't abort the assertion path.
1379
1379
  def collect_plugin_contributions(registry, call_node, current_scope)
1380
- registry.plugins.filter_map do |plugin|
1381
- plugin.flow_contribution_for(call_node: call_node, scope: current_scope)
1380
+ registry.plugins.flat_map do |plugin|
1381
+ contributions = []
1382
+ legacy = plugin.flow_contribution_for(call_node: call_node, scope: current_scope)
1383
+ contributions << legacy if legacy.is_a?(Rigor::FlowContribution)
1384
+ facts = plugin.type_specifier_facts(call_node: call_node, scope: current_scope)
1385
+ contributions << Rigor::FlowContribution.new(post_return_facts: facts) if facts && !facts.empty?
1386
+ contributions
1382
1387
  rescue StandardError
1383
- nil
1388
+ []
1384
1389
  end
1385
1390
  end
1386
1391
 
@@ -29,9 +29,19 @@ module Rigor
29
29
  # floor — the recorded string is the input to a later slice's
30
30
  # precision promotion via ADR-13's `Plugin::TypeNodeResolver`.
31
31
  class SyntheticMethodIndex
32
- attr_reader :entries
32
+ attr_reader :entries, :class_names
33
33
 
34
- def initialize(entries: [])
34
+ # @param entries [Array<SyntheticMethod>]
35
+ # @param class_names [Array<String>, Set<String>] names of
36
+ # classes the substrate synthesises wholesale (ADR-36
37
+ # nested-class emission — the variant subclasses that have
38
+ # no RBS/source declaration of their own). Recorded so
39
+ # `Environment#class_known?` can resolve them as classes
40
+ # (their constant reference + `.new` dispatch) even though
41
+ # nothing else in the type universe declares them. Tier B/C
42
+ # method emissions leave this empty (their receiver classes
43
+ # are already real).
44
+ def initialize(entries: [], class_names: [])
35
45
  unless entries.is_a?(Array) && entries.all?(SyntheticMethod)
36
46
  raise ArgumentError,
37
47
  "SyntheticMethodIndex#entries must be an Array of SyntheticMethod, got #{entries.inspect}"
@@ -40,11 +50,20 @@ module Rigor
40
50
  @entries = Ractor.make_shareable(entries.dup)
41
51
  @by_instance = Ractor.make_shareable(bucket(entries, SyntheticMethod::INSTANCE))
42
52
  @by_singleton = Ractor.make_shareable(bucket(entries, SyntheticMethod::SINGLETON))
53
+ @class_names = Ractor.make_shareable(class_names.to_a.map(&:to_s).uniq.freeze)
54
+ @class_name_set = Ractor.make_shareable(@class_names.to_set)
43
55
  freeze
44
56
  end
45
57
 
46
58
  def empty?
47
- entries.empty?
59
+ entries.empty? && class_names.empty?
60
+ end
61
+
62
+ # True when `name` is a substrate-synthesised class (an
63
+ # ADR-36 variant subclass). Used by `Environment#class_known?`
64
+ # so the constant resolves and `.new` dispatches.
65
+ def knows_class?(name)
66
+ @class_name_set.include?(name.to_s)
48
67
  end
49
68
 
50
69
  # Returns an Array of matching {SyntheticMethod} records in
@@ -59,7 +78,7 @@ module Rigor
59
78
  end
60
79
 
61
80
  def to_h
62
- { "entries" => entries.map(&:to_h) }
81
+ { "entries" => entries.map(&:to_h), "class_names" => class_names }
63
82
  end
64
83
 
65
84
  EMPTY_ROW = [].freeze
@@ -4,6 +4,7 @@ require "prism"
4
4
 
5
5
  require_relative "../plugin/macro/heredoc_template"
6
6
  require_relative "../plugin/macro/trait_registry"
7
+ require_relative "../source/literals"
7
8
  require_relative "synthetic_method"
8
9
  require_relative "synthetic_method_index"
9
10
 
@@ -69,13 +70,15 @@ module Rigor
69
70
  def scan(plugin_registry:, paths:, environment: nil, fact_store: nil, buffer: nil)
70
71
  templates = collect_templates(plugin_registry)
71
72
  registries = collect_trait_registries(plugin_registry)
72
- return SyntheticMethodIndex::EMPTY if templates.empty? && registries.empty?
73
+ nested_templates = collect_nested_class_templates(plugin_registry)
74
+ return SyntheticMethodIndex::EMPTY if templates.empty? && registries.empty? && nested_templates.empty?
73
75
 
74
76
  asts = parse_paths(paths, buffer: buffer)
75
77
  hierarchy = build_hierarchy(asts)
76
78
  concern_index = build_concern_index(asts)
77
79
 
78
80
  entries = []
81
+ class_names = []
79
82
  asts.each do |path, ast|
80
83
  walk_class_bodies(ast) do |class_name, call_node|
81
84
  collect_entries(entries, templates, class_name, call_node, hierarchy, environment, path, fact_store)
@@ -85,9 +88,10 @@ module Rigor
85
88
  templates, registries, hierarchy, environment, path, fact_store
86
89
  )
87
90
  end
91
+ collect_nested_class_entries(entries, class_names, nested_templates, ast, path) unless nested_templates.empty?
88
92
  end
89
93
 
90
- SyntheticMethodIndex.new(entries: entries)
94
+ SyntheticMethodIndex.new(entries: entries, class_names: class_names)
91
95
  end
92
96
 
93
97
  # Aggregates `(plugin_id, template)` pairs across every
@@ -119,6 +123,146 @@ module Rigor
119
123
  end
120
124
  end
121
125
 
126
+ # ADR-36 — aggregates `(plugin_id, template)` pairs across
127
+ # every plugin's `manifest.nested_class_templates`. Empty when
128
+ # no plugin contributes the nested-class emission tier.
129
+ def collect_nested_class_templates(plugin_registry)
130
+ return [] if plugin_registry.nil? || plugin_registry.empty?
131
+
132
+ plugin_registry.plugins.flat_map do |plugin|
133
+ # rigor:disable undefined-method
134
+ plugin.manifest.nested_class_templates.map do |template|
135
+ [plugin.manifest.id, template]
136
+ end
137
+ end
138
+ end
139
+
140
+ # ADR-36 nested-class emission. For each class that `extend`s a
141
+ # template's `receiver_constraint` and carries a
142
+ # `<block_method> do ... end` block, mint one synthetic
143
+ # subclass per `<variant_method> <Const>, <Type>` row:
144
+ #
145
+ # class Shape
146
+ # extend Mangrove::Enum
147
+ # variants do
148
+ # variant Circle, Float
149
+ # end
150
+ # end
151
+ #
152
+ # yields synthetic class `Shape::Circle` + instance method
153
+ # `Shape::Circle#inner -> Float`. The variant subclass name is
154
+ # recorded in `class_names` so `Environment#class_known?`
155
+ # resolves the constant (and `.new` dispatches through
156
+ # `meta_new`); `#inner`'s return type is the literal constant
157
+ # type argument (non-constant inner shapes degrade to
158
+ # `Dynamic[Top]` per the slice-A floor).
159
+ def collect_nested_class_entries(entries, class_names, nested_templates, ast, path)
160
+ return if ast.nil?
161
+
162
+ walk_classes(ast) do |class_name, class_node|
163
+ body = class_body_statements(class_node)
164
+ next if body.empty?
165
+
166
+ nested_templates.each do |(plugin_id, template)|
167
+ next unless body_extends?(body, template.receiver_constraint)
168
+
169
+ each_variant_call(body, template) do |variant_const, inner_node|
170
+ emit_variant(entries, class_names, class_name, variant_const, inner_node, template, plugin_id, path)
171
+ end
172
+ end
173
+ end
174
+ end
175
+
176
+ # Walks every class declaration, yielding its fully-qualified
177
+ # name and the `Prism::ClassNode`. Mirrors `walk_class_bodies`'
178
+ # scope-stack bookkeeping but hands back the class node itself.
179
+ def walk_classes(node, scope_stack = [], &)
180
+ return unless node.respond_to?(:compact_child_nodes)
181
+
182
+ case node
183
+ when Prism::ClassNode
184
+ name = class_name_from(node, scope_stack)
185
+ yield name, node if name
186
+ new_stack = scope_stack + [node]
187
+ node.body&.compact_child_nodes&.each { |child| walk_classes(child, new_stack, &) }
188
+ when Prism::ModuleNode
189
+ new_stack = scope_stack + [node]
190
+ node.body&.compact_child_nodes&.each { |child| walk_classes(child, new_stack, &) }
191
+ else
192
+ node.compact_child_nodes.each { |child| walk_classes(child, scope_stack, &) }
193
+ end
194
+ end
195
+
196
+ def class_body_statements(class_node)
197
+ body = class_node.body
198
+ body.respond_to?(:body) ? body.body.compact : []
199
+ end
200
+
201
+ # True when the class body carries `extend <constraint>`
202
+ # (receiverless `extend` call with the constraint constant as
203
+ # its first argument).
204
+ def body_extends?(body, constraint)
205
+ body.any? do |stmt|
206
+ stmt.is_a?(Prism::CallNode) && stmt.receiver.nil? && stmt.name == :extend &&
207
+ const_name_string(first_arg(stmt)) == constraint
208
+ end
209
+ end
210
+
211
+ # Yields `(variant_const_name, inner_type_node)` for every
212
+ # `<variant_method> <Const>, <Type>` call inside the template's
213
+ # `<block_method> do ... end` block(s).
214
+ def each_variant_call(body, template, &)
215
+ body.each do |stmt|
216
+ next unless variants_block_call?(stmt, template)
217
+
218
+ block_body_statements(stmt.block).each { |call| yield_variant(call, template, &) }
219
+ end
220
+ end
221
+
222
+ def variants_block_call?(stmt, template)
223
+ stmt.is_a?(Prism::CallNode) && stmt.receiver.nil? &&
224
+ stmt.name == template.block_method && stmt.block.is_a?(Prism::BlockNode)
225
+ end
226
+
227
+ def yield_variant(call, template)
228
+ return unless call.is_a?(Prism::CallNode) && call.receiver.nil? && call.name == template.variant_method
229
+
230
+ args = call.arguments&.arguments || []
231
+ variant_const = const_name_string(args[template.name_arg_position])
232
+ return if variant_const.nil?
233
+
234
+ yield variant_const, args[template.inner_arg_position]
235
+ end
236
+
237
+ def block_body_statements(block_node)
238
+ body = block_node.body
239
+ body.respond_to?(:body) ? body.body.compact : []
240
+ end
241
+
242
+ def emit_variant(entries, class_names, enclosing, variant_const, inner_node, template, plugin_id, path) # rubocop:disable Metrics/ParameterLists
243
+ variant_class = "#{enclosing}::#{variant_const}"
244
+ class_names << variant_class
245
+ inner_type = const_name_string(inner_node) || "untyped"
246
+
247
+ entries << SyntheticMethod.new(
248
+ class_name: variant_class,
249
+ method_name: template.inner_reader,
250
+ return_type: inner_type,
251
+ kind: SyntheticMethod::INSTANCE,
252
+ provenance: {
253
+ plugin_id: plugin_id,
254
+ tier: "nested_class",
255
+ enclosing: enclosing,
256
+ variant: variant_const,
257
+ source_path: path
258
+ }
259
+ )
260
+ end
261
+
262
+ def first_arg(call_node)
263
+ call_node.arguments&.arguments&.first
264
+ end
265
+
122
266
  def parse_paths(paths, buffer: nil)
123
267
  paths.to_h do |path|
124
268
  physical = buffer ? buffer.resolve(path) : path
@@ -399,9 +543,7 @@ module Rigor
399
543
  end
400
544
 
401
545
  def literal_symbol_value(node)
402
- case node
403
- when Prism::SymbolNode, Prism::StringNode then node.unescaped.to_sym
404
- end
546
+ Source::Literals.symbol_or_string(node)
405
547
  end
406
548
 
407
549
  def emit_trait_module_entries(entries, class_name, modules, registry, plugin_id, path, call_node, environment) # rubocop:disable Metrics/ParameterLists
@@ -584,15 +726,7 @@ module Rigor
584
726
  end
585
727
 
586
728
  def literal_symbol_arg(call_node, index)
587
- args_node = call_node.arguments
588
- return nil if args_node.nil?
589
-
590
- arg = args_node.arguments[index]
591
- return nil unless arg
592
-
593
- case arg
594
- when Prism::SymbolNode, Prism::StringNode then arg.unescaped.to_sym
595
- end
729
+ Source::Literals.symbol_arg(call_node, index)
596
730
  end
597
731
  end
598
732
  end