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
|
@@ -123,12 +123,12 @@ module Rigor
|
|
|
123
123
|
@protocol_contracts || manifest.protocol_contracts
|
|
124
124
|
end
|
|
125
125
|
|
|
126
|
-
# File-level only: the load-error
|
|
126
|
+
# File-level only: the load-error disclosure. Per-call broadcast validation runs over the
|
|
127
127
|
# engine-owned walk via the node_rule below (ADR-37). The channel index is lazily loaded +
|
|
128
128
|
# memoised by `producer_value`, shared by both surfaces.
|
|
129
129
|
def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
|
|
130
130
|
index = producer_value(:channel_index)
|
|
131
|
-
|
|
131
|
+
disclose_load_error if index.nil? && producer_error(:channel_index)
|
|
132
132
|
|
|
133
133
|
[]
|
|
134
134
|
end
|
|
@@ -151,10 +151,14 @@ module Rigor
|
|
|
151
151
|
"#{base}/**/*.rb"
|
|
152
152
|
end
|
|
153
153
|
|
|
154
|
-
|
|
154
|
+
# Issue #1056 — "the channel index did not load" is a fact about the run's INPUTS, not about the file being
|
|
155
|
+
# analysed, so it is registered rather than returned: see {Plugin::Base#disclose_once} for the
|
|
156
|
+
# channel, the `(plugin id, key)` de-duplication and why the engine positions it at `.rigor.yml:1:1`.
|
|
157
|
+
# Returned from the per-file hook it carried no once-guard at all and repeated on every file.
|
|
158
|
+
def disclose_load_error
|
|
155
159
|
error = producer_error(:channel_index)
|
|
156
|
-
|
|
157
|
-
|
|
160
|
+
disclose_once(
|
|
161
|
+
:channel_index_load_failed,
|
|
158
162
|
message: "rigor-actioncable: failed to discover channels: #{error.class}: #{error.message}",
|
|
159
163
|
severity: :warning,
|
|
160
164
|
rule: "load-error"
|
|
@@ -93,7 +93,10 @@ module Rigor
|
|
|
93
93
|
# loaded + memoised, shared.
|
|
94
94
|
def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
|
|
95
95
|
index = producer_value(:mailer_index)
|
|
96
|
-
|
|
96
|
+
if index.nil? && producer_error(:mailer_index)
|
|
97
|
+
disclose_load_error
|
|
98
|
+
return []
|
|
99
|
+
end
|
|
97
100
|
return [] if index.nil? || index.empty?
|
|
98
101
|
|
|
99
102
|
missing_view_diagnostics(path, index)
|
|
@@ -136,10 +139,16 @@ module Rigor
|
|
|
136
139
|
File.expand_path(path)
|
|
137
140
|
end
|
|
138
141
|
|
|
139
|
-
|
|
142
|
+
# Issue #1056 — "the mailer index did not load" is a fact about the run's INPUTS, not about the file being
|
|
143
|
+
# analysed, so it is registered rather than returned: see {Plugin::Base#disclose_once} for the
|
|
144
|
+
# channel, the `(plugin id, key)` de-duplication and why the engine positions it at `.rigor.yml:1:1`.
|
|
145
|
+
# Returned from the per-file hook it carried no once-guard at all and repeated on every file.
|
|
146
|
+
# The missing-view rows this hook still returns are NOT disclosures: they name the mailer's own
|
|
147
|
+
# source file.
|
|
148
|
+
def disclose_load_error
|
|
140
149
|
error = producer_error(:mailer_index)
|
|
141
|
-
|
|
142
|
-
|
|
150
|
+
disclose_once(
|
|
151
|
+
:mailer_index_load_failed,
|
|
143
152
|
message: "rigor-actionmailer: failed to discover mailers: #{error.class}: #{error.message}",
|
|
144
153
|
severity: :warning,
|
|
145
154
|
rule: "load-error"
|
|
@@ -171,7 +171,6 @@ module Rigor
|
|
|
171
171
|
# since the implicit path would false-positive on `redirect_to` / `head` / early returns.
|
|
172
172
|
#
|
|
173
173
|
# @param ancestors — the lexical ancestor chain
|
|
174
|
-
# @param path — file being analysed
|
|
175
174
|
# @param io_boundary — the plugin's {Rigor::Plugin::IoBoundary}; #629 — every filesystem
|
|
176
175
|
# question this phase asks goes through it, so "no such template" is a recorded dependency and
|
|
177
176
|
# the run-result cache invalidates once the template appears
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rigor/source/node_children"
|
|
4
|
+
|
|
5
|
+
require "prism"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module Plugin
|
|
9
|
+
class Actionpack < Rigor::Plugin::Base
|
|
10
|
+
# The syntactic controller walk two parent-side indexes share: {ViewAssigns}, which reads what a
|
|
11
|
+
# controller action ASSIGNED, and {RenderLocals} (#1047), which reads what a render site PASSED.
|
|
12
|
+
#
|
|
13
|
+
# Both run on the parent before any analysis — `#template_units_for_file` is called before the
|
|
14
|
+
# engine has indexed anything — so neither may ask the typer a question, and both need the same four
|
|
15
|
+
# answers off a Prism tree: which class declarations in this file are controllers, what their
|
|
16
|
+
# qualified names are, which view directory each one owns, and which `def`s sit directly in the body.
|
|
17
|
+
# Keeping one copy is what stops the two indexes from disagreeing about, say, whether
|
|
18
|
+
# `module Admin; class UsersController` is `admin/users` — a disagreement that would be silent, since
|
|
19
|
+
# each side would simply find nothing.
|
|
20
|
+
#
|
|
21
|
+
# It deliberately stops at the shapes {ControllerDiscoverer} already recognises. Anything a
|
|
22
|
+
# controller does at runtime — `define_method`, a concern that adds actions on include — is not read
|
|
23
|
+
# here and is not read there either.
|
|
24
|
+
module ControllerScan
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# Yields `[class_node, enclosing_namespace_segments]` for every `ClassNode` in the tree, with the
|
|
28
|
+
# enclosing `module` chain accumulated. Mirrors {ControllerDiscoverer#walk_declarations}'s
|
|
29
|
+
# qualification reduced to the two shapes that can carry an action: `class Admin::UsersController`
|
|
30
|
+
# and `module Admin; class UsersController`.
|
|
31
|
+
def each_controller(node, namespace, &)
|
|
32
|
+
return unless node.is_a?(Prism::Node)
|
|
33
|
+
|
|
34
|
+
if node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
|
|
35
|
+
yield node, namespace if node.is_a?(Prism::ClassNode)
|
|
36
|
+
inner = namespace + constant_segments(node.constant_path)
|
|
37
|
+
each_controller(node.body, inner, &) if node.body
|
|
38
|
+
return
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
node.rigor_each_child { |child| each_controller(child, namespace, &) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def constant_segments(path)
|
|
45
|
+
case path
|
|
46
|
+
when Prism::ConstantReadNode then [path.name.to_s]
|
|
47
|
+
when Prism::ConstantPathNode then constant_segments(path.parent) + [path.name.to_s]
|
|
48
|
+
else []
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# `["Admin", "UsersController"]` → `"admin/users"`. `ApplicationController` and the other abstract
|
|
53
|
+
# bases are not excluded: an action they define really is inherited, and a template under their own
|
|
54
|
+
# path simply never exists. nil for a class whose name is not a controller's.
|
|
55
|
+
def controller_path(segments)
|
|
56
|
+
return nil unless segments.last&.end_with?("Controller")
|
|
57
|
+
|
|
58
|
+
parts = segments.map { |segment| underscore(segment) }
|
|
59
|
+
parts[-1] = parts[-1].sub(/_controller\z/, "")
|
|
60
|
+
return nil if parts[-1].empty?
|
|
61
|
+
|
|
62
|
+
parts.join("/")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The ASCII-only inflection this needs: a controller constant is a CamelCase identifier, and
|
|
66
|
+
# nothing here has to invert an irregular plural (`ActiveSupport::Inflector` is the analysed
|
|
67
|
+
# project's, not Rigor's).
|
|
68
|
+
def underscore(segment)
|
|
69
|
+
segment.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
70
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
71
|
+
.downcase
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# `{ :show => body_node }` for every `def` directly in the class body.
|
|
75
|
+
def method_bodies(node)
|
|
76
|
+
body = node.body
|
|
77
|
+
return {} if body.nil?
|
|
78
|
+
|
|
79
|
+
body.child_nodes.compact.filter_map do |child|
|
|
80
|
+
next nil unless child.is_a?(Prism::DefNode) && child.receiver.nil?
|
|
81
|
+
|
|
82
|
+
[child.name, child.body]
|
|
83
|
+
end.to_h
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Every implicit-self `render` call node under `node`, at any depth.
|
|
87
|
+
def each_render(node, &)
|
|
88
|
+
return unless node.is_a?(Prism::Node)
|
|
89
|
+
|
|
90
|
+
yield node if node.is_a?(Prism::CallNode) && node.receiver.nil? && node.name == :render
|
|
91
|
+
node.rigor_each_child { |child| each_render(child, &) }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -31,6 +31,11 @@ module Rigor
|
|
|
31
31
|
module Effects
|
|
32
32
|
CONTROLLER = "ActionController::Base"
|
|
33
33
|
|
|
34
|
+
# The view context a template unit's body runs under — the `self_type:` the plugin declares on
|
|
35
|
+
# every {Rigor::Plugin::TemplateUnit}. A `render` inside a template is an implicit-self call on
|
|
36
|
+
# it, which is what lets one row colour the template → partial edge (#1048).
|
|
37
|
+
VIEW = "ActionView::Base"
|
|
38
|
+
|
|
34
39
|
RESPONSE = ["mutate.self", "rails.response.write"].freeze
|
|
35
40
|
SESSION_WRITE = ["mutate", "rails.session.write"].freeze
|
|
36
41
|
SESSION_READ = ["io", "rails.session.read"].freeze
|
|
@@ -46,6 +51,26 @@ module Rigor
|
|
|
46
51
|
# the one genuinely misleading row in the whole Rails layer.
|
|
47
52
|
RENDERERS = %w[render render_to_string render_to_body].freeze
|
|
48
53
|
|
|
54
|
+
# Rails' own name for the implicit render — what `ActionController::Base` calls when an action
|
|
55
|
+
# returns without having answered. The project never writes it, which is exactly the point: the
|
|
56
|
+
# row exists so the engine's unit-level {Rigor::Effects::CalleeRule} has a declaration to read,
|
|
57
|
+
# and it carries no labels because an action that implicitly renders has already been coloured by
|
|
58
|
+
# nothing at all and must not be coloured by a guess (see `why:`).
|
|
59
|
+
IMPLICIT_RENDER = "default_render"
|
|
60
|
+
|
|
61
|
+
# Action View's own format lookup order for a template-side render, as DATA the engine's
|
|
62
|
+
# `rails_render_partial` rule copies onto the edge (#1065). While a `.js.erb` template renders, the
|
|
63
|
+
# lookup context holds `[:js, :html]` — `LookupContext#formats=` appends `:html` to a lone `:js` —
|
|
64
|
+
# so `render "watchers"` runs `_watchers.js.erb` where it exists and `_watchers.html.erb`
|
|
65
|
+
# otherwise. The propagator takes the first key a unit answers.
|
|
66
|
+
#
|
|
67
|
+
# `js` is the only entry, because it is the only fallback Action View itself hard-codes. What a
|
|
68
|
+
# `.json`, `.xml` or `.turbo_stream` template falls back to is whatever the REQUEST's formats
|
|
69
|
+
# happened to be (`prepend_formats` puts the template's format in front of them) — an `Accept`
|
|
70
|
+
# header the source never states, Turbo's included — so those keep their taint rather than guess.
|
|
71
|
+
FORMAT_FALLBACKS = { "js" => ["html"] }.freeze
|
|
72
|
+
Ractor.make_shareable(FORMAT_FALLBACKS)
|
|
73
|
+
|
|
49
74
|
# The cookie jars a Rails app writes through.
|
|
50
75
|
COOKIE_JARS = ["self.cookies", "self.cookies.signed", "self.cookies.encrypted",
|
|
51
76
|
"self.cookies.permanent"].freeze
|
|
@@ -59,35 +84,67 @@ module Rigor
|
|
|
59
84
|
def response_rows
|
|
60
85
|
RESPONSE_WRITERS.map do |selector|
|
|
61
86
|
EffectAttribution.new(
|
|
62
|
-
receiver: CONTROLLER, method: selector, labels: RESPONSE, discharge: true,
|
|
87
|
+
receiver: CONTROLLER, method: selector, labels: RESPONSE, discharge: true, responds: true,
|
|
63
88
|
why: "sets the response on the controller instance — Rack writes the socket later, outside " \
|
|
64
89
|
"any project method, so this is `mutate.self` and deliberately not `io`"
|
|
65
90
|
)
|
|
66
|
-
end + render_rows
|
|
91
|
+
end + render_rows + [implicit_render_row]
|
|
67
92
|
end
|
|
68
93
|
|
|
94
|
+
# `render` and its two `*_to_string` twins. Each carries a `callee:` so the render site edges to
|
|
95
|
+
# the template's own effect unit (#1048), and each keeps the `template-not-analysed` taint — which
|
|
96
|
+
# now rides the edge: a render whose template produced a unit discharges it, and one whose
|
|
97
|
+
# template the plugin declined (a layout, #1047) or whose target is computed keeps it.
|
|
98
|
+
#
|
|
99
|
+
# Only `render` sets `responds:`. `render_to_string` builds a string and leaves the response
|
|
100
|
+
# unanswered, so an action that calls it and returns still takes Rails' implicit render.
|
|
69
101
|
def render_rows
|
|
70
102
|
RENDERERS.map do |selector|
|
|
71
103
|
EffectAttribution.new(
|
|
72
104
|
receiver: CONTROLLER, method: selector, labels: RESPONSE, discharge: true,
|
|
73
|
-
taint: "template-not-analysed",
|
|
105
|
+
taint: "template-not-analysed", callee: "rails_render", responds: selector == "render",
|
|
74
106
|
why: "sets the response body from a template. The controller half is fully stated; the " \
|
|
75
|
-
"template's own effects
|
|
76
|
-
"
|
|
107
|
+
"template's own effects reach it through the `rails_render` callee edge, and the " \
|
|
108
|
+
"taint survives on the edge for a target the rule cannot resolve"
|
|
77
109
|
)
|
|
78
|
-
end
|
|
110
|
+
end + [
|
|
111
|
+
EffectAttribution.new(
|
|
112
|
+
receiver: VIEW, method: :render, labels: [], discharge: true,
|
|
113
|
+
taint: "template-not-analysed", callee: "rails_render_partial",
|
|
114
|
+
callee_fallbacks: FORMAT_FALLBACKS,
|
|
115
|
+
why: "a `render` inside a template runs another template. The edge is the whole " \
|
|
116
|
+
"contribution — what a partial render DOES is what the partial does, and the row " \
|
|
117
|
+
"reaches it now — while a target the rule cannot resolve keeps the taint. In a view " \
|
|
118
|
+
"a bare argument names a PARTIAL and `layout:` names one too, which is why this is a " \
|
|
119
|
+
"separate rule from the controller's"
|
|
120
|
+
)
|
|
121
|
+
]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# The implicit render, as an EDGE and nothing else. An action that never answered still renders
|
|
125
|
+
# `<controller>/<action>`, so its summary must include that template's effects — but the fact is
|
|
126
|
+
# the absence of a call, so there is no site to colour, and inventing a `rails.response.write`
|
|
127
|
+
# here would put it on every private helper a controller defines as well.
|
|
128
|
+
def implicit_render_row
|
|
129
|
+
EffectAttribution.new(
|
|
130
|
+
receiver: CONTROLLER, method: IMPLICIT_RENDER, labels: [], discharge: true,
|
|
131
|
+
callee: "rails_implicit_render",
|
|
132
|
+
why: "Rails renders `<controller>/<action>` for an action that answered nothing. The edge " \
|
|
133
|
+
"is the whole contribution: no label, because an implicit render is observed from a " \
|
|
134
|
+
"body that made no call, and a label read off an absence would colour every helper"
|
|
135
|
+
)
|
|
79
136
|
end
|
|
80
137
|
|
|
81
138
|
# `send_file` streams from disk; `send_data` does not.
|
|
82
139
|
def file_rows
|
|
83
140
|
[
|
|
84
141
|
EffectAttribution.new(
|
|
85
|
-
receiver: CONTROLLER, method: :send_data, labels: RESPONSE, discharge: true,
|
|
142
|
+
receiver: CONTROLLER, method: :send_data, labels: RESPONSE, discharge: true, responds: true,
|
|
86
143
|
why: "sets the response body from an in-memory string"
|
|
87
144
|
),
|
|
88
145
|
EffectAttribution.new(
|
|
89
146
|
receiver: CONTROLLER, method: :send_file, labels: RESPONSE + ["io.fs.read"], discharge: true,
|
|
90
|
-
why: "sets the response AND reads the named file off disk"
|
|
147
|
+
responds: true, why: "sets the response AND reads the named file off disk"
|
|
91
148
|
)
|
|
92
149
|
]
|
|
93
150
|
end
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
|
|
5
|
+
require "rigor/plugin"
|
|
6
|
+
require "rigor/plugin/isolation"
|
|
7
|
+
|
|
8
|
+
module Rigor
|
|
9
|
+
module Plugin
|
|
10
|
+
class Actionpack < Rigor::Plugin::Base
|
|
11
|
+
# #393 — ERB → Ruby, with the line map the template-unit seam (#392) reports through.
|
|
12
|
+
#
|
|
13
|
+
# ## Which compiler
|
|
14
|
+
#
|
|
15
|
+
# [ADR-90](../../../../../../docs/adr/90-target-library-resolution-from-project-bundle.md): Erubi is
|
|
16
|
+
# **never bundled** — it is not a Rigor dependency and nothing here adds it to a Gemfile — but it is
|
|
17
|
+
# what Rails itself compiles with, so it is used when it resolves in the ANALYSED project's bundle,
|
|
18
|
+
# through the same `Isolation.require_with_target_bundle` path `Inflector` takes. Stdlib `ERB` is the
|
|
19
|
+
# fallback, and it is always there. Whichever ran is reported as the unit's `transform_id`
|
|
20
|
+
# (`erubi-1.13.1` / `erb-6.0.1.1`), which rides the unit digest — so a project that installs Erubi
|
|
21
|
+
# between two runs re-analyses rather than replaying the stdlib answer.
|
|
22
|
+
#
|
|
23
|
+
# ## The line map is measured, never assumed
|
|
24
|
+
#
|
|
25
|
+
# Both compilers are line-preserving: a tag's text is rewritten in place and the newlines between
|
|
26
|
+
# tags are emitted verbatim, which is why a Rails backtrace can name `show.html.erb:12`. What differs
|
|
27
|
+
# is the **prologue** — stdlib ERB emits a `#coding:` magic comment above the body, Erubi emits its
|
|
28
|
+
# buffer initialiser — and neither documents its height as API.
|
|
29
|
+
#
|
|
30
|
+
# So the height is measured rather than hardcoded: {.line_offset} compiles a probe template of marked
|
|
31
|
+
# lines once per compiler and reads back which compiled line the first marker landed on, checking at
|
|
32
|
+
# the same time that the following markers step by exactly one. A compiler that fails that check is
|
|
33
|
+
# refused (the caller declines the file) rather than trusted, because the failure mode of a wrong
|
|
34
|
+
# offset is every finding in every template reported at the wrong line — silent, and worse than no
|
|
35
|
+
# unit at all.
|
|
36
|
+
module ErbCompiler
|
|
37
|
+
# A compiler whose probe did not step line-for-line. Caught by {Actionpack#template_units_for_file},
|
|
38
|
+
# which declines the file.
|
|
39
|
+
class Unmappable < StandardError; end
|
|
40
|
+
|
|
41
|
+
# Three marked lines, each a STATEMENT tag. A tag's Ruby is emitted verbatim, so the marker is
|
|
42
|
+
# findable in the compiled source and each one is forced onto its own compiled line.
|
|
43
|
+
#
|
|
44
|
+
# Plain text would not do: consecutive text lines are one chunk and are emitted as ONE append
|
|
45
|
+
# carrying the newlines escaped inside the literal — the compiler then pads the missing lines out
|
|
46
|
+
# BELOW it, so a text probe reads as a collapse when the line numbering is in fact intact. That
|
|
47
|
+
# padding is exactly the property being measured, and a tag probe measures it without tripping
|
|
48
|
+
# over how the padding is spelled.
|
|
49
|
+
PROBE = (1..3).map { |n| "<% RIGOR_ERB_PROBE_#{n} %>\n" }.join.freeze
|
|
50
|
+
private_constant :PROBE
|
|
51
|
+
|
|
52
|
+
# An output tag whose Ruby OPENS A BLOCK: `<%= form_with(model: @user) do |f| %>`, the single most
|
|
53
|
+
# common shape in a real Rails view. Neither compiler handles it — both emit
|
|
54
|
+
# `_buf << (form_with(…) do |f|).to_s`, which is a syntax error, so every template using a form
|
|
55
|
+
# helper produced two parse diagnostics and no unit at all (431 of them on redmine, measured).
|
|
56
|
+
#
|
|
57
|
+
# Rails does not hit this because its own ERB handler carries exactly this rule
|
|
58
|
+
# (`ActionView::Template::Handlers::ERB::BLOCK_EXPR`) and emits `@output_buffer.append= expr`
|
|
59
|
+
# unwrapped for a matching tag. The same rule is applied here one step earlier, as a rewrite of
|
|
60
|
+
# the TEMPLATE rather than of a compiler's output: `<%=` becomes `<%` for such a tag, which is a
|
|
61
|
+
# same-width, same-line edit both compilers then handle, and the discarded value is the buffer
|
|
62
|
+
# append the design note says is not an origin anyway.
|
|
63
|
+
BLOCK_EXPR = /\s*((\s+|\))do|\{)(\s*\|[^|]*\|)?\s*\z/
|
|
64
|
+
private_constant :BLOCK_EXPR
|
|
65
|
+
|
|
66
|
+
# The Erubi/Rails trim markers, which stdlib ERB only understands under a `trim_mode:` that also
|
|
67
|
+
# changes how it emits newlines. They are blanked instead — `<%-` → `<% `, `-%>` → ` %>` — which
|
|
68
|
+
# is the same-width, same-line rewrite {BLOCK_EXPR} uses and keeps the line map out of the
|
|
69
|
+
# compiler's trim rules entirely. Left in place under `trim_mode: nil` they parse as Ruby's unary
|
|
70
|
+
# minus and cost the template its unit (87 of redmine's 506, measured after the block-expression
|
|
71
|
+
# fix and before this one).
|
|
72
|
+
TRIM_OPEN = /<%-/
|
|
73
|
+
private_constant :TRIM_OPEN
|
|
74
|
+
|
|
75
|
+
TRIM_CLOSE = /-%>/
|
|
76
|
+
private_constant :TRIM_CLOSE
|
|
77
|
+
|
|
78
|
+
# #1047 — a LAYOUT's `yield`. `<%= yield %>`, `<%= yield :sidebar %>` and `<% if content_for?(:x) %>`
|
|
79
|
+
# are what a layout is made of, and the first two are legal ERB and illegal Ruby anywhere outside a
|
|
80
|
+
# method body — so every layout's compiled source failed to parse and the file was declined (four of
|
|
81
|
+
# redmine's 506 templates, two of mastodon's 46; `docs/notes/20260917-erb-template-units.md` § 3).
|
|
82
|
+
#
|
|
83
|
+
# The seam deliberately does NOT wrap a unit's body in a synthesised method
|
|
84
|
+
# (`docs/internal-spec/macro-substrate.md` § Positions: wrapping would shift every line and compose
|
|
85
|
+
# a second line map onto the plugin's), so the fix is a rewrite of the TEMPLATE, in the same pre-pass
|
|
86
|
+
# {BLOCK_EXPR} and {TRIM_OPEN} live in: the `yield` KEYWORD inside an ERB tag becomes a call to
|
|
87
|
+
# {YIELD_METHOD}, an ordinary implicit-self method on the synthesised view context, declared in
|
|
88
|
+
# `sig/action_view.rbs` as returning `String`.
|
|
89
|
+
#
|
|
90
|
+
# Unlike the other two rewrites this one is NOT width-preserving, and it does not have to be: a unit
|
|
91
|
+
# whose `line_map` is non-empty reports at column 1 by construction
|
|
92
|
+
# ({Analysis::TemplateUnits#remap} — "the template's lines and the compiled Ruby's columns do not
|
|
93
|
+
# correspond"), so only the LINE count is load-bearing, and the replacement contains no newline.
|
|
94
|
+
#
|
|
95
|
+
# What the rewrite deliberately does not do is give the call a TYPE beyond `String`. Rails' `yield`
|
|
96
|
+
# returns whatever the inner template's buffer holds, and `yield :sidebar` returns the `content_for`
|
|
97
|
+
# buffer — an empty `SafeBuffer` when nothing was provided, never nil. A lenient `String` is the
|
|
98
|
+
# widest honest reading, and inventing anything narrower would be the `Parameters#[]` trap one
|
|
99
|
+
# layer up.
|
|
100
|
+
YIELD_METHOD = "__rigor_yield"
|
|
101
|
+
|
|
102
|
+
# The `yield` keyword, and only the keyword: not `foo.yield`, not `:yield`, not `@yield`, not
|
|
103
|
+
# `yielding`. A `yield` inside a STRING literal in a tag (`<%= t("yield") %>`) is rewritten too —
|
|
104
|
+
# the pre-pass is a Regexp over the tag, not a Ruby lexer — which changes a literal's bytes in a
|
|
105
|
+
# body that is never executed and that no rule reads the contents of. Recorded rather than guarded,
|
|
106
|
+
# because a lexer here would be a second Ruby parser to keep honest.
|
|
107
|
+
YIELD_KEYWORD = /(?<![A-Za-z0-9_.:@$])yield(?![A-Za-z0-9_?!])/
|
|
108
|
+
private_constant :YIELD_KEYWORD
|
|
109
|
+
|
|
110
|
+
# Any ERB tag, non-greedy and multi-line — the region {YIELD_KEYWORD} is applied inside, so a
|
|
111
|
+
# `yield` in the template's HTML text is left exactly as it was written. A `<%%` opener is ERB's
|
|
112
|
+
# escape for a LITERAL `<%` in the output, so what follows it is text too and is skipped.
|
|
113
|
+
ANY_TAG = /<%.*?%>/m
|
|
114
|
+
private_constant :ANY_TAG
|
|
115
|
+
|
|
116
|
+
# `<%= … %>` and `<%== … %>`, non-greedy and multi-line. No capture group: the block below reads
|
|
117
|
+
# the tag it was handed rather than `Regexp.last_match`, whose group is `String?` however sure
|
|
118
|
+
# the pattern makes it — and a rewrite that depends on global match state is the harder one to
|
|
119
|
+
# read anyway.
|
|
120
|
+
OUTPUT_TAG = /<%={1,2}(?!=).*?%>/m
|
|
121
|
+
private_constant :OUTPUT_TAG
|
|
122
|
+
|
|
123
|
+
# The `<%=` / `<%==` opener of a matched tag, which is all that gets rewritten.
|
|
124
|
+
OUTPUT_OPENER = /\A<%=+/
|
|
125
|
+
private_constant :OUTPUT_OPENER
|
|
126
|
+
|
|
127
|
+
module_function
|
|
128
|
+
|
|
129
|
+
# `[ruby_source, line_map, transform_id]` for one template's bytes. `text` must already be valid
|
|
130
|
+
# UTF-8 — {.scrub} is the caller's, because more than one thing reads the template and every one
|
|
131
|
+
# of them has to read the SAME bytes.
|
|
132
|
+
#
|
|
133
|
+
# `line_map` is `{ compiled line => template line }` for every template line, explicitly — NOT the
|
|
134
|
+
# empty "identity" map, even when the offset is zero. An empty map tells the engine the transform
|
|
135
|
+
# was byte-preserving and lets the compiled Ruby's COLUMNS through, and an ERB column names
|
|
136
|
+
# nothing in the template (`macro-substrate.md` § Positions).
|
|
137
|
+
def compile(text)
|
|
138
|
+
offset = line_offset
|
|
139
|
+
raise Unmappable, "#{transform_id} does not emit template lines one per compiled line" if offset.nil?
|
|
140
|
+
|
|
141
|
+
compiled = compile_source(normalize(text))
|
|
142
|
+
map = (1..template_line_count(text)).to_h { |line| [line + offset, line] }
|
|
143
|
+
[compiled, map, transform_id]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# A template's bytes as valid UTF-8. A `.erb` file is whatever the project committed, and an
|
|
147
|
+
# invalid byte is not a reason to refuse it: `ERB`, `Regexp#match?` and Prism all raise on one,
|
|
148
|
+
# so every reader has to be handed the same scrubbed String or they disagree about the file.
|
|
149
|
+
def scrub(source)
|
|
150
|
+
text = source.to_s.dup.force_encoding(Encoding::UTF_8)
|
|
151
|
+
return text if text.valid_encoding?
|
|
152
|
+
|
|
153
|
+
text.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# The compiled Ruby for one template, through whichever compiler resolved.
|
|
157
|
+
def compile_source(text)
|
|
158
|
+
if erubi?
|
|
159
|
+
::Erubi::Engine.new(text).src
|
|
160
|
+
else
|
|
161
|
+
::ERB.new(text, trim_mode: nil).src
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Rewrites every block-opening output tag into a statement tag; see {BLOCK_EXPR}. Line- and
|
|
166
|
+
# width-preserving by construction: only the tag's `=` characters are replaced, by spaces.
|
|
167
|
+
def normalize(text)
|
|
168
|
+
normalize_block_expressions(normalize_yields(text.gsub(TRIM_OPEN, "<% ").gsub(TRIM_CLOSE, " %>")))
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Rewrites the `yield` keyword inside every ERB tag into a call on the view context; see
|
|
172
|
+
# {YIELD_METHOD}. Runs BEFORE {#normalize_block_expressions} so that pass reads the tag bodies it
|
|
173
|
+
# will actually hand to the compiler.
|
|
174
|
+
def normalize_yields(text)
|
|
175
|
+
return text unless text.match?(YIELD_KEYWORD)
|
|
176
|
+
|
|
177
|
+
text.gsub(ANY_TAG) { |tag| tag.start_with?("<%%") ? tag : tag.gsub(YIELD_KEYWORD, YIELD_METHOD) }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def normalize_block_expressions(text)
|
|
181
|
+
text.gsub(OUTPUT_TAG) do |tag|
|
|
182
|
+
opener = tag[OUTPUT_OPENER]
|
|
183
|
+
next tag if opener.nil?
|
|
184
|
+
|
|
185
|
+
body = tag[opener.length...-2].to_s
|
|
186
|
+
next tag unless body.match?(BLOCK_EXPR)
|
|
187
|
+
|
|
188
|
+
"<%#{' ' * (opener.length - 2)}#{body}%>"
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# A template's last line may lack its newline; `String#lines` already counts it, and an EMPTY
|
|
193
|
+
# template has no lines at all and needs no rows.
|
|
194
|
+
def template_line_count(text)
|
|
195
|
+
text.lines.length
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# How many compiled lines sit above the template's first line. Measured once per process per
|
|
199
|
+
# compiler — the answer cannot change under a loaded compiler, and the probe compile is cheap
|
|
200
|
+
# enough that memoising it is about tidiness rather than cost.
|
|
201
|
+
def line_offset
|
|
202
|
+
return @line_offset if defined?(@line_offset) && @line_offset
|
|
203
|
+
|
|
204
|
+
@line_offset = probe_offset
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# `nil` when the loaded compiler does not step line-for-line.
|
|
208
|
+
def probe_offset
|
|
209
|
+
lines = compile_source(PROBE).lines
|
|
210
|
+
first = lines.index { |line| line.include?("RIGOR_ERB_PROBE_1") }
|
|
211
|
+
return nil if first.nil?
|
|
212
|
+
|
|
213
|
+
(2..3).each do |marker|
|
|
214
|
+
return nil unless lines[first + marker - 1]&.include?("RIGOR_ERB_PROBE_#{marker}")
|
|
215
|
+
end
|
|
216
|
+
first
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# `erubi-1.13.1` / `erb-6.0.1.1` — the compiler's identity, which rides the unit digest.
|
|
220
|
+
def transform_id
|
|
221
|
+
@transform_id ||= if erubi?
|
|
222
|
+
"erubi-#{::Erubi::VERSION}"
|
|
223
|
+
else
|
|
224
|
+
"erb-#{::ERB.const_defined?(:VERSION) ? ::ERB::VERSION : 'stdlib'}"
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# True once Erubi has been loaded out of the analysed project's bundle AND its line numbering has
|
|
229
|
+
# been measured. Resolved ONCE per process: a failed resolution is as memoised as a successful
|
|
230
|
+
# one, so a project without Erubi does not pay a `$LOAD_PATH` walk per template.
|
|
231
|
+
#
|
|
232
|
+
# `Isolation.require_with_target_bundle` is [ADR-90](../../../../../../docs/adr/90-target-library-resolution-from-project-bundle.md)'s
|
|
233
|
+
# path — Rigor's own gem env first, then the analysed project's bundle, `$LOAD_PATH` appended and
|
|
234
|
+
# only on a failed require. Erubi is not a Rigor dependency and is never added to one. Any load
|
|
235
|
+
# failure is equally "no Erubi here"; stdlib `ERB` answers the same question, so nothing is
|
|
236
|
+
# reported and nothing degrades but the compiler's name.
|
|
237
|
+
#
|
|
238
|
+
# A resolved Erubi that does NOT step line-for-line is **demoted** rather than trusted. Erubi pads
|
|
239
|
+
# its output to the template's line count deliberately (it is why a Rails backtrace can name
|
|
240
|
+
# `show.html.erb:12`), so the demotion is not expected to fire — but a compiler whose numbering
|
|
241
|
+
# the probe cannot confirm would silently report every finding in every template at the wrong
|
|
242
|
+
# line, and stdlib ERB is right here and always present.
|
|
243
|
+
def erubi?
|
|
244
|
+
return @erubi if defined?(@erubi)
|
|
245
|
+
|
|
246
|
+
@erubi = load_erubi
|
|
247
|
+
return @erubi unless @erubi
|
|
248
|
+
return true unless probe_offset.nil?
|
|
249
|
+
|
|
250
|
+
@erubi = false
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def load_erubi
|
|
254
|
+
Isolation.require_with_target_bundle("erubi", Isolation.target_bundle_root)
|
|
255
|
+
defined?(::Erubi::Engine) ? true : false
|
|
256
|
+
rescue ::StandardError, ::LoadError
|
|
257
|
+
false
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Test seam: forget the probed offset, the resolved compiler and its id, so one process can measure
|
|
261
|
+
# both compilers. Never called by the plugin itself.
|
|
262
|
+
def reset!
|
|
263
|
+
remove_instance_variable(:@line_offset) if defined?(@line_offset)
|
|
264
|
+
remove_instance_variable(:@transform_id) if defined?(@transform_id)
|
|
265
|
+
remove_instance_variable(:@erubi) if defined?(@erubi)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|