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,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../source/constant_path"
|
|
6
|
+
require_relative "file_collection"
|
|
7
|
+
|
|
8
|
+
module Rigor
|
|
9
|
+
module Effects
|
|
10
|
+
# The ancestry half of a file's {FileCollection}: what each class declares above itself, what it
|
|
11
|
+
# includes, and where the scan has to admit it could not read the ancestry at all.
|
|
12
|
+
#
|
|
13
|
+
# Split out of {Scanner} because the three questions are one subject with one invariant — a name goes
|
|
14
|
+
# in **as written**, and the propagator, which is the only place the whole project is in view, decides
|
|
15
|
+
# which constant it meant. The scanner owns unit identity and delegates this; the two tables are handed
|
|
16
|
+
# back by {#superclasses} / {#includes} and go into the collection unchanged.
|
|
17
|
+
class AncestryRecorder
|
|
18
|
+
attr_reader :superclasses, :includes
|
|
19
|
+
|
|
20
|
+
def initialize
|
|
21
|
+
@superclasses = {}
|
|
22
|
+
@includes = {}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# `class Loud < Base` inside `module Tracer` names `Base`, so the candidates go in and the propagator
|
|
26
|
+
# picks the one the merged project defines.
|
|
27
|
+
#
|
|
28
|
+
# A superclass expression that is **not** a constant path — `class K < Struct.new(:a)`,
|
|
29
|
+
# `< Data.define(:a)`, `< DelegateClass(X)`, `< Sequel::Model(:t)` — records
|
|
30
|
+
# {FileCollection::OPAQUE_ANCESTOR} rather than nothing (#1039). Recording nothing made it
|
|
31
|
+
# indistinguishable from a class with no `<` at all, which is the one reading the constructor rule
|
|
32
|
+
# turns into an answer; such a class inherits a constructor built at load time, which is the
|
|
33
|
+
# opposite of an absent one.
|
|
34
|
+
def record_superclass(full_name, node, prefix)
|
|
35
|
+
return if node.superclass.nil? || opaque?(full_name)
|
|
36
|
+
|
|
37
|
+
superclass = Source::ConstantPath.qualified_name(node.superclass)
|
|
38
|
+
@superclasses[full_name] =
|
|
39
|
+
superclass ? lexical_candidates(superclass, prefix) : [FileCollection::OPAQUE_ANCESTOR]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# A class BUILT AT LOAD TIME and assigned to a constant — `Anon = Class.new(Base)`,
|
|
43
|
+
# `Point = Struct.new(:x)`, `Rec = Data.define(:x)`, `Dele = DelegateClass(X)` — records the opaque
|
|
44
|
+
# sentinel (#1039). Without it a later `class Point; def more; end; end` reopening is the only thing
|
|
45
|
+
# the scan sees: the class becomes project-known, nothing says what is above it, and the constructor
|
|
46
|
+
# rule would read that silence as "no constructor anywhere" when the constructor is precisely what
|
|
47
|
+
# the load-time call built. It is the `class K < Struct.new(:a)` case in its other spelling.
|
|
48
|
+
#
|
|
49
|
+
# Only a **call**-valued assignment qualifies. A literal cannot be a class, and a constant-path value
|
|
50
|
+
# (`Alias = Real`) is a second name for a class whose own ancestry the scan already recorded.
|
|
51
|
+
#
|
|
52
|
+
# The sentinel is **sticky**: it overwrites a spelled `< Base` and no later one displaces it, here or
|
|
53
|
+
# in {FileCollection.merge_all}. A reopening's `class Anon < Base` is not more than this says but
|
|
54
|
+
# less — `Anon = Class.new(Base) { def initialize; … end }` puts a constructor in the block, which the
|
|
55
|
+
# scan files under the enclosing namespace and cannot attribute to `Anon` at all, and the spelled
|
|
56
|
+
# superclass says nothing about it. Sticky is also what makes the answer independent of which file a
|
|
57
|
+
# run reads first.
|
|
58
|
+
#
|
|
59
|
+
# `Class.new(Base)` additionally keeps `Base` beside the sentinel. The sentinel still declines
|
|
60
|
+
# `Anon.new` itself, and the parent link is what files `Anon` under `Base` in the subclass index, so
|
|
61
|
+
# a `self.class.new` in `Base` — which the closed-world join says may construct `Anon` — sees an
|
|
62
|
+
# unreadable constructor below it and declines too.
|
|
63
|
+
def record_constant_class(node, prefix)
|
|
64
|
+
value = node.value
|
|
65
|
+
return unless value.is_a?(Prism::CallNode)
|
|
66
|
+
|
|
67
|
+
name = constant_write_name(node, prefix)
|
|
68
|
+
return if name.nil?
|
|
69
|
+
|
|
70
|
+
@superclasses[name] = [FileCollection::OPAQUE_ANCESTOR, *load_time_parent(value, prefix)]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# A receiver-less `include` / `prepend` in `class_name`'s body, its constant arguments recorded as
|
|
74
|
+
# written.
|
|
75
|
+
#
|
|
76
|
+
# Both are calls on `self`, like `define_method`, and the include table is the instance ancestry that
|
|
77
|
+
# `super` and the constructor rule walk, so a call is recorded only where `self` is the class the body
|
|
78
|
+
# opened (see {#instance_side_self?}). Inside `class << self`, or a `class_eval` / `instance_eval`
|
|
79
|
+
# block on `singleton_class`, it mixes the module into the singleton class, which is what `extend`
|
|
80
|
+
# does, and the collection is as blind to it as it is to `extend`.
|
|
81
|
+
#
|
|
82
|
+
# @param context — the {DefinitionContext} of the class-body position the call sits at
|
|
83
|
+
def record_includes(class_name, node, prefix, context)
|
|
84
|
+
return unless instance_side_self?(context)
|
|
85
|
+
|
|
86
|
+
names = node.arguments&.arguments&.filter_map { |argument| Source::ConstantPath.qualified_name(argument) }
|
|
87
|
+
candidates = (names || []).flat_map { |name| lexical_candidates(name, prefix) }
|
|
88
|
+
(@includes[class_name] ||= []).concat(candidates) unless candidates.empty?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# `alias initialize setup` / `alias_method :initialize, :setup` makes the constructor another
|
|
92
|
+
# method's body, and the scan models no aliases at all (#1039). The opaque sentinel is the minimal
|
|
93
|
+
# honest answer: the ancestry stops being readable, the constructor rule declines, and this class's
|
|
94
|
+
# callers stay exactly as unclaimed as they were before that rule existed.
|
|
95
|
+
def record_initialize_alias(class_name)
|
|
96
|
+
(@includes[class_name] ||= []) << FileCollection::OPAQUE_ANCESTOR
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Whether this node aliases the instance-side `initialize`, in either spelling. A `CallNode` qualifies
|
|
100
|
+
# only as a receiver-less `alias_method` whose first symbol argument is the new name.
|
|
101
|
+
#
|
|
102
|
+
# `alias` works on the default definee and `alias_method` on `self`, so the two part in an
|
|
103
|
+
# `instance_eval` block, and inside `class << self` both alias the singleton class's `initialize`,
|
|
104
|
+
# a class method `new` never calls. Where the syntax does not say which class either one is, it
|
|
105
|
+
# records nothing here, as an include there does not (see {#instance_side_self?}).
|
|
106
|
+
#
|
|
107
|
+
# @param context — the {DefinitionContext} of the class-body position the node sits at
|
|
108
|
+
def alias_to_initialize?(node, context)
|
|
109
|
+
case node
|
|
110
|
+
when Prism::AliasMethodNode
|
|
111
|
+
context.definee_singleton == false && literal_name(node.new_name) == "initialize"
|
|
112
|
+
when Prism::CallNode
|
|
113
|
+
node.receiver.nil? && node.name == :alias_method && instance_side_self?(context) &&
|
|
114
|
+
literal_name(first_argument(node)) == "initialize"
|
|
115
|
+
else false
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# An ancestry name is recorded AS WRITTEN — a single file cannot say which constant it resolves to.
|
|
120
|
+
# So the candidates Ruby's own lexical lookup would try go in, most-qualified first, and the
|
|
121
|
+
# propagator picks the one the merged project actually defines. Same shape as `ScopeIndexer`'s
|
|
122
|
+
# as-written superclass table, resolved at the same point: when the whole project is in view.
|
|
123
|
+
def lexical_candidates(name, prefix)
|
|
124
|
+
return [name] if prefix.empty? || name.start_with?("#{prefix.join('::')}::")
|
|
125
|
+
|
|
126
|
+
prefix.length.downto(1).map { |depth| "#{prefix.first(depth).join('::')}::#{name}" } + [name]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
# Whether the syntax shows that a call on `self` written here works on the class the enclosing body
|
|
132
|
+
# opened, or on `Object` through `main` at the top level, so what it declares is that class's instance
|
|
133
|
+
# ancestry.
|
|
134
|
+
#
|
|
135
|
+
# It does not where `self` is the singleton class, nor where the syntax does not say what `self` is:
|
|
136
|
+
# the block of `Class.new` and its kin, an eval on another receiver, `class << obj`. The syntax does not
|
|
137
|
+
# name the class the module goes into there, and filing it under the enclosing class would put it
|
|
138
|
+
# where `super` and the constructor rule look for that class. The class Ruby does put it in misses it,
|
|
139
|
+
# as it would an `extend`, and so does `W` for a `W.class_eval { include M }` inside `class W`, which
|
|
140
|
+
# the context reads as an eval on any other receiver (#1322).
|
|
141
|
+
def instance_side_self?(context)
|
|
142
|
+
!context.self_singleton_class? && context.self_kind != :unknown
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def opaque?(full_name)
|
|
146
|
+
@superclasses.fetch(full_name, []).include?(FileCollection::OPAQUE_ANCESTOR)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The superclass a `Class.new(Base)` names, as candidates. Read from `Class.new` alone: it is the one
|
|
150
|
+
# load-time builder whose first argument IS the superclass, and inventing an ancestry edge from any
|
|
151
|
+
# other call's first constant argument would let an unrelated class's methods resolve through it.
|
|
152
|
+
def load_time_parent(value, prefix)
|
|
153
|
+
return [] unless value.name == :new && constant_receiver_name(value.receiver) == "Class"
|
|
154
|
+
|
|
155
|
+
argument = value.arguments&.arguments&.first
|
|
156
|
+
name = argument && Source::ConstantPath.qualified_name(argument)
|
|
157
|
+
name ? lexical_candidates(name, prefix) : []
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def constant_receiver_name(receiver)
|
|
161
|
+
Source::ConstantPath.qualified_name(receiver) if receiver.is_a?(Prism::ConstantReadNode)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# `alias_method :initialize, :setup` and `alias_method "initialize", "setup"` are the same
|
|
165
|
+
# declaration; only an interpolated name is beyond the scan.
|
|
166
|
+
def literal_name(node)
|
|
167
|
+
node.unescaped if node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The key a constant assignment declares, qualified by the nesting it is written in.
|
|
171
|
+
def constant_write_name(node, prefix)
|
|
172
|
+
case node
|
|
173
|
+
when Prism::ConstantWriteNode then [*prefix, node.name.to_s].join("::")
|
|
174
|
+
when Prism::ConstantPathWriteNode then qualified_write_name(node, prefix)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def qualified_write_name(node, prefix)
|
|
179
|
+
target = Source::ConstantPath.qualified_name(node.target)
|
|
180
|
+
return nil if target.nil?
|
|
181
|
+
return target if prefix.empty? || target.start_with?("#{prefix.join('::')}::")
|
|
182
|
+
|
|
183
|
+
[*prefix, target].join("::")
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def first_argument(node)
|
|
187
|
+
node.arguments&.arguments&.first
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -26,8 +26,12 @@ module Rigor
|
|
|
26
26
|
EMPTY_ROWS = {}.freeze
|
|
27
27
|
private_constant :EMPTY_ROWS
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
# The run's empty table. Eagerly built on the main Ractor at load time (bottom of the class body)
|
|
30
|
+
# rather than memoised on first use: `@empty ||= new({})` is a class-ivar WRITE, which a non-main
|
|
31
|
+
# Ractor may not perform, and every project that configures no attribution reaches it — including
|
|
32
|
+
# from inside {Analysis::WorkerSession#initialize}, which is where a Ractor-pool worker runs (#1055).
|
|
33
|
+
class << self
|
|
34
|
+
attr_reader :empty
|
|
31
35
|
end
|
|
32
36
|
|
|
33
37
|
# @param table — `Configuration#effects_attribution` — method key to
|
|
@@ -69,6 +73,11 @@ module Rigor
|
|
|
69
73
|
out[key.to_s] = set unless set.empty?
|
|
70
74
|
end.freeze
|
|
71
75
|
end
|
|
76
|
+
|
|
77
|
+
# Populates the `@empty` singleton on the main Ractor at load time. `Ractor.make_shareable` rather
|
|
78
|
+
# than `freeze` so a worker's READ of the class ivar is legal too: a non-main Ractor may read a
|
|
79
|
+
# class/module ivar only when the value is deeply shareable.
|
|
80
|
+
@empty = Ractor.make_shareable(new({}))
|
|
72
81
|
end
|
|
73
82
|
end
|
|
74
83
|
end
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Effects
|
|
7
|
+
# Callee resolution for plugin attribution rows (#1048; ADR-103 WD10 / WD13; design note § 11.2).
|
|
8
|
+
#
|
|
9
|
+
# A framework method can be an **edge** as well as a label. `render :show` inside `UsersController`
|
|
10
|
+
# runs `app/views/users/show.html.erb`, synchronously and in-process, and since #393 that template is
|
|
11
|
+
# an effect unit keyed `view:users/show.html` sitting in the very same summaries table. Nothing
|
|
12
|
+
# produced the edge, because the only plugin-facing edge surface ({Plugin::EffectEdge}) names a
|
|
13
|
+
# receiver *class* and mints units on a class body, and {Plugin::EffectAttribution} carried labels but
|
|
14
|
+
# no callee.
|
|
15
|
+
#
|
|
16
|
+
# This module is the missing half, and it is deliberately shaped exactly like {Narrowing}: the plugin
|
|
17
|
+
# writes a **rule name** on its row (`callee: "rails_render"`) and the engine owns the strategy. A
|
|
18
|
+
# block would have to run inside the per-file effect scan — the one place ADR-103 WD13 forbids
|
|
19
|
+
# anything that resolves, walks or types — and would not survive the fork-pool / Ractor boundary. A
|
|
20
|
+
# name is a String: declarative, Marshal-clean, and reviewable in the plugin's manifest.
|
|
21
|
+
#
|
|
22
|
+
# ## What a rule may read
|
|
23
|
+
#
|
|
24
|
+
# **The call's own argument literals, the unit's owner class, and the unit's own key.** No dataflow,
|
|
25
|
+
# no typer question, no filesystem. A rule that needed to know whether `app/views/users/show.html.erb`
|
|
26
|
+
# exists would make a plugin row's meaning a function of the view tree, which the scan does not have
|
|
27
|
+
# and must not read; instead an unresolvable answer is `nil` and a resolvable-looking one that no unit
|
|
28
|
+
# answers is **dropped by the propagator**, which restores the row's taint from
|
|
29
|
+
# {FileCollection::Edge#taint_if_unresolved}. So "the template is not in the table" and "the argument
|
|
30
|
+
# was computed" both keep the `template-not-analysed` taint, and only a render that reached a real
|
|
31
|
+
# unit clears it.
|
|
32
|
+
#
|
|
33
|
+
# ## Two kinds of rule
|
|
34
|
+
#
|
|
35
|
+
# - a **site rule** ({SITE_RULES}) answers for one call node — `render :show`, `render partial: "card"`;
|
|
36
|
+
# - a **unit rule** ({UNIT_RULES}) answers for a whole unit and reads no node at all. Rails' *implicit*
|
|
37
|
+
# render is the case that needs one: an action that falls off its end without rendering still renders
|
|
38
|
+
# `<controller>/<action>`, and the fact that produces the edge is the **absence** of a call. Only the
|
|
39
|
+
# unit scan can observe that, which is why the rule is applied there rather than in
|
|
40
|
+
# {FrameworkUnits} — a class body cannot see which of its methods responded.
|
|
41
|
+
#
|
|
42
|
+
# A unit rule contributes an **edge and nothing else**: no labels, no taint. That is what keeps it
|
|
43
|
+
# FP-safe on the private helper a controller also defines — `def load_user` gets an edge to
|
|
44
|
+
# `view:users/load_user.html`, no unit answers it, and the unit is exactly as it was.
|
|
45
|
+
module CalleeRule
|
|
46
|
+
# The callee an applied rule names, as the two halves {FileCollection::Edge} carries. The key the
|
|
47
|
+
# propagator reconstructs is `"#{receiver}.#{selector}"` — `view:users/show` + `html`.
|
|
48
|
+
#
|
|
49
|
+
# `fallbacks` is the ordered list of selectors the propagator retries when `selector` resolves to
|
|
50
|
+
# nothing (#1065), or nil for none. The rule only COPIES it off the plugin's table: whether
|
|
51
|
+
# `view:users/_row.js` exists is a question about the merged table, which the scan cannot ask.
|
|
52
|
+
Callee = Data.define(:receiver, :selector, :fallbacks) do
|
|
53
|
+
def initialize(fallbacks: nil, **) = super
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Must agree with {MethodKey::TEMPLATE_UNIT_PREFIX} and `Plugin::TemplateUnit::KEY_PREFIX`; pinned
|
|
57
|
+
# equal by spec.
|
|
58
|
+
TEMPLATE_PREFIX = "view:"
|
|
59
|
+
|
|
60
|
+
# The format a controller action renders absent a `formats:` override — Rails' own default, and the
|
|
61
|
+
# only one a `render :show` with no other evidence may be read as.
|
|
62
|
+
DEFAULT_FORMAT = "html"
|
|
63
|
+
|
|
64
|
+
# Rules that answer for one call node.
|
|
65
|
+
SITE_RULES = %w[rails_render rails_render_partial].freeze
|
|
66
|
+
|
|
67
|
+
# Rules that answer for a whole unit, from its owner and its own key. A UNIT rule may additionally
|
|
68
|
+
# be applied **per format arm** of a `respond_to` block (#1071): the arm is the unit's own body, and
|
|
69
|
+
# the arm's format is a literal the unit scan read, so it travels as data the same way the unit's key
|
|
70
|
+
# does.
|
|
71
|
+
UNIT_RULES = %w[rails_implicit_render].freeze
|
|
72
|
+
|
|
73
|
+
RULES = (SITE_RULES + UNIT_RULES).freeze
|
|
74
|
+
|
|
75
|
+
# `render json:` / `plain:` / `inline:` and friends render no template. They are listed so the rule
|
|
76
|
+
# can decline rather than read a `partial:` that is not there — declining keeps the row's taint,
|
|
77
|
+
# which is the conservative answer for a shape this rule does not model.
|
|
78
|
+
NON_TEMPLATE_OPTIONS = %w[json xml plain text html body js inline file nothing].freeze
|
|
79
|
+
|
|
80
|
+
# Template handlers, which a logical name never carries: `Plugin::TemplateUnit#logical_name` is
|
|
81
|
+
# `users/show.html`, not `users/show.html.erb`. An author may still write the handler out —
|
|
82
|
+
# `render template: "users/show.html.erb"` is legal Rails — so it is stripped rather than left to
|
|
83
|
+
# build a key no unit could ever answer.
|
|
84
|
+
HANDLERS = %w[erb haml slim jbuilder builder rabl ruby].freeze
|
|
85
|
+
|
|
86
|
+
module_function
|
|
87
|
+
|
|
88
|
+
def known?(name)
|
|
89
|
+
RULES.include?(name.to_s)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def site_rule?(name)
|
|
93
|
+
SITE_RULES.include?(name.to_s)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def unit_rule?(name)
|
|
97
|
+
UNIT_RULES.include?(name.to_s)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Applies a site rule to one call node.
|
|
101
|
+
#
|
|
102
|
+
# @param name — the row's `callee:`
|
|
103
|
+
# @param node — the `Prism::CallNode` the row matched
|
|
104
|
+
# @param owner_class — the unit's owner (`"UsersController"`, `"ActionView::Base"`)
|
|
105
|
+
# @param unit_key — the unit's own key: a selector for a method, `view:users/show.html` for a
|
|
106
|
+
# template unit
|
|
107
|
+
# @param fallbacks — the row's `callee_fallbacks:` table (#1065). Only a rule whose selector the
|
|
108
|
+
# CONTEXT supplied consults it; see {rails_render_partial}.
|
|
109
|
+
# @return the {Callee} the rule named, or nil whenever it cannot settle the target from
|
|
110
|
+
# literals alone
|
|
111
|
+
def site(name, node, owner_class:, unit_key: nil, fallbacks: nil)
|
|
112
|
+
case name.to_s
|
|
113
|
+
when "rails_render" then rails_render(node, owner_class)
|
|
114
|
+
when "rails_render_partial" then rails_render_partial(node, unit_key, fallbacks)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Applies a unit rule. Reads no node; a `format:` from a `respond_to` arm (#1071) narrows the
|
|
119
|
+
# template the rule names from the default to that arm's own. Nil, or absent (`DEFAULT_FORMAT`).
|
|
120
|
+
#
|
|
121
|
+
# @return the {Callee} the rule named, or nil.
|
|
122
|
+
def unit(name, owner_class:, unit_key: nil, format: nil)
|
|
123
|
+
case name.to_s
|
|
124
|
+
when "rails_implicit_render" then rails_implicit_render(owner_class, unit_key, format: format)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# `render` inside a controller. The positional form names an **action template**
|
|
129
|
+
# (`render :show` → `users/show`), which is the one place a controller and a view disagree about
|
|
130
|
+
# what a bare string means.
|
|
131
|
+
#
|
|
132
|
+
# Never consults a format fallback (#1065). The format here is either one the author wrote, or
|
|
133
|
+
# Rails' `html` default standing in for a REQUEST format the rule cannot see — and the lookup order
|
|
134
|
+
# a controller-side render follows is derived from that request (`request.formats`, an `Accept`
|
|
135
|
+
# header), not from anything in the source.
|
|
136
|
+
def rails_render(node, owner_class)
|
|
137
|
+
directory = controller_directory(owner_class)
|
|
138
|
+
return nil if directory.nil?
|
|
139
|
+
|
|
140
|
+
format = format_for(node, DEFAULT_FORMAT)
|
|
141
|
+
return nil if format.nil?
|
|
142
|
+
|
|
143
|
+
name = template_name(node, directory) || partial_name(node, directory, layout: false)
|
|
144
|
+
name.nil? ? nil : template_callee(name, format)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# `render` inside a template. A bare positional argument is a **partial** here, and so is `layout:`
|
|
148
|
+
# — in a view `render layout: "shared/wrapper"` names `shared/_wrapper`, not an `app/views/layouts`
|
|
149
|
+
# file, because `RenderingHelper#render` rewrites `layout:` to `partial:` when a block is given.
|
|
150
|
+
# Since #1047 a layout compiles to a unit like any other template, so such an edge resolves where
|
|
151
|
+
# the named partial exists and keeps its taint where it does not.
|
|
152
|
+
#
|
|
153
|
+
# **The format fallback (#1065).** The format travels from the enclosing unit, and while a `.js.erb`
|
|
154
|
+
# template is rendering Rails' lookup context holds `[:js, :html]` — `LookupContext#formats=` appends
|
|
155
|
+
# `:html` to a lone `:js`, and `AbstractRenderer#prepend_formats` puts the template's own format in
|
|
156
|
+
# front of the request's. So `render partial: "watchers"` from `_set_watcher.js.erb` runs
|
|
157
|
+
# `_watchers.js.erb` where one exists and `_watchers.html.erb` otherwise. The rule copies the row's
|
|
158
|
+
# table for the inherited format onto the callee, and the propagator takes the first key that
|
|
159
|
+
# resolves.
|
|
160
|
+
#
|
|
161
|
+
# The table is consulted only for an INHERITED format. A `formats:` / `format:` keyword or a format
|
|
162
|
+
# spelled into the name is the author's word, and the fallback stands down: that is the direction
|
|
163
|
+
# that keeps a taint rather than guessing at a lookup the call overrode.
|
|
164
|
+
def rails_render_partial(node, unit_key, fallbacks)
|
|
165
|
+
directory, inherited = template_context(unit_key)
|
|
166
|
+
return nil if directory.nil?
|
|
167
|
+
|
|
168
|
+
format = format_for(node, inherited)
|
|
169
|
+
return nil if format.nil?
|
|
170
|
+
|
|
171
|
+
retry_formats = format_keyword?(node) ? nil : fallbacks&.fetch(format, nil)
|
|
172
|
+
explicit = keyword_name(node, "template")
|
|
173
|
+
return template_callee(qualify(explicit, directory), format, retry_formats) if explicit
|
|
174
|
+
|
|
175
|
+
name = partial_name(node, directory) || positional_partial(node, directory)
|
|
176
|
+
name.nil? ? nil : template_callee(name, format, retry_formats)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Rails' implicit render: an action that never rendered still renders `<controller>/<action>`. A
|
|
180
|
+
# `respond_to` arm (#1071) is the same convention per format: `format.js` with no responding block
|
|
181
|
+
# renders `<controller>/<action>.js`, so the rule narrows the format it answers for, and an arm that
|
|
182
|
+
# IS answered outright is not applied at all (the unit scan decides that half and calls with the
|
|
183
|
+
# arm's format only for arms that still fall through).
|
|
184
|
+
def rails_implicit_render(owner_class, unit_key, format: nil)
|
|
185
|
+
directory = controller_directory(owner_class)
|
|
186
|
+
return nil if directory.nil? || unit_key.nil?
|
|
187
|
+
|
|
188
|
+
action = unit_key.to_s
|
|
189
|
+
return nil unless /\A[a-z_][A-Za-z0-9_]*[?!=]?\z/.match?(action)
|
|
190
|
+
return nil if action.end_with?("?", "!", "=")
|
|
191
|
+
|
|
192
|
+
template_callee("#{directory}/#{action}", format || DEFAULT_FORMAT)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# `UsersController` → `users`; `Admin::UsersController` → `admin/users`. A class whose name does not
|
|
196
|
+
# end in `Controller` is not one this rule can read a view directory off, and answers nil.
|
|
197
|
+
def controller_directory(owner_class)
|
|
198
|
+
return nil if owner_class.nil?
|
|
199
|
+
|
|
200
|
+
segments = owner_class.to_s.split("::")
|
|
201
|
+
last = segments.pop
|
|
202
|
+
return nil unless last&.end_with?("Controller") && last != "Controller"
|
|
203
|
+
|
|
204
|
+
segments.push(last.delete_suffix("Controller"))
|
|
205
|
+
segments.map { |segment| underscore(segment) }.join("/")
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# `[directory, format]` for a template unit's own key — `view:users/show.html` → `["users", "html"]`.
|
|
209
|
+
# A partial rendered from a `.json` template is a `.json` partial, which is why the format travels.
|
|
210
|
+
def template_context(unit_key)
|
|
211
|
+
key = unit_key.to_s
|
|
212
|
+
return [nil, nil] unless key.start_with?(TEMPLATE_PREFIX)
|
|
213
|
+
|
|
214
|
+
logical = key.delete_prefix(TEMPLATE_PREFIX)
|
|
215
|
+
name, _, format = logical.rpartition(".")
|
|
216
|
+
return [nil, nil] if name.empty?
|
|
217
|
+
|
|
218
|
+
directory = name.include?("/") ? name[0...name.rindex("/")] : ""
|
|
219
|
+
[directory, format]
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# The explicit template spellings: `template: "users/show"` and `action: :edit`.
|
|
223
|
+
def template_name(node, directory)
|
|
224
|
+
explicit = keyword_name(node, "template")
|
|
225
|
+
return qualify(explicit, directory) if explicit
|
|
226
|
+
|
|
227
|
+
action = keyword_name(node, "action")
|
|
228
|
+
return "#{directory}/#{action}" if action && !action.include?("/")
|
|
229
|
+
return action if action
|
|
230
|
+
|
|
231
|
+
positional_template(node, directory)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# `render :show` / `render "show"` / `render "admin/form"`, but only when no option keyword the rule
|
|
235
|
+
# does not model is present.
|
|
236
|
+
def positional_template(node, directory)
|
|
237
|
+
name = literal_name(positional(node).first)
|
|
238
|
+
return nil if name.nil?
|
|
239
|
+
|
|
240
|
+
qualify(name, directory)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# `render "card"` inside a template — the same literal, read as a partial.
|
|
244
|
+
def positional_partial(node, directory)
|
|
245
|
+
name = literal_name(positional(node).first)
|
|
246
|
+
name.nil? ? nil : partialize(qualify(name, directory))
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# `partial: "card"` / `partial: "users/card"`, and the view-side `layout:`. `collection:` changes
|
|
250
|
+
# how many times the partial runs and not which one, so it is read and ignored on purpose: an
|
|
251
|
+
# effect summary is an upper bound over the body, not a count.
|
|
252
|
+
def partial_name(node, directory, layout: true)
|
|
253
|
+
name = keyword_name(node, "partial") || (layout ? keyword_name(node, "layout") : nil)
|
|
254
|
+
name.nil? ? nil : partialize(qualify(name, directory))
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# The requested format, or nil to decline. Absent is `fallback`; a literal narrows; anything
|
|
258
|
+
# computed is genuinely unknown and must not be guessed at `html`.
|
|
259
|
+
def format_for(node, fallback)
|
|
260
|
+
return nil if non_template?(node)
|
|
261
|
+
|
|
262
|
+
value = keyword_argument(node, "formats") || keyword_argument(node, "format")
|
|
263
|
+
return fallback if value.nil?
|
|
264
|
+
|
|
265
|
+
literal = literal_name(value) || literal_name(array_head(value))
|
|
266
|
+
literal&.split(".")&.last
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def format_keyword?(node)
|
|
270
|
+
!(keyword_argument(node, "formats") || keyword_argument(node, "format")).nil?
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def non_template?(node)
|
|
274
|
+
NON_TEMPLATE_OPTIONS.any? { |option| keyword_argument(node, option) }
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def array_head(node)
|
|
278
|
+
node.elements.first if node.is_a?(Prism::ArrayNode)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# `fallbacks` survives only when the name spelled no format of its own: `render "row.json"` from a
|
|
282
|
+
# `.js` template is the author naming `json`, and a `js` table has nothing to say about it.
|
|
283
|
+
def template_callee(name, format, fallbacks = nil)
|
|
284
|
+
spelled = split_suffixes(name, nil).last
|
|
285
|
+
name, format = split_suffixes(name, format)
|
|
286
|
+
return nil if name.nil? || name.empty? || format.nil? || format.empty?
|
|
287
|
+
return nil if name.include?(" ") || format.include?(" ") || format.include?(".")
|
|
288
|
+
|
|
289
|
+
fallbacks = nil unless spelled.nil? && fallbacks && !fallbacks.empty?
|
|
290
|
+
Callee.new(receiver: "#{TEMPLATE_PREFIX}#{name}", selector: format, fallbacks: fallbacks)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Splits a written handler and format off the name's last segment, so `render "show.json"` names
|
|
294
|
+
# `view:users/show` + `json` and `render template: "users/show.html.erb"` names
|
|
295
|
+
# `view:users/show` + `html` rather than the impossible `view:users/show.html.erb.html`. A name
|
|
296
|
+
# that spells a format wins over the rule's default, because the author said so; a name that
|
|
297
|
+
# spells only a handler keeps the default.
|
|
298
|
+
def split_suffixes(name, format)
|
|
299
|
+
return [name, format] if name.nil?
|
|
300
|
+
|
|
301
|
+
directory, separator, base = name.rpartition("/")
|
|
302
|
+
segments = base.split(".")
|
|
303
|
+
return [name, format] if segments.length <= 1
|
|
304
|
+
|
|
305
|
+
stem = segments.shift
|
|
306
|
+
segments.reject! { |segment| HANDLERS.include?(segment) }
|
|
307
|
+
["#{directory}#{separator}#{stem}", segments.first || format]
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# `"card"` in `users` → `"users/_card"`; `"admin/card"` → `"admin/_card"`. A name already spelled
|
|
311
|
+
# with the underscore keeps it, because that is what Rails accepts too.
|
|
312
|
+
def partialize(name)
|
|
313
|
+
return nil if name.nil?
|
|
314
|
+
|
|
315
|
+
directory, separator, base = name.rpartition("/")
|
|
316
|
+
return base.start_with?("_") ? name : "_#{name}" if separator.empty?
|
|
317
|
+
|
|
318
|
+
base.start_with?("_") ? name : "#{directory}/_#{base}"
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# A name with a `/` is rooted at the views directory; a bare one is relative to the rendering
|
|
322
|
+
# unit's own directory.
|
|
323
|
+
def qualify(name, directory)
|
|
324
|
+
return nil if name.nil? || name.empty?
|
|
325
|
+
return name if name.include?("/") || directory.nil? || directory.empty?
|
|
326
|
+
|
|
327
|
+
"#{directory}/#{name}"
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def keyword_name(node, key)
|
|
331
|
+
literal_name(keyword_argument(node, key))
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def literal_name(value)
|
|
335
|
+
case value
|
|
336
|
+
when Prism::SymbolNode, Prism::StringNode then value.unescaped
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def positional(node)
|
|
341
|
+
node.arguments&.arguments&.grep_v(Prism::KeywordHashNode) || []
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def keyword_argument(node, name)
|
|
345
|
+
hash = node.arguments&.arguments&.find { |argument| argument.is_a?(Prism::KeywordHashNode) }
|
|
346
|
+
pair = hash&.elements&.find do |element|
|
|
347
|
+
element.is_a?(Prism::AssocNode) && element.key.is_a?(Prism::SymbolNode) &&
|
|
348
|
+
element.key.unescaped == name
|
|
349
|
+
end
|
|
350
|
+
pair&.value
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# `ActiveStorage` → `active_storage`. The engine's own reading of a constant segment; deliberately
|
|
354
|
+
# not an inflector call, because a rule may read nothing outside the strings it was given.
|
|
355
|
+
def underscore(segment)
|
|
356
|
+
segment.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
private_class_method :rails_render, :rails_render_partial, :rails_implicit_render,
|
|
360
|
+
:controller_directory, :template_context, :template_name,
|
|
361
|
+
:positional_template, :positional_partial, :partial_name,
|
|
362
|
+
:format_for, :format_keyword?, :non_template?, :array_head, :template_callee,
|
|
363
|
+
:split_suffixes, :partialize,
|
|
364
|
+
:qualify, :keyword_name, :literal_name, :positional, :keyword_argument,
|
|
365
|
+
:underscore
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
end
|
|
@@ -36,14 +36,17 @@ module Rigor
|
|
|
36
36
|
class Error < StandardError
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
-
DATA_PATH = File.expand_path("../../../data/effects/core.yml", __dir__)
|
|
39
|
+
DATA_PATH = File.expand_path("../../../data/effects/core.yml", __dir__).freeze
|
|
40
40
|
|
|
41
41
|
# The mutator sets a value class may name, by reference. Adding a name here is the only way a
|
|
42
|
-
# class gets one — the data file may not spell a selector list of its own.
|
|
42
|
+
# class gets one — the data file may not spell a selector list of its own. Each is the set
|
|
43
|
+
# {MutationClassifier#mutating?} reads for that class, so a posture's answer and the classifier's
|
|
44
|
+
# cannot disagree: `hash` is the classifier's union of the widening's two Hash tables, because
|
|
45
|
+
# `compare_by_identity` mutates the receiver although the widening keeps it off `HASH_MUTATORS`.
|
|
43
46
|
MUTATOR_SETS = {
|
|
44
47
|
"array" => Inference::MutationWidening::ARRAY_MUTATORS,
|
|
45
|
-
"hash" =>
|
|
46
|
-
"string" =>
|
|
48
|
+
"hash" => MutationClassifier::HASH_MUTATORS,
|
|
49
|
+
"string" => Inference::StringMutation::MUTATORS
|
|
47
50
|
}.freeze
|
|
48
51
|
|
|
49
52
|
NO_MUTATORS = Set[].freeze
|
|
@@ -39,12 +39,16 @@ module Rigor
|
|
|
39
39
|
|
|
40
40
|
# Per-file, per-thread accumulator. Not frozen and never shared: it lives for one `analyze_file`.
|
|
41
41
|
class Accumulator
|
|
42
|
-
attr_reader :path, :calls, :attribution, :envelopes, :plugin_facts
|
|
42
|
+
attr_reader :path, :calls, :attribution, :envelopes, :plugin_facts, :unit_key, :unit_owner
|
|
43
43
|
attr_accessor :root
|
|
44
44
|
|
|
45
45
|
def initialize(path, attribution: Attribution.empty, envelopes: EnvelopeIndex.empty,
|
|
46
|
-
plugin_facts: PluginFacts.empty)
|
|
46
|
+
plugin_facts: PluginFacts.empty, unit_key: nil, unit_owner: nil)
|
|
47
47
|
@path = path
|
|
48
|
+
# #392 — set for a template unit, where the whole file is ONE effect unit keyed
|
|
49
|
+
# `view:<logical_name>` under the plugin's declared `self`.
|
|
50
|
+
@unit_key = unit_key
|
|
51
|
+
@unit_owner = unit_owner
|
|
48
52
|
@attribution = attribution
|
|
49
53
|
@envelopes = envelopes
|
|
50
54
|
@plugin_facts = plugin_facts
|
|
@@ -92,10 +96,11 @@ module Rigor
|
|
|
92
96
|
# {EnvelopeIndex} of #386, carried the same way and for the same reason — the declared lane a call
|
|
93
97
|
# site imports must not depend on which process typed the file.
|
|
94
98
|
def collect_for(path, attribution: Attribution.empty, envelopes: EnvelopeIndex.empty,
|
|
95
|
-
plugin_facts: PluginFacts.empty)
|
|
99
|
+
plugin_facts: PluginFacts.empty, unit_key: nil, unit_owner: nil)
|
|
96
100
|
previous = Thread.current[KEY]
|
|
97
101
|
accumulator = Accumulator.new(path.to_s, attribution: attribution, envelopes: envelopes,
|
|
98
|
-
plugin_facts: plugin_facts
|
|
102
|
+
plugin_facts: plugin_facts, unit_key: unit_key,
|
|
103
|
+
unit_owner: unit_owner)
|
|
99
104
|
Thread.current[KEY] = accumulator
|
|
100
105
|
@mutex.synchronize { @active_count += 1 }
|
|
101
106
|
yield
|
|
@@ -207,7 +212,8 @@ module Rigor
|
|
|
207
212
|
Scanner.scan(
|
|
208
213
|
root: accumulator.root, path: accumulator.path, calls: accumulator.calls,
|
|
209
214
|
attribution: accumulator.attribution, envelopes: accumulator.envelopes,
|
|
210
|
-
plugin_facts: accumulator.plugin_facts
|
|
215
|
+
plugin_facts: accumulator.plugin_facts, unit_key: accumulator.unit_key,
|
|
216
|
+
unit_owner: accumulator.unit_owner
|
|
211
217
|
)
|
|
212
218
|
rescue StandardError
|
|
213
219
|
FileCollection.new(path: accumulator.path, failed: true)
|