rigortype 0.2.9 → 0.3.1
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/builtins/ruby_core/array.yml +416 -392
- data/data/builtins/ruby_core/file.yml +42 -42
- data/data/builtins/ruby_core/hash.yml +302 -302
- data/data/builtins/ruby_core/io.yml +191 -191
- data/data/builtins/ruby_core/numeric.yml +321 -366
- data/data/builtins/ruby_core/proc.yml +124 -124
- data/data/builtins/ruby_core/range.yml +21 -21
- data/data/builtins/ruby_core/rational.yml +39 -39
- data/data/builtins/ruby_core/re.yml +65 -65
- data/data/builtins/ruby_core/set.yml +106 -106
- data/data/builtins/ruby_core/struct.yml +14 -14
- data/data/core_overlay/string_scanner.rbs +6 -5
- data/docs/handbook/01-getting-started.md +22 -34
- data/docs/handbook/03-narrowing.md +2 -1
- data/docs/handbook/04-tuples-and-shapes.md +7 -3
- data/docs/handbook/06-classes.md +19 -10
- data/docs/handbook/07-rbs-and-extended.md +76 -101
- data/docs/handbook/08-understanding-errors.md +114 -246
- data/docs/handbook/09-plugins.md +54 -144
- data/docs/handbook/11-sig-gen.md +11 -1
- data/docs/handbook/README.md +5 -3
- data/docs/handbook/appendix-liskov.md +4 -2
- data/docs/handbook/appendix-mypy.md +4 -3
- data/docs/handbook/appendix-phpstan.md +14 -7
- data/docs/handbook/appendix-steep.md +4 -2
- data/docs/handbook/appendix-type-theory.md +3 -1
- data/docs/install.md +14 -6
- data/docs/manual/01-installation.md +98 -4
- data/docs/manual/02-cli-reference.md +81 -12
- data/docs/manual/03-configuration.md +34 -2
- data/docs/manual/04-diagnostics.md +59 -4
- data/docs/manual/06-baseline.md +35 -1
- data/docs/manual/07-plugins.md +4 -4
- data/docs/manual/08-skills.md +6 -1
- data/docs/manual/09-editor-integration.md +7 -6
- data/docs/manual/11-ci.md +51 -0
- data/docs/manual/12-caching.md +65 -0
- data/docs/manual/plugins/rigor-actioncable.md +32 -0
- data/docs/manual/plugins/rigor-devise.md +4 -2
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -1
- data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +5 -3
- data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +128 -0
- data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +60 -0
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -2
- data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +117 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +125 -0
- data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
- data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -1
- data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +283 -0
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +10 -1
- data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +112 -0
- data/lib/rigor/analysis/check_rules.rb +581 -98
- data/lib/rigor/analysis/dependency_recorder.rb +93 -9
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
- data/lib/rigor/analysis/incremental_session.rb +456 -51
- data/lib/rigor/analysis/path_expansion.rb +42 -0
- data/lib/rigor/analysis/plugin_fact_fingerprint.rb +188 -0
- data/lib/rigor/analysis/rule_catalog.rb +226 -1
- data/lib/rigor/analysis/run_cache_key.rb +78 -0
- data/lib/rigor/analysis/run_cache_probe.rb +72 -0
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +181 -28
- data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -15
- data/lib/rigor/analysis/runner/project_pre_passes.rb +95 -49
- data/lib/rigor/analysis/runner/run_snapshots.rb +9 -2
- data/lib/rigor/analysis/runner.rb +344 -68
- data/lib/rigor/analysis/severity_stamp.rb +43 -0
- data/lib/rigor/analysis/worker_session.rb +30 -2
- data/lib/rigor/bleeding_edge.rb +37 -8
- data/lib/rigor/builtins/regex_refinement.rb +36 -1
- data/lib/rigor/cache/descriptor.rb +88 -28
- data/lib/rigor/cache/file_digest.rb +158 -0
- data/lib/rigor/cache/incremental_snapshot.rb +41 -5
- data/lib/rigor/cache/rbs_cache_producer.rb +11 -1
- data/lib/rigor/cache/rbs_descriptor.rb +56 -10
- data/lib/rigor/cache/rbs_environment_marshal_patch.rb +38 -0
- data/lib/rigor/cache/store.rb +132 -27
- data/lib/rigor/ci_detector.rb +90 -0
- data/lib/rigor/cli/annotate_command.rb +15 -9
- data/lib/rigor/cli/baseline_command.rb +4 -1
- data/lib/rigor/cli/check_command.rb +223 -32
- data/lib/rigor/cli/check_invocation.rb +84 -0
- data/lib/rigor/cli/coverage_command.rb +6 -1
- data/lib/rigor/cli/docs_command.rb +0 -30
- data/lib/rigor/cli/doctor_command.rb +169 -31
- data/lib/rigor/cli/lsp_command.rb +5 -0
- data/lib/rigor/cli/mcp_command.rb +5 -0
- data/lib/rigor/cli/plugins_command.rb +36 -6
- data/lib/rigor/cli/plugins_renderer.rb +44 -12
- data/lib/rigor/cli/probe_environment.rb +85 -0
- data/lib/rigor/cli/sig_gen_command.rb +36 -9
- data/lib/rigor/cli/skill_command.rb +22 -33
- data/lib/rigor/cli/skill_deep_probe.rb +172 -0
- data/lib/rigor/cli/skill_describe.rb +75 -9
- data/lib/rigor/cli/trace_command.rb +5 -6
- data/lib/rigor/cli/type_of_command.rb +9 -8
- data/lib/rigor/cli/type_scan_command.rb +8 -8
- data/lib/rigor/cli.rb +15 -4
- data/lib/rigor/config_audit.rb +48 -2
- data/lib/rigor/configuration/severity_profile.rb +31 -0
- data/lib/rigor/configuration.rb +156 -8
- data/lib/rigor/environment/default_libraries.rb +36 -0
- data/lib/rigor/environment/rbs_loader.rb +164 -5
- data/lib/rigor/environment.rb +29 -28
- data/lib/rigor/inference/budget_trace.rb +77 -2
- data/lib/rigor/inference/closure_escape_analyzer.rb +14 -1
- data/lib/rigor/inference/def_handle.rb +23 -0
- data/lib/rigor/inference/def_node_resolver.rb +90 -0
- data/lib/rigor/inference/def_return_typer.rb +2 -1
- data/lib/rigor/inference/expression_typer.rb +361 -113
- data/lib/rigor/inference/method_dispatcher/data_folding.rb +30 -5
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +200 -0
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +22 -16
- data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +33 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +17 -1
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +42 -9
- data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +5 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +28 -35
- data/lib/rigor/inference/method_dispatcher/struct_folding.rb +40 -8
- data/lib/rigor/inference/method_dispatcher.rb +57 -1
- data/lib/rigor/inference/mutation_widening.rb +42 -8
- data/lib/rigor/inference/narrowing.rb +127 -17
- data/lib/rigor/inference/parameter_inference_collector.rb +15 -0
- data/lib/rigor/inference/project_patched_scanner.rb +2 -1
- data/lib/rigor/inference/rbs_type_translator.rb +8 -1
- data/lib/rigor/inference/scope_indexer.rb +451 -36
- data/lib/rigor/inference/statement_evaluator.rb +64 -15
- data/lib/rigor/inference/struct_fold_safety.rb +48 -11
- data/lib/rigor/inference/synthetic_method_scanner.rb +15 -14
- data/lib/rigor/inference/void_origin.rb +25 -0
- data/lib/rigor/inference/void_tail_summary.rb +220 -0
- data/lib/rigor/language_server/buffer_resolution.rb +6 -3
- data/lib/rigor/language_server/buffer_table.rb +46 -6
- data/lib/rigor/language_server/completion_provider.rb +2 -1
- data/lib/rigor/language_server/diagnostic_publisher.rb +4 -0
- data/lib/rigor/language_server/document_symbol_provider.rb +2 -1
- data/lib/rigor/language_server/folding_range_provider.rb +2 -1
- data/lib/rigor/language_server/incremental_sync.rb +159 -0
- data/lib/rigor/language_server/selection_range_provider.rb +2 -1
- data/lib/rigor/language_server/server.rb +19 -9
- data/lib/rigor/language_server/signature_help_provider.rb +2 -1
- data/lib/rigor/language_server.rb +1 -0
- data/lib/rigor/plugin/base.rb +38 -24
- data/lib/rigor/plugin/inflector.rb +12 -3
- data/lib/rigor/plugin/io_boundary.rb +5 -1
- data/lib/rigor/plugin/isolation.rb +81 -11
- data/lib/rigor/plugin/load_error.rb +10 -1
- data/lib/rigor/plugin/loader.rb +96 -14
- data/lib/rigor/plugin/node_rule_walk.rb +5 -3
- data/lib/rigor/plugin/registry.rb +32 -23
- data/lib/rigor/plugin.rb +26 -0
- data/lib/rigor/protection/mutator.rb +3 -2
- data/lib/rigor/reflection.rb +64 -0
- data/lib/rigor/runtime/jit.rb +128 -0
- data/lib/rigor/scope/discovery_index.rb +12 -2
- data/lib/rigor/scope.rb +122 -28
- data/lib/rigor/sig_gen/classification.rb +5 -1
- data/lib/rigor/sig_gen/generator.rb +38 -6
- data/lib/rigor/sig_gen/observation_collector.rb +4 -3
- data/lib/rigor/sig_gen/rbs_validity.rb +44 -0
- data/lib/rigor/sig_gen/renderer.rb +10 -0
- data/lib/rigor/sig_gen/write_result.rb +9 -4
- data/lib/rigor/sig_gen/writer.rb +207 -49
- data/lib/rigor/source/node_children.rb +116 -0
- data/lib/rigor/source/node_locator.rb +3 -1
- data/lib/rigor/source/node_walker.rb +4 -2
- data/lib/rigor/source.rb +1 -0
- data/lib/rigor/type/difference.rb +28 -24
- data/lib/rigor/type/hash_shape.rb +34 -10
- data/lib/rigor/version.rb +1 -1
- data/lib/rigortype.rb +24 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +4 -2
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +6 -4
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +5 -3
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +4 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +3 -1
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +4 -2
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -1
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +4 -2
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +38 -6
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -1
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +3 -1
- data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +10 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +11 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +3 -1
- data/plugins/rigor-rails/lib/rigor-rails.rb +11 -12
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +3 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +3 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +7 -5
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +83 -9
- data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +9 -5
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +3 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +11 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +4 -1
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +4 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +3 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +74 -14
- data/sig/prism_node_children.rbs +9 -0
- data/sig/rigor/cache.rbs +6 -0
- data/sig/rigor/inference/void_origin.rbs +18 -0
- data/sig/rigor/inference.rbs +7 -0
- data/sig/rigor/plugin/base.rbs +6 -6
- data/sig/rigor/reflection.rbs +1 -0
- data/sig/rigor/scope.rbs +12 -1
- data/skills/rigor-ci-setup/SKILL.md +10 -0
- data/skills/rigor-editor-setup/SKILL.md +11 -0
- data/skills/rigor-next-steps/SKILL.md +23 -2
- data/skills/rigor-plugin-author/SKILL.md +3 -3
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +2 -2
- data/skills/rigor-plugin-review/SKILL.md +2 -2
- data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +5 -4
- data/skills/rigor-project-init/SKILL.md +29 -3
- data/skills/rigor-project-init/references/01-detect.md +1 -1
- data/skills/rigor-project-init/references/02-configure.md +39 -5
- data/skills/rigor-project-init/references/03-baseline-and-bugs.md +20 -0
- data/skills/rigor-project-init/references/05-jit-performance.md +125 -0
- metadata +48 -26
- data/lib/rigor/cli/ci_detector.rb +0 -89
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Analysis
|
|
7
|
+
module CheckRules
|
|
8
|
+
# ADR-67 WD6b — the shared "is this node rooted at an inferred parameter?" predicate.
|
|
9
|
+
#
|
|
10
|
+
# When the `parameter_inference:` gate seeds an undeclared parameter from its call-site union, that type
|
|
11
|
+
# is a *lower bound* (only the resolved call sites contribute; an unseen / dynamic caller could pass any
|
|
12
|
+
# wider value). A diagnostic decided against a lower bound is a false positive by construction — the same
|
|
13
|
+
# reasoning that keeps ADR-67 WD1 non-negotiable at the parameter boundary. So every negative in-body
|
|
14
|
+
# rule must decline when the value it reasons about is (transitively) an inferred parameter.
|
|
15
|
+
#
|
|
16
|
+
# `rooted?` walks a receiver / argument / condition-subject node down to its syntactic root and reports
|
|
17
|
+
# whether that root is a pristine inferred-parameter local. It is purely syntactic — a bounded AST walk
|
|
18
|
+
# over `.receiver` — not a value-flow provenance channel (the mark stays on the direct parameter local
|
|
19
|
+
# in the scope; this reads the chain root at the firing site). It covers:
|
|
20
|
+
#
|
|
21
|
+
# - the direct receiver / subject: `param.foo`, `case param`
|
|
22
|
+
# - an indexed element: `param[i] - x` (the `-` receiver is the `[]` call whose root is `param`)
|
|
23
|
+
# - any method chain: `param.foo.bar`, `param.foo(arg).baz`, `param.key?(:x)`
|
|
24
|
+
#
|
|
25
|
+
# Every value derived from an inferred parameter by dispatch is itself a lower bound, so firing against it
|
|
26
|
+
# is an FP by the same construction. No-op on a normal `check` run — `Scope#inferred_param?` is false
|
|
27
|
+
# unless the gate seeded the table.
|
|
28
|
+
module InferredParamGuard
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
# A defensive depth cap against a pathological receiver chain (the walk is otherwise linear in chain
|
|
32
|
+
# length, which is tiny in practice).
|
|
33
|
+
MAX_DEPTH = 64
|
|
34
|
+
|
|
35
|
+
def rooted?(node, scope, depth = 0)
|
|
36
|
+
return false if node.nil? || depth > MAX_DEPTH
|
|
37
|
+
|
|
38
|
+
case node
|
|
39
|
+
when Prism::LocalVariableReadNode then scope.inferred_param?(node.name)
|
|
40
|
+
when Prism::CallNode then rooted?(node.receiver, scope, depth + 1)
|
|
41
|
+
# Value-combining forms: the produced value is a union of the operands, so it is a lower bound when
|
|
42
|
+
# ANY operand is inferred-parameter-rooted (`cps[i] - x rescue -1`, `a && param.foo`, `param || b`).
|
|
43
|
+
# Declining on the whole expression is FP-safe by the same lower-bound argument.
|
|
44
|
+
when Prism::RescueModifierNode
|
|
45
|
+
rooted?(node.expression, scope, depth + 1) || rooted?(node.rescue_expression, scope, depth + 1)
|
|
46
|
+
when Prism::AndNode, Prism::OrNode
|
|
47
|
+
rooted?(node.left, scope, depth + 1) || rooted?(node.right, scope, depth + 1)
|
|
48
|
+
when Prism::ParenthesesNode then rooted?(last_statement(node.body), scope, depth + 1)
|
|
49
|
+
when Prism::StatementsNode then rooted?(node.body.last, scope, depth + 1)
|
|
50
|
+
else false
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def last_statement(body)
|
|
55
|
+
body.is_a?(Prism::StatementsNode) ? body.body.last : body
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "prism"
|
|
4
4
|
|
|
5
5
|
require_relative "../../source/constant_path"
|
|
6
|
+
require_relative "../../source/node_children"
|
|
6
7
|
|
|
7
8
|
module Rigor
|
|
8
9
|
module Analysis
|
|
@@ -78,7 +79,7 @@ module Rigor
|
|
|
78
79
|
return
|
|
79
80
|
end
|
|
80
81
|
|
|
81
|
-
node.
|
|
82
|
+
node.rigor_each_child { |child| walk(child, qualified_prefix) }
|
|
82
83
|
end
|
|
83
84
|
|
|
84
85
|
def collect_def_writes(def_node, qualified_prefix)
|
|
@@ -95,7 +96,7 @@ module Rigor
|
|
|
95
96
|
record_write(node, class_name) if node.is_a?(Prism::InstanceVariableWriteNode)
|
|
96
97
|
return if BARRIER_NODES.any? { |klass| node.is_a?(klass) }
|
|
97
98
|
|
|
98
|
-
node.
|
|
99
|
+
node.rigor_each_child { |child| gather_writes(child, class_name) }
|
|
99
100
|
end
|
|
100
101
|
|
|
101
102
|
def record_write(node, class_name)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../../source/node_children"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module Analysis
|
|
9
|
+
module CheckRules
|
|
10
|
+
# Walks a parse tree and collects every explicit `ReturnNode` that sits lexically inside the `ensure`
|
|
11
|
+
# clause of a `begin` / `def` / `class` body. In Ruby, `return` inside `ensure` silently discards
|
|
12
|
+
# both the method's in-flight return value and any in-flight exception — the classic footgun RuboCop
|
|
13
|
+
# ships as `Lint/EnsureReturn` and PHPStan as `OverwrittenExitPointByFinallyRule`.
|
|
14
|
+
#
|
|
15
|
+
# The check is purely syntactic (no type engine involved) with a frame-aware envelope so it only
|
|
16
|
+
# flags a `return` that actually belongs to the frame whose `ensure` it sits in:
|
|
17
|
+
#
|
|
18
|
+
# - A `return` inside a nested `def`, a lambda (`-> {}` / `lambda {}`), or a `define_method` block
|
|
19
|
+
# within the ensure body is skipped — that `return` exits the inner frame, not the one whose
|
|
20
|
+
# in-flight exception the ensure guards.
|
|
21
|
+
# - A `return` inside a *plain* block (`each do ... return ... end`) within the ensure body DOES
|
|
22
|
+
# belong to the enclosing method and fires.
|
|
23
|
+
# - A nested `begin ... ensure ... end` *inside* an ensure body is not descended into at its own
|
|
24
|
+
# `ensure` clause — that inner `EnsureNode` is visited separately (every `BeginNode` is), so each
|
|
25
|
+
# offending `return` is collected exactly once.
|
|
26
|
+
class ReturnInEnsureCollector
|
|
27
|
+
# ADR-53 Track B — the node classes the shared {RuleWalk} dispatches to this collector. Prism
|
|
28
|
+
# represents every construct that can carry an `ensure` clause — `begin ... end`, a `def` body with
|
|
29
|
+
# `rescue` / `ensure`, a class / module body with them — as a `BeginNode`, so one dispatch class
|
|
30
|
+
# covers all the syntactic hosts. No context gates: the footgun is frame-relative, not
|
|
31
|
+
# position-relative, so a `begin/ensure` inside a loop, block, or lambda is checked the same way.
|
|
32
|
+
NODE_CLASSES = [Prism::BeginNode].freeze
|
|
33
|
+
|
|
34
|
+
# Receiver-less calls whose attached block opens a new return frame: a `return` inside their block
|
|
35
|
+
# exits the lambda / defined method, not the method whose `ensure` we are scanning. `proc` is
|
|
36
|
+
# deliberately absent — `return` inside a `Proc` block returns from the enclosing method, so it
|
|
37
|
+
# stays in scope for the rule.
|
|
38
|
+
FRAME_BARRIER_CALL_NAMES = %i[lambda define_method].freeze
|
|
39
|
+
|
|
40
|
+
def initialize(scope_index)
|
|
41
|
+
@scope_index = scope_index
|
|
42
|
+
@results = []
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Legacy single-collector walk — the oracle the ADR-53 Track B equivalence harness compares
|
|
46
|
+
# {RuleWalk} against.
|
|
47
|
+
def collect(root)
|
|
48
|
+
walk_for_begin_nodes(root)
|
|
49
|
+
@results.freeze
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# {RuleWalk} entry point: invoked at every `BeginNode` the shared traversal reaches. The `context`
|
|
53
|
+
# is unused — this collector checks every ensure clause.
|
|
54
|
+
def visit(begin_node, _context = nil)
|
|
55
|
+
collect_ensure_returns(begin_node)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Returns `Array<{return_node:}>` — one entry per offending `return`, frozen the same way
|
|
59
|
+
# `#collect` returns it.
|
|
60
|
+
def results
|
|
61
|
+
@results.freeze
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def walk_for_begin_nodes(node)
|
|
67
|
+
return unless node.is_a?(Prism::Node)
|
|
68
|
+
|
|
69
|
+
collect_ensure_returns(node) if node.is_a?(Prism::BeginNode)
|
|
70
|
+
node.rigor_each_child { |child| walk_for_begin_nodes(child) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def collect_ensure_returns(begin_node)
|
|
74
|
+
ensure_clause = begin_node.ensure_clause
|
|
75
|
+
return if ensure_clause.nil?
|
|
76
|
+
|
|
77
|
+
gather_returns(ensure_clause.statements)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def gather_returns(node)
|
|
81
|
+
return unless node.is_a?(Prism::Node)
|
|
82
|
+
|
|
83
|
+
case node
|
|
84
|
+
when Prism::ReturnNode
|
|
85
|
+
@results << { return_node: node }
|
|
86
|
+
when Prism::DefNode, Prism::LambdaNode, Prism::EnsureNode
|
|
87
|
+
# Def / lambda open a new return frame — a `return` below them exits the nested frame, not the
|
|
88
|
+
# one whose ensure we are scanning. A nested `begin/ensure`'s own EnsureNode is skipped for a
|
|
89
|
+
# different reason: its BeginNode is visited separately, which collects those returns once —
|
|
90
|
+
# descending here would double-collect them.
|
|
91
|
+
return
|
|
92
|
+
when Prism::CallNode
|
|
93
|
+
return gather_returns_around_barrier_block(node) if frame_barrier_call?(node)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
node.rigor_each_child { |child| gather_returns(child) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# `lambda { ... }` / `define_method(:x) { ... }`: the receiver and arguments stay in the current
|
|
100
|
+
# frame (and may themselves contain an offending `return`), only the attached block opens a new
|
|
101
|
+
# one — descend into everything except the block.
|
|
102
|
+
def gather_returns_around_barrier_block(call_node)
|
|
103
|
+
barrier_block = call_node.block
|
|
104
|
+
call_node.rigor_each_child do |child|
|
|
105
|
+
gather_returns(child) unless child.equal?(barrier_block)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def frame_barrier_call?(call_node)
|
|
110
|
+
call_node.receiver.nil? &&
|
|
111
|
+
FRAME_BARRIER_CALL_NAMES.include?(call_node.name) &&
|
|
112
|
+
call_node.block.is_a?(Prism::BlockNode)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Analysis
|
|
5
|
+
module CheckRules
|
|
6
|
+
# Canonical identifiers for each rule. Per ADR-8 § "Diagnostic ID family hierarchy", rule names are
|
|
7
|
+
# `family.rule-name` two-segment strings; the families group diagnostics by where they originate
|
|
8
|
+
# (`call.*` for call-site rules, `flow.*` for flow-analysis proofs, `assert.*` for runtime-assertion
|
|
9
|
+
# rules, `dump.*` for debug helpers, `def.*` for method-definition rules). Used by the configuration
|
|
10
|
+
# `disable:` list and the in-source `# rigor:disable <rule>` suppression comment system; new rules MUST
|
|
11
|
+
# register here so user configuration can refer to them.
|
|
12
|
+
#
|
|
13
|
+
# ADR-87 WD4 — this pure-data constant table is split out of the (engine-heavy) `check_rules.rb` so
|
|
14
|
+
# {Analysis::RuleCatalog} — which the CLI's JSON `evidence_tier` / `documentation_url` enrichment reads
|
|
15
|
+
# — can require it WITHOUT loading the inference engine. The boot-slimming hit path stays engine-free
|
|
16
|
+
# even when it formats JSON.
|
|
17
|
+
RULE_UNDEFINED_METHOD = "call.undefined-method"
|
|
18
|
+
RULE_SELF_UNDEFINED_METHOD = "call.self-undefined-method"
|
|
19
|
+
RULE_UNRESOLVED_TOPLEVEL = "call.unresolved-toplevel"
|
|
20
|
+
RULE_WRONG_ARITY = "call.wrong-arity"
|
|
21
|
+
RULE_ARGUMENT_TYPE = "call.argument-type-mismatch"
|
|
22
|
+
RULE_NIL_RECEIVER = "call.possible-nil-receiver"
|
|
23
|
+
RULE_RAISE_NON_EXCEPTION = "call.raise-non-exception"
|
|
24
|
+
RULE_DUMP_TYPE = "dump.type"
|
|
25
|
+
RULE_ASSERT_TYPE = "assert.type-mismatch"
|
|
26
|
+
RULE_ALWAYS_RAISES = "flow.always-raises"
|
|
27
|
+
RULE_UNREACHABLE_BRANCH = "flow.unreachable-branch"
|
|
28
|
+
RULE_RETURN_TYPE = "def.return-type-mismatch"
|
|
29
|
+
RULE_VISIBILITY_MISMATCH = "def.method-visibility-mismatch"
|
|
30
|
+
RULE_OVERRIDE_VISIBILITY_REDUCED = "def.override-visibility-reduced"
|
|
31
|
+
RULE_OVERRIDE_RETURN_WIDENED = "def.override-return-widened"
|
|
32
|
+
RULE_OVERRIDE_PARAM_NARROWED = "def.override-param-narrowed"
|
|
33
|
+
RULE_IVAR_WRITE_MISMATCH = "def.ivar-write-mismatch"
|
|
34
|
+
RULE_DEAD_ASSIGNMENT = "flow.dead-assignment"
|
|
35
|
+
RULE_ALWAYS_TRUTHY_CONDITION = "flow.always-truthy-condition"
|
|
36
|
+
RULE_UNREACHABLE_CLAUSE = "flow.unreachable-clause"
|
|
37
|
+
RULE_DUPLICATE_HASH_KEY = "flow.duplicate-hash-key"
|
|
38
|
+
RULE_RETURN_IN_ENSURE = "flow.return-in-ensure"
|
|
39
|
+
RULE_SHADOWED_RESCUE_CLAUSE = "flow.shadowed-rescue-clause"
|
|
40
|
+
RULE_SUPPRESSION_UNKNOWN_RULE = "suppression.unknown-rule"
|
|
41
|
+
RULE_SUPPRESSION_EMPTY = "suppression.empty"
|
|
42
|
+
RULE_SUPPRESSION_UNKNOWN_MARKER = "suppression.unknown-marker"
|
|
43
|
+
# ADR-100 — the first `static.value-use.*` id: a value recovered from an author-declared `-> void`
|
|
44
|
+
# return, used in value context. Authored `:warning`, resolved `:off` by every profile and promoted to
|
|
45
|
+
# `:warning` only by the `use-of-void-value` bleeding-edge feature.
|
|
46
|
+
RULE_VALUE_USE_VOID = "static.value-use.void"
|
|
47
|
+
|
|
48
|
+
ALL_RULES = [
|
|
49
|
+
RULE_UNDEFINED_METHOD,
|
|
50
|
+
RULE_SELF_UNDEFINED_METHOD,
|
|
51
|
+
RULE_UNRESOLVED_TOPLEVEL,
|
|
52
|
+
RULE_WRONG_ARITY,
|
|
53
|
+
RULE_ARGUMENT_TYPE,
|
|
54
|
+
RULE_NIL_RECEIVER,
|
|
55
|
+
RULE_RAISE_NON_EXCEPTION,
|
|
56
|
+
RULE_DUMP_TYPE,
|
|
57
|
+
RULE_ASSERT_TYPE,
|
|
58
|
+
RULE_ALWAYS_RAISES,
|
|
59
|
+
RULE_UNREACHABLE_BRANCH,
|
|
60
|
+
RULE_DEAD_ASSIGNMENT,
|
|
61
|
+
RULE_ALWAYS_TRUTHY_CONDITION,
|
|
62
|
+
RULE_UNREACHABLE_CLAUSE,
|
|
63
|
+
RULE_DUPLICATE_HASH_KEY,
|
|
64
|
+
RULE_RETURN_IN_ENSURE,
|
|
65
|
+
RULE_SHADOWED_RESCUE_CLAUSE,
|
|
66
|
+
RULE_RETURN_TYPE,
|
|
67
|
+
RULE_VISIBILITY_MISMATCH,
|
|
68
|
+
RULE_OVERRIDE_VISIBILITY_REDUCED,
|
|
69
|
+
RULE_OVERRIDE_RETURN_WIDENED,
|
|
70
|
+
RULE_OVERRIDE_PARAM_NARROWED,
|
|
71
|
+
RULE_IVAR_WRITE_MISMATCH,
|
|
72
|
+
RULE_SUPPRESSION_UNKNOWN_RULE,
|
|
73
|
+
RULE_SUPPRESSION_EMPTY,
|
|
74
|
+
RULE_SUPPRESSION_UNKNOWN_MARKER,
|
|
75
|
+
RULE_VALUE_USE_VOID
|
|
76
|
+
].freeze
|
|
77
|
+
|
|
78
|
+
# Backward-compat alias table (ADR-8 § "Backward compatibility"). Existing user code with
|
|
79
|
+
# `# rigor:disable undefined-method` / `disable: [undefined-method]` keeps working — the legacy
|
|
80
|
+
# unprefixed identifiers map to their canonical `family.rule-name` form here. Removing the aliases is a
|
|
81
|
+
# future ADR once user code has migrated; until then, both spellings resolve identically.
|
|
82
|
+
LEGACY_RULE_ALIASES = {
|
|
83
|
+
"undefined-method" => RULE_UNDEFINED_METHOD,
|
|
84
|
+
"self-undefined-method" => RULE_SELF_UNDEFINED_METHOD,
|
|
85
|
+
"wrong-arity" => RULE_WRONG_ARITY,
|
|
86
|
+
"argument-type-mismatch" => RULE_ARGUMENT_TYPE,
|
|
87
|
+
"possible-nil-receiver" => RULE_NIL_RECEIVER,
|
|
88
|
+
"raise-non-exception" => RULE_RAISE_NON_EXCEPTION,
|
|
89
|
+
"dump-type" => RULE_DUMP_TYPE,
|
|
90
|
+
"assert-type" => RULE_ASSERT_TYPE,
|
|
91
|
+
"always-raises" => RULE_ALWAYS_RAISES,
|
|
92
|
+
"unreachable-branch" => RULE_UNREACHABLE_BRANCH,
|
|
93
|
+
"method-visibility-mismatch" => RULE_VISIBILITY_MISMATCH,
|
|
94
|
+
"ivar-write-mismatch" => RULE_IVAR_WRITE_MISMATCH,
|
|
95
|
+
"dead-assignment" => RULE_DEAD_ASSIGNMENT,
|
|
96
|
+
"always-truthy-condition" => RULE_ALWAYS_TRUTHY_CONDITION,
|
|
97
|
+
"unreachable-clause" => RULE_UNREACHABLE_CLAUSE,
|
|
98
|
+
"duplicate-hash-key" => RULE_DUPLICATE_HASH_KEY,
|
|
99
|
+
"return-in-ensure" => RULE_RETURN_IN_ENSURE,
|
|
100
|
+
"shadowed-rescue-clause" => RULE_SHADOWED_RESCUE_CLAUSE
|
|
101
|
+
}.freeze
|
|
102
|
+
|
|
103
|
+
# Family wildcard — a `<family>` token in a suppression comment or `disable:` list disables every rule
|
|
104
|
+
# whose canonical id starts with `<family>.`. Per ADR-8 § "1".
|
|
105
|
+
RULE_FAMILIES = %w[call flow assert dump def suppression static].freeze
|
|
106
|
+
|
|
107
|
+
# Families of diagnostics the engine emits OUTSIDE the CheckRules catalogue (aggregator-level and
|
|
108
|
+
# reporter-level diagnostics such as `rbs_extended.unsatisfied-conformance`,
|
|
109
|
+
# `dynamic.dependency-source.*`, `rbs.coverage.*`, `pre-eval.parse-error`), plus the `plugin.` prefix
|
|
110
|
+
# reserved for plugin-produced identifiers. `suppression.unknown-rule` treats a dotted token whose
|
|
111
|
+
# first segment appears here as KNOWN and stays silent: these ids are legitimate suppression /
|
|
112
|
+
# `severity_overrides:` vocabulary the light rule-id table cannot enumerate (plugins load dynamically;
|
|
113
|
+
# aggregator ids live in the engine-heavy runner), so under-warning is the FP-safe direction.
|
|
114
|
+
NON_CHECK_DIAGNOSTIC_FAMILIES = %w[rbs_extended dynamic rbs pre-eval plugin].freeze
|
|
115
|
+
|
|
116
|
+
# Bare (dot-less) diagnostic ids the engine emits outside the catalogue (see the `rule:` literals in
|
|
117
|
+
# `Analysis::Runner` / `Runner::DiagnosticAggregator`). A token equal to one of these is treated as
|
|
118
|
+
# known by `suppression.unknown-rule` even though it carries no family prefix; extend the list when
|
|
119
|
+
# the runner grows a new bare id.
|
|
120
|
+
NON_CHECK_DIAGNOSTIC_IDS = %w[
|
|
121
|
+
configuration-error load-error pool-degraded runtime-error source-rbs-synthesis-failed
|
|
122
|
+
].freeze
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "prism"
|
|
4
4
|
|
|
5
5
|
require_relative "../../source/constant_path"
|
|
6
|
+
require_relative "../../source/node_children"
|
|
6
7
|
|
|
7
8
|
module Rigor
|
|
8
9
|
module Analysis
|
|
@@ -145,7 +146,7 @@ module Rigor
|
|
|
145
146
|
|
|
146
147
|
dispatch(node, hooks, context)
|
|
147
148
|
child_context = descend(node, context)
|
|
148
|
-
node.
|
|
149
|
+
node.rigor_each_child { |child| walk(child, hooks, child_context) }
|
|
149
150
|
end
|
|
150
151
|
|
|
151
152
|
def dispatch(node, hooks, context)
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "prism"
|
|
4
4
|
|
|
5
5
|
require_relative "../../source/constant_path"
|
|
6
|
+
require_relative "../../source/node_children"
|
|
6
7
|
|
|
7
8
|
module Rigor
|
|
8
9
|
module Analysis
|
|
@@ -52,7 +53,7 @@ module Rigor
|
|
|
52
53
|
names << child_prefix.join("::") if name && class_surface_open?(node)
|
|
53
54
|
walk(node.body, child_prefix, names) if node.body
|
|
54
55
|
else
|
|
55
|
-
node.
|
|
56
|
+
node.rigor_each_child { |child| walk(child, prefix, names) }
|
|
56
57
|
end
|
|
57
58
|
end
|
|
58
59
|
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../../reflection"
|
|
6
|
+
require_relative "../../source/constant_path"
|
|
7
|
+
require_relative "../../source/node_children"
|
|
8
|
+
|
|
9
|
+
module Rigor
|
|
10
|
+
module Analysis
|
|
11
|
+
module CheckRules
|
|
12
|
+
# `flow.shadowed-rescue-clause` — a `rescue` clause that can never run because an EARLIER clause of the
|
|
13
|
+
# same `begin` / `def` rescue chain already catches a superclass (or the same class) of every exception
|
|
14
|
+
# class the later clause names (`rescue StandardError => e … rescue ArgumentError => e` — the
|
|
15
|
+
# ArgumentError arm is dead; a bare `rescue` counts as `StandardError` on either side). Modeled on the
|
|
16
|
+
# shadowed-arm half of PHPStan's `CatchWithUnthrownExceptionRule`; the never-thrown half is deliberately
|
|
17
|
+
# out of scope (unprovable in Ruby).
|
|
18
|
+
#
|
|
19
|
+
# The false-positive envelope is ancestry-certainty, not flow analysis — the rule is purely syntactic
|
|
20
|
+
# plus class-hierarchy lookups, so it fires ONLY when:
|
|
21
|
+
#
|
|
22
|
+
# - every exception reference in BOTH the earlier and the later clause is a constant path that resolves
|
|
23
|
+
# (against the clause's lexical namespace) to a CLASS with known ancestry — an RBS / registry core
|
|
24
|
+
# class, or a project class whose `class Foo < Bar` superclass chain is discovered. Any unresolved
|
|
25
|
+
# constant, dynamic expression (`rescue klass_var`), or splat (`rescue *ERRORS`) makes its clause
|
|
26
|
+
# opaque: an opaque earlier clause contributes no coverage, an opaque later clause never fires.
|
|
27
|
+
# - no reference resolves to a MODULE (the `rescue MyGem::Error` module-tag mixin pattern — module
|
|
28
|
+
# `===` semantics are custom, so any clause naming one stays out of every comparison). A
|
|
29
|
+
# project-discovered constant is certified as a class only via the `class Foo < Bar` superclass
|
|
30
|
+
# table; a plain `class Foo` / `module Foo` declaration is indistinguishable in the discovery table
|
|
31
|
+
# and stays silent.
|
|
32
|
+
# - a multi-class arm (`rescue A, B => e`) is shadowed only when EVERY class it names is covered by
|
|
33
|
+
# some earlier comparable clause — one diagnostic per clause, naming the covering earlier clause(s).
|
|
34
|
+
#
|
|
35
|
+
# A later clause naming a SUPERCLASS of an earlier one is the normal narrow-to-wide rescue order and
|
|
36
|
+
# never fires. `retry` in an earlier arm does not un-shadow (the re-raise re-enters from the top).
|
|
37
|
+
# Nested `begin` / `rescue` inside a rescue body compares only clauses of its OWN `BeginNode` — the
|
|
38
|
+
# walk dispatches per `BeginNode` and never crosses into another chain.
|
|
39
|
+
class ShadowedRescueCollector
|
|
40
|
+
# One shadowed clause. `clause` is the dead `Prism::RescueNode`; `clause_source` its rendered
|
|
41
|
+
# `rescue A, B` condition list (`"rescue"` for a bare rescue); `earlier_sources` /
|
|
42
|
+
# `earlier_lines` name the earlier clause(s) whose classes cover it.
|
|
43
|
+
Result = Data.define(:clause, :clause_source, :earlier_sources, :earlier_lines)
|
|
44
|
+
|
|
45
|
+
NODE_CLASSES = [Prism::BeginNode].freeze
|
|
46
|
+
|
|
47
|
+
# Cycle / depth bound for the project `class Foo < Bar` superclass-chain walk.
|
|
48
|
+
SUPERCLASS_WALK_LIMIT = 32
|
|
49
|
+
|
|
50
|
+
# The implicit class of a bare `rescue` / `rescue => e`.
|
|
51
|
+
IMPLICIT_RESCUE_CLASS = "StandardError"
|
|
52
|
+
|
|
53
|
+
def initialize(scope_index)
|
|
54
|
+
@scope_index = scope_index
|
|
55
|
+
@results = []
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Legacy single-collector walk — the oracle the shadow harness (`RIGOR_SHADOW_RULE_WALK=1`) compares
|
|
59
|
+
# the shared {RuleWalk} traversal against. Tracks the lexical class / module prefix itself, exactly
|
|
60
|
+
# as {RuleWalk} threads `qualified_prefix`.
|
|
61
|
+
# @return [Array<Result>] one entry per provably-shadowed rescue clause.
|
|
62
|
+
def collect(root)
|
|
63
|
+
walk(root, [])
|
|
64
|
+
@results.freeze
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# {RuleWalk} entry point. `context.qualified_prefix` carries the enclosing class / module names the
|
|
68
|
+
# exception constants resolve against.
|
|
69
|
+
def visit(node, context = nil)
|
|
70
|
+
prefix = context.respond_to?(:qualified_prefix) ? context.qualified_prefix : []
|
|
71
|
+
collect_begin(node, prefix)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def results
|
|
75
|
+
@results.freeze
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def walk(node, prefix)
|
|
81
|
+
return unless node.is_a?(Prism::Node)
|
|
82
|
+
|
|
83
|
+
collect_begin(node, prefix) if node.is_a?(Prism::BeginNode)
|
|
84
|
+
child_prefix = extend_prefix(node, prefix)
|
|
85
|
+
node.rigor_each_child { |child| walk(child, child_prefix) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def extend_prefix(node, prefix)
|
|
89
|
+
return prefix unless node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
|
|
90
|
+
|
|
91
|
+
name = Source::ConstantPath.qualified_name(node.constant_path)
|
|
92
|
+
name ? prefix + [name] : prefix
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def collect_begin(node, prefix)
|
|
96
|
+
clauses = rescue_chain(node.rescue_clause)
|
|
97
|
+
return if clauses.size < 2
|
|
98
|
+
|
|
99
|
+
scope = scope_for(node)
|
|
100
|
+
return if scope.nil?
|
|
101
|
+
|
|
102
|
+
infos = clauses.map { |clause| clause_info(clause, prefix, scope) }
|
|
103
|
+
infos.each_with_index do |info, index|
|
|
104
|
+
next if index.zero? || info.nil?
|
|
105
|
+
|
|
106
|
+
check_shadowed(info, infos.first(index), scope)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The ordered `rescue` clauses of one `begin` node (the `subsequent` chain).
|
|
111
|
+
def rescue_chain(clause)
|
|
112
|
+
chain = []
|
|
113
|
+
while clause
|
|
114
|
+
chain << clause
|
|
115
|
+
clause = clause.subsequent
|
|
116
|
+
end
|
|
117
|
+
chain
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# A `Scope` for the environment / discovery lookups. Every scope of the file shares the discovery
|
|
121
|
+
# index and environment, so any scope recorded on the begin node's neighbourhood serves; nil (no
|
|
122
|
+
# scope recorded) keeps the whole chain silent.
|
|
123
|
+
def scope_for(node)
|
|
124
|
+
@scope_index[node] || @scope_index[node.statements] || @scope_index[node.rescue_clause]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Resolves one clause to `{clause:, names: [String, …]}` where `names` are the certified exception
|
|
128
|
+
# CLASS names it rescues, or nil when the clause is opaque (any entry unresolved / dynamic / splat /
|
|
129
|
+
# module — the comparisons involving it stay silent). A bare `rescue` names the implicit
|
|
130
|
+
# `StandardError`.
|
|
131
|
+
def clause_info(clause, prefix, scope)
|
|
132
|
+
exceptions = clause.exceptions
|
|
133
|
+
if exceptions.empty?
|
|
134
|
+
return unless certified_class?(IMPLICIT_RESCUE_CLASS, scope)
|
|
135
|
+
|
|
136
|
+
return { clause: clause, names: [IMPLICIT_RESCUE_CLASS] }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
names = exceptions.map { |exc| certified_exception_class(exc, prefix, scope) }
|
|
140
|
+
return if names.any?(&:nil?)
|
|
141
|
+
|
|
142
|
+
{ clause: clause, names: names }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# The resolved, certified class name for one exception reference, or nil (opaque). Only constant
|
|
146
|
+
# reads / constant paths participate; the name must resolve lexically and certify as a class with
|
|
147
|
+
# known ancestry.
|
|
148
|
+
def certified_exception_class(node, prefix, scope)
|
|
149
|
+
name = resolve_constant(node, prefix, scope)
|
|
150
|
+
return nil if name.nil?
|
|
151
|
+
|
|
152
|
+
certified_class?(name, scope) ? name : nil
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Lexical constant resolution: innermost enclosing namespace first, then outward, then top-level —
|
|
156
|
+
# first candidate any knowledge source (project discovery or RBS / registry) recognises wins. An
|
|
157
|
+
# absolute path (`::Foo`) skips the lexical ladder. Returns nil for a non-constant node or a path
|
|
158
|
+
# with a dynamic base.
|
|
159
|
+
def resolve_constant(node, prefix, scope)
|
|
160
|
+
name = Source::ConstantPath.qualified_name_or_nil(node)
|
|
161
|
+
return nil if name.nil?
|
|
162
|
+
|
|
163
|
+
return name if absolute_path?(node)
|
|
164
|
+
|
|
165
|
+
candidates = []
|
|
166
|
+
prefix.size.downto(1) { |i| candidates << (prefix.first(i) + [name]).join("::") }
|
|
167
|
+
candidates << name
|
|
168
|
+
candidates.find { |candidate| known_name?(candidate, scope) }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def absolute_path?(node)
|
|
172
|
+
return false unless node.is_a?(Prism::ConstantPathNode)
|
|
173
|
+
|
|
174
|
+
parent = node.parent
|
|
175
|
+
return true if parent.nil? # `::Foo`
|
|
176
|
+
|
|
177
|
+
parent.is_a?(Prism::ConstantPathNode) && absolute_path?(parent)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Presence check used to pick the lexical candidate — deliberately broader than certification, so a
|
|
181
|
+
# name that resolves to a project module is FOUND here and then rejected by {#certified_class?}
|
|
182
|
+
# (rather than mis-resolving outward to a same-named core class).
|
|
183
|
+
def known_name?(name, scope)
|
|
184
|
+
scope.discovered_superclasses.key?(name) ||
|
|
185
|
+
scope.discovered_classes.key?(name) ||
|
|
186
|
+
scope.environment.class_known?(name)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# A name is a certified exception-comparable class when it is either a project class with a
|
|
190
|
+
# discovered `class Foo < Bar` superclass entry, or an RBS / registry class that is not a module.
|
|
191
|
+
# A project-discovered name WITHOUT a superclass entry cannot be told apart from a module (the
|
|
192
|
+
# discovery table records both), so it stays uncertified — conservative, per the FP discipline.
|
|
193
|
+
def certified_class?(name, scope)
|
|
194
|
+
return true if scope.discovered_superclasses.key?(name)
|
|
195
|
+
return false if scope.discovered_classes.key?(name)
|
|
196
|
+
|
|
197
|
+
environment = scope.environment
|
|
198
|
+
environment.class_known?(name) && !environment.rbs_module?(name)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Fires when EVERY class the later clause names is covered by some earlier comparable clause.
|
|
202
|
+
def check_shadowed(info, earlier_infos, scope)
|
|
203
|
+
comparable_earlier = earlier_infos.compact
|
|
204
|
+
return if comparable_earlier.empty?
|
|
205
|
+
|
|
206
|
+
covering = info[:names].map do |name|
|
|
207
|
+
comparable_earlier.find { |earlier| earlier[:names].any? { |e| covered_by?(name, e, scope) } }
|
|
208
|
+
end
|
|
209
|
+
return if covering.any?(&:nil?)
|
|
210
|
+
|
|
211
|
+
clauses = covering.uniq
|
|
212
|
+
@results << Result.new(
|
|
213
|
+
clause: info[:clause],
|
|
214
|
+
clause_source: clause_source(info[:clause]),
|
|
215
|
+
earlier_sources: clauses.map { |c| clause_source(c[:clause]) },
|
|
216
|
+
earlier_lines: clauses.map { |c| c[:clause].location.start_line }
|
|
217
|
+
)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def clause_source(clause)
|
|
221
|
+
exceptions = clause.exceptions
|
|
222
|
+
return "rescue" if exceptions.empty?
|
|
223
|
+
|
|
224
|
+
"rescue #{exceptions.map(&:slice).join(', ')}"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Is `later` the same class as, or a subclass of, `earlier`? RBS / registry ancestry answers first;
|
|
228
|
+
# `:unknown` (a project-defined class on either side) falls through to the discovered
|
|
229
|
+
# `class Foo < Bar` superclass-chain walk. `:superclass` / `:disjoint` are definitive "no".
|
|
230
|
+
def covered_by?(later, earlier, scope)
|
|
231
|
+
return true if later == earlier
|
|
232
|
+
|
|
233
|
+
case Reflection.class_ordering(later, earlier, scope: scope)
|
|
234
|
+
when :equal, :subclass then true
|
|
235
|
+
when :unknown then project_chain_covered?(later, earlier, scope)
|
|
236
|
+
else false
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Walks `later`'s discovered superclass chain (each parent recorded AS WRITTEN at its
|
|
241
|
+
# `class Foo < Bar` site, resolved against the child's namespace) looking for `earlier` — directly,
|
|
242
|
+
# or through an RBS-known ancestor that orders at-or-below `earlier`. Cycle-guarded and depth-capped;
|
|
243
|
+
# a chain that leaves both knowledge sources stays silent.
|
|
244
|
+
def project_chain_covered?(later, earlier, scope)
|
|
245
|
+
supers = scope.discovered_superclasses
|
|
246
|
+
current = later
|
|
247
|
+
seen = {}
|
|
248
|
+
SUPERCLASS_WALK_LIMIT.times do
|
|
249
|
+
parent = supers[current.to_s]
|
|
250
|
+
return false if parent.nil?
|
|
251
|
+
|
|
252
|
+
resolved = resolve_parent(current, parent.to_s, scope)
|
|
253
|
+
return false if seen[resolved]
|
|
254
|
+
|
|
255
|
+
seen[resolved] = true
|
|
256
|
+
return true if resolved == earlier
|
|
257
|
+
|
|
258
|
+
case Reflection.class_ordering(resolved, earlier, scope: scope)
|
|
259
|
+
when :equal, :subclass then return true
|
|
260
|
+
when :unknown then current = resolved
|
|
261
|
+
else return false
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
false
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Resolves a superclass name as written (`Error`) against the child's namespace (`Mail::POP3` →
|
|
268
|
+
# `Mail::Error`), longest prefix first; falls back to the as-written name (which RBS may know, e.g.
|
|
269
|
+
# `StandardError`).
|
|
270
|
+
def resolve_parent(child, parent, scope)
|
|
271
|
+
segments = child.to_s.split("::")[0...-1]
|
|
272
|
+
until segments.empty?
|
|
273
|
+
candidate = "#{segments.join('::')}::#{parent}"
|
|
274
|
+
return candidate if known_name?(candidate, scope)
|
|
275
|
+
|
|
276
|
+
segments.pop
|
|
277
|
+
end
|
|
278
|
+
parent
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
require "prism"
|
|
4
4
|
|
|
5
|
+
require_relative "../../source/node_children"
|
|
6
|
+
require_relative "inferred_param_guard"
|
|
7
|
+
|
|
5
8
|
module Rigor
|
|
6
9
|
module Analysis
|
|
7
10
|
module CheckRules
|
|
@@ -85,7 +88,7 @@ module Rigor
|
|
|
85
88
|
visit(node) if case_like?(node) && !in_loop_or_block
|
|
86
89
|
|
|
87
90
|
child_in_loop_or_block = in_loop_or_block || enters_loop_or_block?(node)
|
|
88
|
-
node.
|
|
91
|
+
node.rigor_each_child { |child| walk(child, in_loop_or_block: child_in_loop_or_block) }
|
|
89
92
|
end
|
|
90
93
|
|
|
91
94
|
# `case/when` is a `CaseNode`; `case/in` is a `CaseMatchNode`. Both carry `predicate` + `conditions`
|
|
@@ -102,6 +105,12 @@ module Rigor
|
|
|
102
105
|
subject = node.predicate
|
|
103
106
|
return unless subject.is_a?(Prism::LocalVariableReadNode)
|
|
104
107
|
|
|
108
|
+
# ADR-67 WD6b — a `case` subject that is an inferred parameter narrows to `Bot` in the non-matching
|
|
109
|
+
# clauses only because its seeded type is a lower bound; a wider real caller could match them. Decline
|
|
110
|
+
# the whole case so no clause fires unreachable.
|
|
111
|
+
scope = @scope_index[node]
|
|
112
|
+
return if scope && InferredParamGuard.rooted?(subject, scope)
|
|
113
|
+
|
|
105
114
|
entry_type = entry_subject_type(node, subject.name)
|
|
106
115
|
# Only meaningful for a concrete subject type: a `Dynamic` subject can never be proven disjoint
|
|
107
116
|
# (the gradual guarantee), and an already-`Bot` subject is dead code, not a clause-ordering error.
|