rigortype 0.4.4 → 0.4.6
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 +5 -5
- data/docs/handbook/11-sig-gen.md +100 -18
- data/docs/manual/02-cli-reference.md +85 -16
- data/docs/manual/03-configuration.md +12 -9
- data/docs/manual/04-diagnostics.md +17 -6
- data/docs/manual/05-inspecting-types.md +2 -2
- data/docs/manual/06-baseline.md +1 -1
- data/docs/manual/07-plugins.md +2 -1
- data/docs/manual/08-skills.md +7 -0
- data/docs/manual/10-mcp-server.md +17 -2
- data/docs/manual/12-caching.md +8 -1
- data/docs/manual/18-removing-dead-code.md +18 -7
- data/docs/manual/plugins/rigor-actionmailer.md +30 -0
- data/docs/manual/plugins/rigor-actionpack.md +30 -0
- data/docs/manual/plugins/rigor-devise.md +15 -0
- data/docs/manual/plugins/rigor-rspec.md +102 -1
- data/lib/rigor/analysis/buffer_binding.rb +14 -0
- data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
- data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +30 -9
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +142 -15
- data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +115 -7
- data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +253 -0
- data/lib/rigor/analysis/check_rules/rebinding_block_callees/dispatch.rb +112 -0
- data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +394 -0
- data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +7 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +3 -1
- data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +64 -4
- data/lib/rigor/analysis/check_rules.rb +319 -21
- data/lib/rigor/analysis/dependency_recorder.rb +11 -0
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
- data/lib/rigor/analysis/edit_replay.rb +67 -0
- data/lib/rigor/analysis/incremental.rb +86 -0
- data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
- data/lib/rigor/analysis/incremental_session.rb +336 -46
- data/lib/rigor/analysis/path_expansion.rb +44 -1
- data/lib/rigor/analysis/reachability/graph.rb +90 -13
- data/lib/rigor/analysis/reachability/scan.rb +279 -39
- data/lib/rigor/analysis/require_relative_closure.rb +100 -0
- data/lib/rigor/analysis/rule_catalog.rb +22 -5
- data/lib/rigor/analysis/run_disclosure_credit.rb +80 -0
- data/lib/rigor/analysis/run_disclosure_ledger.rb +155 -0
- data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +6 -2
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +11 -3
- data/lib/rigor/analysis/runner/pool_coordinator.rb +82 -16
- data/lib/rigor/analysis/runner/project_pre_passes.rb +45 -3
- data/lib/rigor/analysis/runner/scan_environment.rb +51 -0
- data/lib/rigor/analysis/runner.rb +222 -111
- data/lib/rigor/analysis/severity_stamp.rb +11 -0
- data/lib/rigor/analysis/worker_session.rb +24 -1
- data/lib/rigor/cache/descriptor.rb +40 -7
- data/lib/rigor/cache/engine_source.rb +3 -2
- data/lib/rigor/cache/incremental_snapshot.rb +49 -8
- data/lib/rigor/cache/rbs_descriptor.rb +2 -1
- data/lib/rigor/cache/store.rb +4 -3
- data/lib/rigor/cli/check_command.rb +93 -4
- data/lib/rigor/cli/command.rb +3 -1
- data/lib/rigor/cli/diff_command.rb +2 -1
- data/lib/rigor/cli/docs_command.rb +3 -2
- data/lib/rigor/cli/doctor_command.rb +1 -1
- data/lib/rigor/cli/explain_command.rb +3 -2
- data/lib/rigor/cli/lens_command.rb +5 -5
- data/lib/rigor/cli/mcp_command.rb +2 -1
- data/lib/rigor/cli/options.rb +20 -3
- data/lib/rigor/cli/plugin_command.rb +2 -1
- data/lib/rigor/cli/plugins_command.rb +7 -1
- data/lib/rigor/cli/plugins_renderer.rb +24 -2
- data/lib/rigor/cli/show_bleedingedge_command.rb +2 -1
- data/lib/rigor/cli/sig_gen_command.rb +73 -13
- data/lib/rigor/cli/skill_command.rb +2 -1
- data/lib/rigor/cli/skill_describe.rb +3 -2
- data/lib/rigor/cli/type_of_command.rb +2 -1
- data/lib/rigor/cli/type_of_project_scope.rb +27 -0
- data/lib/rigor/cli/unused_command.rb +10 -7
- data/lib/rigor/cli.rb +3 -2
- data/lib/rigor/config_audit.rb +72 -16
- data/lib/rigor/configuration/severity_profile.rb +20 -2
- data/lib/rigor/configuration.rb +43 -7
- data/lib/rigor/effects/signature_sources.rb +3 -1
- data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
- data/lib/rigor/environment/gated_signature_guard.rb +2 -1
- data/lib/rigor/environment/gated_signature_plan.rb +2 -1
- data/lib/rigor/environment/installed_gem_set.rb +2 -1
- data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
- data/lib/rigor/environment/rbs_loader.rb +136 -7
- data/lib/rigor/environment/required_features.rb +136 -29
- data/lib/rigor/environment/ruby_library_roots.rb +122 -0
- data/lib/rigor/environment.rb +4 -2
- data/lib/rigor/inference/block_call_timing.rb +7 -1
- data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
- data/lib/rigor/inference/callee_mutation_chain.rb +115 -0
- data/lib/rigor/inference/captured_locals.rb +44 -13
- data/lib/rigor/inference/clause_widening.rb +90 -0
- data/lib/rigor/inference/deep_erasure.rb +138 -0
- data/lib/rigor/inference/def_return_typer.rb +159 -9
- data/lib/rigor/inference/definer_resolution.rb +101 -20
- data/lib/rigor/inference/error_info.rb +17 -3
- data/lib/rigor/inference/expression_typer.rb +97 -18
- data/lib/rigor/inference/global_write_census.rb +36 -1
- data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
- data/lib/rigor/inference/ivar_writer_census.rb +195 -0
- data/lib/rigor/inference/loop_value_record.rb +53 -0
- data/lib/rigor/inference/macro_block_self_type.rb +20 -6
- data/lib/rigor/inference/match_rebinding/frame.rb +19 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +278 -42
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +6 -2
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
- data/lib/rigor/inference/method_dispatcher/set_folding.rb +53 -20
- data/lib/rigor/inference/method_dispatcher.rb +113 -19
- data/lib/rigor/inference/module_function_state/spellings.rb +64 -0
- data/lib/rigor/inference/module_function_state.rb +149 -42
- data/lib/rigor/inference/mutation_widening.rb +5 -2
- data/lib/rigor/inference/narrowing.rb +116 -11
- data/lib/rigor/inference/object_mixins.rb +13 -1
- data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
- data/lib/rigor/inference/project_patched_methods.rb +168 -4
- data/lib/rigor/inference/project_patched_mixins.rb +162 -0
- data/lib/rigor/inference/project_patched_scanner.rb +51 -12
- data/lib/rigor/inference/project_patched_visibility.rb +214 -0
- data/lib/rigor/inference/project_seed_tables.rb +121 -0
- data/lib/rigor/inference/project_yielder.rb +128 -0
- data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
- data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
- data/lib/rigor/inference/reflective_ivar_write.rb +6 -6
- data/lib/rigor/inference/return_barrier.rb +16 -0
- data/lib/rigor/inference/scope_indexer/certainty.rb +9 -0
- data/lib/rigor/inference/scope_indexer/declared_ivar_types.rb +64 -0
- data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +162 -0
- data/lib/rigor/inference/scope_indexer/unseen_ivar_writers.rb +133 -0
- data/lib/rigor/inference/scope_indexer.rb +1144 -197
- data/lib/rigor/inference/self_call_ivar_writes.rb +29 -12
- data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
- data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
- data/lib/rigor/inference/statement_evaluator.rb +1088 -213
- data/lib/rigor/inference/synthetic_method_index.rb +23 -0
- data/lib/rigor/inference/synthetic_method_scanner/project_members.rb +130 -0
- data/lib/rigor/inference/synthetic_method_scanner.rb +72 -12
- data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
- data/lib/rigor/language_server/buffer_table.rb +6 -0
- data/lib/rigor/language_server/diagnostic_publisher.rb +20 -6
- data/lib/rigor/language_server/project_context.rb +59 -3
- data/lib/rigor/language_server/server.rb +5 -3
- data/lib/rigor/lens/contract_reader.rb +45 -2
- data/lib/rigor/lens/document.rb +2 -1
- data/lib/rigor/lens/file_reader.rb +12 -2
- data/lib/rigor/lens/members.rb +16 -2
- data/lib/rigor/lens/run.rb +34 -7
- data/lib/rigor/mcp/server.rb +11 -3
- data/lib/rigor/plugin/base.rb +29 -13
- data/lib/rigor/plugin/io_boundary.rb +42 -6
- data/lib/rigor/plugin/isolation.rb +4 -3
- data/lib/rigor/plugin/manifest.rb +29 -3
- data/lib/rigor/plugin/registry.rb +38 -0
- data/lib/rigor/protection/discovery_seed.rb +64 -36
- data/lib/rigor/protection/mutation_cache.rb +8 -2
- data/lib/rigor/rooted_glob.rb +36 -0
- data/lib/rigor/scope/discovery_index.rb +5 -0
- data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
- data/lib/rigor/scope/resolution_chain.rb +40 -5
- data/lib/rigor/scope.rb +59 -7
- data/lib/rigor/sig_gen/alias_index.rb +2 -1
- data/lib/rigor/sig_gen/classification.rb +4 -1
- data/lib/rigor/sig_gen/generator.rb +465 -49
- data/lib/rigor/sig_gen/layout_index.rb +5 -2
- data/lib/rigor/sig_gen/observation_collector.rb +141 -12
- data/lib/rigor/sig_gen/rbs_validity.rb +1 -1
- data/lib/rigor/sig_gen/renderer.rb +16 -155
- data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
- data/lib/rigor/sig_gen/type_elaborator.rb +21 -5
- data/lib/rigor/sig_gen/write_reporter.rb +203 -0
- data/lib/rigor/sig_gen/write_result.rb +3 -1
- data/lib/rigor/sig_gen/writer.rb +5 -3
- data/lib/rigor/signature_path_audit.rb +4 -2
- data/lib/rigor/type/combinator.rb +4 -3
- data/lib/rigor/type/constant.rb +54 -3
- data/lib/rigor/type/hash_shape.rb +14 -9
- data/lib/rigor/type/struct_instance.rb +3 -2
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +61 -44
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +20 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +13 -0
- data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +4 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
- data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +5 -3
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +7 -6
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +38 -9
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +102 -11
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +4 -2
- data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
- data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
- data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +1 -1
- data/sig/rigor/analysis/baseline.rbs +3 -2
- data/sig/rigor/effects/file_collection.rbs +5 -6
- data/sig/rigor/effects/registry.rbs +8 -6
- data/sig/rigor/effects/summary.rbs +6 -5
- data/sig/rigor/environment.rbs +5 -3
- data/sig/rigor/inference/optimistic_origin.rbs +11 -0
- data/sig/rigor/inference.rbs +6 -0
- data/sig/rigor/plugin/additional_initializer.rbs +5 -5
- data/sig/rigor/plugin/effect_ancestry.rbs +6 -4
- data/sig/rigor/plugin/effect_attribution.rbs +25 -10
- data/sig/rigor/plugin/effect_edge.rbs +12 -5
- data/sig/rigor/plugin/effect_entry_points.rbs +8 -3
- data/sig/rigor/plugin/io_boundary.rbs +5 -2
- data/sig/rigor/plugin/manifest.rbs +16 -18
- data/sig/rigor/plugin/protocol_contract.rbs +10 -9
- data/sig/rigor/plugin/registry.rbs +1 -1
- data/sig/rigor/rooted_glob.rbs +7 -0
- data/sig/rigor/scope.rbs +34 -52
- data/sig/rigor/type.rbs +0 -6
- data/skills/rigor-doctor/references/01-checks.md +23 -11
- data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
- metadata +32 -1
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "classification"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module SigGen
|
|
9
|
+
# Reports a `rigor sig-gen --write` run (the per-target {WriteResult}s) and the `--check` verdict. Split out of
|
|
10
|
+
# {Renderer}, which formats candidates; this formats what the writer did with them.
|
|
11
|
+
class WriteReporter
|
|
12
|
+
def initialize(out:)
|
|
13
|
+
@out = out
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Renders the per-source-file outcomes of a `--write` run. Distinct from {Renderer#render} because the write
|
|
17
|
+
# path's reporting surface is action-oriented (created / updated / skipped) rather than candidate-oriented.
|
|
18
|
+
# @param refused — methods refused as `sig.skipped.inline-differs`; the JSON payload names them under
|
|
19
|
+
# `refused` (absent when there are none, so an ordinary payload is unchanged), and text mode leaves them
|
|
20
|
+
# to the command's stderr `REFUSED` lines.
|
|
21
|
+
def render_write(results:, format:, refused: [])
|
|
22
|
+
case format
|
|
23
|
+
when "json" then render_write_json(results, refused)
|
|
24
|
+
when "text" then render_write_text(results)
|
|
25
|
+
else raise ArgumentError, "unsupported format: #{format}"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# ADR-112 WD4 — `sig-gen --check`: the results of a dry-run `--write`. Only the targets `--write` would
|
|
30
|
+
# change (or refuse) are shown, each with the lines it would add; the verdict is the exit status, which
|
|
31
|
+
# the command derives from the same results ({.out_of_date}).
|
|
32
|
+
#
|
|
33
|
+
# @param refused — the methods the generator refused to reconcile (`sig.skipped.inline-differs`):
|
|
34
|
+
# `sig/` is not up to date while one stands, and `--write` cannot fix it.
|
|
35
|
+
def render_check(results:, format:, refused: [])
|
|
36
|
+
stale = self.class.out_of_date(results)
|
|
37
|
+
case format
|
|
38
|
+
when "json"
|
|
39
|
+
@out.puts(JSON.pretty_generate({ up_to_date: stale.empty? && refused.empty?,
|
|
40
|
+
results: stale.map { |r| check_entry(r) },
|
|
41
|
+
refused: refused.map(&:to_h) }))
|
|
42
|
+
when "text" then render_check_text(stale, refused)
|
|
43
|
+
else raise ArgumentError, "unsupported format: #{format}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# One line per method sig-gen refused to reconcile with its `sig/` copy. Shared by `--write` (on stderr,
|
|
48
|
+
# next to the write report) and `--check`.
|
|
49
|
+
def self.refusal_lines(refused)
|
|
50
|
+
refused.map do |candidate|
|
|
51
|
+
separator = candidate.kind == :singleton ? "." : "#"
|
|
52
|
+
"REFUSED #{candidate.path}: #{candidate.class_name}#{separator}#{candidate.method_name} — its inline " \
|
|
53
|
+
"declaration and its sig/ declaration disagree, so neither was changed " \
|
|
54
|
+
"(#{Classification::SKIP_DIAGNOSTIC_IDS.fetch(candidate.skip_reason)}). Make them agree, or pass " \
|
|
55
|
+
"--overwrite to replace the sig/ member with the inline declaration; `rigor explain " \
|
|
56
|
+
"#{Classification::SKIP_DIAGNOSTIC_IDS.fetch(candidate.skip_reason)}` says when that is not enough."
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The results that make a `--check` fail: a target `--write` would create or change, and one it would
|
|
61
|
+
# refuse, since a write that cannot happen is not an up-to-date `sig/` either.
|
|
62
|
+
def self.out_of_date(results)
|
|
63
|
+
results.reject { |result| %i[noop skipped_outside_sig_root].include?(result.action) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Nothing was written, so the entry must not read like one that was: `created` / `updated` become
|
|
67
|
+
# `would_create` / `would_update`. A refusal keeps its action — `--write` would refuse the same way.
|
|
68
|
+
CHECK_ACTIONS = { created: "would_create", updated: "would_update" }.freeze
|
|
69
|
+
private_constant :CHECK_ACTIONS
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def check_entry(result)
|
|
74
|
+
entry = result.to_h
|
|
75
|
+
entry[:action] = CHECK_ACTIONS.fetch(result.action, entry[:action])
|
|
76
|
+
entry
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def render_check_text(stale, refused)
|
|
80
|
+
if stale.empty? && refused.empty?
|
|
81
|
+
@out.puts("sig/ is up to date")
|
|
82
|
+
return
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
self.class.refusal_lines(refused).each { |line| @out.puts(line) }
|
|
86
|
+
|
|
87
|
+
stale.each do |result|
|
|
88
|
+
case result.action
|
|
89
|
+
when :created, :updated then render_check_change(result)
|
|
90
|
+
when :skipped_invalid_rbs then render_write_invalid(result)
|
|
91
|
+
when :skipped_invalid_encoding then render_write_invalid_encoding(result)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def render_check_change(result)
|
|
97
|
+
counts = result.action == :created ? "#{result.applied.size} method(s)" : applied_counts(result)
|
|
98
|
+
verb = result.action == :created ? "would create" : "would update"
|
|
99
|
+
@out.puts("#{verb} #{result.target_path} (#{counts})")
|
|
100
|
+
result.applied.each do |candidate|
|
|
101
|
+
@out.puts(" - #{candidate.declared_rbs}") if candidate.declared_rbs
|
|
102
|
+
candidate.rbs_lines.each { |line| @out.puts(" + #{line}") }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def render_write_text(results)
|
|
107
|
+
if results.all? { |r| r.action == :noop && r.skipped.empty? }
|
|
108
|
+
@out.puts("No changes")
|
|
109
|
+
return
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
results.each do |result|
|
|
113
|
+
case result.action
|
|
114
|
+
when :created then render_write_created(result)
|
|
115
|
+
when :updated then render_write_updated(result)
|
|
116
|
+
when :noop then render_write_unchanged(result)
|
|
117
|
+
when :skipped_outside_sig_root then render_write_skipped(result)
|
|
118
|
+
when :skipped_invalid_rbs then render_write_invalid(result)
|
|
119
|
+
when :skipped_invalid_encoding then render_write_invalid_encoding(result)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# `+N` counts added lines; an existing line replaced (`--overwrite`, or an inline update) is counted apart,
|
|
125
|
+
# because "added 2" when one of them rewrote a line the project already had would understate the change.
|
|
126
|
+
def applied_counts(result)
|
|
127
|
+
added = "+#{result.applied.size - result.replaced.size}"
|
|
128
|
+
result.replaced.empty? ? added : "#{added}, replaced #{result.replaced.size}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def render_write_created(result)
|
|
132
|
+
@out.puts("created #{result.target_path} (#{result.applied.size} method(s))")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def render_write_updated(result)
|
|
136
|
+
@out.puts("updated #{result.target_path} (#{applied_counts(result)}, " \
|
|
137
|
+
"skipped #{result.skipped.size} user-authored)")
|
|
138
|
+
render_write_skips(result)
|
|
139
|
+
render_left_unreadable(result)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# A target the writer opened and changed nothing in, because every proposal for it was a member the
|
|
143
|
+
# project already declares (#1975). Without this line the run read as "No changes" or said nothing about it.
|
|
144
|
+
def render_write_unchanged(result)
|
|
145
|
+
return if result.skipped.empty?
|
|
146
|
+
|
|
147
|
+
@out.puts("unchanged #{result.target_path} (skipped #{result.skipped.size} user-authored)")
|
|
148
|
+
render_write_skips(result)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# One line per proposal the writer declined, so a change `--diff` or `--print` showed is never dropped
|
|
152
|
+
# without a reason. The same entries are the `skipped` rows of the `--format=json` payload.
|
|
153
|
+
def render_write_skips(result)
|
|
154
|
+
result.skipped.each do |candidate, reason|
|
|
155
|
+
separator = candidate.kind == :singleton ? "." : "#"
|
|
156
|
+
label = "#{candidate.class_name}#{separator}#{candidate.method_name}"
|
|
157
|
+
@out.puts(" skipped #{label} (#{reason.to_s.tr('_', '-')}): #{candidate.rbs_lines.join(' ')}")
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# ADR-103 WD9 — the declarations whose annotation region the writer refused to rewrite. Named per
|
|
162
|
+
# method rather than counted: the fix is a human reading one existing annotation and deciding what
|
|
163
|
+
# it should say, and there is no count of those a reader could act on.
|
|
164
|
+
def render_left_unreadable(result)
|
|
165
|
+
return if result.left_unreadable.empty?
|
|
166
|
+
|
|
167
|
+
@out.puts(" left #{result.left_unreadable.size} existing annotation(s) byte-untouched " \
|
|
168
|
+
"(sig.effect.left-unreadable):")
|
|
169
|
+
result.left_unreadable.each do |candidate|
|
|
170
|
+
@out.puts(" #{candidate.class_name}##{candidate.method_name} — " \
|
|
171
|
+
"would have emitted #{candidate.annotations.join(' ')}")
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def render_write_skipped(result)
|
|
176
|
+
@out.puts("skipped #{result.source_path} -> #{result.target_path} (outside sig root)")
|
|
177
|
+
render_write_skips(result)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# The assembled file does not parse, so it was NOT written. Writing it would poison the sig tree — the
|
|
181
|
+
# consumer quarantines an unparseable `.rbs` whole, taking every other type in that file down with it,
|
|
182
|
+
# including the user's own hand-written ones.
|
|
183
|
+
def render_write_invalid(result)
|
|
184
|
+
@out.puts("REFUSED #{result.target_path} — the generated RBS does not parse, so it was not written")
|
|
185
|
+
@out.puts(" #{result.error}")
|
|
186
|
+
@out.puts(" This is a bug in Rigor's RBS rendering, not in your code — please report it.")
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# The EXISTING target file is not valid UTF-8, so the writer will not merge into it — the opposite
|
|
190
|
+
# attribution from {#render_write_invalid}: this one is the file's problem, and the fix is the user's.
|
|
191
|
+
def render_write_invalid_encoding(result)
|
|
192
|
+
@out.puts("REFUSED #{result.target_path} — the existing file is not valid UTF-8, so it was not updated")
|
|
193
|
+
@out.puts(" Re-save the file as UTF-8 and re-run; sig-gen never modifies a file it cannot read faithfully.")
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def render_write_json(results, refused)
|
|
197
|
+
payload = { results: results.map(&:to_h) }
|
|
198
|
+
payload[:refused] = refused.map(&:to_h) unless refused.empty?
|
|
199
|
+
@out.puts(JSON.pretty_generate(payload))
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
@@ -16,7 +16,9 @@ module Rigor
|
|
|
16
16
|
# - `replaced` — the subset of `applied` that replaced an existing declaration rather than adding one
|
|
17
17
|
# (`--overwrite`'s tighter returns, and inline updates).
|
|
18
18
|
# - `skipped` — the {MethodCandidate}s the writer declined (e.g. tighter-return without `--overwrite`). Each
|
|
19
|
-
# entry pairs the candidate with a skip reason keyword
|
|
19
|
+
# entry pairs the candidate with a skip reason keyword: `:user_authored` (the target already declares the
|
|
20
|
+
# member, and the writer does not replace it) or `:outside_sig_root` (the member routes to a file outside
|
|
21
|
+
# the writer's sig root, so nothing was attempted).
|
|
20
22
|
# - `left_unreadable` — the {MethodCandidate}s whose effect annotation was NOT written because the
|
|
21
23
|
# target declaration already carries annotations, so its bytes were left alone
|
|
22
24
|
# (`sig.effect.left-unreadable`; see {Writer#splice_annotations}). The signature line itself was
|
data/lib/rigor/sig_gen/writer.rb
CHANGED
|
@@ -98,7 +98,8 @@ module Rigor
|
|
|
98
98
|
source_path ||= candidates.first&.path
|
|
99
99
|
unless inside_sig_root?(target)
|
|
100
100
|
return WriteResult.new(source_path: source_path, target_path: target,
|
|
101
|
-
action: :skipped_outside_sig_root
|
|
101
|
+
action: :skipped_outside_sig_root,
|
|
102
|
+
skipped: candidates.map { |c| [c, :outside_sig_root] })
|
|
102
103
|
end
|
|
103
104
|
|
|
104
105
|
target.exist? ? update_existing(source_path, target, candidates) : create_new(source_path, target, candidates)
|
|
@@ -636,7 +637,8 @@ module Rigor
|
|
|
636
637
|
end
|
|
637
638
|
|
|
638
639
|
# Returns a list of `[method_name (Symbol), kind (Symbol)]` pairs for every method-like member in the
|
|
639
|
-
# declaration ({LayoutIndex.member_keys}). ADR-14 slice 4
|
|
640
|
+
# declaration ({LayoutIndex.member_keys}); an `alias` declares its new name (#1975). ADR-14 slice 4
|
|
641
|
+
# recognises the three `attr_*` declaration kinds
|
|
640
642
|
# so a source-side `attr_reader :name` and an RBS-side `attr_reader name: T` are treated as the same member
|
|
641
643
|
# (i.e. user-authored); a `def self?.f` counts as both an instance and a singleton `f` (#1488).
|
|
642
644
|
def collect_member_pairs(decl)
|
|
@@ -754,7 +756,7 @@ module Rigor
|
|
|
754
756
|
|
|
755
757
|
def find_attr_member(decl, method_name, kind)
|
|
756
758
|
decl.members.find do |m|
|
|
757
|
-
next false if m.is_a?(RBS::AST::Members::MethodDefinition)
|
|
759
|
+
next false if m.is_a?(RBS::AST::Members::MethodDefinition) || m.is_a?(RBS::AST::Members::Alias)
|
|
758
760
|
|
|
759
761
|
LayoutIndex.member_keys(m).include?([method_name, kind])
|
|
760
762
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "rooted_glob"
|
|
4
|
+
|
|
3
5
|
module Rigor
|
|
4
6
|
# Classifies each configured `signature_paths:` entry by what it actually contributes to
|
|
5
7
|
# the RBS environment, so a caller can warn when a configured path resolves to nothing.
|
|
@@ -90,7 +92,7 @@ module Rigor
|
|
|
90
92
|
return Entry.new(path: path, status: :missing, rbs_file_count: 0) unless File.exist?(path)
|
|
91
93
|
return Entry.new(path: path, status: :not_directory, rbs_file_count: 0) unless File.directory?(path)
|
|
92
94
|
|
|
93
|
-
count =
|
|
95
|
+
count = RootedGlob.glob(path, "**/*.rbs").size
|
|
94
96
|
Entry.new(path: path, status: count.zero? ? :empty : :ok, rbs_file_count: count)
|
|
95
97
|
end
|
|
96
98
|
|
|
@@ -204,7 +206,7 @@ module Rigor
|
|
|
204
206
|
private_class_method :listed?
|
|
205
207
|
|
|
206
208
|
def self.canonical_rbs_files(dir)
|
|
207
|
-
|
|
209
|
+
RootedGlob.glob(dir, "**/*.rbs").filter_map { |file| canonical_path(file) }
|
|
208
210
|
end
|
|
209
211
|
private_class_method :canonical_rbs_files
|
|
210
212
|
|
|
@@ -1040,9 +1040,10 @@ module Rigor
|
|
|
1040
1040
|
# No type carrier has a method that changes it once built (a built type is frozen; one `Marshal.load`
|
|
1041
1041
|
# restores is not, but nothing mutates it either), so a memoised key cannot go stale. The memo is weak
|
|
1042
1042
|
# in both key and value: it keeps neither the type nor the string alive, and an entry whose string
|
|
1043
|
-
# nothing else holds is dropped at the next GC. It is keyed by identity on purpose:
|
|
1044
|
-
# `Constant[
|
|
1045
|
-
# module's ivar, so a pool worker renders the key every time, as every
|
|
1043
|
+
# nothing else holds is dropped at the next GC. It is keyed by identity on purpose: two equal members
|
|
1044
|
+
# need not render alike (`Constant[1..2]` and `Constant[1..2.0]`), so equality is not a safe key. A
|
|
1045
|
+
# non-main Ractor may not read the module's ivar, so a pool worker renders the key every time, as every
|
|
1046
|
+
# run did before.
|
|
1046
1047
|
def sort_key(member)
|
|
1047
1048
|
return member.describe(:short) unless Ractor.main?
|
|
1048
1049
|
|
data/lib/rigor/type/constant.rb
CHANGED
|
@@ -78,7 +78,11 @@ module Rigor
|
|
|
78
78
|
# `RbsTypeTranslator#translate_literal` already maps the parsed RBS Literal back to `Constant`.
|
|
79
79
|
# Scalar carriers without RBS Literal support (Float, Range, Rational, Complex, Regexp, Pathname)
|
|
80
80
|
# keep their pre-existing widen-to-class-name behaviour because RBS rejects their literal spellings
|
|
81
|
-
# as syntax errors.
|
|
81
|
+
# as syntax errors. `Range` is generic (`Range[out E]`), and a bare `Range` fails `rbs validate`, so
|
|
82
|
+
# it carries the class of its endpoints, or `untyped` when both are nil (#1891). `Set[unchecked out A]` is
|
|
83
|
+
# generic too, and a set is mutable: the constant's elements are only what it held when it was built, and
|
|
84
|
+
# `x << "a"` adds a member they do not name. It erases to `Set[untyped]`, as an Array or Hash literal's
|
|
85
|
+
# element type is widened rather than pinned (#2177).
|
|
82
86
|
def erase_to_rbs
|
|
83
87
|
case value
|
|
84
88
|
when true then "true"
|
|
@@ -86,6 +90,8 @@ module Rigor
|
|
|
86
90
|
when nil then "nil"
|
|
87
91
|
when Integer then value.to_s
|
|
88
92
|
when Symbol, String then value.inspect
|
|
93
|
+
when Range then "Range[#{range_element_rbs}]"
|
|
94
|
+
when ::Set then "Set[untyped]"
|
|
89
95
|
else value.class.name
|
|
90
96
|
end
|
|
91
97
|
end
|
|
@@ -94,18 +100,63 @@ module Rigor
|
|
|
94
100
|
|
|
95
101
|
include Rigor::Type::AcceptanceRouter
|
|
96
102
|
|
|
103
|
+
# Two carriers are equal when they pin the same value, which for a Float is stricter than Ruby's `==`
|
|
104
|
+
# (#1513): `0.0` and `-0.0` are distinct values (`1.0 / x` tells them apart), so `0.0 | -0.0` keeps both
|
|
105
|
+
# members, and a NaN equals another NaN, so `==` stays reflexive and a union never collects one NaN member
|
|
106
|
+
# per occurrence. A Complex compares its parts the same way.
|
|
97
107
|
def ==(other)
|
|
98
|
-
other.is_a?(Constant) && value.class == other.value.class && value
|
|
108
|
+
other.is_a?(Constant) && value.class == other.value.class && Constant.same_value?(value, other.value)
|
|
99
109
|
end
|
|
100
110
|
alias eql? ==
|
|
101
111
|
|
|
102
112
|
def hash
|
|
103
|
-
[Constant, value.class, value].hash
|
|
113
|
+
[Constant, value.class, Constant.value_key(value)].hash
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# True when `left` and `right` (of one class) are the same value in the sense of {#==}.
|
|
117
|
+
def self.same_value?(left, right)
|
|
118
|
+
case left
|
|
119
|
+
when Float then same_float?(left, right)
|
|
120
|
+
when Complex then same_float?(left.real, right.real) && same_float?(left.imaginary, right.imaginary)
|
|
121
|
+
else left == right
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def self.same_float?(left, right)
|
|
126
|
+
return left == right unless left.is_a?(Float) && right.is_a?(Float)
|
|
127
|
+
return right.nan? if left.nan?
|
|
128
|
+
|
|
129
|
+
left.eql?(right) && (!left.zero? || (1.0 / left).positive? == (1.0 / right).positive?)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# A key whose `eql?` / `hash` agree with {.same_value?}: `uniq { |v| Constant.value_key(v) }` keeps
|
|
133
|
+
# `0.0` and `-0.0` apart and folds every NaN into one. Any other value is its own key, so plain `uniq`
|
|
134
|
+
# semantics are unchanged for it.
|
|
135
|
+
def self.value_key(value)
|
|
136
|
+
case value
|
|
137
|
+
when Float then float_key(value)
|
|
138
|
+
when Complex then [Complex, float_key(value.real), float_key(value.imaginary)]
|
|
139
|
+
else value
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def self.float_key(value)
|
|
144
|
+
return value unless value.is_a?(Float)
|
|
145
|
+
return :nan if value.nan?
|
|
146
|
+
|
|
147
|
+
value.zero? ? [Float, (1.0 / value)] : value
|
|
104
148
|
end
|
|
105
149
|
|
|
106
150
|
def inspect
|
|
107
151
|
"#<Rigor::Type::Constant #{describe(:short)}>"
|
|
108
152
|
end
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
def range_element_rbs
|
|
157
|
+
classes = [value.begin, value.end].compact.map { |endpoint| endpoint.class.name }.uniq.sort
|
|
158
|
+
classes.empty? ? "untyped" : classes.join(" | ")
|
|
159
|
+
end
|
|
109
160
|
end
|
|
110
161
|
end
|
|
111
162
|
end
|
|
@@ -29,7 +29,7 @@ module Rigor
|
|
|
29
29
|
EXTRA_KEY_POLICIES = %i[open closed].freeze
|
|
30
30
|
POLICY_KEYWORDS = %i[required_keys optional_keys read_only_keys extra_keys].freeze
|
|
31
31
|
# A Symbol whose text matches renders as a bare RBS record key (`lang:`) in {#erase_to_rbs};
|
|
32
|
-
# anything else
|
|
32
|
+
# anything else is a quoted Symbol with a fat arrow (`:"data-contrast" =>`). See {#erase_key_prefix}.
|
|
33
33
|
BARE_RECORD_KEY = /\A[A-Za-z_][A-Za-z0-9_]*[?!]?\z/
|
|
34
34
|
|
|
35
35
|
attr_reader :pairs, :required_keys, :optional_keys, :read_only_keys, :extra_keys
|
|
@@ -72,7 +72,7 @@ module Rigor
|
|
|
72
72
|
def erase_to_rbs
|
|
73
73
|
return "{}" if pairs.empty? && closed?
|
|
74
74
|
return hash_erasure unless closed?
|
|
75
|
-
return hash_erasure if pairs.each_key.any? { |k| !k.is_a?(Symbol) }
|
|
75
|
+
return hash_erasure if pairs.each_key.any? { |k| !k.is_a?(Symbol) || !record_key_spellable?(k) }
|
|
76
76
|
|
|
77
77
|
rendered = pairs.map { |k, v| "#{erase_key_prefix(k)} #{v.erase_to_rbs}" }
|
|
78
78
|
"{ #{rendered.join(', ')} }"
|
|
@@ -221,21 +221,26 @@ module Rigor
|
|
|
221
221
|
|
|
222
222
|
# An RBS record entry's key + separator, for the {#erase_to_rbs} path (which only ever sees
|
|
223
223
|
# Symbol keys — string-keyed shapes degrade to `Hash[...]` before reaching here). A Symbol whose
|
|
224
|
-
# text is a plain identifier is a bare key with a colon (`lang
|
|
225
|
-
# Symbol
|
|
226
|
-
# non-identifier (`data-contrast:`) and a quoted key with a colon
|
|
227
|
-
#
|
|
228
|
-
#
|
|
229
|
-
# form, so only genuinely non-identifier keys change. Optional keys carry a leading `?`.
|
|
224
|
+
# text is a plain identifier is a bare key with a colon (`lang:`, Ruby keywords like `class`
|
|
225
|
+
# included). Any other Symbol is a quoted Symbol literal with a fat arrow (`:"data-contrast" =>`):
|
|
226
|
+
# RBS rejects a bare non-identifier (`data-contrast:`) and a quoted key with a colon
|
|
227
|
+
# (`"data-contrast":`), and reads `"data-contrast" =>` as the String key, so the written record
|
|
228
|
+
# declared a key the body never has (#2176). Optional keys carry a leading `?`.
|
|
230
229
|
def erase_key_prefix(key)
|
|
231
230
|
optional = optional_key?(key) ? "?" : ""
|
|
232
231
|
if BARE_RECORD_KEY.match?(key.to_s)
|
|
233
232
|
"#{optional}#{key}:"
|
|
234
233
|
else
|
|
235
|
-
"#{optional}
|
|
234
|
+
"#{optional}:#{key.to_s.inspect} =>"
|
|
236
235
|
end
|
|
237
236
|
end
|
|
238
237
|
|
|
238
|
+
# `String#inspect` escapes `#{`, `#$` and `#@` as `\#`, which RBS's parser does not read back as
|
|
239
|
+
# `#`. A key spelled that way would name a different Symbol, so the shape erases to `Hash[...]`.
|
|
240
|
+
def record_key_spellable?(key)
|
|
241
|
+
!key.to_s.inspect.include?("\\#")
|
|
242
|
+
end
|
|
243
|
+
|
|
239
244
|
def hash_erasure
|
|
240
245
|
return "Hash[top, top]" if open?
|
|
241
246
|
return "Hash" if pairs.empty?
|
|
@@ -63,10 +63,11 @@ module Rigor
|
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
# Erases to the tagging class nominal (conservative: the structural members are not RBS-expressible
|
|
66
|
-
# as a class instance). The anonymous case erases to the `Struct` supertype
|
|
66
|
+
# as a class instance). The anonymous case erases to the `Struct` supertype with an `untyped`
|
|
67
|
+
# element: `Struct[Elem]` is generic, and a bare `Struct` fails `rbs validate` (#2177).
|
|
67
68
|
def erase_to_rbs
|
|
68
69
|
name = class_name
|
|
69
|
-
return "Struct" if name.nil?
|
|
70
|
+
return "Struct[untyped]" if name.nil?
|
|
70
71
|
|
|
71
72
|
name
|
|
72
73
|
end
|
data/lib/rigor/version.rb
CHANGED
|
@@ -99,7 +99,7 @@ module Rigor
|
|
|
99
99
|
# ADR-45 WD1b (#613) — boundary-probed: a root that appears later invalidates the warm run.
|
|
100
100
|
next [] unless @io_boundary.directory?(absolute)
|
|
101
101
|
|
|
102
|
-
Dir.glob(File.join(absolute,
|
|
102
|
+
Dir.glob("**/*.rb", base: absolute).map { |path| File.join(absolute, path) }
|
|
103
103
|
end
|
|
104
104
|
end
|
|
105
105
|
|
|
@@ -90,7 +90,7 @@ module Rigor
|
|
|
90
90
|
# ADR-45 WD1b (#613) — boundary-probed: a root that appears later invalidates the warm run.
|
|
91
91
|
next [] unless @io_boundary.directory?(absolute)
|
|
92
92
|
|
|
93
|
-
Dir.glob(File.join(absolute,
|
|
93
|
+
Dir.glob("**/*.rb", base: absolute).map { |path| File.join(absolute, path) }
|
|
94
94
|
end
|
|
95
95
|
end
|
|
96
96
|
|
|
@@ -216,9 +216,10 @@ module Rigor
|
|
|
216
216
|
# - `initialize`
|
|
217
217
|
# - methods starting with `_` (Ruby convention for private/internal)
|
|
218
218
|
# - `def self.<name>` (singleton-side)
|
|
219
|
-
# - methods
|
|
220
|
-
#
|
|
221
|
-
#
|
|
219
|
+
# - methods that are not public at the end of the class body, read in source order: after a bare
|
|
220
|
+
# `private` / `protected` (until a `public` switches back), named by `private :foo` /
|
|
221
|
+
# `protected :foo`, or defined inline (`private def foo`) — Rails builds `action_methods` from the
|
|
222
|
+
# public instance methods. `public :foo` after a `protected` section makes `foo` an action again.
|
|
222
223
|
# - methods named as a callback target (`before_action :name`, `after_action`, `around_action`)
|
|
223
224
|
#
|
|
224
225
|
# Pre-fix, Mastodon's `AdminMailer#process_params` / `set_instance` / `set_locale` /
|
|
@@ -227,64 +228,80 @@ module Rigor
|
|
|
227
228
|
CALLBACK_DECLARATIONS = %i[before_action after_action around_action].freeze
|
|
228
229
|
private_constant :CALLBACK_DECLARATIONS
|
|
229
230
|
|
|
231
|
+
VISIBILITY_KEYWORDS = %i[public private protected].freeze
|
|
232
|
+
private_constant :VISIBILITY_KEYWORDS
|
|
233
|
+
|
|
230
234
|
def collect_action_defs(body)
|
|
231
235
|
return [] if body.nil?
|
|
232
236
|
|
|
233
|
-
|
|
234
|
-
|
|
237
|
+
callback_names = collect_callback_names(body)
|
|
238
|
+
def_nodes, final_visibility = scan_visibility(body)
|
|
235
239
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
next
|
|
239
|
-
next
|
|
240
|
-
next
|
|
241
|
-
next [] if node.name.to_s.start_with?("_")
|
|
242
|
-
next [] if visibility == :private
|
|
243
|
-
next [] if private_names.include?(node.name)
|
|
244
|
-
next [] if callback_names.include?(node.name)
|
|
240
|
+
def_nodes.select do |node|
|
|
241
|
+
next false if node.receiver.is_a?(Prism::SelfNode)
|
|
242
|
+
next false if node.name == :initialize
|
|
243
|
+
next false if node.name.to_s.start_with?("_")
|
|
244
|
+
next false if callback_names.include?(node.name)
|
|
245
245
|
|
|
246
|
-
|
|
246
|
+
# Rails builds `action_methods` from the PUBLIC instance methods, so only a method whose final
|
|
247
|
+
# visibility is public is an action.
|
|
248
|
+
final_visibility[node.name] == :public
|
|
247
249
|
end
|
|
248
250
|
end
|
|
249
251
|
|
|
250
|
-
#
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
def collect_visibility_and_callbacks(body)
|
|
254
|
-
private_names = []
|
|
255
|
-
callback_names = []
|
|
256
|
-
|
|
252
|
+
# Symbol arguments to callback declarations (`before_action :setup`, etc.).
|
|
253
|
+
def collect_callback_names(body)
|
|
254
|
+
names = []
|
|
257
255
|
body.rigor_each_child do |node|
|
|
258
|
-
next unless node.is_a?(Prism::CallNode) && node.receiver.nil?
|
|
256
|
+
next unless node.is_a?(Prism::CallNode) && node.receiver.nil? && CALLBACK_DECLARATIONS.include?(node.name)
|
|
259
257
|
|
|
260
|
-
|
|
261
|
-
Rigor::Source::Literals.symbol(arg)
|
|
258
|
+
(node.arguments&.arguments || []).each do |arg|
|
|
259
|
+
symbol = Rigor::Source::Literals.symbol(arg)
|
|
260
|
+
names << symbol if symbol
|
|
262
261
|
end
|
|
263
|
-
|
|
262
|
+
end
|
|
263
|
+
names.to_set
|
|
264
|
+
end
|
|
264
265
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
266
|
+
# Walks the class body in source order and returns `[def_nodes, final_visibility]`: every `def` the body
|
|
267
|
+
# spells (a direct statement or the inline argument of a visibility call, `private def x`), and the
|
|
268
|
+
# visibility each method name ends with. A bare `public` / `private` / `protected` sets the default for
|
|
269
|
+
# the defs after it; `private :x` / `public :x` / `protected def x` set that one method, whichever side of
|
|
270
|
+
# its `def` they fall on in the source, and the last statement to name a method wins.
|
|
271
|
+
def scan_visibility(body)
|
|
272
|
+
def_nodes = []
|
|
273
|
+
final_visibility = {}
|
|
274
|
+
default = :public
|
|
275
|
+
|
|
276
|
+
body.compact_child_nodes.each do |node|
|
|
277
|
+
case node
|
|
278
|
+
when Prism::DefNode
|
|
279
|
+
def_nodes << node
|
|
280
|
+
final_visibility[node.name] = default
|
|
281
|
+
when Prism::CallNode
|
|
282
|
+
default = apply_visibility_call(node, default, def_nodes, final_visibility)
|
|
268
283
|
end
|
|
269
284
|
end
|
|
270
285
|
|
|
271
|
-
[
|
|
286
|
+
[def_nodes, final_visibility]
|
|
272
287
|
end
|
|
273
288
|
|
|
274
|
-
#
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
return
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
289
|
+
# Applies one receiverless `public` / `private` / `protected` call and returns the (possibly new) default.
|
|
290
|
+
def apply_visibility_call(node, default, def_nodes, final_visibility)
|
|
291
|
+
return default unless node.receiver.nil? && VISIBILITY_KEYWORDS.include?(node.name)
|
|
292
|
+
|
|
293
|
+
args = node.arguments&.arguments || []
|
|
294
|
+
return node.name if args.empty?
|
|
295
|
+
|
|
296
|
+
args.flat_map { |arg| arg.is_a?(Prism::ArrayNode) ? arg.elements : [arg] }.each do |arg|
|
|
297
|
+
if arg.is_a?(Prism::DefNode)
|
|
298
|
+
def_nodes << arg
|
|
299
|
+
final_visibility[arg.name] = node.name
|
|
300
|
+
elsif (name = Rigor::Source::Literals.symbol_or_string(arg))
|
|
301
|
+
final_visibility[name] = node.name
|
|
302
|
+
end
|
|
287
303
|
end
|
|
304
|
+
default
|
|
288
305
|
end
|
|
289
306
|
|
|
290
307
|
def build_class_entry(class_name, file_path, def_nodes, includes = [], module_actions = {})
|
|
@@ -52,13 +52,32 @@ module Rigor
|
|
|
52
52
|
# Bumped 2026-09-02 (#621) — mailer keys are de-rooted at the producer and a reopened class's
|
|
53
53
|
# actions are UNIONed rather than clobbered, so a cached 0.4.0 index can be missing actions this
|
|
54
54
|
# version knows about.
|
|
55
|
-
|
|
55
|
+
#
|
|
56
|
+
# Bumped 2026-10-23 (#2308) — a method after a bare `protected` is no longer an action, so a cached 0.5.0
|
|
57
|
+
# index can list protected helpers as actions (and as missing-view candidates).
|
|
58
|
+
version: "0.5.1",
|
|
56
59
|
description: "Validates ActionMailer call shape and view template existence.",
|
|
57
60
|
config_schema: {
|
|
58
61
|
"mailer_search_paths" => { kind: :array, default: ["app/mailers"] },
|
|
59
62
|
"mailer_base_classes" => { kind: :array, default: %w[ApplicationMailer ActionMailer::Base] },
|
|
60
63
|
"views_root" => { kind: :string, default: "app/views" }
|
|
61
64
|
},
|
|
65
|
+
# ADR-125 (#575, #2308) — a mailer instance serves exactly one action (`Mailer.welcome(...)` builds a
|
|
66
|
+
# fresh instance and runs that one method), so ivar writes in two different public actions are never live
|
|
67
|
+
# together and `def.ivar-write-mismatch` stops comparing them. `ActionMailer::Base` has no signature here;
|
|
68
|
+
# the rule matches an undeclared superclass by its written name.
|
|
69
|
+
per_invocation_classes: ["ActionMailer::Base"],
|
|
70
|
+
# A public method these macros name is a callback or a view helper, not an action: it runs inside
|
|
71
|
+
# whichever action triggers it (AbstractController::Callbacks / Helpers), so its writes stay compared with
|
|
72
|
+
# every action's. The actionpack list, plus the `*_deliver` callbacks (ActionMailer::Callbacks), which run
|
|
73
|
+
# on the action's own instance.
|
|
74
|
+
per_invocation_non_action_macros: %i[
|
|
75
|
+
before_action prepend_before_action append_before_action skip_before_action
|
|
76
|
+
after_action prepend_after_action append_after_action skip_after_action
|
|
77
|
+
around_action prepend_around_action append_around_action skip_around_action
|
|
78
|
+
before_deliver after_deliver around_deliver
|
|
79
|
+
helper_method
|
|
80
|
+
],
|
|
62
81
|
# ADR-103 WD10 (#387) — see {Effects} for what each row is and why.
|
|
63
82
|
effect_root: "rails",
|
|
64
83
|
effect_labels: ["rails.actionmailer.deliver"],
|
|
@@ -58,7 +58,7 @@ module Rigor
|
|
|
58
58
|
# ADR-45 WD1b (#613) — boundary-probed: a root that appears later invalidates the warm run.
|
|
59
59
|
next [] unless @io_boundary.directory?(absolute)
|
|
60
60
|
|
|
61
|
-
Dir.glob(File.join(absolute,
|
|
61
|
+
Dir.glob("**/*.rb", base: absolute).map { |path| File.join(absolute, path) }
|
|
62
62
|
end
|
|
63
63
|
end
|
|
64
64
|
|