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.
- checksums.yaml +4 -4
- data/README.md +4 -3
- data/data/builtins/ruby_core/io.yml +1 -0
- data/data/builtins/ruby_core/proc.yml +4 -0
- data/data/builtins/ruby_core/random.yml +4 -0
- data/data/builtins/ruby_core/struct.yml +3 -0
- data/data/core_overlay/env.rbs +34 -0
- data/data/core_overlay/gc.rbs +50 -0
- data/data/core_overlay/hash.rbs +45 -0
- data/data/core_overlay/integer.rbs +31 -0
- data/data/core_overlay/io_buffer.rbs +27 -0
- data/data/core_overlay/kernel.rbs +40 -0
- data/data/core_overlay/match_data.rbs +28 -0
- data/data/core_overlay/module.rbs +51 -0
- data/data/core_overlay/object_space.rbs +29 -0
- data/data/core_overlay/proc.rbs +45 -0
- data/data/core_overlay/range.rbs +40 -0
- data/data/core_overlay/source_range.rbs +78 -0
- data/data/core_overlay/string.rbs +86 -0
- data/data/vendored_gem_sigs/prime/integer-extension.rbs +41 -0
- data/data/vendored_gem_sigs/prime/prime.rbs +372 -0
- data/docs/handbook/01-getting-started.md +3 -3
- data/docs/handbook/06-classes.md +3 -1
- data/docs/handbook/08-understanding-errors.md +3 -2
- data/docs/handbook/11-sig-gen.md +6 -0
- data/docs/handbook/appendix-liskov.md +5 -0
- data/docs/install.md +87 -15
- data/docs/manual/02-cli-reference.md +2 -2
- data/docs/manual/03-configuration.md +6 -1
- data/docs/manual/04-diagnostics.md +2 -1
- data/docs/manual/06-baseline.md +2 -0
- data/docs/manual/07-plugins.md +1 -1
- data/docs/manual/08-skills.md +4 -3
- data/docs/manual/12-caching.md +43 -4
- data/docs/manual/14-rails-quickstart.md +19 -11
- data/docs/manual/18-removing-dead-code.md +32 -2
- data/docs/manual/plugins/README.md +6 -1
- data/docs/manual/plugins/rigor-activerecord.md +21 -0
- data/docs/manual/plugins/rigor-alba.md +60 -0
- data/docs/manual/plugins/rigor-minitest.md +22 -4
- data/docs/manual/plugins/rigor-typelizer.md +71 -0
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +27 -3
- data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +21 -92
- data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +275 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +4 -0
- data/lib/rigor/analysis/check_rules/source_arity.rb +184 -61
- data/lib/rigor/analysis/check_rules.rb +407 -144
- data/lib/rigor/analysis/dependency_recorder.rb +215 -18
- data/lib/rigor/analysis/dependency_source_inference/builder.rb +8 -1
- data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +21 -0
- data/lib/rigor/analysis/dependency_source_inference/index.rb +36 -11
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +196 -27
- data/lib/rigor/analysis/erb_template_detector.rb +2 -1
- data/lib/rigor/analysis/incremental.rb +29 -9
- data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +291 -0
- data/lib/rigor/analysis/incremental_run_slot.rb +173 -0
- data/lib/rigor/analysis/incremental_session.rb +287 -50
- data/lib/rigor/analysis/plugin_fact_fingerprint.rb +24 -13
- data/lib/rigor/analysis/reachability/graph.rb +123 -23
- data/lib/rigor/analysis/reachability/scan.rb +290 -36
- data/lib/rigor/analysis/reachability/scan_cache.rb +2 -1
- data/lib/rigor/analysis/rule_catalog.rb +43 -0
- data/lib/rigor/analysis/run_cache_key.rb +43 -9
- data/lib/rigor/analysis/run_stats.rb +2 -2
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +88 -6
- data/lib/rigor/analysis/runner/pool_coordinator.rb +52 -13
- data/lib/rigor/analysis/runner/project_pre_passes.rb +21 -13
- data/lib/rigor/analysis/runner/run_snapshots.rb +6 -1
- data/lib/rigor/analysis/runner.rb +371 -49
- data/lib/rigor/analysis/shadow_harness.rb +27 -0
- data/lib/rigor/analysis/source_rbs_gate.rb +157 -0
- data/lib/rigor/analysis/template_unit_paths.rb +6 -4
- data/lib/rigor/analysis/template_units.rb +15 -5
- data/lib/rigor/analysis/worker_session.rb +11 -7
- data/lib/rigor/cache/descriptor.rb +40 -1
- data/lib/rigor/cache/file_digest.rb +28 -0
- data/lib/rigor/cache/incremental_snapshot.rb +92 -8
- data/lib/rigor/cache/store.rb +63 -3
- data/lib/rigor/cache/value_digest.rb +325 -0
- data/lib/rigor/cli/annotate_command.rb +5 -0
- data/lib/rigor/cli/baseline_command.rb +7 -18
- data/lib/rigor/cli/check_command.rb +85 -14
- data/lib/rigor/cli/doctor_command.rb +1 -1
- data/lib/rigor/cli/effects_report.rb +2 -2
- data/lib/rigor/cli/trace_command.rb +5 -0
- data/lib/rigor/cli/type_of_command.rb +5 -3
- data/lib/rigor/cli/unused_command.rb +16 -10
- data/lib/rigor/cli.rb +11 -3
- data/lib/rigor/config_audit.rb +3 -2
- data/lib/rigor/config_path.rb +29 -0
- data/lib/rigor/configuration/severity_profile.rb +11 -0
- data/lib/rigor/configuration.rb +52 -7
- data/lib/rigor/effects/plugin_facts.rb +6 -6
- data/lib/rigor/environment/bundle_sig_discovery.rb +5 -3
- data/lib/rigor/environment/gated_signature_guard.rb +43 -0
- data/lib/rigor/environment/gated_signature_plan.rb +339 -0
- data/lib/rigor/environment/lockfile_resolver.rb +10 -1
- data/lib/rigor/environment/rbs_collection_discovery.rb +10 -1
- data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
- data/lib/rigor/environment/rbs_loader.rb +284 -15
- data/lib/rigor/environment/required_features.rb +206 -0
- data/lib/rigor/environment/source_rbs_synthesis.rb +147 -0
- data/lib/rigor/environment.rb +34 -73
- data/lib/rigor/inference/acceptance.rb +28 -3
- data/lib/rigor/inference/block_parameter_binder.rb +29 -13
- data/lib/rigor/inference/builtins/cruby_definers.rb +61 -0
- data/lib/rigor/inference/builtins/method_catalog.rb +3 -0
- data/lib/rigor/inference/captured_locals.rb +7 -1
- data/lib/rigor/inference/declaration_walk/context.rb +195 -0
- data/lib/rigor/inference/declaration_walk/errors.rb +18 -0
- data/lib/rigor/inference/declaration_walk/shadow.rb +147 -0
- data/lib/rigor/inference/declaration_walk/traversal.rb +441 -0
- data/lib/rigor/inference/declaration_walk.rb +7 -0
- data/lib/rigor/inference/def_node_resolver.rb +26 -3
- data/lib/rigor/inference/definer_resolution.rb +266 -0
- data/lib/rigor/inference/expression_typer.rb +351 -61
- data/lib/rigor/inference/external_ancestor_resolution.rb +13 -16
- data/lib/rigor/inference/global_write_census.rb +7 -4
- data/lib/rigor/inference/guard_rebinding.rb +54 -13
- data/lib/rigor/inference/in_effect_refinements/proc_literals.rb +83 -0
- data/lib/rigor/inference/in_effect_refinements/refine_defs.rb +54 -0
- data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +282 -0
- data/lib/rigor/inference/in_effect_refinements/walk.rb +276 -0
- data/lib/rigor/inference/in_effect_refinements.rb +256 -0
- data/lib/rigor/inference/key_presence_guard/scan.rb +281 -0
- data/lib/rigor/inference/key_presence_guard.rb +303 -0
- data/lib/rigor/inference/macro_block_self_type.rb +73 -14
- data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +36 -0
- data/lib/rigor/inference/method_dispatcher/arithmetic_sequence_elements.rb +73 -0
- data/lib/rigor/inference/method_dispatcher/imprecise_argument.rb +68 -0
- data/lib/rigor/inference/method_dispatcher/integer_step_block_params.rb +81 -0
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -0
- data/lib/rigor/inference/method_dispatcher/keyword_arguments.rb +267 -0
- data/lib/rigor/inference/method_dispatcher/method_folding.rb +2 -1
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +122 -105
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +305 -54
- data/lib/rigor/inference/method_dispatcher/splat_arity.rb +71 -0
- data/lib/rigor/inference/method_dispatcher.rb +44 -11
- data/lib/rigor/inference/module_function_state.rb +214 -0
- data/lib/rigor/inference/multi_target_binder.rb +21 -16
- data/lib/rigor/inference/narrowing.rb +166 -22
- data/lib/rigor/inference/object_mixins.rb +180 -0
- data/lib/rigor/inference/parameter_inference_collector.rb +10 -9
- data/lib/rigor/inference/project_patched_methods.rb +10 -5
- data/lib/rigor/inference/project_patched_scanner.rb +71 -3
- data/lib/rigor/inference/refine_census.rb +239 -0
- data/lib/rigor/inference/refine_self.rb +317 -0
- data/lib/rigor/inference/refined_dispatch/own_method.rb +403 -0
- data/lib/rigor/inference/refined_dispatch.rb +272 -0
- data/lib/rigor/inference/scope_indexer/certainty.rb +176 -0
- data/lib/rigor/inference/scope_indexer/class_cvars_collector.rb +35 -0
- data/lib/rigor/inference/scope_indexer/def_nestings_collector.rb +47 -0
- data/lib/rigor/inference/scope_indexer/layered_def_nestings.rb +36 -0
- data/lib/rigor/inference/scope_indexer/member_layouts_collector.rb +61 -0
- data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +143 -0
- data/lib/rigor/inference/scope_indexer/superclasses_collector.rb +64 -0
- data/lib/rigor/inference/scope_indexer.rb +2129 -354
- data/lib/rigor/inference/singleton_hook_decline.rb +253 -0
- data/lib/rigor/inference/statement_evaluator.rb +149 -32
- data/lib/rigor/inference/string_mutation.rb +9 -3
- data/lib/rigor/inference/synthetic_method_scanner.rb +2 -1
- data/lib/rigor/inference/version_guard.rb +19 -10
- data/lib/rigor/language_server/project_context.rb +31 -0
- data/lib/rigor/language_server/server.rb +18 -0
- data/lib/rigor/plugin/base.rb +28 -8
- data/lib/rigor/plugin/inflector.rb +1 -1
- data/lib/rigor/plugin/io_boundary.rb +124 -7
- data/lib/rigor/plugin/isolation.rb +1 -1
- data/lib/rigor/plugin/macro/block_as_method.rb +51 -8
- data/lib/rigor/plugin/trust_policy.rb +3 -3
- data/lib/rigor/project_environment.rb +29 -6
- data/lib/rigor/protection/discovery_seed.rb +18 -9
- data/lib/rigor/protection/mutation_cache.rb +4 -4
- data/lib/rigor/protection/mutator.rb +1 -1
- data/lib/rigor/reflection/constant_ancestors.rb +211 -46
- data/lib/rigor/reflection/constant_path.rb +33 -7
- data/lib/rigor/reflection.rb +41 -10
- data/lib/rigor/scope/discovery_index.rb +258 -3
- data/lib/rigor/scope/resolution_chain/relevance.rb +165 -0
- data/lib/rigor/scope/resolution_chain.rb +1032 -0
- data/lib/rigor/scope.rb +480 -328
- data/lib/rigor/sig_gen/alias_index.rb +1 -1
- data/lib/rigor/sig_gen/classification.rb +4 -1
- data/lib/rigor/sig_gen/effect_annotation.rb +2 -2
- data/lib/rigor/sig_gen/generator.rb +77 -21
- data/lib/rigor/sig_gen/method_candidate.rb +2 -2
- data/lib/rigor/sig_gen/skip_reason_catalog.rb +12 -0
- data/lib/rigor/source/alias_names.rb +52 -0
- data/lib/rigor/source/literals.rb +3 -2
- data/lib/rigor/source/symbol_name.rb +20 -0
- data/lib/rigor/type/combinator.rb +59 -3
- data/lib/rigor/type/float_range.rb +4 -1
- data/lib/rigor/type/nominal.rb +6 -1
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-ac-library-rb/lib/rigor/plugin/ac_library_rb.rb +28 -0
- data/plugins/rigor-ac-library-rb/lib/rigor-ac-library-rb.rb +3 -0
- data/plugins/rigor-ac-library-rb/sig/ac_library_rb/convolution.rbs +7 -0
- data/plugins/rigor-ac-library-rb/sig/ac_library_rb/data_structures.rbs +160 -0
- data/plugins/rigor-ac-library-rb/sig/ac_library_rb/graph.rbs +94 -0
- data/plugins/rigor-ac-library-rb/sig/ac_library_rb/math.rbs +42 -0
- data/plugins/rigor-ac-library-rb/sig/ac_library_rb/modint.rbs +71 -0
- data/plugins/rigor-ac-library-rb/sig/ac_library_rb/object.rbs +37 -0
- data/plugins/rigor-ac-library-rb/sig/ac_library_rb/segtree.rbs +83 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +2 -1
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/association_roots.rb +100 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +94 -10
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +43 -2
- data/plugins/rigor-activerecord/sig/active_record/relation.rbs +3 -1
- data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_collector.rb +149 -0
- data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +74 -0
- data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_index.rb +104 -0
- data/plugins/rigor-alba/lib/rigor/plugin/alba.rb +85 -0
- data/plugins/rigor-alba/lib/rigor-alba.rb +3 -0
- data/plugins/rigor-alba/sig/alba.rbs +4 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +2 -2
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +29 -12
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +13 -2
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +130 -21
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +6 -4
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +4 -4
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +1 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1 -1
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +31 -0
- data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_collector.rb +102 -0
- data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +87 -0
- data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_index.rb +76 -0
- data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +73 -0
- data/plugins/rigor-typelizer/lib/rigor-typelizer.rb +3 -0
- data/sig/rigor/analysis/dependency_source_inference/index.rbs +1 -1
- data/sig/rigor/environment.rbs +2 -0
- data/sig/rigor/plugin/io_boundary.rbs +4 -0
- data/sig/rigor/scope.rbs +69 -4
- data/sig/rigor/source.rbs +4 -0
- data/sig/rigor.rbs +3 -1
- data/skills/rigor-project-init/SKILL.md +37 -11
- data/skills/rigor-project-init/references/02-configure.md +7 -2
- 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
|
|
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
|
-
#
|
|
1267
|
-
#
|
|
1268
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
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.
|
|
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: [
|
|
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
|