rigortype 0.3.9 → 0.4.0
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 +2 -2
- data/data/core_overlay/enumerable.rbs +51 -0
- data/data/core_overlay/enumerator.rbs +84 -0
- data/data/core_overlay/hash_rbs3.rbs +41 -0
- data/data/core_overlay/process.rbs +40 -0
- data/data/core_overlay/string_io.rbs +33 -0
- data/data/effects/core.yml +3 -3
- data/data/gem_overlay/activesupport/core_ext.rbs +127 -4
- data/docs/handbook/03-narrowing.md +95 -10
- data/docs/handbook/04-tuples-and-shapes.md +8 -6
- data/docs/handbook/07-rbs-and-extended.md +16 -11
- data/docs/handbook/10-sorbet.md +9 -10
- data/docs/handbook/11-sig-gen.md +454 -13
- data/docs/manual/02-cli-reference.md +63 -2
- data/docs/manual/03-configuration.md +7 -0
- data/docs/manual/04-diagnostics.md +4 -0
- data/docs/manual/07-plugins.md +1 -1
- data/docs/manual/10-mcp-server.md +3 -2
- data/docs/manual/16-rbs-extended-annotations.md +40 -7
- data/docs/manual/19-effect-labels.md +10 -0
- data/docs/manual/plugins/README.md +7 -0
- data/docs/manual/plugins/rigor-actioncable.md +8 -1
- data/docs/manual/plugins/rigor-actionmailer.md +7 -0
- data/docs/manual/plugins/rigor-actionpack.md +243 -0
- data/docs/manual/plugins/rigor-active-model-serializers.md +153 -0
- data/docs/manual/plugins/rigor-activejob.md +7 -0
- data/docs/manual/plugins/rigor-activerecord.md +110 -4
- data/docs/manual/plugins/rigor-activestorage.md +8 -1
- data/docs/manual/plugins/rigor-activesupport-core-ext.md +11 -5
- data/docs/manual/plugins/rigor-grape.md +106 -0
- data/docs/manual/plugins/rigor-graphql.md +23 -2
- data/docs/manual/plugins/rigor-pundit.md +8 -1
- data/docs/manual/plugins/rigor-rails-i18n.md +10 -5
- data/docs/manual/plugins/rigor-rails-routes.md +7 -0
- data/docs/manual/plugins/rigor-rbs-inline.md +212 -25
- data/docs/manual/plugins/rigor-sidekiq.md +8 -1
- data/docs/manual/plugins/rigor-sorbet.md +21 -5
- data/exe/rigor +19 -4
- data/lib/rigor/analysis/baseline.rb +1 -1
- data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +167 -2
- data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +189 -0
- data/lib/rigor/analysis/check_rules/main_pass_collector.rb +5 -3
- data/lib/rigor/analysis/check_rules/rule_ids.rb +20 -5
- data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
- data/lib/rigor/analysis/check_rules/source_arity.rb +323 -0
- data/lib/rigor/analysis/check_rules/special_global_setters.rb +146 -0
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +36 -2
- data/lib/rigor/analysis/check_rules.rb +520 -49
- data/lib/rigor/analysis/dependency_recorder.rb +23 -0
- data/lib/rigor/analysis/fact_store.rb +9 -0
- data/lib/rigor/analysis/incremental.rb +26 -0
- data/lib/rigor/analysis/incremental_session.rb +36 -4
- data/lib/rigor/analysis/project_scan.rb +11 -1
- data/lib/rigor/analysis/reachability/plugin_roots.rb +0 -1
- data/lib/rigor/analysis/reachability/scan_cache.rb +1 -0
- data/lib/rigor/analysis/rule_catalog.rb +127 -0
- data/lib/rigor/analysis/run_cache_key.rb +20 -11
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +167 -11
- data/lib/rigor/analysis/runner/effect_envelope_pass.rb +9 -4
- data/lib/rigor/analysis/runner/pool_coordinator.rb +107 -15
- data/lib/rigor/analysis/runner/project_pre_passes.rb +26 -7
- data/lib/rigor/analysis/runner.rb +278 -20
- data/lib/rigor/analysis/template_unit_collector.rb +303 -0
- data/lib/rigor/analysis/template_unit_paths.rb +91 -0
- data/lib/rigor/analysis/template_unit_positions.rb +293 -0
- data/lib/rigor/analysis/template_units.rb +399 -0
- data/lib/rigor/analysis/worker_session.rb +53 -10
- data/lib/rigor/bleeding_edge.rb +0 -2
- data/lib/rigor/builtins/hkt_builtins.rb +1 -0
- data/lib/rigor/builtins/imported_refinements.rb +4 -0
- data/lib/rigor/builtins/regex_refinement.rb +17 -9
- data/lib/rigor/builtins/static_return_refinements.rb +2 -0
- data/lib/rigor/cache/descriptor.rb +6 -1
- data/lib/rigor/cache/engine_source.rb +29 -1
- data/lib/rigor/cache/incremental_snapshot.rb +33 -1
- data/lib/rigor/cache/rbs_cache_producer.rb +4 -0
- data/lib/rigor/cache/rbs_descriptor.rb +37 -7
- data/lib/rigor/cache/store.rb +0 -1
- data/lib/rigor/ci_detector.rb +1 -0
- data/lib/rigor/cli/doc_links.rb +1 -1
- data/lib/rigor/cli/docs_command.rb +4 -4
- data/lib/rigor/cli/plugin_command.rb +3 -3
- data/lib/rigor/cli/prism_colorizer.rb +0 -1
- data/lib/rigor/cli/sig_gen_command.rb +210 -29
- data/lib/rigor/cli/skill_command.rb +1 -1
- data/lib/rigor/cli/skill_describe.rb +0 -1
- data/lib/rigor/cli/type_of_command.rb +19 -5
- data/lib/rigor/cli/type_of_renderer.rb +20 -9
- data/lib/rigor/cli/type_of_template_probe.rb +189 -0
- data/lib/rigor/cli.rb +21 -3
- data/lib/rigor/configuration/severity_profile.rb +19 -3
- data/lib/rigor/configuration.rb +66 -3
- data/lib/rigor/effects/ancestry_recorder.rb +191 -0
- data/lib/rigor/effects/attribution.rb +11 -2
- data/lib/rigor/effects/callee_rule.rb +368 -0
- data/lib/rigor/effects/catalog.rb +7 -4
- data/lib/rigor/effects/collector.rb +11 -5
- data/lib/rigor/effects/config_envelopes.rb +9 -2
- data/lib/rigor/effects/definition_context.rb +179 -0
- data/lib/rigor/effects/effect_table.rb +11 -3
- data/lib/rigor/effects/envelope_check.rb +1 -1
- data/lib/rigor/effects/envelope_index.rb +15 -0
- data/lib/rigor/effects/file_collection.rb +59 -5
- data/lib/rigor/effects/framework_units.rb +1 -1
- data/lib/rigor/effects/identity.rb +16 -0
- data/lib/rigor/effects/local_ownership.rb +38 -12
- data/lib/rigor/effects/method_key.rb +21 -0
- data/lib/rigor/effects/mutation_classifier.rb +23 -12
- data/lib/rigor/effects/plugin_facts.rb +43 -31
- data/lib/rigor/effects/propagator.rb +295 -16
- data/lib/rigor/effects/registry.rb +1 -1
- data/lib/rigor/effects/scanner.rb +121 -72
- data/lib/rigor/effects/signature_sources.rb +1 -1
- data/lib/rigor/effects/snapshot.rb +2 -1
- data/lib/rigor/effects/summary.rb +27 -4
- data/lib/rigor/effects/unit_scan.rb +385 -30
- data/lib/rigor/effects/visibility.rb +101 -0
- data/lib/rigor/environment/lockfile_resolver.rb +17 -0
- data/lib/rigor/environment/member_consistency/comparator.rb +708 -0
- data/lib/rigor/environment/member_consistency.rb +298 -0
- data/lib/rigor/environment/rbs_loader.rb +399 -133
- data/lib/rigor/environment.rb +103 -38
- data/lib/rigor/hashing/xxh3.rb +264 -0
- data/lib/rigor/inference/acceptance.rb +139 -9
- data/lib/rigor/inference/block_auto_splat.rb +216 -0
- data/lib/rigor/inference/block_call_timing.rb +338 -0
- data/lib/rigor/inference/block_parameter_binder.rb +73 -27
- data/lib/rigor/inference/block_repetition.rb +71 -0
- data/lib/rigor/inference/body_fixpoint.rb +2 -1
- data/lib/rigor/inference/budget_trace.rb +2 -1
- data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
- data/lib/rigor/inference/builtins/string_catalog.rb +1 -1
- data/lib/rigor/inference/captured_locals.rb +387 -15
- data/lib/rigor/inference/closure_escape_analyzer.rb +157 -13
- data/lib/rigor/inference/content_join.rb +200 -27
- data/lib/rigor/inference/def_return_typer.rb +11 -7
- data/lib/rigor/inference/define_method_block_self.rb +64 -0
- data/lib/rigor/inference/element_read_widening.rb +22 -10
- data/lib/rigor/inference/error_info.rb +196 -0
- data/lib/rigor/inference/expression_typer.rb +1532 -496
- data/lib/rigor/inference/external_ancestor_resolution.rb +267 -0
- data/lib/rigor/inference/fresh_frame_blocks.rb +127 -0
- data/lib/rigor/inference/global_write_census.rb +239 -0
- data/lib/rigor/inference/guard_rebinding.rb +447 -0
- data/lib/rigor/inference/hash_lookup_mutation.rb +88 -0
- data/lib/rigor/inference/index_write_widening.rb +16 -3
- data/lib/rigor/inference/indexed_narrowing.rb +61 -9
- data/lib/rigor/inference/jump_targets.rb +82 -0
- data/lib/rigor/inference/last_line/implicit_self.rb +210 -0
- data/lib/rigor/inference/last_line/self_evidence.rb +329 -0
- data/lib/rigor/inference/last_line.rb +340 -0
- data/lib/rigor/inference/last_status.rb +144 -0
- data/lib/rigor/inference/macro_block_self_type.rb +167 -17
- data/lib/rigor/inference/match_rebinding/calls.rb +281 -0
- data/lib/rigor/inference/match_rebinding/frame.rb +148 -0
- data/lib/rigor/inference/match_rebinding/operands.rb +236 -0
- data/lib/rigor/inference/match_rebinding/self_calls.rb +83 -0
- data/lib/rigor/inference/match_rebinding.rb +392 -0
- data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +36 -0
- data/lib/rigor/inference/method_dispatcher/block_folding.rb +85 -24
- data/lib/rigor/inference/method_dispatcher/facet_distribution.rb +147 -0
- data/lib/rigor/inference/method_dispatcher/hash_transform_keys_folding.rb +191 -0
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +4 -0
- data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +159 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +113 -73
- data/lib/rigor/inference/method_dispatcher/process_folding.rb +56 -0
- data/lib/rigor/inference/method_dispatcher/proven_overload.rb +72 -0
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +881 -38
- data/lib/rigor/inference/method_dispatcher/self_substitute.rb +142 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +128 -53
- data/lib/rigor/inference/method_dispatcher/struct_folding.rb +1 -1
- data/lib/rigor/inference/method_dispatcher.rb +214 -13
- data/lib/rigor/inference/method_parameter_binder.rb +8 -3
- data/lib/rigor/inference/multi_target_binder.rb +340 -52
- data/lib/rigor/inference/mutation_rejoin.rb +4 -1
- data/lib/rigor/inference/mutation_widening.rb +70 -48
- data/lib/rigor/inference/narrowing.rb +540 -120
- data/lib/rigor/inference/operand_effects.rb +167 -0
- data/lib/rigor/inference/operand_walk.rb +88 -0
- data/lib/rigor/inference/optimistic_origin.rb +152 -9
- data/lib/rigor/inference/parameter_inference_collector.rb +3 -2
- data/lib/rigor/inference/project_method_ownership.rb +136 -0
- data/lib/rigor/inference/project_patched_methods.rb +7 -2
- data/lib/rigor/inference/project_patched_scanner.rb +7 -3
- data/lib/rigor/inference/receiver_alias.rb +90 -1
- data/lib/rigor/inference/receiver_blind_block.rb +219 -0
- data/lib/rigor/inference/refinement_mutation.rb +15 -11
- data/lib/rigor/inference/repeated_or_writes.rb +463 -0
- data/lib/rigor/inference/return_barrier.rb +54 -0
- data/lib/rigor/inference/rewrite_mutation.rb +120 -0
- data/lib/rigor/inference/scope_indexer.rb +4615 -551
- data/lib/rigor/inference/statement_evaluator.rb +3176 -490
- data/lib/rigor/inference/stored_block_call.rb +54 -0
- data/lib/rigor/inference/string_mutation.rb +44 -7
- data/lib/rigor/inference/unknown_store_widening.rb +200 -0
- data/lib/rigor/inference/unthreaded_rebinds.rb +282 -0
- data/lib/rigor/language_server/debouncer.rb +0 -1
- data/lib/rigor/language_server/diagnostic_publisher.rb +3 -2
- data/lib/rigor/language_server/hover_renderer.rb +3 -3
- data/lib/rigor/language_server/project_context.rb +5 -3
- data/lib/rigor/mcp/server.rb +2 -1
- data/lib/rigor/plugin/base.rb +168 -5
- data/lib/rigor/plugin/box_probe.rb +91 -0
- data/lib/rigor/plugin/bundled_catalog.rb +1 -1
- data/lib/rigor/plugin/effect_attribution.rb +58 -4
- data/lib/rigor/plugin/loader.rb +2 -1
- data/lib/rigor/plugin/macro/block_as_method.rb +45 -6
- data/lib/rigor/plugin/manifest.rb +71 -10
- data/lib/rigor/plugin/registry.rb +35 -1
- data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
- data/lib/rigor/plugin/template_unit.rb +196 -0
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/protection/discovery_seed.rb +3 -1
- data/lib/rigor/protection/kill_signature.rb +0 -1
- data/lib/rigor/protection/mutation_cache.rb +1 -2
- data/lib/rigor/rbs_extended.rb +27 -0
- data/lib/rigor/reflection/constant_ancestors.rb +97 -0
- data/lib/rigor/reflection/constant_path.rb +19 -6
- data/lib/rigor/reflection.rb +72 -105
- data/lib/rigor/scope/discovery_index.rb +135 -2
- data/lib/rigor/scope.rb +859 -49
- data/lib/rigor/sig_gen/alias_index.rb +289 -0
- data/lib/rigor/sig_gen/classification.rb +22 -5
- data/lib/rigor/sig_gen/declaration_equivalence.rb +127 -0
- data/lib/rigor/sig_gen/effect_annotation.rb +202 -0
- data/lib/rigor/sig_gen/generator.rb +558 -31
- data/lib/rigor/sig_gen/inline_declarations.rb +184 -0
- data/lib/rigor/sig_gen/method_candidate.rb +47 -3
- data/lib/rigor/sig_gen/observation_collector.rb +1 -1
- data/lib/rigor/sig_gen/renderer.rb +124 -9
- data/lib/rigor/sig_gen/skip_reason_catalog.rb +44 -1
- data/lib/rigor/sig_gen/write_result.rb +19 -3
- data/lib/rigor/sig_gen/writer.rb +166 -35
- data/lib/rigor/sig_gen.rb +3 -0
- data/lib/rigor/signature_path_audit.rb +1 -1
- data/lib/rigor/source/node_walker.rb +0 -3
- data/lib/rigor/source/parameter_envelope.rb +72 -0
- data/lib/rigor/source.rb +1 -0
- data/lib/rigor/type/combinator.rb +88 -12
- data/lib/rigor/type/difference.rb +1 -0
- data/lib/rigor/type/hash_shape.rb +1 -1
- data/lib/rigor/type/refined.rb +1 -0
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +1 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +1 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +9 -5
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -4
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +0 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_scan.rb +96 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +65 -8
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/erb_compiler.rb +270 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +402 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +370 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_units.rb +132 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +239 -4
- data/plugins/rigor-actionpack/sig/action_view.rbs +43 -0
- data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +220 -0
- data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_index.rb +56 -0
- data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +263 -0
- data/plugins/rigor-active-model-serializers/lib/rigor-active-model-serializers.rb +7 -0
- data/plugins/rigor-active-model-serializers/sig/active_model_serializers.rbs +45 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +1 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +9 -5
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +35 -3
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +72 -12
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +380 -3
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +14 -6
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +281 -35
- data/plugins/rigor-activerecord/sig/active_record/relation.rbs +206 -31
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +25 -14
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +9 -2
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +171 -5
- data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +2 -0
- data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +1 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +1 -1
- data/plugins/rigor-grape/lib/rigor/plugin/grape.rb +141 -0
- data/plugins/rigor-grape/lib/rigor-grape.rb +6 -0
- data/plugins/rigor-grape/sig/grape.rbs +263 -0
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +72 -2
- data/plugins/rigor-graphql/sig/graphql.rbs +485 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +2 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +11 -7
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +35 -37
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +0 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +2 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +1 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +9 -17
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline/same_line_annotations.rb +149 -0
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +217 -36
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +1 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +9 -5
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +0 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +46 -1
- data/plugins/rigor-sorbet/sig/sorbet.rbs +277 -0
- data/sig/rigor/analysis/baseline.rbs +69 -7
- data/sig/rigor/analysis/fact_store.rbs +1 -1
- data/sig/rigor/analysis/project_scan.rbs +74 -0
- data/sig/rigor/effects/config_envelopes.rbs +100 -0
- data/sig/rigor/effects/effect_table.rbs +60 -0
- data/sig/rigor/effects/envelope.rbs +97 -0
- data/sig/rigor/effects/envelope_index.rbs +33 -0
- data/sig/rigor/effects/file_collection.rbs +81 -0
- data/sig/rigor/effects/label.rbs +26 -0
- data/sig/rigor/effects/label_set.rbs +46 -0
- data/sig/rigor/effects/method_key.rbs +24 -0
- data/sig/rigor/effects/origin.rbs +50 -0
- data/sig/rigor/effects/plugin_facts.rbs +141 -0
- data/sig/rigor/effects/registry.rbs +68 -0
- data/sig/rigor/effects/summary.rbs +50 -0
- data/sig/rigor/effects/taint_cause.rbs +12 -0
- data/sig/rigor/environment.rbs +12 -10
- data/sig/rigor/inference/optimistic_origin.rbs +10 -0
- data/sig/rigor/inference.rbs +6 -4
- data/sig/rigor/plugin/additional_initializer.rbs +36 -0
- data/sig/rigor/plugin/base.rbs +30 -7
- data/sig/rigor/plugin/effect_ancestry.rbs +27 -0
- data/sig/rigor/plugin/effect_attribution.rbs +66 -0
- data/sig/rigor/plugin/effect_edge.rbs +33 -0
- data/sig/rigor/plugin/effect_entry_points.rbs +25 -0
- data/sig/rigor/plugin/io_boundary.rbs +1 -1
- data/sig/rigor/plugin/loader.rbs +3 -3
- data/sig/rigor/plugin/manifest.rbs +50 -11
- data/sig/rigor/plugin/protocol_contract.rbs +68 -0
- data/sig/rigor/plugin/registry.rbs +62 -1
- data/sig/rigor/plugin.rbs +1 -1
- data/sig/rigor/rbs_extended.rbs +1 -1
- data/sig/rigor/reflection.rbs +7 -6
- data/sig/rigor/scope.rbs +135 -20
- data/sig/rigor/sig_gen/skip_reason_catalog.rbs +14 -7
- data/sig/rigor/source.rbs +4 -4
- data/sig/rigor/testing.rbs +10 -4
- data/sig/rigor/type.rbs +6 -0
- data/sig/rigor.rbs +38 -20
- data/skills/rigor-ask/SKILL.md +8 -5
- data/skills/rigor-baseline-reduce/SKILL.md +4 -6
- data/skills/rigor-ci-setup/SKILL.md +17 -21
- data/skills/rigor-doctor/SKILL.md +24 -22
- data/skills/rigor-doctor/references/01-checks.md +97 -33
- data/skills/rigor-editor-setup/SKILL.md +6 -4
- data/skills/rigor-mcp-setup/SKILL.md +5 -4
- data/skills/rigor-monkeypatch-resolve/SKILL.md +5 -3
- data/skills/rigor-next-steps/SKILL.md +4 -2
- data/skills/rigor-plugin-author/SKILL.md +19 -23
- data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +9 -8
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +12 -25
- data/skills/rigor-plugin-author/references/03-test-and-ship.md +6 -8
- data/skills/rigor-plugin-review/SKILL.md +6 -4
- data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +2 -1
- data/skills/rigor-plugin-tune/SKILL.md +4 -2
- data/skills/rigor-project-init/SKILL.md +9 -7
- data/skills/rigor-project-init/references/01-detect.md +13 -9
- data/skills/rigor-project-init/references/02-configure.md +33 -8
- data/skills/rigor-project-init/references/03-baseline-and-bugs.md +14 -14
- data/skills/rigor-project-init/references/04-sig-uplift.md +27 -14
- data/skills/rigor-protection-uplift/SKILL.md +4 -6
- data/skills/rigor-rbs-setup/SKILL.md +4 -2
- data/skills/rigor-type-oracle/SKILL.md +4 -6
- data/skills/rigor-type-oracle/references/01-oracle-commands.md +12 -8
- data/skills/rigor-type-oracle/references/03-gap-protocol.md +0 -3
- data/skills/rigor-unused-adjudicate/SKILL.md +4 -2
- data/skills/rigor-upgrade/SKILL.md +13 -8
- metadata +108 -1
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "controller_scan"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module Plugin
|
|
9
|
+
class Actionpack < Rigor::Plugin::Base
|
|
10
|
+
# #393 — which controller action renders which template, and what it assigned.
|
|
11
|
+
#
|
|
12
|
+
# A template unit's `ivar_seeds:` are the controller's assigns, and the design note (§ 11.3) names
|
|
13
|
+
# `ScopeIndexer`'s per-method definite-assignment table as the source. That table does not exist yet
|
|
14
|
+
# when a unit is built: `#template_units_for_file` runs on the parent BEFORE any analysis, which is
|
|
15
|
+
# what keeps the plugin off every hot path. So the seeds are derived here, syntactically, from the
|
|
16
|
+
# controller sources the plugin already reads for its filter-chain index.
|
|
17
|
+
#
|
|
18
|
+
# ## The inference is deliberately narrow
|
|
19
|
+
#
|
|
20
|
+
# Only `@user = Model.find(...)`-shaped assignments contribute, and only for a closed set of
|
|
21
|
+
# constructors and finders that **cannot return nil** — `find`, `find_by!`, `sole`, `first!`,
|
|
22
|
+
# `last!`, `new`, `create`, `create!`. A seeded type is a claim the engine acts on, and a wrong one
|
|
23
|
+
# is worse than none: seeding `@user` from `find_by` (which returns nil at runtime) would hand the
|
|
24
|
+
# flow rules a non-nil nominal and license folds that draw diagnostics on correct templates — the
|
|
25
|
+
# trap `Actionpack::STRONG_PARAMS_CHAIN_METHODS` documents at length for `Parameters#[]`. Anything
|
|
26
|
+
# else assigns nothing, the ivar stays unseeded, and the template reads it as `Dynamic`, which
|
|
27
|
+
# taints honestly (ADR-5). A call on that list that returns, or may return, SEVERAL records —
|
|
28
|
+
# `find(a, b)`, `find([1, 2])`, `create([{…}, {…}])` — is excluded by the same rule; see
|
|
29
|
+
# {Builder#single_record?}.
|
|
30
|
+
#
|
|
31
|
+
# Two assignments of the same ivar that disagree drop it for that template, for the same reason.
|
|
32
|
+
#
|
|
33
|
+
# ## Which templates an action reaches
|
|
34
|
+
#
|
|
35
|
+
# The implicit render (`UsersController#show` → `users/show`) plus the explicit forms
|
|
36
|
+
# {Analyzer.render_target_for} already recognises at a render site: `render :edit`,
|
|
37
|
+
# `render "admin/shared/form"`. A partial reached through `render partial:` inherits nothing: its
|
|
38
|
+
# assigns come from whichever template renders it, which this slice does not trace (see the
|
|
39
|
+
# follow-up named in `docs/internal-spec/macro-substrate.md`).
|
|
40
|
+
class ViewAssigns
|
|
41
|
+
# Finders and constructors whose Rails implementation raises rather than returning nil. Anything
|
|
42
|
+
# nil-able is excluded on purpose; see the class comment.
|
|
43
|
+
NON_NIL_PRODUCERS = %i[find find_by! find_sole_by sole first! last! new create create!].freeze
|
|
44
|
+
|
|
45
|
+
# `{ "users/show" => { "@user" => "User" } }`, logical template name (no format, no handler) to
|
|
46
|
+
# seeds. A template's unit looks itself up by the same name with the format stripped.
|
|
47
|
+
attr_reader :by_template
|
|
48
|
+
|
|
49
|
+
def initialize(by_template)
|
|
50
|
+
@by_template = by_template.freeze
|
|
51
|
+
freeze
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.empty
|
|
55
|
+
new({})
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# `users/show.html` → the seeds recorded for `users/show`. The format is dropped because a
|
|
59
|
+
# controller action renders one logical template across every format it responds to, and the
|
|
60
|
+
# assigns are the same for all of them.
|
|
61
|
+
#
|
|
62
|
+
# A PARTIAL (`users/_card.html`) has no action of its own, but an ivar is not a local: Rails puts
|
|
63
|
+
# the controller's assigns on the view context, so `@user` reads the same inside a partial as in
|
|
64
|
+
# the template that rendered it. Its seeds are therefore the union of the seeds of the templates
|
|
65
|
+
# in its own directory — the directory is the controller, by Rails' own convention — with any ivar
|
|
66
|
+
# the members type differently dropped, the same conflict rule that applies within one action.
|
|
67
|
+
#
|
|
68
|
+
# That is an approximation in one direction only: a partial rendered from ANOTHER controller's
|
|
69
|
+
# view sees that controller's assigns, which are not in this union, so the ivar stays unseeded and
|
|
70
|
+
# reads `Dynamic`. Tracing render sites across templates is the follow-up
|
|
71
|
+
# `docs/internal-spec/macro-substrate.md` names.
|
|
72
|
+
def seeds_for(logical_name)
|
|
73
|
+
name = logical_name.sub(%r{\.[^./]+\z}, "")
|
|
74
|
+
return @by_template.fetch(name, {}) unless partial?(name)
|
|
75
|
+
|
|
76
|
+
directory_seeds(name)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def partial?(name)
|
|
80
|
+
File.basename(name).start_with?("_")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def directory_seeds(name)
|
|
84
|
+
prefix = name.include?("/") ? "#{File.dirname(name)}/" : ""
|
|
85
|
+
seeds = {}
|
|
86
|
+
conflicts = []
|
|
87
|
+
@by_template.each do |template, assigns|
|
|
88
|
+
next unless template.start_with?(prefix) && !template.delete_prefix(prefix).include?("/")
|
|
89
|
+
|
|
90
|
+
assigns.each do |ivar, type_name|
|
|
91
|
+
conflicts << ivar if seeds.key?(ivar) && seeds[ivar] != type_name
|
|
92
|
+
seeds[ivar] = type_name
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
conflicts.each { |ivar| seeds.delete(ivar) }
|
|
96
|
+
seeds
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Builds the index from the controller sources under `search_paths`.
|
|
100
|
+
class Builder
|
|
101
|
+
def initialize(io_boundary:, search_paths:)
|
|
102
|
+
@io_boundary = io_boundary
|
|
103
|
+
@search_paths = search_paths
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def build
|
|
107
|
+
by_template = {}
|
|
108
|
+
controller_files.each { |path| harvest(path, by_template) }
|
|
109
|
+
ViewAssigns.new(by_template.transform_values(&:freeze))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def controller_files
|
|
115
|
+
@search_paths.flat_map do |root|
|
|
116
|
+
absolute = File.expand_path(root)
|
|
117
|
+
next [] unless @io_boundary.directory?(absolute)
|
|
118
|
+
|
|
119
|
+
Dir.glob(File.join(absolute, "**", "*.rb"))
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def harvest(path, by_template)
|
|
124
|
+
contents = @io_boundary.read_file(path)
|
|
125
|
+
result = Prism.parse(contents)
|
|
126
|
+
return unless result.errors.empty?
|
|
127
|
+
|
|
128
|
+
ControllerScan.each_controller(result.value, []) do |node, namespace|
|
|
129
|
+
harvest_controller(node, namespace, by_template)
|
|
130
|
+
end
|
|
131
|
+
rescue Plugin::AccessDeniedError, Errno::ENOENT
|
|
132
|
+
nil
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def harvest_controller(node, namespace, by_template)
|
|
136
|
+
segments = namespace + ControllerScan.constant_segments(node.constant_path)
|
|
137
|
+
prefix = ControllerScan.controller_path(segments)
|
|
138
|
+
return if prefix.nil?
|
|
139
|
+
|
|
140
|
+
methods = ControllerScan.method_bodies(node)
|
|
141
|
+
filters = filter_chain(node)
|
|
142
|
+
methods.each do |name, body|
|
|
143
|
+
assigns = filter_assigns(filters, name, methods).merge(ivar_assigns(body))
|
|
144
|
+
next if assigns.empty?
|
|
145
|
+
|
|
146
|
+
templates_for(name, body, prefix).each do |template|
|
|
147
|
+
merge_seeds(by_template, template, assigns)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# `before_action :set_user, only: %i[show edit]` → `[[:set_user, [:show, :edit], nil]]`.
|
|
153
|
+
# `except:` rides the third slot. `prepend_before_action` is the same chain; `after_action` and
|
|
154
|
+
# `around_action` are not — an assign made after the render cannot be a seed.
|
|
155
|
+
def filter_chain(node)
|
|
156
|
+
body = node.body
|
|
157
|
+
return [] if body.nil?
|
|
158
|
+
|
|
159
|
+
body.child_nodes.compact.flat_map do |child|
|
|
160
|
+
next [] unless child.is_a?(Prism::CallNode) && child.receiver.nil?
|
|
161
|
+
next [] unless %i[before_action prepend_before_action].include?(child.name)
|
|
162
|
+
|
|
163
|
+
filter_entries(child)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def filter_entries(call)
|
|
168
|
+
arguments = call.arguments&.arguments || []
|
|
169
|
+
options = arguments.last.is_a?(Prism::KeywordHashNode) ? arguments.pop : nil
|
|
170
|
+
names = arguments.filter_map { |argument| argument.is_a?(Prism::SymbolNode) ? argument.unescaped.to_sym : nil }
|
|
171
|
+
only = symbol_list(options, :only)
|
|
172
|
+
except = symbol_list(options, :except)
|
|
173
|
+
conditional = %i[if unless].any? { |key| option?(options, key) }
|
|
174
|
+
names.map { |name| [name, only, except, conditional] }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def option?(options, key)
|
|
178
|
+
!pair_for(options, key).nil?
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def symbol_list(options, key)
|
|
182
|
+
pair = pair_for(options, key)
|
|
183
|
+
return nil if pair.nil?
|
|
184
|
+
|
|
185
|
+
symbols_in(pair.value)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def pair_for(options, key)
|
|
189
|
+
return nil if options.nil?
|
|
190
|
+
|
|
191
|
+
options.elements.find do |element|
|
|
192
|
+
element.is_a?(Prism::AssocNode) && element.key.is_a?(Prism::SymbolNode) &&
|
|
193
|
+
element.key.unescaped.to_sym == key
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def symbols_in(node)
|
|
198
|
+
case node
|
|
199
|
+
when Prism::SymbolNode then [node.unescaped.to_sym]
|
|
200
|
+
when Prism::ArrayNode then node.elements.filter_map { |e| e.unescaped.to_sym if e.is_a?(Prism::SymbolNode) }
|
|
201
|
+
else []
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# The assigns every `before_action` that UNCONDITIONALLY runs for `action` made, in chain order,
|
|
206
|
+
# so a later filter's assignment of the same ivar wins — and the action's own assignment wins
|
|
207
|
+
# over all of them, which is why the caller merges this UNDER the action's.
|
|
208
|
+
#
|
|
209
|
+
# `only:` / `except:` are decided here, per action, because they are static. `if:` / `unless:`
|
|
210
|
+
# cannot be: the filter may not run, so nothing it assigns is definite, and seeding from it
|
|
211
|
+
# would claim a non-nil type for an ivar that is nil at render time — see {#definite?}.
|
|
212
|
+
def filter_assigns(filters, action, methods)
|
|
213
|
+
filters.each_with_object({}) do |(name, only, except, conditional), seeds|
|
|
214
|
+
next if conditional
|
|
215
|
+
next if only && !only.include?(action)
|
|
216
|
+
next if except&.include?(action)
|
|
217
|
+
|
|
218
|
+
body = methods[name]
|
|
219
|
+
next if body.nil?
|
|
220
|
+
|
|
221
|
+
seeds.merge!(ivar_assigns(body))
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# `@user = User.find(params[:id])` → `{ "@user" => "User" }`.
|
|
226
|
+
#
|
|
227
|
+
# **Definite assignments only.** An assignment the method may not reach — inside an `if`, a
|
|
228
|
+
# `case`, a `rescue`, a loop, or a block that may not run — does not contribute, because a seed
|
|
229
|
+
# is a claim about what the template will FIND, and `@user = User.find(1) if params[:pick]`
|
|
230
|
+
# leaves `@user` nil on the other path. A non-nil nominal standing in for a runtime nil is the
|
|
231
|
+
# `Parameters#[]` trap (`Actionpack::STRONG_PARAMS_CHAIN_METHODS`): the flow rules act on it and
|
|
232
|
+
# report live branches, which is exactly what a reader of a template would see with
|
|
233
|
+
# `view_type_checks:` on. The unseeded ivar reads `Dynamic` instead and taints honestly (ADR-5).
|
|
234
|
+
#
|
|
235
|
+
# Two disagreeing types drop the ivar for the same reason.
|
|
236
|
+
def ivar_assigns(body)
|
|
237
|
+
seeds = {}
|
|
238
|
+
conflicts = []
|
|
239
|
+
walk_assignments(body) do |name, type_name|
|
|
240
|
+
existing = seeds[name]
|
|
241
|
+
conflicts << name if existing && existing != type_name
|
|
242
|
+
seeds[name] = type_name
|
|
243
|
+
end
|
|
244
|
+
conflicts.each { |name| seeds.delete(name) }
|
|
245
|
+
seeds
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# The nodes a method body reaches on EVERY path: its own statement list, a parenthesised
|
|
249
|
+
# expression, and a `begin` that cannot be cut short. Everything else — `if` / `unless` /
|
|
250
|
+
# `case` / `while` / `until` / `rescue` / `for`, and any block — is a branch, so the walk stops
|
|
251
|
+
# there rather than descending.
|
|
252
|
+
def walk_assignments(node, &)
|
|
253
|
+
return unless node.is_a?(Prism::Node)
|
|
254
|
+
|
|
255
|
+
if node.is_a?(Prism::InstanceVariableWriteNode)
|
|
256
|
+
type_name = produced_type(node.value)
|
|
257
|
+
yield node.name.to_s, type_name if type_name
|
|
258
|
+
return
|
|
259
|
+
end
|
|
260
|
+
return unless definite?(node)
|
|
261
|
+
|
|
262
|
+
node.rigor_each_child { |child| walk_assignments(child, &) }
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# A `BeginNode` carrying a `rescue_clause` is NOT every-path, and that covers `def show; … rescue
|
|
266
|
+
# …; end` as well as an explicit `begin`: an exception raised by the first statement leaves the
|
|
267
|
+
# rest unassigned, and if the rescue renders anything the template reads a nil ivar. An
|
|
268
|
+
# `ensure`-only `begin` has no such exit and stays. The modifier form
|
|
269
|
+
# (`@u = User.find(1) rescue render :missing`) never seeds either — its value node is a
|
|
270
|
+
# `RescueModifierNode`, which {#produced_type} does not recognise.
|
|
271
|
+
def definite?(node)
|
|
272
|
+
return node.rescue_clause.nil? if node.is_a?(Prism::BeginNode)
|
|
273
|
+
|
|
274
|
+
node.is_a?(Prism::StatementsNode) || node.is_a?(Prism::ParenthesesNode)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# The narrow inference. `Model.find(…)` / `Model.new` → `"Model"`, and nothing else.
|
|
278
|
+
def produced_type(value)
|
|
279
|
+
return nil unless value.is_a?(Prism::CallNode)
|
|
280
|
+
return nil unless NON_NIL_PRODUCERS.include?(value.name)
|
|
281
|
+
return nil unless single_record?(value)
|
|
282
|
+
|
|
283
|
+
receiver = value.receiver
|
|
284
|
+
return nil unless receiver.is_a?(Prism::ConstantReadNode) || receiver.is_a?(Prism::ConstantPathNode)
|
|
285
|
+
|
|
286
|
+
name = ControllerScan.constant_segments(receiver).join("::")
|
|
287
|
+
name.empty? ? nil : name
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Argument shapes that can stand for several ids, or several attribute hashes, at once.
|
|
291
|
+
LIST_ARGUMENTS = [Prism::SplatNode, Prism::ArrayNode, Prism::ForwardingArgumentsNode].freeze
|
|
292
|
+
private_constant :LIST_ARGUMENTS
|
|
293
|
+
|
|
294
|
+
# Whether the call returns ONE record. A seed names a class and nothing more — `resolve` in
|
|
295
|
+
# `Analysis::TemplateUnits` looks the string up as a nominal — so an `Array[Model]` result has no
|
|
296
|
+
# spelling here, and seeding the element type would contradict rigor-activerecord, which types the
|
|
297
|
+
# controller's own `Model.find(a, b)` as `Array[Model]` (#1321).
|
|
298
|
+
#
|
|
299
|
+
# `find` therefore seeds only for exactly one plain positional argument and no block. Two or more
|
|
300
|
+
# ids return an Array. So may `find([1, 2])` and `find(*ids)`: rigor-activerecord keeps the model
|
|
301
|
+
# for both, because a composite key's tuple is one record, but the view declines where the runtime
|
|
302
|
+
# answer may be an Array, which leaves it `Dynamic` rather than contradicting the controller. A
|
|
303
|
+
# block hands the call to `Enumerable#find`, which the arity rule does not describe. One argument
|
|
304
|
+
# that merely EVALUATES to an Array (`find(params[:ids])`, `create(rows)`) cannot be told apart from
|
|
305
|
+
# one id or one attribute hash — the limit the bundled `Relation#find` RBS states too — and keeps
|
|
306
|
+
# the model, as it does there. `create` / `create!` given an Array of attribute hashes return one
|
|
307
|
+
# record per hash.
|
|
308
|
+
def single_record?(call)
|
|
309
|
+
arguments = call.arguments&.arguments || []
|
|
310
|
+
case call.name
|
|
311
|
+
when :find
|
|
312
|
+
arguments.size == 1 && call.block.nil? && !list_argument?(arguments.first) &&
|
|
313
|
+
!arguments.first.is_a?(Prism::KeywordHashNode)
|
|
314
|
+
when :create, :create!
|
|
315
|
+
arguments.none? { |argument| list_argument?(argument) }
|
|
316
|
+
else
|
|
317
|
+
true
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def list_argument?(node)
|
|
322
|
+
LIST_ARGUMENTS.any? { |klass| node.is_a?(klass) }
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# The implicit render plus every explicit one the body spells. An action that renders nothing
|
|
326
|
+
# recognisable still gets its implicit template — that is Rails' default, not a guess.
|
|
327
|
+
def templates_for(action, body, prefix)
|
|
328
|
+
templates = ["#{prefix}/#{action}"]
|
|
329
|
+
ControllerScan.each_render(body) do |node|
|
|
330
|
+
target = render_target(node, prefix)
|
|
331
|
+
templates << target if target
|
|
332
|
+
end
|
|
333
|
+
templates.uniq
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# `render :edit` and `render "admin/shared/form"` only. `render partial:` is excluded: a partial's
|
|
337
|
+
# bindings are the render site's `locals:` — which {RenderLocals} reads — not the action's
|
|
338
|
+
# assigns, and seeding it with the latter would state something the call site did not.
|
|
339
|
+
def render_target(node, prefix)
|
|
340
|
+
case (first = node.arguments&.arguments&.first)
|
|
341
|
+
when Prism::SymbolNode then "#{prefix}/#{first.unescaped}"
|
|
342
|
+
when Prism::StringNode then first.unescaped
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# A template rendered by two actions gets the UNION of their assigns, and an ivar the two type
|
|
347
|
+
# differently is dropped — the same rule {#ivar_assigns} applies within one action, for the same
|
|
348
|
+
# reason. Rails really does render one template from several actions (`new` and `create` on a
|
|
349
|
+
# validation failure), and the seeds have to describe both.
|
|
350
|
+
def merge_seeds(by_template, template, assigns)
|
|
351
|
+
existing = by_template[template]
|
|
352
|
+
if existing.nil?
|
|
353
|
+
by_template[template] = assigns.dup
|
|
354
|
+
return
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
assigns.each do |name, type_name|
|
|
358
|
+
if existing.key?(name) && existing[name] != type_name
|
|
359
|
+
existing[name] = nil
|
|
360
|
+
else
|
|
361
|
+
existing[name] ||= type_name
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
existing.compact!
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Plugin
|
|
5
|
+
class Actionpack < Rigor::Plugin::Base
|
|
6
|
+
# #393 — the naming and binding rules that turn one ERB file into a {Plugin::TemplateUnit}.
|
|
7
|
+
# The compiler is {ErbCompiler}; the controller-side seeds are {ViewAssigns}.
|
|
8
|
+
module ViewUnits
|
|
9
|
+
# The `self` every view body is typed as. A bare, RBS-declared, OPEN nominal: `sig/action_view.rbs`
|
|
10
|
+
# names the class so the constant resolves and `open_receivers:` keeps its method surface lenient,
|
|
11
|
+
# which is what makes `link_to`, `form_with`, `t`, `turbo_frame_tag` and every other helper —
|
|
12
|
+
# including the project's own `ApplicationHelper` methods, which are implicit-self calls on this
|
|
13
|
+
# receiver — resolve lenient-to-`Dynamic` rather than drawing a finding per line.
|
|
14
|
+
#
|
|
15
|
+
# Enumerating the helper surface in RBS was considered and refused, for the reason
|
|
16
|
+
# `sig/action_controller.rbs` gives about `ActionController::Parameters`: a declared class drops
|
|
17
|
+
# every member its signature omits, and no signature completes a surface that `helper_method`,
|
|
18
|
+
# every `*Helper` module in the project and a dozen gems all extend. The per-controller view class
|
|
19
|
+
# the design note describes (§ 11.3 — `ActionView::Base` + `ApplicationHelper` + `<C>Helper` +
|
|
20
|
+
# route helpers) is the same shape one level finer, and needs a way for a plugin to synthesise a
|
|
21
|
+
# class; it is the follow-up named in `docs/internal-spec/macro-substrate.md`.
|
|
22
|
+
SELF_TYPE = "ActionView::Base"
|
|
23
|
+
|
|
24
|
+
# The type a strict-locals name is seeded with. Deliberately NOT declared in `sig/action_view.rbs`
|
|
25
|
+
# and deliberately not a real class: the engine binds an unresolvable name to `Dynamic[top]`
|
|
26
|
+
# (`Analysis::TemplateUnits#resolve`), which is the honest reading — the comment states the
|
|
27
|
+
# parameter's NAME, and Rails' strict-locals syntax carries no type. What the seed buys is the
|
|
28
|
+
# name: without it Prism reads a bare `user` as a method call and the binding is never consulted.
|
|
29
|
+
UNKNOWN_LOCAL = "ActionView::UnknownLocal"
|
|
30
|
+
|
|
31
|
+
# Rails 7.1's strict-locals magic comment: `<%# locals: (user:, size: :md) %>`. Only the first one
|
|
32
|
+
# counts, as in Rails, and only the names are read — a default value is a default, not a type.
|
|
33
|
+
STRICT_LOCALS = /<%#-?\s*locals:\s*\(([^)]*)\)\s*-?%>/
|
|
34
|
+
private_constant :STRICT_LOCALS
|
|
35
|
+
|
|
36
|
+
LOCAL_NAME = /([a-z_][A-Za-z0-9_]*):/
|
|
37
|
+
private_constant :LOCAL_NAME
|
|
38
|
+
|
|
39
|
+
# #1047 — the ways a template tests for its OWN optional local, each capturing the name:
|
|
40
|
+
# `defined?(size)` / `defined? size`, `local_assigns[:size]`, and `local_assigns.key?(:size)` with
|
|
41
|
+
# its `has_key?` / `include?` / `fetch` spellings.
|
|
42
|
+
#
|
|
43
|
+
# The `defined?` forms take the name only when it is the WHOLE operand — `defined?(size)` closed by
|
|
44
|
+
# its parenthesis, or `defined? size` followed by the end of the expression — so
|
|
45
|
+
# `defined?(link_to "x", y)` and `defined?(obj.meth)` name nothing.
|
|
46
|
+
SELF_DECLARED = [
|
|
47
|
+
/\bdefined\?\s*\(\s*([a-z_][A-Za-z0-9_]*)\s*\)/,
|
|
48
|
+
/\bdefined\?[ \t]+([a-z_][A-Za-z0-9_]*)(?=\s*(?:%>|\)|&&|\|\||;|\n|\z|\b(?:and|or|then|if|unless)\b))/,
|
|
49
|
+
/\blocal_assigns\s*\[\s*:([a-z_][A-Za-z0-9_]*)\s*\]/,
|
|
50
|
+
/\blocal_assigns\s*\.\s*(?:key\?|has_key\?|include\?|fetch)\s*\(?\s*:([a-z_][A-Za-z0-9_]*)/
|
|
51
|
+
].freeze
|
|
52
|
+
private_constant :SELF_DECLARED
|
|
53
|
+
|
|
54
|
+
# A tested name that is a Ruby keyword or a pseudo-variable is not a local: `defined?(super)`,
|
|
55
|
+
# `defined?(yield)` and `defined?(self)` all read something else.
|
|
56
|
+
NOT_LOCALS = %w[
|
|
57
|
+
super yield self nil true false __method__ __FILE__ __LINE__ __dir__ __encoding__ binding
|
|
58
|
+
block_given
|
|
59
|
+
].freeze
|
|
60
|
+
private_constant :NOT_LOCALS
|
|
61
|
+
|
|
62
|
+
# Every ERB tag that holds Ruby — the only place a template's own tests live. `<%%` is literal text
|
|
63
|
+
# and `<%#` is a comment, so `<%# if defined?(old_local) %>` declares nothing.
|
|
64
|
+
RUBY_TAG = /<%(?![%#]).*?%>/m
|
|
65
|
+
private_constant :RUBY_TAG
|
|
66
|
+
|
|
67
|
+
module_function
|
|
68
|
+
|
|
69
|
+
# `app/views/users/show.html.erb` → `users/show.html`; `app/views/users/_card.html.erb` →
|
|
70
|
+
# `users/_card.html`. The handler is dropped and the format is kept, which is Rails' own logical
|
|
71
|
+
# name and what the seam asks for so an ERB → Haml rewrite is not a rename. A template with no
|
|
72
|
+
# format segment (`users/show.erb`) keeps the name it has.
|
|
73
|
+
def logical_name(path, roots)
|
|
74
|
+
relative = strip_root(path, roots)
|
|
75
|
+
relative.sub(/\.erb\z/, "")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def strip_root(path, roots)
|
|
79
|
+
roots.each do |root|
|
|
80
|
+
prefix = "#{root.to_s.sub(%r{\A\./}, '').chomp('/')}/"
|
|
81
|
+
return path.delete_prefix(prefix) if path.start_with?(prefix)
|
|
82
|
+
end
|
|
83
|
+
path
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# #1047 — `{ "path" => UNKNOWN_LOCAL }` for every bare local a template tests for itself:
|
|
87
|
+
#
|
|
88
|
+
# <% path = nil unless defined? path %>
|
|
89
|
+
# <% size = local_assigns.fetch(:size, :md) %>
|
|
90
|
+
#
|
|
91
|
+
# That test IS the author's declaration that `path` is an optional local, and it needs no render
|
|
92
|
+
# site to be believed. It matters because a render site is not always readable: `locals: opts`
|
|
93
|
+
# passes a computed hash, `locals: { **opts }` a splat, a helper in `app/helpers` renders without
|
|
94
|
+
# being scanned, and an optional local with a default is by definition passed nowhere. Leaving any
|
|
95
|
+
# of those names unbound makes the preamble a fresh nil assignment in the compiled Ruby, and the
|
|
96
|
+
# `flow.` rules fold every branch it guards on a partial Rails renders correctly. Binding it costs
|
|
97
|
+
# nothing — over-binding is the safe direction ({RenderLocals}) — so every such name is seeded,
|
|
98
|
+
# typed `Dynamic`.
|
|
99
|
+
#
|
|
100
|
+
# A name the template ALSO assigns without such a test (`<% total = 0 %>`) is untouched: it is only
|
|
101
|
+
# seeded when it is tested, and a test on a name the body always assigns first is dead code the
|
|
102
|
+
# binding cannot make worse.
|
|
103
|
+
#
|
|
104
|
+
# A name a project HELPER defines is not seeded (`helpers:`, the `def` names under `app/helpers`):
|
|
105
|
+
# `<% if defined?(current_user) && current_user %>` in a shared partial tests a helper, and binding
|
|
106
|
+
# it as a local would turn every later `current_user` from a call into a `Dynamic` read. The trade
|
|
107
|
+
# left is stated in the manual: a helper that a gem or a concern defines is not seen by that scan,
|
|
108
|
+
# so the name is still seeded — which reads `Dynamic` where it would otherwise have been an
|
|
109
|
+
# open-receiver call, and no finding either way.
|
|
110
|
+
def self_declared_locals(source, helpers: {})
|
|
111
|
+
source.scan(RUBY_TAG).each_with_object({}) do |tag, locals|
|
|
112
|
+
SELF_DECLARED.each do |pattern|
|
|
113
|
+
tag.scan(pattern).flatten.each do |name|
|
|
114
|
+
next if NOT_LOCALS.include?(name) || helpers.key?(name)
|
|
115
|
+
|
|
116
|
+
locals[name] = UNKNOWN_LOCAL
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# `{ "user" => UNKNOWN_LOCAL }` for a template carrying the strict-locals comment, `{}` otherwise.
|
|
123
|
+
def strict_locals(source)
|
|
124
|
+
match = STRICT_LOCALS.match(source)
|
|
125
|
+
return {} if match.nil?
|
|
126
|
+
|
|
127
|
+
match[1].scan(LOCAL_NAME).flatten.to_h { |name| [name, UNKNOWN_LOCAL] }
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|