rigortype 0.0.8 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +234 -22
  3. data/data/builtins/ruby_core/encoding.yml +210 -0
  4. data/data/builtins/ruby_core/exception.yml +641 -0
  5. data/data/builtins/ruby_core/numeric.yml +3 -2
  6. data/data/builtins/ruby_core/proc.yml +731 -0
  7. data/data/builtins/ruby_core/random.yml +166 -0
  8. data/data/builtins/ruby_core/re.yml +689 -0
  9. data/data/builtins/ruby_core/struct.yml +449 -0
  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 +199 -4
  13. data/lib/rigor/builtins/imported_refinements.rb +6 -1
  14. data/lib/rigor/cache/rbs_class_ancestor_table.rb +63 -0
  15. data/lib/rigor/cache/rbs_class_type_param_names.rb +60 -0
  16. data/lib/rigor/cache/rbs_constant_table.rb +15 -51
  17. data/lib/rigor/cache/rbs_descriptor.rb +55 -0
  18. data/lib/rigor/cache/rbs_environment.rb +52 -0
  19. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +40 -0
  20. data/lib/rigor/cache/rbs_instance_definitions.rb +79 -0
  21. data/lib/rigor/cache/rbs_known_class_names.rb +43 -0
  22. data/lib/rigor/cache/store.rb +81 -15
  23. data/lib/rigor/cli.rb +45 -7
  24. data/lib/rigor/configuration/severity_profile.rb +109 -0
  25. data/lib/rigor/configuration.rb +110 -6
  26. data/lib/rigor/environment/rbs_hierarchy.rb +18 -5
  27. data/lib/rigor/environment/rbs_loader.rb +220 -32
  28. data/lib/rigor/environment.rb +11 -2
  29. data/lib/rigor/flow_contribution/conflict.rb +81 -0
  30. data/lib/rigor/flow_contribution/element.rb +53 -0
  31. data/lib/rigor/flow_contribution/fact.rb +88 -0
  32. data/lib/rigor/flow_contribution/merge_result.rb +67 -0
  33. data/lib/rigor/flow_contribution/merger.rb +275 -0
  34. data/lib/rigor/flow_contribution.rb +179 -0
  35. data/lib/rigor/inference/block_parameter_binder.rb +15 -0
  36. data/lib/rigor/inference/builtins/encoding_catalog.rb +67 -0
  37. data/lib/rigor/inference/builtins/exception_catalog.rb +92 -0
  38. data/lib/rigor/inference/builtins/proc_catalog.rb +122 -0
  39. data/lib/rigor/inference/builtins/random_catalog.rb +58 -0
  40. data/lib/rigor/inference/builtins/re_catalog.rb +81 -0
  41. data/lib/rigor/inference/builtins/struct_catalog.rb +55 -0
  42. data/lib/rigor/inference/expression_typer.rb +110 -6
  43. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +16 -1
  44. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +173 -0
  45. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +21 -1
  46. data/lib/rigor/inference/method_dispatcher.rb +2 -0
  47. data/lib/rigor/inference/multi_target_binder.rb +2 -0
  48. data/lib/rigor/inference/narrowing.rb +134 -144
  49. data/lib/rigor/inference/scope_indexer.rb +75 -1
  50. data/lib/rigor/inference/statement_evaluator.rb +380 -40
  51. data/lib/rigor/plugin/access_denied_error.rb +24 -0
  52. data/lib/rigor/plugin/base.rb +241 -0
  53. data/lib/rigor/plugin/io_boundary.rb +102 -0
  54. data/lib/rigor/plugin/load_error.rb +23 -0
  55. data/lib/rigor/plugin/loader.rb +191 -0
  56. data/lib/rigor/plugin/manifest.rb +134 -0
  57. data/lib/rigor/plugin/registry.rb +50 -0
  58. data/lib/rigor/plugin/services.rb +65 -0
  59. data/lib/rigor/plugin/trust_policy.rb +99 -0
  60. data/lib/rigor/plugin.rb +61 -0
  61. data/lib/rigor/rbs_extended.rb +103 -0
  62. data/lib/rigor/reflection.rb +2 -2
  63. data/lib/rigor/type/combinator.rb +72 -0
  64. data/lib/rigor/type/refined.rb +50 -2
  65. data/lib/rigor/version.rb +1 -1
  66. data/lib/rigor.rb +13 -0
  67. data/sig/rigor/environment.rbs +7 -1
  68. data/sig/rigor/inference.rbs +1 -0
  69. data/sig/rigor/rbs_extended.rbs +2 -0
  70. data/sig/rigor/scope.rbs +1 -0
  71. data/sig/rigor/type.rbs +7 -0
  72. data/sig/rigor.rbs +3 -1
  73. metadata +38 -1
data/lib/rigor/cli.rb CHANGED
@@ -72,13 +72,19 @@ module Rigor
72
72
 
73
73
  cache_root = ".rigor/cache"
74
74
  handle_clear_cache(cache_root) if options.fetch(:clear_cache)
75
+ cache_store = options.fetch(:no_cache) ? nil : Cache::Store.new(root: cache_root)
75
76
 
76
77
  configuration = Configuration.load(options.fetch(:config))
77
78
  paths = @argv.empty? ? configuration.paths : @argv
78
- result = Analysis::Runner.new(configuration: configuration, explain: options.fetch(:explain)).run(paths)
79
+ runner = Analysis::Runner.new(
80
+ configuration: configuration,
81
+ explain: options.fetch(:explain),
82
+ cache_store: cache_store
83
+ )
84
+ result = runner.run(paths)
79
85
 
80
86
  write_result(result, options.fetch(:format))
81
- write_cache_stats(cache_root) if options.fetch(:cache_stats)
87
+ write_cache_stats(cache_root, runner.cache_store) if options.fetch(:cache_stats)
82
88
  result.success? ? 0 : 1
83
89
  end
84
90
 
@@ -88,7 +94,8 @@ module Rigor
88
94
  format: "text",
89
95
  explain: false,
90
96
  cache_stats: false,
91
- clear_cache: false
97
+ clear_cache: false,
98
+ no_cache: false
92
99
  }
93
100
  parser = OptionParser.new do |opts|
94
101
  opts.banner = "Usage: rigor check [options] [paths]"
@@ -97,6 +104,7 @@ module Rigor
97
104
  opts.on("--explain", "Surface fail-soft fallback events as :info diagnostics") { options[:explain] = true }
98
105
  opts.on("--cache-stats", "Print on-disk cache inventory at end of run") { options[:cache_stats] = true }
99
106
  opts.on("--clear-cache", "Remove the .rigor/cache directory before running") { options[:clear_cache] = true }
107
+ opts.on("--no-cache", "Disable the persistent cache for this run") { options[:no_cache] = true }
100
108
  end
101
109
  parser.parse!(@argv)
102
110
  options
@@ -111,13 +119,18 @@ module Rigor
111
119
  end
112
120
  end
113
121
 
114
- def write_cache_stats(cache_root)
122
+ def write_cache_stats(cache_root, runtime_store)
115
123
  inv = Cache::Store.disk_inventory(root: cache_root)
116
124
 
117
125
  @out.puts("")
118
126
  @out.puts("Cache (root: #{inv.fetch(:root)})")
119
127
  schema = inv.fetch(:schema_version)
120
128
  @out.puts(" schema_version: #{schema.nil? ? 'absent' : schema}")
129
+ write_disk_inventory(inv)
130
+ write_runtime_stats(runtime_store) if runtime_store
131
+ end
132
+
133
+ def write_disk_inventory(inv)
121
134
  if inv.fetch(:total_entries).zero?
122
135
  @out.puts(" (empty)")
123
136
  return
@@ -130,6 +143,25 @@ module Rigor
130
143
  end
131
144
  end
132
145
 
146
+ def write_runtime_stats(store)
147
+ stats = store.stats
148
+ hits = stats.fetch(:hits)
149
+ misses = stats.fetch(:misses)
150
+ writes = stats.fetch(:writes)
151
+ @out.puts(" this run: #{hits} #{plural(hits, 'hit')}, " \
152
+ "#{misses} #{plural(misses, 'miss', 'misses')}, " \
153
+ "#{writes} #{plural(writes, 'write')}")
154
+ stats.fetch(:by_producer).each do |id, counts|
155
+ @out.puts(" #{id}: #{counts.fetch(:hits)} #{plural(counts.fetch(:hits), 'hit')}, " \
156
+ "#{counts.fetch(:misses)} #{plural(counts.fetch(:misses), 'miss', 'misses')}, " \
157
+ "#{counts.fetch(:writes)} #{plural(counts.fetch(:writes), 'write')}")
158
+ end
159
+ end
160
+
161
+ def plural(count, singular, plural = "#{singular}s")
162
+ count == 1 ? singular : plural
163
+ end
164
+
133
165
  def format_bytes(bytes)
134
166
  return "#{bytes} B" if bytes < 1024
135
167
  return format("%.1f KiB", bytes / 1024.0) if bytes < 1024 * 1024
@@ -179,9 +211,15 @@ module Rigor
179
211
  # (no plugins are loaded today).
180
212
  # - disable: list of `rigor check` rule identifiers to
181
213
  # silence project-wide. The shipped rules are
182
- # undefined-method, wrong-arity,
183
- # argument-type-mismatch, possible-nil-receiver,
184
- # dump-type, assert-type. In-source
214
+ # call.undefined-method, call.wrong-arity,
215
+ # call.argument-type-mismatch,
216
+ # call.possible-nil-receiver, dump.type,
217
+ # assert.type-mismatch, flow.always-raises.
218
+ # A bare family token (`call`, `flow`,
219
+ # `assert`, `dump`, `def`) wildcards every
220
+ # rule under that prefix. Legacy unprefixed
221
+ # names (`undefined-method`, …) still
222
+ # resolve. In-source
185
223
  # `# rigor:disable <rule>` comments at the end
186
224
  # of an offending line silence per-line; use
187
225
  # `# rigor:disable all` to suppress every rule.
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ class Configuration
5
+ # ADR-8 § "Severity profile" — three named profiles tune the
6
+ # severity of every built-in `Analysis::CheckRules` rule for
7
+ # the run. Profiles are applied as a **final filter** on
8
+ # `Diagnostic#severity`: rules emit with their authored
9
+ # severity, then `Analysis::Runner` re-stamps the severity
10
+ # from the active profile before adding the diagnostic to
11
+ # the result.
12
+ #
13
+ # Three profiles:
14
+ #
15
+ # - `lenient`: Only proven (`:no`) diagnostics are errors;
16
+ # uncertain (`:maybe`) drop to `:warning`. Useful for
17
+ # incremental adoption on legacy code.
18
+ # - `balanced` (**default**): Current Rigor stance — most
19
+ # rules `:error`; `dump.type` `:info`; uncertain rules
20
+ # `:warning`.
21
+ # - `strict`: Every rule is `:error`. CI-friendly.
22
+ #
23
+ # The profile resolution order:
24
+ #
25
+ # 1. Profile-specific entry for the canonical rule id.
26
+ # 2. The diagnostic's own authored severity (the rule's
27
+ # default).
28
+ # 3. `:error` (catch-all so an unrecognised rule still emits
29
+ # visibly — the public-API drift spec catches the
30
+ # bookkeeping gap separately).
31
+ module SeverityProfile
32
+ VALID_PROFILES = %i[lenient balanced strict].freeze
33
+ VALID_SEVERITIES = %i[error warning info off].freeze
34
+
35
+ DEFAULT_PROFILE = :balanced
36
+
37
+ # Per-profile severity tables. Missing keys fall back to
38
+ # the diagnostic's authored severity (typically `:error`).
39
+ PROFILES = {
40
+ lenient: {
41
+ "call.undefined-method" => :error,
42
+ "call.wrong-arity" => :error,
43
+ "call.argument-type-mismatch" => :warning,
44
+ "call.possible-nil-receiver" => :warning,
45
+ "flow.always-raises" => :warning,
46
+ "assert.type-mismatch" => :error,
47
+ "dump.type" => :info,
48
+ "def.return-type-mismatch" => :warning
49
+ }.freeze,
50
+ balanced: {
51
+ "call.undefined-method" => :error,
52
+ "call.wrong-arity" => :error,
53
+ "call.argument-type-mismatch" => :error,
54
+ "call.possible-nil-receiver" => :error,
55
+ "flow.always-raises" => :error,
56
+ "assert.type-mismatch" => :error,
57
+ "dump.type" => :info,
58
+ "def.return-type-mismatch" => :warning
59
+ }.freeze,
60
+ strict: {
61
+ "call.undefined-method" => :error,
62
+ "call.wrong-arity" => :error,
63
+ "call.argument-type-mismatch" => :error,
64
+ "call.possible-nil-receiver" => :error,
65
+ "flow.always-raises" => :error,
66
+ "assert.type-mismatch" => :error,
67
+ "dump.type" => :error,
68
+ "def.return-type-mismatch" => :error
69
+ }.freeze
70
+ }.freeze
71
+
72
+ module_function
73
+
74
+ # Resolves the configured severity for a diagnostic given
75
+ # the active profile and any per-rule overrides.
76
+ #
77
+ # @param rule [String, nil] canonical rule id (`call.undefined-method`).
78
+ # @param authored_severity [Symbol] severity the rule emitted
79
+ # the diagnostic with (`:error`, `:warning`, `:info`).
80
+ # @param profile [Symbol] one of {VALID_PROFILES}; falls back
81
+ # to {DEFAULT_PROFILE} for unknown values.
82
+ # @param overrides [Hash{String => Symbol}] per-rule severity
83
+ # overrides from `.rigor.yml`'s `severity_overrides:` map.
84
+ # Keys are canonical rule ids; values are
85
+ # {VALID_SEVERITIES} symbols. Family-wildcard keys
86
+ # (`call`) match every rule under that prefix.
87
+ # @return [Symbol] the resolved severity. Returns `:off` to
88
+ # mean "drop the diagnostic entirely".
89
+ def resolve(rule:, authored_severity:, profile: DEFAULT_PROFILE, overrides: {})
90
+ return authored_severity if rule.nil?
91
+
92
+ override = overrides[rule] || family_override(rule, overrides)
93
+ return override.to_sym if override
94
+
95
+ profile_table = PROFILES[profile] || PROFILES.fetch(DEFAULT_PROFILE)
96
+ profile_table.fetch(rule, authored_severity)
97
+ end
98
+
99
+ def family_override(rule, overrides)
100
+ family = rule.split(".").first
101
+ return nil if family.nil?
102
+
103
+ overrides[family]
104
+ end
105
+
106
+ private_class_method :family_override
107
+ end
108
+ end
109
+ end
@@ -2,8 +2,10 @@
2
2
 
3
3
  require "yaml"
4
4
 
5
+ require_relative "configuration/severity_profile"
6
+
5
7
  module Rigor
6
- class Configuration
8
+ class Configuration # rubocop:disable Metrics/ClassLength
7
9
  DEFAULT_PATH = ".rigor.yml"
8
10
  DEFAULTS = {
9
11
  "target_ruby" => "4.0",
@@ -15,11 +17,19 @@ module Rigor
15
17
  "fold_platform_specific_paths" => false,
16
18
  "cache" => {
17
19
  "path" => ".rigor/cache"
18
- }
20
+ },
21
+ "plugins_io" => {
22
+ "network" => "disabled",
23
+ "allowed_paths" => []
24
+ },
25
+ "severity_profile" => "balanced",
26
+ "severity_overrides" => {}
19
27
  }.freeze
20
28
 
21
29
  attr_reader :target_ruby, :paths, :plugins, :cache_path, :disabled_rules,
22
- :libraries, :signature_paths, :fold_platform_specific_paths
30
+ :libraries, :signature_paths, :fold_platform_specific_paths,
31
+ :plugins_io_network, :plugins_io_allowed_paths,
32
+ :severity_profile, :severity_overrides
23
33
 
24
34
  def self.load(path = DEFAULT_PATH)
25
35
  data = if File.exist?(path)
@@ -31,12 +41,15 @@ module Rigor
31
41
  new(DEFAULTS.merge(data))
32
42
  end
33
43
 
34
- def initialize(data = DEFAULTS) # rubocop:disable Metrics/AbcSize
44
+ def initialize(data = DEFAULTS) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity
35
45
  cache = DEFAULTS.fetch("cache").merge(data.fetch("cache", {}))
46
+ plugins_io = DEFAULTS.fetch("plugins_io").merge(data.fetch("plugins_io", {}))
36
47
 
37
48
  @target_ruby = data.fetch("target_ruby", DEFAULTS.fetch("target_ruby")).to_s
38
49
  @paths = Array(data.fetch("paths", DEFAULTS.fetch("paths"))).map(&:to_s)
39
- @plugins = Array(data.fetch("plugins", DEFAULTS.fetch("plugins"))).map(&:to_s)
50
+ @plugins = Array(data.fetch("plugins", DEFAULTS.fetch("plugins"))).map do |entry|
51
+ coerce_plugin_entry(entry)
52
+ end.freeze
40
53
  @disabled_rules = Array(data.fetch("disable", DEFAULTS.fetch("disable"))).map(&:to_s).freeze
41
54
  @libraries = Array(data.fetch("libraries", DEFAULTS.fetch("libraries"))).map(&:to_s).freeze
42
55
  sig_paths = data.fetch("signature_paths", DEFAULTS.fetch("signature_paths"))
@@ -45,6 +58,14 @@ module Rigor
45
58
  "fold_platform_specific_paths", DEFAULTS.fetch("fold_platform_specific_paths")
46
59
  ) == true
47
60
  @cache_path = cache.fetch("path").to_s
61
+ @plugins_io_network = coerce_network_policy(plugins_io.fetch("network"))
62
+ @plugins_io_allowed_paths = Array(plugins_io.fetch("allowed_paths")).map(&:to_s).freeze
63
+ @severity_profile = coerce_severity_profile(
64
+ data.fetch("severity_profile", DEFAULTS.fetch("severity_profile"))
65
+ )
66
+ @severity_overrides = coerce_severity_overrides(
67
+ data.fetch("severity_overrides", DEFAULTS.fetch("severity_overrides"))
68
+ )
48
69
  end
49
70
 
50
71
  def to_h
@@ -58,8 +79,91 @@ module Rigor
58
79
  "fold_platform_specific_paths" => fold_platform_specific_paths,
59
80
  "cache" => {
60
81
  "path" => cache_path
61
- }
82
+ },
83
+ "plugins_io" => {
84
+ "network" => plugins_io_network.to_s,
85
+ "allowed_paths" => plugins_io_allowed_paths
86
+ },
87
+ "severity_profile" => severity_profile.to_s,
88
+ "severity_overrides" => severity_overrides.to_h { |k, v| [k, v.to_s] }
62
89
  }
63
90
  end
91
+
92
+ private
93
+
94
+ # Accepts either `"rigor-foo"` (gem-name shorthand) or
95
+ # `{ "gem" => "rigor-foo", "id" => "foo", "config" => {...} }`
96
+ # (full form). Returns the canonical hash form so the loader
97
+ # works against a single shape.
98
+ def coerce_plugin_entry(entry)
99
+ case entry
100
+ when String
101
+ entry.dup.freeze
102
+ when Hash
103
+ entry.to_h { |k, v| [k.to_s, v] }.freeze
104
+ else
105
+ raise ArgumentError,
106
+ "plugin configuration entry must be a String or Hash, got #{entry.inspect}"
107
+ end
108
+ end
109
+
110
+ # Slice 2 only accepts `:disabled` for the network policy. The
111
+ # YAML scalar may arrive as a String (`"disabled"`) or already
112
+ # as the Symbol; coerce to the canonical Symbol shape so the
113
+ # downstream `TrustPolicy` constructor stays strict.
114
+ #
115
+ # The accepted set is duplicated from
116
+ # {Rigor::Plugin::TrustPolicy::VALID_NETWORK_POLICIES} so
117
+ # `Configuration` does not require the plugin namespace at
118
+ # load time (Configuration is loaded before Plugin in
119
+ # `lib/rigor.rb`); the two stay in lockstep via spec.
120
+ VALID_NETWORK_POLICIES = %i[disabled].freeze
121
+ private_constant :VALID_NETWORK_POLICIES
122
+
123
+ def coerce_network_policy(value)
124
+ sym = value.to_sym
125
+ unless VALID_NETWORK_POLICIES.include?(sym)
126
+ raise ArgumentError,
127
+ "plugins_io.network must be one of #{VALID_NETWORK_POLICIES.inspect}, got #{value.inspect}"
128
+ end
129
+
130
+ sym
131
+ end
132
+
133
+ # ADR-8 § "Severity profile" — accepts the canonical Symbol
134
+ # form or its String spelling; rejects unknown profile names
135
+ # so typos fail loudly.
136
+ def coerce_severity_profile(value)
137
+ sym = value.to_sym
138
+ unless SeverityProfile::VALID_PROFILES.include?(sym)
139
+ raise ArgumentError,
140
+ "severity_profile must be one of " \
141
+ "#{SeverityProfile::VALID_PROFILES.inspect}, got #{value.inspect}"
142
+ end
143
+
144
+ sym
145
+ end
146
+
147
+ # ADR-8 § "Severity profile" — `severity_overrides:` is a
148
+ # `{ rule => severity }` map. Keys are canonical rule ids
149
+ # (`call.undefined-method`) or family wildcards (`call`).
150
+ # Values are {SeverityProfile::VALID_SEVERITIES} symbols
151
+ # (`:error` / `:warning` / `:info` / `:off`). Unknown
152
+ # severities raise; unknown rule ids are silently kept (the
153
+ # override is inert until the rule lands).
154
+ def coerce_severity_overrides(value)
155
+ raise ArgumentError, "severity_overrides must be a Hash, got #{value.inspect}" unless value.is_a?(Hash)
156
+
157
+ value.to_h do |k, v|
158
+ sym = v.to_sym
159
+ unless SeverityProfile::VALID_SEVERITIES.include?(sym)
160
+ raise ArgumentError,
161
+ "severity_overrides[#{k.inspect}] must be one of " \
162
+ "#{SeverityProfile::VALID_SEVERITIES.inspect}, got #{v.inspect}"
163
+ end
164
+
165
+ [k.to_s, sym]
166
+ end.freeze
167
+ end
64
168
  end
65
169
  end
@@ -45,15 +45,28 @@ module Rigor
45
45
  key = normalize_name(class_name)
46
46
  return @ancestor_names_cache[key] if @ancestor_names_cache.key?(key)
47
47
 
48
- definition = loader.instance_definition(key)
49
48
  @ancestor_names_cache[key] =
50
- if definition
51
- definition.ancestors.ancestors.map { |ancestor| normalize_name(ancestor.name.to_s) }.uniq.freeze
49
+ if loader.cache_store
50
+ ancestor_table.fetch(key, [].freeze)
52
51
  else
53
- [].freeze
52
+ compute_ancestor_names(key)
54
53
  end
54
+ end
55
+
56
+ def compute_ancestor_names(key)
57
+ definition = loader.instance_definition(key)
58
+ return [].freeze if definition.nil?
59
+
60
+ definition.ancestors.ancestors.map { |ancestor| normalize_name(ancestor.name.to_s) }.uniq.freeze
55
61
  rescue StandardError
56
- @ancestor_names_cache[key] = [].freeze
62
+ [].freeze
63
+ end
64
+
65
+ def ancestor_table
66
+ @ancestor_table ||= begin
67
+ require_relative "../cache/rbs_class_ancestor_table"
68
+ Cache::RbsClassAncestorTable.fetch(loader: loader, store: loader.cache_store)
69
+ end
57
70
  end
58
71
 
59
72
  def normalize_name(name)