rigortype 0.4.0 → 0.4.2

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 (237) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -3
  3. data/data/builtins/ruby_core/io.yml +1 -0
  4. data/data/builtins/ruby_core/proc.yml +4 -0
  5. data/data/builtins/ruby_core/random.yml +4 -0
  6. data/data/builtins/ruby_core/struct.yml +3 -0
  7. data/data/core_overlay/env.rbs +34 -0
  8. data/data/core_overlay/gc.rbs +50 -0
  9. data/data/core_overlay/hash.rbs +45 -0
  10. data/data/core_overlay/integer.rbs +31 -0
  11. data/data/core_overlay/io_buffer.rbs +27 -0
  12. data/data/core_overlay/kernel.rbs +40 -0
  13. data/data/core_overlay/match_data.rbs +28 -0
  14. data/data/core_overlay/module.rbs +51 -0
  15. data/data/core_overlay/object_space.rbs +29 -0
  16. data/data/core_overlay/proc.rbs +45 -0
  17. data/data/core_overlay/range.rbs +40 -0
  18. data/data/core_overlay/source_range.rbs +78 -0
  19. data/data/core_overlay/string.rbs +86 -0
  20. data/data/vendored_gem_sigs/prime/integer-extension.rbs +41 -0
  21. data/data/vendored_gem_sigs/prime/prime.rbs +372 -0
  22. data/docs/handbook/01-getting-started.md +3 -3
  23. data/docs/handbook/06-classes.md +3 -1
  24. data/docs/handbook/08-understanding-errors.md +3 -2
  25. data/docs/handbook/11-sig-gen.md +6 -0
  26. data/docs/handbook/appendix-liskov.md +5 -0
  27. data/docs/install.md +87 -15
  28. data/docs/manual/02-cli-reference.md +2 -2
  29. data/docs/manual/03-configuration.md +6 -1
  30. data/docs/manual/04-diagnostics.md +2 -1
  31. data/docs/manual/06-baseline.md +2 -0
  32. data/docs/manual/07-plugins.md +1 -1
  33. data/docs/manual/08-skills.md +4 -3
  34. data/docs/manual/12-caching.md +43 -4
  35. data/docs/manual/14-rails-quickstart.md +19 -11
  36. data/docs/manual/18-removing-dead-code.md +32 -2
  37. data/docs/manual/plugins/README.md +6 -1
  38. data/docs/manual/plugins/rigor-activerecord.md +21 -0
  39. data/docs/manual/plugins/rigor-alba.md +60 -0
  40. data/docs/manual/plugins/rigor-minitest.md +22 -4
  41. data/docs/manual/plugins/rigor-typelizer.md +71 -0
  42. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +27 -3
  43. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +21 -92
  44. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +275 -0
  45. data/lib/rigor/analysis/check_rules/rule_ids.rb +4 -0
  46. data/lib/rigor/analysis/check_rules/source_arity.rb +184 -61
  47. data/lib/rigor/analysis/check_rules.rb +407 -144
  48. data/lib/rigor/analysis/dependency_recorder.rb +215 -18
  49. data/lib/rigor/analysis/dependency_source_inference/builder.rb +8 -1
  50. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +21 -0
  51. data/lib/rigor/analysis/dependency_source_inference/index.rb +36 -11
  52. data/lib/rigor/analysis/dependency_source_inference/walker.rb +196 -27
  53. data/lib/rigor/analysis/erb_template_detector.rb +2 -1
  54. data/lib/rigor/analysis/incremental.rb +29 -9
  55. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +291 -0
  56. data/lib/rigor/analysis/incremental_run_slot.rb +173 -0
  57. data/lib/rigor/analysis/incremental_session.rb +287 -50
  58. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +24 -13
  59. data/lib/rigor/analysis/reachability/graph.rb +123 -23
  60. data/lib/rigor/analysis/reachability/scan.rb +290 -36
  61. data/lib/rigor/analysis/reachability/scan_cache.rb +2 -1
  62. data/lib/rigor/analysis/rule_catalog.rb +43 -0
  63. data/lib/rigor/analysis/run_cache_key.rb +43 -9
  64. data/lib/rigor/analysis/run_stats.rb +2 -2
  65. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +88 -6
  66. data/lib/rigor/analysis/runner/pool_coordinator.rb +52 -13
  67. data/lib/rigor/analysis/runner/project_pre_passes.rb +21 -13
  68. data/lib/rigor/analysis/runner/run_snapshots.rb +6 -1
  69. data/lib/rigor/analysis/runner.rb +371 -49
  70. data/lib/rigor/analysis/shadow_harness.rb +27 -0
  71. data/lib/rigor/analysis/source_rbs_gate.rb +157 -0
  72. data/lib/rigor/analysis/template_unit_paths.rb +6 -4
  73. data/lib/rigor/analysis/template_units.rb +15 -5
  74. data/lib/rigor/analysis/worker_session.rb +11 -7
  75. data/lib/rigor/cache/descriptor.rb +40 -1
  76. data/lib/rigor/cache/file_digest.rb +28 -0
  77. data/lib/rigor/cache/incremental_snapshot.rb +92 -8
  78. data/lib/rigor/cache/store.rb +63 -3
  79. data/lib/rigor/cache/value_digest.rb +325 -0
  80. data/lib/rigor/cli/annotate_command.rb +5 -0
  81. data/lib/rigor/cli/baseline_command.rb +7 -18
  82. data/lib/rigor/cli/check_command.rb +85 -14
  83. data/lib/rigor/cli/doctor_command.rb +1 -1
  84. data/lib/rigor/cli/effects_report.rb +2 -2
  85. data/lib/rigor/cli/trace_command.rb +5 -0
  86. data/lib/rigor/cli/type_of_command.rb +5 -3
  87. data/lib/rigor/cli/unused_command.rb +16 -10
  88. data/lib/rigor/cli.rb +11 -3
  89. data/lib/rigor/config_audit.rb +3 -2
  90. data/lib/rigor/config_path.rb +29 -0
  91. data/lib/rigor/configuration/severity_profile.rb +11 -0
  92. data/lib/rigor/configuration.rb +52 -7
  93. data/lib/rigor/effects/plugin_facts.rb +6 -6
  94. data/lib/rigor/environment/bundle_sig_discovery.rb +5 -3
  95. data/lib/rigor/environment/gated_signature_guard.rb +43 -0
  96. data/lib/rigor/environment/gated_signature_plan.rb +339 -0
  97. data/lib/rigor/environment/lockfile_resolver.rb +10 -1
  98. data/lib/rigor/environment/rbs_collection_discovery.rb +10 -1
  99. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  100. data/lib/rigor/environment/rbs_loader.rb +284 -15
  101. data/lib/rigor/environment/required_features.rb +206 -0
  102. data/lib/rigor/environment/source_rbs_synthesis.rb +147 -0
  103. data/lib/rigor/environment.rb +34 -73
  104. data/lib/rigor/inference/acceptance.rb +28 -3
  105. data/lib/rigor/inference/block_parameter_binder.rb +29 -13
  106. data/lib/rigor/inference/builtins/cruby_definers.rb +61 -0
  107. data/lib/rigor/inference/builtins/method_catalog.rb +3 -0
  108. data/lib/rigor/inference/captured_locals.rb +7 -1
  109. data/lib/rigor/inference/declaration_walk/context.rb +195 -0
  110. data/lib/rigor/inference/declaration_walk/errors.rb +18 -0
  111. data/lib/rigor/inference/declaration_walk/shadow.rb +147 -0
  112. data/lib/rigor/inference/declaration_walk/traversal.rb +441 -0
  113. data/lib/rigor/inference/declaration_walk.rb +7 -0
  114. data/lib/rigor/inference/def_node_resolver.rb +26 -3
  115. data/lib/rigor/inference/definer_resolution.rb +266 -0
  116. data/lib/rigor/inference/expression_typer.rb +351 -61
  117. data/lib/rigor/inference/external_ancestor_resolution.rb +13 -16
  118. data/lib/rigor/inference/global_write_census.rb +7 -4
  119. data/lib/rigor/inference/guard_rebinding.rb +54 -13
  120. data/lib/rigor/inference/in_effect_refinements/proc_literals.rb +83 -0
  121. data/lib/rigor/inference/in_effect_refinements/refine_defs.rb +54 -0
  122. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +282 -0
  123. data/lib/rigor/inference/in_effect_refinements/walk.rb +276 -0
  124. data/lib/rigor/inference/in_effect_refinements.rb +256 -0
  125. data/lib/rigor/inference/key_presence_guard/scan.rb +281 -0
  126. data/lib/rigor/inference/key_presence_guard.rb +303 -0
  127. data/lib/rigor/inference/macro_block_self_type.rb +73 -14
  128. data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +36 -0
  129. data/lib/rigor/inference/method_dispatcher/arithmetic_sequence_elements.rb +73 -0
  130. data/lib/rigor/inference/method_dispatcher/imprecise_argument.rb +68 -0
  131. data/lib/rigor/inference/method_dispatcher/integer_step_block_params.rb +81 -0
  132. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -0
  133. data/lib/rigor/inference/method_dispatcher/keyword_arguments.rb +267 -0
  134. data/lib/rigor/inference/method_dispatcher/method_folding.rb +2 -1
  135. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +122 -105
  136. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +305 -54
  137. data/lib/rigor/inference/method_dispatcher/splat_arity.rb +71 -0
  138. data/lib/rigor/inference/method_dispatcher.rb +44 -11
  139. data/lib/rigor/inference/module_function_state.rb +214 -0
  140. data/lib/rigor/inference/multi_target_binder.rb +21 -16
  141. data/lib/rigor/inference/narrowing.rb +166 -22
  142. data/lib/rigor/inference/object_mixins.rb +180 -0
  143. data/lib/rigor/inference/parameter_inference_collector.rb +10 -9
  144. data/lib/rigor/inference/project_patched_methods.rb +10 -5
  145. data/lib/rigor/inference/project_patched_scanner.rb +71 -3
  146. data/lib/rigor/inference/refine_census.rb +239 -0
  147. data/lib/rigor/inference/refine_self.rb +317 -0
  148. data/lib/rigor/inference/refined_dispatch/own_method.rb +403 -0
  149. data/lib/rigor/inference/refined_dispatch.rb +272 -0
  150. data/lib/rigor/inference/scope_indexer/certainty.rb +176 -0
  151. data/lib/rigor/inference/scope_indexer/class_cvars_collector.rb +35 -0
  152. data/lib/rigor/inference/scope_indexer/def_nestings_collector.rb +47 -0
  153. data/lib/rigor/inference/scope_indexer/layered_def_nestings.rb +36 -0
  154. data/lib/rigor/inference/scope_indexer/member_layouts_collector.rb +61 -0
  155. data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +143 -0
  156. data/lib/rigor/inference/scope_indexer/superclasses_collector.rb +64 -0
  157. data/lib/rigor/inference/scope_indexer.rb +2129 -354
  158. data/lib/rigor/inference/singleton_hook_decline.rb +253 -0
  159. data/lib/rigor/inference/statement_evaluator.rb +149 -32
  160. data/lib/rigor/inference/string_mutation.rb +9 -3
  161. data/lib/rigor/inference/synthetic_method_scanner.rb +2 -1
  162. data/lib/rigor/inference/version_guard.rb +19 -10
  163. data/lib/rigor/language_server/project_context.rb +31 -0
  164. data/lib/rigor/language_server/server.rb +18 -0
  165. data/lib/rigor/plugin/base.rb +28 -8
  166. data/lib/rigor/plugin/inflector.rb +1 -1
  167. data/lib/rigor/plugin/io_boundary.rb +124 -7
  168. data/lib/rigor/plugin/isolation.rb +1 -1
  169. data/lib/rigor/plugin/macro/block_as_method.rb +51 -8
  170. data/lib/rigor/plugin/trust_policy.rb +3 -3
  171. data/lib/rigor/project_environment.rb +29 -6
  172. data/lib/rigor/protection/discovery_seed.rb +18 -9
  173. data/lib/rigor/protection/mutation_cache.rb +4 -4
  174. data/lib/rigor/protection/mutator.rb +1 -1
  175. data/lib/rigor/reflection/constant_ancestors.rb +211 -46
  176. data/lib/rigor/reflection/constant_path.rb +33 -7
  177. data/lib/rigor/reflection.rb +41 -10
  178. data/lib/rigor/scope/discovery_index.rb +258 -3
  179. data/lib/rigor/scope/resolution_chain/relevance.rb +165 -0
  180. data/lib/rigor/scope/resolution_chain.rb +1032 -0
  181. data/lib/rigor/scope.rb +480 -328
  182. data/lib/rigor/sig_gen/alias_index.rb +1 -1
  183. data/lib/rigor/sig_gen/classification.rb +4 -1
  184. data/lib/rigor/sig_gen/effect_annotation.rb +2 -2
  185. data/lib/rigor/sig_gen/generator.rb +77 -21
  186. data/lib/rigor/sig_gen/method_candidate.rb +2 -2
  187. data/lib/rigor/sig_gen/skip_reason_catalog.rb +12 -0
  188. data/lib/rigor/source/alias_names.rb +52 -0
  189. data/lib/rigor/source/literals.rb +3 -2
  190. data/lib/rigor/source/symbol_name.rb +20 -0
  191. data/lib/rigor/type/combinator.rb +59 -3
  192. data/lib/rigor/type/float_range.rb +4 -1
  193. data/lib/rigor/type/nominal.rb +6 -1
  194. data/lib/rigor/version.rb +1 -1
  195. data/plugins/rigor-ac-library-rb/lib/rigor/plugin/ac_library_rb.rb +28 -0
  196. data/plugins/rigor-ac-library-rb/lib/rigor-ac-library-rb.rb +3 -0
  197. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/convolution.rbs +7 -0
  198. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/data_structures.rbs +160 -0
  199. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/graph.rbs +94 -0
  200. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/math.rbs +42 -0
  201. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/modint.rbs +71 -0
  202. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/object.rbs +37 -0
  203. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/segtree.rbs +83 -0
  204. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +2 -1
  205. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/association_roots.rb +100 -0
  206. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +94 -10
  207. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +43 -2
  208. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +3 -1
  209. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_collector.rb +149 -0
  210. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +74 -0
  211. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_index.rb +104 -0
  212. data/plugins/rigor-alba/lib/rigor/plugin/alba.rb +85 -0
  213. data/plugins/rigor-alba/lib/rigor-alba.rb +3 -0
  214. data/plugins/rigor-alba/sig/alba.rbs +4 -0
  215. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +2 -2
  216. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +29 -12
  217. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +13 -2
  218. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +130 -21
  219. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +6 -4
  220. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +4 -4
  221. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +1 -1
  222. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1 -1
  223. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +31 -0
  224. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_collector.rb +102 -0
  225. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +87 -0
  226. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_index.rb +76 -0
  227. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +73 -0
  228. data/plugins/rigor-typelizer/lib/rigor-typelizer.rb +3 -0
  229. data/sig/rigor/analysis/dependency_source_inference/index.rbs +1 -1
  230. data/sig/rigor/environment.rbs +2 -0
  231. data/sig/rigor/plugin/io_boundary.rbs +4 -0
  232. data/sig/rigor/scope.rbs +69 -4
  233. data/sig/rigor/source.rbs +4 -0
  234. data/sig/rigor.rbs +3 -1
  235. data/skills/rigor-project-init/SKILL.md +37 -11
  236. data/skills/rigor-project-init/references/02-configure.md +7 -2
  237. metadata +90 -1
@@ -940,7 +940,7 @@ module Rigor
940
940
  node.name == :define_singleton_method
941
941
  arg0 = node.arguments&.arguments&.first
942
942
  name = Rigor::Source::Literals.symbol_name(arg0) || (arg0.is_a?(Prism::StringNode) ? arg0.unescaped : nil)
943
- key = TABLE_NAME_DECORATOR_METHODS[name.to_sym] if name
943
+ key = TABLE_NAME_DECORATOR_METHODS[Rigor::Source::SymbolName.of(name)] if name
944
944
  if key
945
945
  readers << key
946
946
  decorators[key] = node.block.is_a?(Prism::BlockNode) ? literal_def_value(node.block.body) : { computed: true }
@@ -1263,9 +1263,10 @@ module Rigor
1263
1263
  # association / enum / validation declared inside is invisible to the discoverer, turning
1264
1264
  # `where(<assoc>: ...)` into a false `unknown-column`. Nested `with_options` blocks recurse.
1265
1265
  #
1266
- # The options the `with_options` call itself carries (e.g. `with_options class_name: 'Account'`)
1267
- # are NOT merged into the nested calls — discovering the declaration name is what clears the
1268
- # false positive; the merged-option target precision is a separate refinement.
1266
+ # This method returns only the nodes and does NOT merge the group's own options (e.g.
1267
+ # `with_options class_name: 'Account'`); {#association_calls} is the variant that does, and it is
1268
+ # what the association rows use. The other lookups (enums, scopes, validations, callbacks) ignore
1269
+ # group options.
1269
1270
  def declaration_calls(body)
1270
1271
  return [] if body.nil?
1271
1272
 
@@ -1291,20 +1292,42 @@ module Rigor
1291
1292
  return [] if body.nil?
1292
1293
 
1293
1294
  rows = []
1294
- declaration_calls(body).each do |node|
1295
+ association_calls(body, [], false).each do |node, inherited, opaque|
1295
1296
  kind = ASSOCIATION_METHODS[node.name]
1296
1297
  next if kind.nil?
1297
1298
  next if node.receiver # skip `self.has_many` and similar
1298
1299
 
1299
- row = build_association_row(node, kind)
1300
+ row = build_association_row(node, kind, inherited, opaque)
1300
1301
  rows << row unless row.nil?
1301
1302
  end
1302
1303
  rows
1303
1304
  end
1304
1305
 
1305
- def build_association_row(node, kind)
1306
- args = node.arguments&.arguments
1307
- return nil if args.nil? || args.empty?
1306
+ # {#declaration_calls} that also carries the options of the enclosing `with_options(...)` groups, as
1307
+ # `[node, option_args, opaque]`. `option_args` are the keyword-hash arguments of those groups, which
1308
+ # ActiveRecord merges under the call's own options; `opaque` is true when a group passes anything
1309
+ # else (`with_options opts do`), so its options are unknown.
1310
+ def association_calls(body, inherited, opaque)
1311
+ return [] if body.nil?
1312
+
1313
+ body.compact_child_nodes.flat_map do |node|
1314
+ next [] unless node.is_a?(Prism::CallNode)
1315
+
1316
+ if node.name == :with_options && node.block.is_a?(Prism::BlockNode)
1317
+ args = node.arguments&.arguments || []
1318
+ hashes = args.grep(Prism::KeywordHashNode)
1319
+ association_calls(node.block.body, hashes + inherited, opaque || hashes.size != args.size)
1320
+ else
1321
+ [[node, inherited, opaque]]
1322
+ end
1323
+ end
1324
+ end
1325
+
1326
+ def build_association_row(node, kind, inherited, opaque)
1327
+ own = node.arguments&.arguments
1328
+ return nil if own.nil? || own.empty?
1329
+
1330
+ args = own + inherited
1308
1331
 
1309
1332
  name = Rigor::Source::Literals.symbol_name(args.first)
1310
1333
  return nil if name.nil?
@@ -1322,7 +1345,65 @@ module Rigor
1322
1345
  end
1323
1346
 
1324
1347
  { name: name, kind: kind, target: target, polymorphic: polymorphic,
1325
- nullable: association_nullable?(node.name, args) }
1348
+ nullable: association_nullable?(node.name, args),
1349
+ macro: node.name, class_name_option: class_name_option(args),
1350
+ through: association_key?(args, "through"),
1351
+ source_type_option: literal_option(args, "source_type"),
1352
+ roots_declined: opaque || association_key?(args, "anonymous_class") || own_options_unknown?(own) }
1353
+ end
1354
+
1355
+ # The `class_name:` option as ActiveRecord's `compute_type` will read it: the literal String (a
1356
+ # Symbol is `to_s`'d by Rails too), `nil` when the option is absent, and `:dynamic` when it is
1357
+ # present but not a literal — the `:reachability_roots` consumer must decline then, where the
1358
+ # `target` above falls back to the inferred name.
1359
+ # A `**opts` splat may carry a `class_name:` too, so it reads as present-but-unknown.
1360
+ def class_name_option(args)
1361
+ literal_option(args, "class_name") ||
1362
+ (args.any? { |a| a.is_a?(Prism::KeywordHashNode) && a.elements.any?(Prism::AssocSplatNode) } ? :dynamic : nil)
1363
+ end
1364
+
1365
+ def literal_option(args, key)
1366
+ args.each do |arg|
1367
+ next unless arg.is_a?(Prism::KeywordHashNode)
1368
+
1369
+ arg.elements.each do |pair|
1370
+ next unless pair.is_a?(Prism::AssocNode) && Source::Literals.symbol_named?(pair.key, key)
1371
+
1372
+ value = pair.value
1373
+ return value.unescaped if value.is_a?(Prism::StringNode) || value.is_a?(Prism::SymbolNode)
1374
+
1375
+ return :dynamic
1376
+ end
1377
+ end
1378
+ nil
1379
+ end
1380
+
1381
+ ROOT_OPTION_KEYS = %w[class_name polymorphic through source_type anonymous_class].freeze
1382
+ LITERAL_OPTION_VALUES = [Prism::StringNode, Prism::SymbolNode, Prism::TrueNode, Prism::FalseNode].freeze
1383
+ private_constant :ROOT_OPTION_KEYS, :LITERAL_OPTION_VALUES
1384
+
1385
+ # Whether the association call's OWN arguments hide an option that decides its target class: a
1386
+ # positional options hash or variable, a `**splat`, or a non-literal value for one of the keys. A
1387
+ # `with_options` group's literal value must not stand in for it.
1388
+ def own_options_unknown?(own)
1389
+ own.drop(1).any? do |arg|
1390
+ next false if arg.is_a?(Prism::LambdaNode) # the scope argument carries no class option
1391
+ next true unless arg.is_a?(Prism::KeywordHashNode)
1392
+
1393
+ arg.elements.any? do |pair|
1394
+ next true unless pair.is_a?(Prism::AssocNode)
1395
+
1396
+ ROOT_OPTION_KEYS.any? { |key| Source::Literals.symbol_named?(pair.key, key) } &&
1397
+ LITERAL_OPTION_VALUES.none? { |klass| pair.value.is_a?(klass) }
1398
+ end
1399
+ end
1400
+ end
1401
+
1402
+ def association_key?(args, key)
1403
+ args.any? do |arg|
1404
+ arg.is_a?(Prism::KeywordHashNode) &&
1405
+ arg.elements.any? { |pair| pair.is_a?(Prism::AssocNode) && Source::Literals.symbol_named?(pair.key, key) }
1406
+ end
1326
1407
  end
1327
1408
 
1328
1409
  # Whether a `:singular` association's accessor can return `nil`. `has_one` genuinely can (no
@@ -1356,6 +1437,9 @@ module Rigor
1356
1437
 
1357
1438
  return true if pair.value.is_a?(Prism::TrueNode)
1358
1439
  return false if pair.value.is_a?(Prism::FalseNode)
1440
+
1441
+ # The call's own non-literal value shadows a `with_options` group's literal one.
1442
+ return nil
1359
1443
  end
1360
1444
  end
1361
1445
  nil
@@ -7,6 +7,7 @@ require_relative "activerecord/schema_parser"
7
7
  require_relative "activerecord/structure_sql_parser"
8
8
  require_relative "activerecord/model_index"
9
9
  require_relative "activerecord/model_discoverer"
10
+ require_relative "activerecord/association_roots"
10
11
  require_relative "activerecord/analyzer"
11
12
  require_relative "activerecord/effects"
12
13
 
@@ -64,6 +65,11 @@ module Rigor
64
65
  manifest(
65
66
  id: "activerecord",
66
67
  target_gems: ["activerecord"],
68
+ # 0.14.0, 2026-10-10 (#1721) — the plugin publishes `:reachability_roots`: the models ActiveRecord loads
69
+ # by name from an association. The association rows gain `macro`, `class_name_option`, `through` and
70
+ # `source_type_option`, so a cached 0.13.0 payload lacks the fields the roots are derived from and
71
+ # would publish none; the bump (part of the producer cache KEY) is what stops a warm run serving it.
72
+ #
67
73
  # 0.13.0, 2026-09-24 (#1321) — the class-side `Model.find` with two or more arguments returns
68
74
  # `Array[Model]` instead of the model, and its `model-call` note says so. A model that defines its own
69
75
  # `self.find` keeps its own answer; the note reads syntax alone and does not see that override. No
@@ -118,7 +124,7 @@ module Rigor
118
124
  # a scope lambda body / class-method body now contributes `Relation[Model]` via `scope.self_type`
119
125
  # instead of falling through to `Kernel#select` (the IO multiplexer, `Array[String]` return). Plus
120
126
  # `:select` added to the relation-entry-point list.
121
- version: "0.13.0",
127
+ version: "0.14.0",
122
128
  description: "Types ActiveRecord finders against the project's db/schema.rb and AR models.",
123
129
  config_schema: {
124
130
  "schema_file" => { kind: :string, default: "db/schema.rb" },
@@ -128,7 +134,7 @@ module Rigor
128
134
  "model_search_paths" => { kind: :array, default: ["app/models"] },
129
135
  "model_base_classes" => { kind: :array, default: %w[ApplicationRecord ActiveRecord::Base] }
130
136
  },
131
- produces: [:model_index],
137
+ produces: %i[model_index reachability_roots],
132
138
  # ADR-25 — the bundled `ActiveRecord::Relation` RBS that relation-typed call sites (`has_many`
133
139
  # accessors, `Model.where`, scopes) dispatch against.
134
140
  signature_paths: ["sig"],
@@ -241,6 +247,9 @@ module Rigor
241
247
  # this, so the disclosure is in the coordinator's table before a single worker is forked.
242
248
  disclose_load_errors
243
249
  return if index.nil? || index.empty?
250
+
251
+ # Roots do not read columns, so reduced mode (Redmine) publishes them too.
252
+ publish_association_roots(services, index)
244
253
  # Reduced mode stays UNPUBLISHED. Every consumer reads `columns:` as authoritative and fires on a
245
254
  # key missing from it — rigor-actionpack's `permit(:title)` check and rigor-shoulda-matchers'
246
255
  # `have_db_column(:title)` / `validate_presence_of(:title)` matchers both do — so publishing the
@@ -256,6 +265,18 @@ module Rigor
256
265
  )
257
266
  end
258
267
 
268
+ # ADR-102 WD3 — the models an association loads by name; see {AssociationRoots}. An unavailable
269
+ # inflector (ADR-39: decline, never approximate) leaves the report as it was.
270
+ def publish_association_roots(services, index)
271
+ roots = AssociationRoots.call(index)
272
+ return if roots.empty?
273
+
274
+ services.fact_store.publish(plugin_id: manifest.id, name: :reachability_roots, value: roots)
275
+ rescue Rigor::Plugin::Inflector::Unavailable
276
+ nil
277
+ end
278
+ private :publish_association_roots
279
+
259
280
  def diagnostics_for_file(path:, scope:, root:)
260
281
  index = model_index
261
282
  # The schema-load disclosure is independent of whether an index was built: reduced mode still
@@ -401,6 +422,7 @@ module Rigor
401
422
 
402
423
  if call_node.receiver
403
424
  class_call_return_type(call_node, scope, index) ||
425
+ relation_splat_find_return_type(call_node, scope) ||
404
426
  relation_call_return_type(call_node, scope, index) ||
405
427
  instance_call_return_type(call_node, scope, index)
406
428
  else
@@ -670,6 +692,25 @@ module Rigor
670
692
  relation_of(model_name)
671
693
  end
672
694
 
695
+ # `relation.find(*ids)` answers by argument count as `Model.find` does (`finder_return_type`), counting a splat or
696
+ # a `**` hash as one argument, which is the policy the bundled `Relation#find` documents: `find(*ids)` is the
697
+ # element, and `find(id, *ids)` and `find(*args, **opts)` are Arrays. The engine lets a splat stand for any
698
+ # number of arguments (#1801), so through the RBS overloads `find(*ids)` would join `Dynamic[Array[Elem] |
699
+ # Elem]`, on which a following `update` or `save` no longer resolves on the model and loses its
700
+ # `io.db.write`. Only a block-less call with a splat is answered here; every other `find` stays with the RBS.
701
+ def relation_splat_find_return_type(call_node, scope)
702
+ return nil unless call_node.name == :find && call_node.block.nil?
703
+ return nil unless call_node.arguments&.arguments&.any?(Prism::SplatNode)
704
+
705
+ model_name = relation_element_class_name(scope.type_of(call_node.receiver))
706
+ return nil if model_name.nil?
707
+
708
+ model = Rigor::Type::Combinator.nominal_of(model_name)
709
+ return model if call_argument_count(call_node) < 2
710
+
711
+ Rigor::Type::Combinator.nominal_of("Array", type_args: [model])
712
+ end
713
+
673
714
  # Whether `name` is a declared `scope` on ANY model in the index. A run-lifetime memoised Set so the
674
715
  # per-call check in `relation_call_return_type` stays O(1).
675
716
  def scope_name?(name, index)
@@ -292,7 +292,9 @@ module ActiveRecord
292
292
  # loses its `io.db.write`. So a single argument stays `Elem`. A
293
293
  # splat and a `**` hash each count as one argument, so `find(*ids)`
294
294
  # is `Elem`, while `find(id, *ids)` and `find(*args, **opts)` are
295
- # Arrays even when the splat or the hash turns out empty.
295
+ # Arrays even when the splat or the hash turns out empty. The engine
296
+ # reads a splat as any number of arguments (#1801), so the plugin
297
+ # answers a splat call by this count (`relation_splat_find_return_type`).
296
298
  #
297
299
  # With a block `find` is `Enumerable#find` (`return super if
298
300
  # block_given?`), so the id overloads do not apply: no argument
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require "rigor/inference/declaration_walk"
6
+
7
+ require_relative "resource_index"
8
+
9
+ module Rigor
10
+ module Plugin
11
+ class Alba < Rigor::Plugin::Base
12
+ # A {Rigor::Inference::DeclarationWalk} collector over one file: the classes it declares, whether each
13
+ # `include Alba::Resource`s, and the association declarations whose resource
14
+ # class alba infers from the name.
15
+ #
16
+ # An association is recorded only when alba would take the inference path (`Alba::Association#
17
+ # resource_from`): a literal Symbol/String name, exactly one positional argument, no block, and no
18
+ # `resource:` / `serializer:` keyword. A keyword splat (`**opts`) might carry either, so it is not
19
+ # recorded either — a skipped association costs a root, never invents one.
20
+ class ResourceCollector
21
+ include Rigor::Inference::DeclarationWalk::Collector
22
+
23
+ ASSOCIATION_METHODS = %i[association one many has_one has_many].freeze
24
+ RESOURCE_MODULE = "Alba::Resource"
25
+ RESOURCE_KEYWORDS = %i[resource serializer].freeze
26
+ # The calls whose block alba `class_eval`s on an anonymous class (`name == nil`): an association
27
+ # lookup inside one sees no nesting. Any other block (`each`, `class_eval`, ...) leaves it alone.
28
+ ANONYMOUS_CLASS_BLOCK_METHODS = (ASSOCIATION_METHODS + %i[trait nested nested_attribute]).freeze
29
+ # `Alba.serialize(x) { ... }` / `Alba.hashify` / `Alba.resource_class` evaluate their block on an anonymous
30
+ # class too, wherever they are written — including inside a namespaced resource.
31
+ ALBA_ANONYMOUS_BLOCK_METHODS = %i[serialize hashify resource_class].freeze
32
+
33
+ attr_reader :classes, :associations
34
+
35
+ def initialize
36
+ @entries = {}
37
+ @includes = {}
38
+ @nestings = {}
39
+ @blocks = []
40
+ @associations = []
41
+ end
42
+
43
+ def on_declaration(node, context, body_context)
44
+ return Rigor::Inference::DeclarationWalk::DESCEND unless node.is_a?(Prism::ClassNode)
45
+
46
+ name = body_context.prefix.join("::")
47
+ unless name.empty?
48
+ @entries[name] = superclass_name(node.superclass)
49
+ @nestings[name] = context.nesting || []
50
+ end
51
+ Rigor::Inference::DeclarationWalk::DESCEND
52
+ end
53
+
54
+ def on_call(node, context)
55
+ owner = context.prefix.join("::")
56
+ record_call(node, owner) unless owner.empty? || !node.receiver.nil?
57
+ remember_block(node)
58
+ Rigor::Inference::DeclarationWalk::DESCEND
59
+ end
60
+
61
+ # The classes this file declares, as {ResourceIndex::ClassEntry} values.
62
+ def class_entries
63
+ @entries.map do |name, superclass|
64
+ ResourceIndex::ClassEntry.new(
65
+ name: name, superclass: superclass, nesting: @nestings.fetch(name),
66
+ includes_resource: @includes.fetch(name, false)
67
+ )
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ def record_call(node, owner)
74
+ if node.name == :include
75
+ @includes[owner] = true if includes_resource?(node)
76
+ elsif ASSOCIATION_METHODS.include?(node.name)
77
+ name = inferred_association_name(node)
78
+ @associations << ResourceIndex::Association.new(owner: owner, name: name, in_block: in_block?(node)) if name
79
+ end
80
+ end
81
+
82
+ # The calls are visited outer-first, so a call's block is known before the calls inside it.
83
+ def remember_block(node)
84
+ block = node.block
85
+ return unless block.is_a?(Prism::BlockNode)
86
+ return unless ANONYMOUS_CLASS_BLOCK_METHODS.include?(node.name) || alba_anonymous_block_call?(node)
87
+
88
+ @blocks << (block.location.start_offset...block.location.end_offset)
89
+ end
90
+
91
+ def alba_anonymous_block_call?(node)
92
+ ALBA_ANONYMOUS_BLOCK_METHODS.include?(node.name) &&
93
+ constant_name(node.receiver)&.delete_prefix("::") == "Alba"
94
+ end
95
+
96
+ def in_block?(node)
97
+ offset = node.location.start_offset
98
+ @blocks.any? { |range| range.cover?(offset) }
99
+ end
100
+
101
+ def includes_resource?(node)
102
+ arguments = node.arguments&.arguments || []
103
+ arguments.any? { |argument| constant_name(argument)&.delete_prefix("::") == RESOURCE_MODULE }
104
+ end
105
+
106
+ def inferred_association_name(node)
107
+ return nil unless node.block.nil?
108
+
109
+ arguments = node.arguments&.arguments || []
110
+ positional = arguments.grep_v(Prism::KeywordHashNode)
111
+ keywords = arguments.grep(Prism::KeywordHashNode)
112
+ return nil unless positional.size == 1 && keywords.size <= 1
113
+ return nil if keywords.any? { |hash| names_resource?(hash) }
114
+
115
+ literal_name(positional.first)
116
+ end
117
+
118
+ def names_resource?(hash)
119
+ hash.elements.any? do |element|
120
+ !element.is_a?(Prism::AssocNode) || !element.key.is_a?(Prism::SymbolNode) ||
121
+ RESOURCE_KEYWORDS.include?(element.key.unescaped.to_sym)
122
+ end
123
+ end
124
+
125
+ def literal_name(node)
126
+ case node
127
+ when Prism::SymbolNode, Prism::StringNode then node.unescaped
128
+ end
129
+ end
130
+
131
+ def superclass_name(node)
132
+ node && constant_name(node)
133
+ end
134
+
135
+ def constant_name(node)
136
+ case node
137
+ when Prism::ConstantReadNode then node.name.to_s
138
+ when Prism::ConstantPathNode
139
+ parent = node.parent
140
+ return "::#{node.name}" if parent.nil?
141
+
142
+ prefix = constant_name(parent)
143
+ prefix && "#{prefix}::#{node.name}"
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require "rigor/inference/declaration_walk"
6
+
7
+ require_relative "resource_collector"
8
+ require_relative "resource_index"
9
+
10
+ module Rigor
11
+ module Plugin
12
+ class Alba < Rigor::Plugin::Base
13
+ # Reads every `.rb` file under the configured search paths through the plugin's `IoBoundary`, runs a
14
+ # {ResourceCollector} over each and merges the results into one {ResourceIndex}.
15
+ class ResourceDiscoverer
16
+ def initialize(io_boundary:, search_paths:)
17
+ @io_boundary = io_boundary
18
+ @search_paths = search_paths
19
+ end
20
+
21
+ def discover
22
+ entries = []
23
+ associations = []
24
+ ruby_files_under(@search_paths).each do |path|
25
+ contents = read_safely(path)
26
+ next if contents.nil?
27
+
28
+ parsed = Prism.parse(contents)
29
+ next unless parsed.success?
30
+
31
+ collector = ResourceCollector.new
32
+ Rigor::Inference::DeclarationWalk.run(
33
+ parsed.value, [collector], Rigor::Inference::DeclarationWalk::Context.root(nesting: [])
34
+ )
35
+ entries.concat(collector.class_entries)
36
+ associations.concat(collector.associations)
37
+ end
38
+ ResourceIndex.new(classes: merge(entries), associations: associations)
39
+ end
40
+
41
+ private
42
+
43
+ # A class reopened across files: it is a resource if any opening says so.
44
+ def merge(entries)
45
+ entries.group_by(&:name).map do |name, openings|
46
+ headed = openings.find(&:superclass) || openings.first
47
+ ResourceIndex::ClassEntry.new(
48
+ name: name,
49
+ superclass: headed.superclass,
50
+ nesting: headed.nesting,
51
+ includes_resource: openings.any?(&:includes_resource)
52
+ )
53
+ end
54
+ end
55
+
56
+ def read_safely(path)
57
+ @io_boundary.read_file(path)
58
+ rescue Plugin::AccessDeniedError, Errno::ENOENT
59
+ nil
60
+ end
61
+
62
+ def ruby_files_under(roots)
63
+ roots.flat_map do |root|
64
+ absolute = File.expand_path(root)
65
+ # ADR-45 WD1b (#613) — boundary-probed: a root that appears later invalidates the warm run.
66
+ next [] unless @io_boundary.directory?(absolute)
67
+
68
+ Dir.glob(File.join(absolute, "**", "*.rb"))
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ class Alba < Rigor::Plugin::Base
6
+ # The project's alba resource classes, as the {ResourceCollector} found them. A frozen value object the
7
+ # `:resource_index` producer caches.
8
+ #
9
+ # One question is answered from it, and deliberately conservatively — a wrong "no" costs a
10
+ # missing root, a wrong "yes" costs a false positive or a hidden dead class:
11
+ #
12
+ # {#inferred_roots}: which classes alba's association inference would load, for associations that name
13
+ # neither `resource:` nor `serializer:` (see {ResourceCollector}).
14
+ class ResourceIndex
15
+ # `owner` is the enclosing class's full name, which alba hands to `Object.const_get` as `nesting`.
16
+ # `in_block` is true when the call sits in a block of the resource body (`trait`, `nested`, an
17
+ # association's own block): alba `class_eval`s those on an anonymous class, whose `name` is nil, so
18
+ # only the top-level candidates are tried.
19
+ Association = Data.define(:owner, :name, :in_block)
20
+
21
+ # A class the project declares. `superclass` is the constant as written (`nil` when absent) and
22
+ # `nesting` the `Module.nesting` chain (innermost first) its header was written under, which is where
23
+ # Ruby resolves that constant.
24
+ ClassEntry = Data.define(:name, :superclass, :nesting, :includes_resource)
25
+
26
+ # The resource-class suffixes alba's `infer_resource_class` tries, in order.
27
+ SUFFIXES = %w[Resource Serializer].freeze
28
+ VALID_ASSOCIATION_NAME = /\A[a-z_][a-z0-9_]*\z/
29
+
30
+ def initialize(classes:, associations:)
31
+ @classes = classes.to_h { |entry| [entry.name, entry] }.freeze
32
+ @associations = associations.freeze
33
+ freeze
34
+ end
35
+
36
+ def empty?
37
+ @classes.empty?
38
+ end
39
+
40
+ # The classes alba infers for the associations that name no resource, limited to
41
+ # those that exist in the project. The block receives each association name and returns alba's
42
+ # `classify` of it (the caller owns the inflector; ADR-39).
43
+ def inferred_roots(&classify)
44
+ roots = @associations.filter_map do |association|
45
+ next unless resource?(association.owner)
46
+
47
+ inferred_class(association, classify)
48
+ end
49
+ roots.uniq.sort
50
+ end
51
+
52
+ private
53
+
54
+ # Alba tries `<Nesting>::<X>Resource`, then the top-level one (`const_get` on a module looks in Object),
55
+ # and only then the same two for `<X>Serializer`. The first that exists is the one alba loads.
56
+ def inferred_class(association, classify)
57
+ return nil unless VALID_ASSOCIATION_NAME.match?(association.name)
58
+
59
+ base = classify.call(association.name)
60
+ return nil unless base.is_a?(String) && /\A[A-Z][A-Za-z0-9]*\z/.match?(base)
61
+
62
+ nesting = association.in_block ? "" : association.owner.rpartition("::").first
63
+ SUFFIXES.each do |suffix|
64
+ candidates = ["#{base}#{suffix}"]
65
+ candidates.unshift("#{nesting}::#{base}#{suffix}") unless nesting.empty?
66
+ found = candidates.find { |candidate| @classes.key?(candidate) }
67
+ return found if found
68
+ end
69
+ nil
70
+ end
71
+
72
+ def resource?(name)
73
+ resource_chain?(name, {})
74
+ end
75
+
76
+ def resource_chain?(name, seen)
77
+ entry = @classes[name]
78
+ return false if entry.nil? || seen[name]
79
+
80
+ seen[name] = true
81
+ return true if entry.includes_resource
82
+
83
+ parent = superclass_of(entry)
84
+ !parent.nil? && resource_chain?(parent, seen)
85
+ end
86
+
87
+ # Resolves the superclass as written the way Ruby does: against the `Module.nesting` chain of the header,
88
+ # innermost first, then the top level. A compact header (`class Admin::UserResource < Base`) does not put
89
+ # `Admin` on the chain, so `Base` is not looked up under it.
90
+ def superclass_of(entry)
91
+ written = entry.superclass
92
+ return nil if written.nil?
93
+
94
+ if written.start_with?("::")
95
+ rooted = written.delete_prefix("::")
96
+ return @classes.key?(rooted) ? rooted : nil
97
+ end
98
+
99
+ (entry.nesting.map { |scope| "#{scope}::#{written}" } + [written]).find { |name| @classes.key?(name) }
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rigor/plugin"
4
+
5
+ require_relative "alba/resource_index"
6
+ require_relative "alba/resource_collector"
7
+ require_relative "alba/resource_discoverer"
8
+
9
+ module Rigor
10
+ module Plugin
11
+ # rigor-alba — recognises the alba JSON serializer (https://github.com/okuramasafumi/alba) in an
12
+ # application that uses it. Two facets, each of which only removes a diagnostic or adds a root:
13
+ #
14
+ # 1. **Block self.** `Alba.serialize(obj) { attributes :id }` and `Alba.hashify(obj) { ... }` `class_eval`
15
+ # their block on an anonymous `Class.new { include Alba::Resource }`, so the DSL calls inside resolve
16
+ # against `singleton(Alba::Resource)` (alba's own sig declares `[self: singleton(Resource)]` from 4.0) instead of
17
+ # firing `call.unresolved-toplevel`.
18
+ # 2. **`rigor unused` roots.** `many :articles` with no `resource:` makes alba infer `ArticleResource` (or
19
+ # `ArticleSerializer`) through `Alba.inflector`; that name appears nowhere in source, so the class would
20
+ # be listed as unused. It is published as a root — and only when a class of that name exists.
21
+ #
22
+ # No return type is contributed. `Alba.serialize(obj)` runs the project's own `<Class>Resource#serialize`,
23
+ # which the project may override to return anything, and `hashify` / `to_h` / `as_json` return whatever the
24
+ # resource's attributes build.
25
+ #
26
+ # alba keeps its `sig/` in the repository but does not ship it in the gem, so in a user project the
27
+ # constant `Alba` is unknown. The bundled `sig/alba.rbs` therefore declares just the two namespaces the
28
+ # block-self matching needs (`Alba`, `Alba::Resource`), and both are `open_receivers` so no
29
+ # undeclared call on them is ever diagnosed.
30
+ class Alba < Rigor::Plugin::Base
31
+ manifest(
32
+ id: "alba",
33
+ target_gems: ["alba"],
34
+ version: "0.1.0",
35
+ description: "Types alba's inline-resource block self and roots inferred " \
36
+ "association resources for `rigor unused`.",
37
+ config_schema: {
38
+ "resource_search_paths" => { kind: :array, default: ["app"] }
39
+ },
40
+ signature_paths: ["sig"],
41
+ open_receivers: %w[Alba Alba::Resource],
42
+ produces: [:reachability_roots],
43
+ block_as_methods: [
44
+ Rigor::Plugin::Macro::BlockAsMethod.new(
45
+ receiver_constraint: "Alba",
46
+ method_names: %i[serialize hashify],
47
+ self_type: "singleton(Alba::Resource)"
48
+ )
49
+ ]
50
+ )
51
+
52
+ producer :resource_index, watch: -> { [[@resource_search_paths, "**/*.rb"]] } do |_params|
53
+ ResourceDiscoverer.new(io_boundary: io_boundary, search_paths: @resource_search_paths).discover
54
+ end
55
+
56
+ def init(_services)
57
+ @resource_search_paths = Array(config.fetch("resource_search_paths")).map(&:to_s)
58
+ end
59
+
60
+ # ADR-102 WD3 — the resource classes alba's association inference loads. Published only where the
61
+ # inferred name resolves to a class the project declares ({ResourceIndex#inferred_roots}); a name nothing
62
+ # matches contributes nothing, and an unavailable inflector (ADR-39: decline, never approximate) leaves
63
+ # the report as it was.
64
+ def prepare(services)
65
+ index = resource_index
66
+ return if index.nil?
67
+
68
+ roots = index.inferred_roots { |name| Rigor::Plugin::Inflector.classify(name) }
69
+ return if roots.empty?
70
+
71
+ services.fact_store.publish(plugin_id: manifest.id, name: :reachability_roots, value: roots)
72
+ rescue Rigor::Plugin::Inflector::Unavailable
73
+ nil
74
+ end
75
+
76
+ private
77
+
78
+ def resource_index
79
+ producer_value(:resource_index)
80
+ end
81
+ end
82
+
83
+ Rigor::Plugin.register(Alba)
84
+ end
85
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rigor/plugin/alba"