rigortype 0.0.9 → 0.1.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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +45 -2
  3. data/data/builtins/ruby_core/array.yml +6 -6
  4. data/data/builtins/ruby_core/hash.yml +1 -1
  5. data/data/builtins/ruby_core/io.yml +3 -3
  6. data/data/builtins/ruby_core/numeric.yml +1 -1
  7. data/data/builtins/ruby_core/pathname.yml +100 -100
  8. data/data/builtins/ruby_core/proc.yml +1 -1
  9. data/data/builtins/ruby_core/time.yml +3 -3
  10. data/lib/rigor/analysis/check_rules.rb +228 -40
  11. data/lib/rigor/analysis/diagnostic.rb +15 -1
  12. data/lib/rigor/analysis/runner.rb +269 -7
  13. data/lib/rigor/builtins/regex_refinement.rb +104 -0
  14. data/lib/rigor/cache/rbs_class_ancestor_table.rb +1 -1
  15. data/lib/rigor/cache/rbs_class_type_param_names.rb +1 -1
  16. data/lib/rigor/cache/rbs_constant_table.rb +2 -2
  17. data/lib/rigor/cache/rbs_descriptor.rb +2 -0
  18. data/lib/rigor/cache/rbs_instance_definitions.rb +79 -0
  19. data/lib/rigor/cache/store.rb +2 -0
  20. data/lib/rigor/cli/type_of_command.rb +3 -3
  21. data/lib/rigor/cli/type_scan_command.rb +4 -4
  22. data/lib/rigor/cli.rb +20 -7
  23. data/lib/rigor/configuration/severity_profile.rb +109 -0
  24. data/lib/rigor/configuration.rb +286 -15
  25. data/lib/rigor/environment/rbs_loader.rb +89 -13
  26. data/lib/rigor/environment.rb +12 -4
  27. data/lib/rigor/flow_contribution/conflict.rb +81 -0
  28. data/lib/rigor/flow_contribution/element.rb +53 -0
  29. data/lib/rigor/flow_contribution/fact.rb +88 -0
  30. data/lib/rigor/flow_contribution/merge_result.rb +67 -0
  31. data/lib/rigor/flow_contribution/merger.rb +275 -0
  32. data/lib/rigor/flow_contribution.rb +51 -0
  33. data/lib/rigor/inference/block_parameter_binder.rb +15 -0
  34. data/lib/rigor/inference/expression_typer.rb +87 -6
  35. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +31 -0
  36. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +136 -9
  37. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +21 -1
  38. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +68 -2
  39. data/lib/rigor/inference/method_dispatcher.rb +50 -1
  40. data/lib/rigor/inference/multi_target_binder.rb +2 -0
  41. data/lib/rigor/inference/narrowing.rb +246 -127
  42. data/lib/rigor/inference/scope_indexer.rb +124 -16
  43. data/lib/rigor/inference/statement_evaluator.rb +406 -37
  44. data/lib/rigor/plugin/access_denied_error.rb +24 -0
  45. data/lib/rigor/plugin/base.rb +284 -0
  46. data/lib/rigor/plugin/fact_store.rb +92 -0
  47. data/lib/rigor/plugin/io_boundary.rb +102 -0
  48. data/lib/rigor/plugin/load_error.rb +35 -0
  49. data/lib/rigor/plugin/loader.rb +307 -0
  50. data/lib/rigor/plugin/manifest.rb +203 -0
  51. data/lib/rigor/plugin/registry.rb +50 -0
  52. data/lib/rigor/plugin/services.rb +77 -0
  53. data/lib/rigor/plugin/trust_policy.rb +99 -0
  54. data/lib/rigor/plugin.rb +62 -0
  55. data/lib/rigor/rbs_extended.rb +57 -9
  56. data/lib/rigor/reflection.rb +2 -2
  57. data/lib/rigor/trinary.rb +1 -1
  58. data/lib/rigor/type/integer_range.rb +6 -2
  59. data/lib/rigor/version.rb +1 -1
  60. data/lib/rigor.rb +7 -0
  61. data/sig/rigor/environment.rbs +10 -3
  62. data/sig/rigor/inference.rbs +1 -0
  63. data/sig/rigor/rbs_extended.rbs +2 -0
  64. data/sig/rigor/scope.rbs +1 -0
  65. data/sig/rigor/type.rbs +7 -0
  66. data/sig/rigor.rbs +8 -2
  67. metadata +20 -1
@@ -277,7 +277,7 @@ classes:
277
277
  singleton_methods: {}
278
278
  undefined: []
279
279
  SystemStackError:
280
- parent: rb_eException
280
+ parent: Exception
281
281
  defined_at: references/ruby/proc.c:4593
282
282
  includes: []
283
283
  constants: {}
@@ -684,7 +684,7 @@ classes:
684
684
  body_kind: composed
685
685
  cexpr_target:
686
686
  prelude_at: references/ruby/timev.rb:440
687
- purity: unknown
687
+ purity: dispatch
688
688
  arity: -1
689
689
  cfunc:
690
690
  defined_at: references/ruby/timev.rb:440
@@ -726,7 +726,7 @@ classes:
726
726
  body_kind: composed
727
727
  cexpr_target:
728
728
  prelude_at: references/ruby/timev.rb:270
729
- purity: unknown
729
+ purity: dispatch
730
730
  arity: -1
731
731
  cfunc:
732
732
  defined_at: references/ruby/timev.rb:270
@@ -739,7 +739,7 @@ classes:
739
739
  body_kind: composed
740
740
  cexpr_target:
741
741
  prelude_at: references/ruby/timev.rb:329
742
- purity: unknown
742
+ purity: dispatch
743
743
  arity: -2
744
744
  cfunc:
745
745
  defined_at: references/ruby/timev.rb:329
@@ -42,19 +42,25 @@ module Rigor
42
42
  # the first preview; later slices broaden it.
43
43
  # rubocop:disable Metrics/ModuleLength
44
44
  module CheckRules
45
- # Stable identifiers for each rule. Used by the
46
- # configuration `disable:` list and the in-source
47
- # `# rigor:disable <rule>` suppression comment system
48
- # to identify diagnostics by category. Rule identifiers
49
- # are kebab-case strings; new rules MUST register here
50
- # so user configuration can refer to them.
51
- RULE_UNDEFINED_METHOD = "undefined-method"
52
- RULE_WRONG_ARITY = "wrong-arity"
53
- RULE_ARGUMENT_TYPE = "argument-type-mismatch"
54
- RULE_NIL_RECEIVER = "possible-nil-receiver"
55
- RULE_DUMP_TYPE = "dump-type"
56
- RULE_ASSERT_TYPE = "assert-type"
57
- RULE_ALWAYS_RAISES = "always-raises"
45
+ # Canonical identifiers for each rule. Per ADR-8 §
46
+ # "Diagnostic ID family hierarchy", rule names are
47
+ # `family.rule-name` two-segment strings; the families
48
+ # group diagnostics by where they originate
49
+ # (`call.*` for call-site rules, `flow.*` for flow-analysis
50
+ # proofs, `assert.*` for runtime-assertion rules,
51
+ # `dump.*` for debug helpers, `def.*` for method-definition
52
+ # rules). Used by the configuration `disable:` list and the
53
+ # in-source `# rigor:disable <rule>` suppression comment
54
+ # system; new rules MUST register here so user configuration
55
+ # can refer to them.
56
+ RULE_UNDEFINED_METHOD = "call.undefined-method"
57
+ RULE_WRONG_ARITY = "call.wrong-arity"
58
+ RULE_ARGUMENT_TYPE = "call.argument-type-mismatch"
59
+ RULE_NIL_RECEIVER = "call.possible-nil-receiver"
60
+ RULE_DUMP_TYPE = "dump.type"
61
+ RULE_ASSERT_TYPE = "assert.type-mismatch"
62
+ RULE_ALWAYS_RAISES = "flow.always-raises"
63
+ RULE_RETURN_TYPE = "def.return-type-mismatch"
58
64
 
59
65
  ALL_RULES = [
60
66
  RULE_UNDEFINED_METHOD,
@@ -63,9 +69,46 @@ module Rigor
63
69
  RULE_NIL_RECEIVER,
64
70
  RULE_DUMP_TYPE,
65
71
  RULE_ASSERT_TYPE,
66
- RULE_ALWAYS_RAISES
72
+ RULE_ALWAYS_RAISES,
73
+ RULE_RETURN_TYPE
67
74
  ].freeze
68
75
 
76
+ # Backward-compat alias table (ADR-8 § "Backward
77
+ # compatibility"). Existing user code with
78
+ # `# rigor:disable undefined-method` /
79
+ # `disable: [undefined-method]` keeps working — the
80
+ # legacy unprefixed identifiers map to their canonical
81
+ # `family.rule-name` form here. Removing the aliases is
82
+ # a future ADR once user code has migrated; until then,
83
+ # both spellings resolve identically.
84
+ LEGACY_RULE_ALIASES = {
85
+ "undefined-method" => RULE_UNDEFINED_METHOD,
86
+ "wrong-arity" => RULE_WRONG_ARITY,
87
+ "argument-type-mismatch" => RULE_ARGUMENT_TYPE,
88
+ "possible-nil-receiver" => RULE_NIL_RECEIVER,
89
+ "dump-type" => RULE_DUMP_TYPE,
90
+ "assert-type" => RULE_ASSERT_TYPE,
91
+ "always-raises" => RULE_ALWAYS_RAISES
92
+ }.freeze
93
+
94
+ # Family wildcard — a `<family>` token in a suppression
95
+ # comment or `disable:` list disables every rule whose
96
+ # canonical id starts with `<family>.`. Per ADR-8 § "1".
97
+ RULE_FAMILIES = %w[call flow assert dump def].freeze
98
+
99
+ # Resolves a user-supplied rule token (`undefined-method`,
100
+ # `call.undefined-method`, or the family wildcard `call`)
101
+ # to the set of canonical rule identifiers it disables.
102
+ # Returns `nil` for `"all"` (the existing wildcard meaning
103
+ # "every rule"), or for unknown tokens.
104
+ def self.resolve_rule_token(token)
105
+ return nil if token == "all"
106
+ return [LEGACY_RULE_ALIASES.fetch(token)] if LEGACY_RULE_ALIASES.key?(token)
107
+ return ALL_RULES.select { |r| r.start_with?("#{token}.") } if RULE_FAMILIES.include?(token)
108
+
109
+ ALL_RULES.include?(token) ? [token] : []
110
+ end
111
+
69
112
  module_function
70
113
 
71
114
  # Yields diagnostics for every unrecognised method call on
@@ -78,35 +121,31 @@ module Rigor
78
121
  # @param root [Prism::Node]
79
122
  # @param scope_index [Hash{Prism::Node => Rigor::Scope}]
80
123
  # @return [Array<Rigor::Analysis::Diagnostic>]
81
- def diagnose(path:, root:, scope_index:, comments: [], disabled_rules: []) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
124
+ def diagnose(path:, root:, scope_index:, comments: [], disabled_rules: [])
82
125
  diagnostics = []
83
126
  Source::NodeWalker.each(root) do |node|
84
- next unless node.is_a?(Prism::CallNode)
85
-
86
- diagnostic = undefined_method_diagnostic(path, node, scope_index)
87
- diagnostics << diagnostic if diagnostic
88
-
89
- arity_diagnostic = wrong_arity_diagnostic(path, node, scope_index)
90
- diagnostics << arity_diagnostic if arity_diagnostic
91
-
92
- arg_type_diagnostic = argument_type_diagnostic(path, node, scope_index)
93
- diagnostics << arg_type_diagnostic if arg_type_diagnostic
94
-
95
- nil_diagnostic = nil_receiver_diagnostic(path, node, scope_index)
96
- diagnostics << nil_diagnostic if nil_diagnostic
97
-
98
- dump_diagnostic = dump_type_diagnostic(path, node, scope_index)
99
- diagnostics << dump_diagnostic if dump_diagnostic
100
-
101
- assert_diagnostic = assert_type_diagnostic(path, node, scope_index)
102
- diagnostics << assert_diagnostic if assert_diagnostic
103
-
104
- raises_diagnostic = always_raises_diagnostic(path, node, scope_index)
105
- diagnostics << raises_diagnostic if raises_diagnostic
127
+ if node.is_a?(Prism::CallNode)
128
+ diagnostics.concat(call_node_diagnostics(path, node, scope_index))
129
+ elsif node.is_a?(Prism::DefNode)
130
+ return_diagnostic = return_type_mismatch_diagnostic(path, node, scope_index)
131
+ diagnostics << return_diagnostic if return_diagnostic
132
+ end
106
133
  end
107
134
  filter_suppressed(diagnostics, comments: comments, disabled_rules: disabled_rules)
108
135
  end
109
136
 
137
+ def call_node_diagnostics(path, node, scope_index)
138
+ [
139
+ undefined_method_diagnostic(path, node, scope_index),
140
+ wrong_arity_diagnostic(path, node, scope_index),
141
+ argument_type_diagnostic(path, node, scope_index),
142
+ nil_receiver_diagnostic(path, node, scope_index),
143
+ dump_type_diagnostic(path, node, scope_index),
144
+ assert_type_diagnostic(path, node, scope_index),
145
+ always_raises_diagnostic(path, node, scope_index)
146
+ ].compact
147
+ end
148
+
110
149
  # v0.0.2 #6 — diagnostic suppression. Two kinds of
111
150
  # suppression compose:
112
151
  #
@@ -125,7 +164,7 @@ module Rigor
125
164
  # silence away.
126
165
  def filter_suppressed(diagnostics, comments:, disabled_rules:)
127
166
  suppressions = parse_suppression_comments(comments)
128
- disabled = disabled_rules.to_set(&:to_s)
167
+ disabled = expand_rule_tokens(disabled_rules)
129
168
 
130
169
  diagnostics.reject do |diagnostic|
131
170
  rule = diagnostic.rule
@@ -137,7 +176,7 @@ module Rigor
137
176
  end
138
177
  end
139
178
 
140
- SUPPRESSION_PATTERN = /#\s*rigor:disable\s+(?<rules>[\w,\s-]+)/
179
+ SUPPRESSION_PATTERN = /#\s*rigor:disable\s+(?<rules>[\w.,\s-]+)/
141
180
  private_constant :SUPPRESSION_PATTERN
142
181
 
143
182
  def parse_suppression_comments(comments)
@@ -148,11 +187,29 @@ module Rigor
148
187
  next if match.nil?
149
188
 
150
189
  rules = match[:rules].to_s.split(/[\s,]+/).reject(&:empty?)
151
- rules.each { |rule| result[comment.location.start_line] << rule }
190
+ rules.each { |token| result[comment.location.start_line].merge(expand_token(token)) }
152
191
  end
153
192
  result
154
193
  end
155
194
 
195
+ # Expands a list of user-supplied rule tokens into the
196
+ # canonical-id set per ADR-8 § "Backward compatibility".
197
+ # `disabled_rules` accepts unprefixed legacy names
198
+ # (`undefined-method`), canonical names
199
+ # (`call.undefined-method`), and family wildcards (`call`).
200
+ def expand_rule_tokens(tokens)
201
+ Array(tokens).each_with_object(Set.new) do |token, set|
202
+ set.merge(expand_token(token.to_s))
203
+ end
204
+ end
205
+
206
+ def expand_token(token)
207
+ return ["all"] if token == "all"
208
+
209
+ resolved = resolve_rule_token(token)
210
+ resolved.nil? || resolved.empty? ? [token] : resolved
211
+ end
212
+
156
213
  # rubocop:disable Metrics/ClassLength
157
214
  class << self
158
215
  private
@@ -792,6 +849,137 @@ module Rigor
792
849
  severity: :error
793
850
  )
794
851
  end
852
+
853
+ # ADR-8 § "`def.return-type-mismatch` rule" — flags a
854
+ # `def m(...) ... end` whose body's last expression's
855
+ # type cannot satisfy the RBS-declared return type.
856
+ # Conservative envelope (v0.1.x first cut):
857
+ #
858
+ # - Skips methods without an RBS declaration. The rule
859
+ # has no contract to compare against for source-only
860
+ # methods.
861
+ # - Skips methods whose enclosing class isn't a
862
+ # `Type::Singleton` self_type that we can name (top-
863
+ # level / module-level methods land outside the rule).
864
+ # - Skips methods whose body's last expression is
865
+ # absent or types as `Dynamic[top]` (the analyzer's
866
+ # fail-soft fallback) — emitting on `Dynamic[top]`
867
+ # would be noise.
868
+ # - Compares the inferred body type against the
869
+ # declared return via `accepts?`:
870
+ # :yes → silent
871
+ # :no → emit at :error (severity_profile may
872
+ # re-stamp; default `balanced` keeps the
873
+ # authored severity).
874
+ # :maybe → emit at :warning. Promoted to :error
875
+ # under `severity_profile: strict` per
876
+ # ADR-8 § "Severity profile".
877
+ def return_type_mismatch_diagnostic(path, def_node, scope_index) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
878
+ return nil if def_node.body.nil?
879
+
880
+ last_expr = body_last_expression(def_node.body)
881
+ return nil if last_expr.nil?
882
+
883
+ inner_scope = scope_index[last_expr] || scope_index[def_node.body] || scope_index[def_node]
884
+ return nil if inner_scope.nil?
885
+
886
+ declared = declared_return_type(def_node, scope_index)
887
+ return nil if declared.nil?
888
+
889
+ inferred = inner_scope.type_of(last_expr)
890
+ return nil if dynamic_top?(inferred)
891
+
892
+ severity = compare_return(declared, inferred)
893
+ return nil if severity.nil?
894
+
895
+ build_return_type_mismatch_diagnostic(path, def_node, declared, inferred, severity)
896
+ end
897
+
898
+ # The body of a `def` is the last `Prism::StatementsNode`
899
+ # child (or a single expression for one-liner defs).
900
+ # Take the last statement; that's the implicit return.
901
+ def body_last_expression(body)
902
+ case body
903
+ when Prism::StatementsNode then body.body.last
904
+ when Prism::BeginNode then body_last_expression(body.statements)
905
+ else body
906
+ end
907
+ end
908
+
909
+ # Pulls the declared RBS return type for the def. The
910
+ # enclosing class name comes from the def's scope's
911
+ # `self_type`; the method name is on the def itself.
912
+ # `def self.foo` is a singleton method — dispatched
913
+ # through `Reflection.singleton_method_definition`;
914
+ # plain `def foo` uses `instance_method_definition`.
915
+ # Method overloads contribute their union of declared
916
+ # return types (any one of them satisfying the body
917
+ # silences the rule).
918
+ def declared_return_type(def_node, scope_index)
919
+ scope = scope_index[def_node]
920
+ return nil if scope.nil?
921
+
922
+ self_type = scope.self_type
923
+ return nil unless self_type.respond_to?(:class_name)
924
+
925
+ method_def =
926
+ if def_node.receiver.nil?
927
+ Reflection.instance_method_definition(self_type.class_name, def_node.name, scope: scope)
928
+ else
929
+ Reflection.singleton_method_definition(self_type.class_name, def_node.name, scope: scope)
930
+ end
931
+ return nil if method_def.nil?
932
+
933
+ declared_return_union(method_def, scope.environment)
934
+ end
935
+
936
+ def declared_return_union(method_def, _environment)
937
+ translated = method_def.method_types.filter_map do |mt|
938
+ Inference::RbsTypeTranslator.translate(
939
+ mt.type.return_type,
940
+ self_type: nil, instance_type: nil, type_vars: {}
941
+ )
942
+ rescue StandardError
943
+ nil
944
+ end
945
+ return nil if translated.empty?
946
+
947
+ translated.size == 1 ? translated.first : Type::Combinator.union(*translated)
948
+ end
949
+
950
+ def dynamic_top?(type)
951
+ type.is_a?(Type::Dynamic) || (type.respond_to?(:top?) && type.top?.yes?)
952
+ end
953
+
954
+ # Returns the severity to emit at, or nil to stay
955
+ # silent. The first-cut implementation only fires on
956
+ # proven (`:no`) mismatches; `:maybe` is treated as
957
+ # silent until the analyzer's narrowing becomes precise
958
+ # enough to avoid noise on common patterns (`{}` →
959
+ # declared `Hash[K, V]`, `Set.new` → declared
960
+ # `Set[Symbol]`, …). ADR-8's promise to emit on
961
+ # `:maybe` under `severity_profile: strict` is
962
+ # deferred to a follow-up that lands together with the
963
+ # narrowing precision improvements.
964
+ def compare_return(declared, inferred)
965
+ result = declared.accepts(inferred)
966
+ return :error if result.no?
967
+
968
+ nil
969
+ end
970
+
971
+ def build_return_type_mismatch_diagnostic(path, def_node, declared, inferred, severity)
972
+ location = def_node.name_loc || def_node.location
973
+ Diagnostic.new(
974
+ rule: RULE_RETURN_TYPE,
975
+ path: path,
976
+ line: location.start_line,
977
+ column: location.start_column + 1,
978
+ message: "return-type mismatch on `#{def_node.name}': " \
979
+ "declared #{declared.describe(:short)}, inferred #{inferred.describe(:short)}",
980
+ severity: severity
981
+ )
982
+ end
795
983
  end
796
984
  # rubocop:enable Metrics/ClassLength
797
985
  end
@@ -64,8 +64,22 @@ module Rigor
64
64
  }
65
65
  end
66
66
 
67
+ # Text rendering for `rigor check`. The qualified rule
68
+ # identifier (per ADR-2 § "Plugin Diagnostic Provenance" —
69
+ # `plugin.<id>.<rule>`, `rbs_extended.<rule>`,
70
+ # `generated.<provider>.<rule>`) is appended in brackets
71
+ # whenever the diagnostic carries a non-default `source_family`,
72
+ # so plugin / RBS::Extended / generated provenance is visible
73
+ # in the standard text output without changing the layout for
74
+ # built-in rules. Slice 5 (v0.1.0) wires this surface.
67
75
  def to_s
68
- "#{path}:#{line}:#{column}: #{severity}: #{message}"
76
+ base = "#{path}:#{line}:#{column}: #{severity}: #{message}"
77
+ return base if source_family == DEFAULT_SOURCE_FAMILY
78
+
79
+ qualified = qualified_rule
80
+ return base if qualified.nil?
81
+
82
+ "#{base} [#{qualified}]"
69
83
  end
70
84
  end
71
85
  end