rigortype 0.1.18 → 0.1.19

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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +159 -224
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -3
  4. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
  5. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +29 -0
  6. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
  7. data/lib/rigor/analysis/check_rules/rule_walk.rb +169 -23
  8. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +9 -3
  9. data/lib/rigor/analysis/check_rules.rb +266 -63
  10. data/lib/rigor/analysis/diagnostic.rb +8 -0
  11. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +2 -1
  12. data/lib/rigor/analysis/runner/project_pre_passes.rb +4 -1
  13. data/lib/rigor/analysis/runner.rb +58 -21
  14. data/lib/rigor/analysis/worker_session.rb +21 -11
  15. data/lib/rigor/bleeding_edge.rb +123 -0
  16. data/lib/rigor/cache/descriptor.rb +86 -8
  17. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  18. data/lib/rigor/cli/annotate_command.rb +100 -15
  19. data/lib/rigor/cli/check_command.rb +3 -0
  20. data/lib/rigor/cli/plugins_command.rb +2 -4
  21. data/lib/rigor/cli/plugins_renderer.rb +0 -2
  22. data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
  23. data/lib/rigor/cli/triage_command.rb +6 -3
  24. data/lib/rigor/cli/triage_renderer.rb +15 -1
  25. data/lib/rigor/cli.rb +9 -1
  26. data/lib/rigor/configuration/severity_profile.rb +13 -1
  27. data/lib/rigor/configuration.rb +57 -1
  28. data/lib/rigor/environment/rbs_loader.rb +25 -0
  29. data/lib/rigor/inference/body_fixpoint.rb +89 -0
  30. data/lib/rigor/inference/budget_trace.rb +29 -2
  31. data/lib/rigor/inference/expression_typer.rb +1052 -43
  32. data/lib/rigor/inference/macro_block_self_type.rb +2 -2
  33. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
  34. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +54 -14
  35. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
  36. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
  37. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +148 -10
  38. data/lib/rigor/inference/method_dispatcher.rb +72 -1
  39. data/lib/rigor/inference/method_parameter_binder.rb +56 -2
  40. data/lib/rigor/inference/multi_target_binder.rb +46 -3
  41. data/lib/rigor/inference/mutation_widening.rb +142 -0
  42. data/lib/rigor/inference/narrowing.rb +270 -37
  43. data/lib/rigor/inference/scope_indexer.rb +696 -25
  44. data/lib/rigor/inference/statement_evaluator.rb +963 -16
  45. data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
  46. data/lib/rigor/plugin/base.rb +235 -79
  47. data/lib/rigor/plugin/macro/block_as_method.rb +22 -21
  48. data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
  49. data/lib/rigor/plugin/macro.rb +2 -3
  50. data/lib/rigor/plugin/manifest.rb +4 -24
  51. data/lib/rigor/plugin/node_rule_walk.rb +59 -14
  52. data/lib/rigor/plugin/registry.rb +12 -11
  53. data/lib/rigor/scope/discovery_index.rb +2 -0
  54. data/lib/rigor/scope.rb +132 -6
  55. data/lib/rigor/sig_gen/generator.rb +8 -0
  56. data/lib/rigor/triage/catalogue.rb +4 -19
  57. data/lib/rigor/triage.rb +69 -1
  58. data/lib/rigor/type/combinator.rb +29 -0
  59. data/lib/rigor/version.rb +1 -1
  60. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +13 -29
  61. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
  62. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +27 -90
  63. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
  64. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +20 -19
  65. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +10 -8
  66. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
  67. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +1 -1
  68. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
  69. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +21 -34
  70. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +11 -18
  71. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  72. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +2 -13
  73. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
  74. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
  75. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
  76. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +25 -0
  77. data/sig/rigor/analysis/fact_store.rbs +3 -0
  78. data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
  79. data/sig/rigor/plugin/base.rbs +5 -2
  80. data/sig/rigor/plugin/manifest.rbs +1 -2
  81. data/sig/rigor/scope.rbs +10 -1
  82. data/sig/rigor/type.rbs +1 -0
  83. data/sig/rigor.rbs +1 -1
  84. data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
  85. data/skills/rigor-plugin-author/SKILL.md +6 -4
  86. data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
  87. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
  88. metadata +7 -2
  89. data/lib/rigor/plugin/macro/external_file.rb +0 -143
@@ -43,7 +43,7 @@ module Rigor
43
43
 
44
44
  attr_reader :id, :version, :description, :config_schema, :config_defaults, :produces, :consumes,
45
45
  :owns_receivers, :open_receivers, :type_node_resolvers, :block_as_methods,
46
- :heredoc_templates, :nested_class_templates, :trait_registries, :external_files,
46
+ :heredoc_templates, :nested_class_templates, :trait_registries,
47
47
  :hkt_registrations, :hkt_definitions, :signature_paths, :protocol_contracts,
48
48
  :source_rbs_synthesizer, :additional_initializers
49
49
 
@@ -52,7 +52,7 @@ module Rigor
52
52
  description: nil, config_schema: {},
53
53
  produces: [], consumes: [], owns_receivers: [], open_receivers: [], type_node_resolvers: [],
54
54
  block_as_methods: [], heredoc_templates: [], nested_class_templates: [],
55
- trait_registries: [], external_files: [],
55
+ trait_registries: [],
56
56
  hkt_registrations: [], hkt_definitions: [], signature_paths: [], protocol_contracts: [],
57
57
  source_rbs_synthesizer: nil, additional_initializers: []
58
58
  )
@@ -67,7 +67,6 @@ module Rigor
67
67
  validate_heredoc_templates!(heredoc_templates)
68
68
  validate_nested_class_templates!(nested_class_templates)
69
69
  validate_trait_registries!(trait_registries)
70
- validate_external_files!(external_files)
71
70
  validate_hkt_registrations!(hkt_registrations)
72
71
  validate_hkt_definitions!(hkt_definitions)
73
72
  validate_signature_paths!(signature_paths)
@@ -77,7 +76,7 @@ module Rigor
77
76
 
78
77
  assign_fields(id, version, description, config_schema, produces, consumes, owns_receivers,
79
78
  open_receivers, type_node_resolvers, block_as_methods, heredoc_templates, trait_registries,
80
- external_files, hkt_registrations, hkt_definitions, signature_paths, protocol_contracts,
79
+ hkt_registrations, hkt_definitions, signature_paths, protocol_contracts,
81
80
  source_rbs_synthesizer)
82
81
  assign_nested_class_templates(nested_class_templates)
83
82
  assign_additional_initializers(additional_initializers)
@@ -89,7 +88,7 @@ module Rigor
89
88
  # rubocop:disable Metrics/ParameterLists, Metrics/AbcSize
90
89
  def assign_fields(id, version, description, config_schema, produces, consumes, owns_receivers,
91
90
  open_receivers, type_node_resolvers, block_as_methods, heredoc_templates, trait_registries,
92
- external_files, hkt_registrations, hkt_definitions, signature_paths, protocol_contracts,
91
+ hkt_registrations, hkt_definitions, signature_paths, protocol_contracts,
93
92
  source_rbs_synthesizer)
94
93
  @id = id.dup.freeze
95
94
  @version = version.dup.freeze
@@ -104,7 +103,6 @@ module Rigor
104
103
  @block_as_methods = block_as_methods.dup.freeze
105
104
  @heredoc_templates = heredoc_templates.dup.freeze
106
105
  @trait_registries = trait_registries.dup.freeze
107
- @external_files = external_files.dup.freeze
108
106
  @hkt_registrations = hkt_registrations.dup.freeze
109
107
  @hkt_definitions = hkt_definitions.dup.freeze
110
108
  @signature_paths = signature_paths.map { |p| p.to_s.dup.freeze }.freeze
@@ -170,7 +168,6 @@ module Rigor
170
168
  "heredoc_templates" => heredoc_templates.map(&:to_h),
171
169
  "nested_class_templates" => nested_class_templates.map(&:to_h),
172
170
  "trait_registries" => trait_registries.map(&:to_h),
173
- "external_files" => external_files.map(&:to_h),
174
171
  "hkt_registrations" => hkt_registrations.map(&:to_h),
175
172
  "hkt_definitions" => hkt_definitions.map { |d| { "uri" => d.uri, "params" => d.params } },
176
173
  "signature_paths" => signature_paths,
@@ -389,23 +386,6 @@ module Rigor
389
386
  "Rigor::Plugin::Macro::TraitRegistry instances, got #{entries.inspect}"
390
387
  end
391
388
 
392
- # ADR-16 slice 5a — `external_files:` declares the Tier D
393
- # substrate entries (external-Ruby-file inclusion under a
394
- # declared `self`). Slice 5a carries the declarations on
395
- # the manifest; the engine integration that walks the
396
- # matched files + narrows their entry scope is **queued for
397
- # slice 5b**, gated on demonstrated demand from concrete
398
- # plugin targets (Redmine webhook payloads, tDiary plugin
399
- # loader, etc.). Plugin authors MAY declare entries today;
400
- # the substrate does not yet act on them.
401
- def validate_external_files!(entries)
402
- return if entries.is_a?(Array) && entries.all?(Macro::ExternalFile)
403
-
404
- raise ArgumentError,
405
- "plugin manifest external_files must be an Array of " \
406
- "Rigor::Plugin::Macro::ExternalFile instances, got #{entries.inspect}"
407
- end
408
-
409
389
  # ADR-20 slice 6 — `hkt_registrations:` declares the
410
390
  # Lightweight HKT URI registrations this plugin ships
411
391
  # (analogous to `%a{rigor:v1:hkt_register: ...}` directives
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "node_context"
4
4
  require_relative "../source/node_walker"
5
+ require_relative "../analysis/check_rules/rule_walk"
5
6
 
6
7
  module Rigor
7
8
  module Plugin
@@ -64,30 +65,74 @@ module Rigor
64
65
  # Walk `root` once, dispatching every node to each matching
65
66
  # `(plugin, rule)`. Returns an Array of {Result} in plugin
66
67
  # (registry) order. `root` nil yields one empty Result per plugin.
67
- def diagnostics_for_file(path:, scope:, root:)
68
+ #
69
+ # ADR-53 B4 — when `collector_driver` is given (an
70
+ # {Analysis::CheckRules::RuleWalk::CollectorDriver}), the SAME
71
+ # single traversal also drives the built-in {CheckRules} node
72
+ # collectors: each visited node is dispatched both to the plugin
73
+ # rules (this walk's original job) and to the built-in collectors
74
+ # (the `CollectorDriver`), so a file is walked once for both
75
+ # instead of once each. The two dispatch models coexist: plugin
76
+ # rules keep `is_a?` matching via the per-class memo and receive a
77
+ # lazily-built {NodeContext} (ancestors); built-in collectors keep
78
+ # exact-node-class dispatch and receive the immutable
79
+ # {RuleWalk::Context} threaded through the descent. Order is
80
+ # preserved because each side accumulates into its own bucket
81
+ # (per-plugin {Result}s / per-collector `results`) and the two are
82
+ # assembled separately by their respective diagnostic builders.
83
+ # A raising plugin rule isolates only that plugin (per-{State}
84
+ # rescue) and never aborts built-in collection, nor vice versa
85
+ # (the collectors' `visit` is the verbatim legacy gather logic,
86
+ # which does not raise on the corpora).
87
+ def diagnostics_for_file(path:, scope:, root:, collector_driver: nil)
68
88
  return @entries.map { |plugin, _| Result.new(plugin, [], nil) } if root.nil?
69
89
 
70
90
  states = @entries.map { |plugin, rules| State.new(plugin, rules, scope, root) }
71
- walk(path, scope, root, states)
91
+ walk(path, scope, root, states, collector_driver)
72
92
  states.map(&:result)
73
93
  end
74
94
 
75
95
  private
76
96
 
77
- def walk(path, scope, root, states)
78
- Source::NodeWalker.each_with_ancestors(root) do |node, ancestors|
79
- context = nil
80
- states.each do |state|
81
- next if state.failed?
97
+ def walk(path, scope, root, states, collector_driver)
98
+ context = collector_driver ? Analysis::CheckRules::RuleWalk::Context.root : nil
99
+ walk_node(root, [], context, path, scope, states, collector_driver)
100
+ end
101
+
102
+ # The single converged DFS pre-order traversal. Threads both the
103
+ # live `ancestors` stack (for plugin {NodeContext}) and the
104
+ # immutable built-in {RuleWalk::Context} (for the collectors),
105
+ # derived together as the walk descends — the cheap-ancestors
106
+ # option from the ADR-53 B4 design note. Identical pre-order over
107
+ # `compact_child_nodes` to both the legacy
108
+ # `Source::NodeWalker.each_with_ancestors` and `RuleWalk.walk`, so
109
+ # every node is visited in the same order each side saw before.
110
+ def walk_node(node, ancestors, context, path, scope, states, collector_driver)
111
+ return unless node.is_a?(Prism::Node)
112
+
113
+ dispatch_plugins(node, ancestors, path, scope, states)
114
+ collector_driver&.visit(node, context)
115
+
116
+ child_context = collector_driver&.descend(node, context)
117
+ ancestors.push(node)
118
+ node.compact_child_nodes.each do |child|
119
+ walk_node(child, ancestors, child_context, path, scope, states, collector_driver)
120
+ end
121
+ ancestors.pop
122
+ end
82
123
 
83
- matched = state.rules_for(node)
84
- next if matched.empty?
124
+ def dispatch_plugins(node, ancestors, path, scope, states)
125
+ node_context = nil
126
+ states.each do |state|
127
+ next if state.failed?
85
128
 
86
- # One frozen NodeContext per node, built lazily and shared
87
- # across every plugin that matches this node.
88
- context ||= NodeContext.new(ancestors)
89
- state.run_rules(matched, node, scope, path, context)
90
- end
129
+ matched = state.rules_for(node)
130
+ next if matched.empty?
131
+
132
+ # One frozen NodeContext per node, built lazily and shared
133
+ # across every plugin that matches this node.
134
+ node_context ||= NodeContext.new(ancestors)
135
+ state.run_rules(matched, node, scope, path, node_context)
91
136
  end
92
137
  end
93
138
 
@@ -7,7 +7,7 @@ module Rigor
7
7
  # ADR-52 WD1 — the compiled contribution table. Categorises a loaded
8
8
  # plugin set by which per-call contribution paths each plugin
9
9
  # actually implements, AND compiles the declarative gates (method
10
- # names, `block_as_methods` verbs, `owns_receivers`) into frozen
10
+ # names, `block_as_methods` method names, `owns_receivers`) into frozen
11
11
  # lookup structures, so the engine's hot sites discover "no plugin
12
12
  # cares about this call" in O(1) instead of O(plugins × rules) — a
13
13
  # top hotspot on plugin-heavy projects (GitLab's 11 plugins, of
@@ -16,7 +16,7 @@ module Rigor
16
16
  #
17
17
  # Ordering contract: the gates only PRUNE consultations that could
18
18
  # not fire (every pruned rule would have failed its own `methods:` /
19
- # `verbs:` check); the engine still iterates the plugin subsets in
19
+ # `method_names:` check); the engine still iterates the plugin subsets in
20
20
  # registry order and each plugin's rules in declaration order, so
21
21
  # the surviving contributions arrive in exactly the order the
22
22
  # ungated walk produced — diagnostics stay byte-identical. The
@@ -36,7 +36,7 @@ module Rigor
36
36
  def initialize(plugins)
37
37
  compile_memberships(plugins)
38
38
  compile_gates
39
- @block_entries_by_verb = build_block_entries(plugins)
39
+ @block_entries_by_method_name = build_block_entries(plugins)
40
40
  @owns_receivers = plugins.flat_map { |p| manifest_for(p)&.owns_receivers || [] }.uniq.freeze
41
41
  # Per-run ancestry verdict memo, keyed by environment identity
42
42
  # then class name. Mutable inside the frozen index — sound
@@ -88,11 +88,12 @@ module Rigor
88
88
  gate.nil? || gate.include?(method_name)
89
89
  end
90
90
 
91
- # The `Macro::BlockAsMethod` entries whose `verbs` include `verb`,
92
- # in (plugin registration, manifest declaration) order — the same
93
- # first-match order the previous plugins × entries walk visited.
94
- def block_entries_for(verb)
95
- @block_entries_by_verb.fetch(verb, EMPTY_BLOCK_ENTRIES)
91
+ # The `Macro::BlockAsMethod` entries whose `method_names` include
92
+ # `method_name`, in (plugin registration, manifest declaration)
93
+ # order — the same first-match order the previous plugins ×
94
+ # entries walk visited.
95
+ def block_entries_for(method_name)
96
+ @block_entries_by_method_name.fetch(method_name, EMPTY_BLOCK_ENTRIES)
96
97
  end
97
98
 
98
99
  # True when `class_name` equals or inherits from any plugin's
@@ -188,15 +189,15 @@ module Rigor
188
189
  gates.values.reduce(Set.new) { |acc, names| acc.merge(names) }.freeze
189
190
  end
190
191
 
191
- # `verb Symbol → [BlockAsMethod entries]`, insertion-ordered by
192
- # (plugin, declaration). Verbs are Symbol-normalised by
192
+ # `method-name Symbol → [BlockAsMethod entries]`, insertion-ordered
193
+ # by (plugin, declaration). Method names are Symbol-normalised by
193
194
  # `Macro::BlockAsMethod#initialize`.
194
195
  def build_block_entries(plugins)
195
196
  table = {}
196
197
  plugins.each do |plugin|
197
198
  entries = manifest_for(plugin)&.block_as_methods || []
198
199
  entries.each do |entry|
199
- entry.verbs.each { |verb| (table[verb] ||= []) << entry }
200
+ entry.method_names.each { |name| (table[name] ||= []) << entry }
200
201
  end
201
202
  end
202
203
  table.each_value(&:freeze)
@@ -22,6 +22,7 @@ module Rigor
22
22
  :in_source_constants,
23
23
  :discovered_methods,
24
24
  :discovered_def_nodes,
25
+ :discovered_singleton_def_nodes,
25
26
  :discovered_def_sources,
26
27
  :discovered_method_visibilities,
27
28
  :discovered_superclasses,
@@ -46,6 +47,7 @@ module Rigor
46
47
  in_source_constants: EMPTY_TABLE,
47
48
  discovered_methods: EMPTY_TABLE,
48
49
  discovered_def_nodes: EMPTY_TABLE,
50
+ discovered_singleton_def_nodes: EMPTY_TABLE,
49
51
  discovered_def_sources: EMPTY_TABLE,
50
52
  discovered_method_visibilities: EMPTY_TABLE,
51
53
  discovered_superclasses: EMPTY_TABLE,
data/lib/rigor/scope.rb CHANGED
@@ -22,6 +22,7 @@ module Rigor
22
22
  attr_reader :environment, :locals, :fact_store, :self_type,
23
23
  :ivars, :cvars, :globals,
24
24
  :indexed_narrowings, :method_chain_narrowings,
25
+ :declaration_sourced,
25
26
  :source_path, :discovery
26
27
 
27
28
  # ADR-53 Track A — the seed-time discovery tables live on the
@@ -43,6 +44,7 @@ module Rigor
43
44
  def in_source_constants = @discovery.in_source_constants
44
45
  def discovered_methods = @discovery.discovered_methods
45
46
  def discovered_def_nodes = @discovery.discovered_def_nodes
47
+ def discovered_singleton_def_nodes = @discovery.discovered_singleton_def_nodes
46
48
  def discovered_def_sources = @discovery.discovered_def_sources
47
49
  def discovered_method_visibilities = @discovery.discovered_method_visibilities
48
50
  def discovered_superclasses = @discovery.discovered_superclasses
@@ -86,8 +88,19 @@ module Rigor
86
88
  EMPTY_VAR_BINDINGS = {}.freeze
87
89
  EMPTY_INDEXED_NARROWINGS = {}.freeze
88
90
  EMPTY_CHAIN_NARROWINGS = {}.freeze
91
+ # ADR-58 WD1 — the set of variable references whose binding's `nil`
92
+ # constituent is *declaration-sourced*: it arrives only via the
93
+ # class-ivar index seed (a ctor `@x = nil` written in another method),
94
+ # never through a method-local write, narrowing, or parameter. Members
95
+ # are frozen `[kind, name]` pairs (`[:ivar, :@x]`, `[:local, :r]`).
96
+ # `possible-nil-receiver` consults this set and declines to fire when the
97
+ # receiver's optionality is purely declaration-sourced — the working
98
+ # program's cross-method invariant is assumed per the robustness
99
+ # principle. Any flow-live touch (write / narrowing) drops the mark, so
100
+ # the diagnostic keeps firing exactly as before on flow-observed nil.
101
+ EMPTY_DECLARATION_SOURCED = Set.new.freeze
89
102
  private_constant :EMPTY_VAR_BINDINGS, :EMPTY_INDEXED_NARROWINGS,
90
- :EMPTY_CHAIN_NARROWINGS
103
+ :EMPTY_CHAIN_NARROWINGS, :EMPTY_DECLARATION_SOURCED
91
104
 
92
105
  class << self
93
106
  def empty(environment: Environment.default, source_path: nil)
@@ -106,6 +119,7 @@ module Rigor
106
119
  discovery: DiscoveryIndex::EMPTY,
107
120
  indexed_narrowings: EMPTY_INDEXED_NARROWINGS,
108
121
  method_chain_narrowings: EMPTY_CHAIN_NARROWINGS,
122
+ declaration_sourced: EMPTY_DECLARATION_SOURCED,
109
123
  source_path: nil
110
124
  )
111
125
  @environment = environment
@@ -118,6 +132,7 @@ module Rigor
118
132
  @discovery = discovery
119
133
  @indexed_narrowings = indexed_narrowings
120
134
  @method_chain_narrowings = method_chain_narrowings
135
+ @declaration_sourced = declaration_sourced
121
136
  @source_path = source_path
122
137
  freeze
123
138
  end
@@ -141,9 +156,15 @@ module Rigor
141
156
  # narrowing keyed on `(local, :x, :last)` no longer holds.
142
157
  new_indexed_narrowings = drop_indexed_narrowings_for(:local, name)
143
158
  new_chain_narrowings = drop_chain_narrowings_for(:local, name)
159
+ # ADR-58 WD1 — rebinding a local is a flow-live touch: any prior
160
+ # declaration-sourced mark on `name` no longer holds (the new value
161
+ # may carry a method-local nil). `with_declaration_sourced_local`
162
+ # re-establishes the mark afterward when the RHS is a pure copy of a
163
+ # declaration-sourced ivar read; the default is to drop it.
144
164
  rebuild(locals: new_locals, fact_store: new_fact_store,
145
165
  indexed_narrowings: new_indexed_narrowings,
146
- method_chain_narrowings: new_chain_narrowings)
166
+ method_chain_narrowings: new_chain_narrowings,
167
+ declaration_sourced: drop_declaration_sourced_for(:local, name))
147
168
  end
148
169
 
149
170
  def with_fact(fact)
@@ -201,9 +222,46 @@ module Rigor
201
222
  def with_ivar(name, type)
202
223
  new_indexed_narrowings = drop_indexed_narrowings_for(:ivar, name)
203
224
  new_chain_narrowings = drop_chain_narrowings_for(:ivar, name)
225
+ # ADR-58 WD1 — a method-local ivar write or narrowing is flow-live:
226
+ # drop any declaration-sourced mark so subsequent reads of `@name`
227
+ # observe flow-live provenance and fire as before. The seed path uses
228
+ # `seed_declaration_sourced_ivar` to (re-)establish the mark.
204
229
  rebuild(ivars: @ivars.merge(name.to_sym => type).freeze,
205
230
  indexed_narrowings: new_indexed_narrowings,
206
- method_chain_narrowings: new_chain_narrowings)
231
+ method_chain_narrowings: new_chain_narrowings,
232
+ declaration_sourced: drop_declaration_sourced_for(:ivar, name))
233
+ end
234
+
235
+ # ADR-58 WD1 — used by the method-entry seed to mark an ivar whose only
236
+ # provenance is the class-ivar index. Unlike `with_ivar` this binds the
237
+ # type AND records the declaration-sourced mark in one transition.
238
+ def seed_declaration_sourced_ivar(name, type)
239
+ rebuild(ivars: @ivars.merge(name.to_sym => type).freeze,
240
+ declaration_sourced: add_declaration_sourced(:ivar, name))
241
+ end
242
+
243
+ # ADR-58 WD1 — a local assignment `r = @right` whose RHS is a pure read
244
+ # of a declaration-sourced ivar inherits the mark, so the survey's exact
245
+ # rotation/traversal shape (`r = @right; r.key`) does not fire. Binds the
246
+ # type and stamps the local's mark in one transition (the plain
247
+ # `with_local` would have dropped it).
248
+ def with_declaration_sourced_local(name, type)
249
+ written = with_local(name, type)
250
+ written.with_local_declaration_mark(name)
251
+ end
252
+
253
+ # ADR-58 WD1 — re-stamp the local mark on a scope produced by
254
+ # `with_local` (which always drops it). Public so the sibling
255
+ # `with_declaration_sourced_local` can call it across the new
256
+ # post-write receiver without reaching into a private method.
257
+ def with_local_declaration_mark(name)
258
+ rebuild(declaration_sourced: add_declaration_sourced(:local, name))
259
+ end
260
+
261
+ # ADR-58 WD1 — true when `(kind, name)`'s binding optionality is purely
262
+ # declaration-sourced (no flow-live write/narrowing has touched it).
263
+ def declaration_sourced?(kind, name)
264
+ @declaration_sourced.include?([kind.to_sym, name.to_sym])
207
265
  end
208
266
 
209
267
  def with_cvar(name, type)
@@ -214,6 +272,23 @@ module Rigor
214
272
  rebuild(globals: @globals.merge(name.to_sym => type).freeze)
215
273
  end
216
274
 
275
+ # Regex match-data globals (`$~`, `$&`, `$1..$9`, the pre/post-match
276
+ # and last-paren back-references). Narrowed on a successful-`=~` /
277
+ # `case`-`when` match edge (see `Narrowing#regex_match_predicate_scopes`);
278
+ # any subsequent method call could run another match and rebind every
279
+ # one of them, so `eval_call` forgets the narrowed facts here. Always
280
+ # safe — only drops facts, so a subsequent read falls back to the
281
+ # default `String | nil`. Program-level `$GLOBAL = ...` seeds use other
282
+ # names and are untouched.
283
+ MATCH_DATA_GLOBALS = %i[$~ $& $` $' $+ $1 $2 $3 $4 $5 $6 $7 $8 $9].freeze
284
+ private_constant :MATCH_DATA_GLOBALS
285
+
286
+ def forget_match_globals
287
+ return self unless @globals.keys.any? { |k| MATCH_DATA_GLOBALS.include?(k) }
288
+
289
+ rebuild(globals: @globals.except(*MATCH_DATA_GLOBALS).freeze)
290
+ end
291
+
217
292
  # Slice 7 phase 2 — class-level ivar accumulator. Keyed by
218
293
  # the qualified class name (e.g. `"Rigor::Scope"`); the
219
294
  # value is a `Hash[Symbol, Type::t]` of every ivar that
@@ -263,8 +338,9 @@ module Rigor
263
338
  # scope (no enclosing `class` / `module` body). True at the top
264
339
  # of a file AND inside a top-level `def` body (since toplevel
265
340
  # defs leave `self_type` nil per the existing scope-construction
266
- # contract, mirroring how ADR-24's `adoptable_self_call_result?`
267
- # also keys on `self_type.nil?` for the same context). Used by
341
+ # contract the same nil-`self_type` signal ADR-24's self-call
342
+ # return adoption historically keyed on before ADR-57 opened the
343
+ # gate unconditionally). Used by
268
344
  # `CheckRules#unresolved_toplevel_diagnostic` to gate the
269
345
  # `call.unresolved-toplevel` rule so it fires only outside
270
346
  # class / module bodies, where Rails-DSL metaprogramming
@@ -289,6 +365,22 @@ module Rigor
289
365
  node
290
366
  end
291
367
 
368
+ # Module-singleton call resolution (ADR-57 follow-up) — companion of
369
+ # {#user_def_for} for SINGLETON-side defs (`def self.x`, `def Foo.x`,
370
+ # `class << self` bodies, and `module_function` defs). Returns the
371
+ # `Prism::DefNode` for `class_name.method_name` invoked on the
372
+ # module/class constant itself, or nil. The `discovered_def_nodes`
373
+ # table is deliberately instance-side only (its ancestor walk binds
374
+ # `self` as `Nominal`), so singleton bodies live in a parallel table
375
+ # the `ScopeIndexer` populates alongside it. Records the same
376
+ # cross-file dependency edge as the instance path (ADR-46).
377
+ def singleton_def_for(class_name, method_name)
378
+ table = @discovery.discovered_singleton_def_nodes[class_name.to_s]
379
+ node = table && table[method_name.to_sym]
380
+ record_cross_file_method(class_name, method_name, node) if Analysis::DependencyRecorder.active?
381
+ node
382
+ end
383
+
292
384
  # ADR-46 slice 1 — note the cross-file dependency this resolution
293
385
  # creates: the file defining `class_name#method_name` (the consumer's
294
386
  # analysis reads its body via `infer_user_method_return`), or, when
@@ -564,7 +656,8 @@ module Rigor
564
656
  @cvars == other.cvars &&
565
657
  @globals == other.globals &&
566
658
  @indexed_narrowings == other.indexed_narrowings &&
567
- @method_chain_narrowings == other.method_chain_narrowings
659
+ @method_chain_narrowings == other.method_chain_narrowings &&
660
+ @declaration_sourced == other.declaration_sourced
568
661
  end
569
662
  alias eql? ==
570
663
 
@@ -580,6 +673,7 @@ module Rigor
580
673
  discovery: @discovery,
581
674
  indexed_narrowings: @indexed_narrowings,
582
675
  method_chain_narrowings: @method_chain_narrowings,
676
+ declaration_sourced: @declaration_sourced,
583
677
  source_path: @source_path
584
678
  )
585
679
  self.class.new(
@@ -589,6 +683,7 @@ module Rigor
589
683
  discovery: discovery,
590
684
  indexed_narrowings: indexed_narrowings,
591
685
  method_chain_narrowings: method_chain_narrowings,
686
+ declaration_sourced: declaration_sourced,
592
687
  source_path: source_path
593
688
  )
594
689
  end
@@ -621,10 +716,22 @@ module Rigor
621
716
  discovery: @discovery,
622
717
  indexed_narrowings: join_bindings(@indexed_narrowings, other.indexed_narrowings),
623
718
  method_chain_narrowings: join_bindings(@method_chain_narrowings, other.method_chain_narrowings),
719
+ # ADR-58 WD1 — a ref is declaration-sourced after a join only when
720
+ # BOTH branches agree it is. If either path made the binding
721
+ # flow-live (a method-local nil write / failed-guard narrowing), the
722
+ # merge is flow-live and `possible-nil-receiver` fires as before.
723
+ declaration_sourced: join_declaration_sourced(other),
624
724
  source_path: source_path
625
725
  )
626
726
  end
627
727
 
728
+ def join_declaration_sourced(other)
729
+ return @declaration_sourced if @declaration_sourced.equal?(other.declaration_sourced)
730
+ return EMPTY_DECLARATION_SOURCED if @declaration_sourced.empty? || other.declaration_sourced.empty?
731
+
732
+ (@declaration_sourced & other.declaration_sourced).freeze
733
+ end
734
+
628
735
  def indexed_key(receiver_kind, receiver_name, key)
629
736
  IndexedKey.new(
630
737
  receiver_kind: receiver_kind.to_sym,
@@ -652,6 +759,25 @@ module Rigor
652
759
  filtered.size == @indexed_narrowings.size ? @indexed_narrowings : filtered.freeze
653
760
  end
654
761
 
762
+ # ADR-58 WD1 — set/clear the declaration-sourced provenance mark.
763
+ def add_declaration_sourced(kind, name)
764
+ ref = [kind.to_sym, name.to_sym]
765
+ return @declaration_sourced if @declaration_sourced.include?(ref)
766
+
767
+ (@declaration_sourced.dup << ref).freeze
768
+ end
769
+
770
+ def drop_declaration_sourced_for(kind, name)
771
+ return @declaration_sourced if @declaration_sourced.empty?
772
+
773
+ ref = [kind.to_sym, name.to_sym]
774
+ return @declaration_sourced unless @declaration_sourced.include?(ref)
775
+
776
+ dropped = @declaration_sourced.dup
777
+ dropped.delete(ref)
778
+ dropped.freeze
779
+ end
780
+
655
781
  def drop_chain_narrowings_for(receiver_kind, receiver_name)
656
782
  return @method_chain_narrowings if @method_chain_narrowings.empty?
657
783
 
@@ -118,7 +118,15 @@ module Rigor
118
118
  @class_shells = Set.new
119
119
  defs = collect_method_definitions(parse_result.value)
120
120
  candidates_from_defs = defs.filter_map do |def_node, class_name, kind|
121
+ # An analyzer bug typing one def's body must cost only that
122
+ # def's candidate, never the whole `rigor sig-gen` run. The
123
+ # `check` path recovers each *file* this way
124
+ # (worker_session.rb); sig-gen recovers per-def so the rest of
125
+ # the file's candidates still emit.
126
+
121
127
  classify_def(path, def_node, class_name, kind, scope_index)
128
+ rescue StandardError
129
+ nil
122
130
  end
123
131
  obs_ivar_map = build_observed_ivar_map(parse_result.value)
124
132
  candidates_from_defs + collect_attr_candidates(parse_result.value, path, scope_index, obs_ivar_map)
@@ -304,26 +304,11 @@ module Rigor
304
304
  m && [m[1], m[2]]
305
305
  end
306
306
 
307
- # Normalises a message receiver token to a class name.
308
- # Integer / string / symbol literals fold to their class;
309
- # `Foo[...]` keeps the `Array[...]` form (H4 needs it);
310
- # `singleton(Foo)` and bare `Foo` fold to `Foo`.
307
+ # Normalises a message receiver token to a class name. The fold
308
+ # logic is shared with the selector axis see
309
+ # {Triage.normalize_receiver}.
311
310
  def receiver_class(token)
312
- t = token.strip
313
- return "Integer" if t.match?(/\A-?\d+\z/)
314
- return "Float" if t.match?(/\A-?\d+\.\d+\z/)
315
- return "String" if t.start_with?('"', "'")
316
- return "Symbol" if t.start_with?(":")
317
-
318
- singleton = t[/\Asingleton\(([\w:]+)\)\z/, 1]
319
- return singleton if singleton
320
- return t if t.start_with?("Array[")
321
-
322
- nominal = t[/\A([\w:]+)\[/, 1]
323
- return nominal if nominal
324
- return t if t.match?(/\A[\w:]+\z/)
325
-
326
- nil
311
+ Triage.normalize_receiver(token)
327
312
  end
328
313
 
329
314
  def activesupport?(receiver, method)
data/lib/rigor/triage.rb CHANGED
@@ -18,7 +18,15 @@ module Rigor
18
18
  Summary = Data.define(:total, :error, :warning, :info)
19
19
  RuleCount = Data.define(:rule, :count)
20
20
  Hotspot = Data.define(:file, :count, :by_rule)
21
- Report = Data.define(:summary, :distribution, :hotspots, :hints)
21
+ # A (receiver-class, method) dispatch target the diagnostics
22
+ # cluster on, built from the structured `Diagnostic#receiver_type`
23
+ # / `#method_name` fields — never from message-string parsing.
24
+ # `receiver` is nil for method-only diagnostics (a toplevel call,
25
+ # a `def`-side return / override finding that has no call
26
+ # receiver); `files` is the distinct-file count (a systemic vs.
27
+ # localised signal); `rules` is the per-rule breakdown.
28
+ Selector = Data.define(:receiver, :method_name, :count, :files, :rules)
29
+ Report = Data.define(:summary, :distribution, :selectors, :hotspots, :hints)
22
30
 
23
31
  module_function
24
32
 
@@ -30,6 +38,7 @@ module Rigor
30
38
  Report.new(
31
39
  summary: build_summary(diagnostics),
32
40
  distribution: build_distribution(diagnostics),
41
+ selectors: build_selectors(diagnostics),
33
42
  hotspots: build_hotspots(diagnostics, top),
34
43
  hints: hints ? Catalogue.recognise(diagnostics) : []
35
44
  )
@@ -57,6 +66,61 @@ module Rigor
57
66
  .sort_by { |row| [-row.count, row.rule] }
58
67
  end
59
68
 
69
+ # The class/method aggregation axis (ADR-23 follow-up). Groups
70
+ # every diagnostic that carries a `method_name` by its
71
+ # `(receiver_type, method_name)` pair so a consumer can answer
72
+ # "which method / class concentrates the diagnostics?" with a
73
+ # `jq` query over the JSON instead of parsing message text.
74
+ # Method-only diagnostics (nil `receiver_type`) keep a null
75
+ # `receiver` and still group by method. The full list is
76
+ # returned uncapped — the JSON is the agent-facing surface; the
77
+ # text renderer caps its own rows.
78
+ def build_selectors(diagnostics)
79
+ diagnostics.select(&:method_name)
80
+ .group_by { |d| [normalize_receiver(d.receiver_type) || d.receiver_type, d.method_name.to_s] }
81
+ .map { |(receiver, method), group| selector_for(receiver, method, group) }
82
+ .sort_by { |s| [-s.count, s.receiver.to_s, s.method_name] }
83
+ end
84
+
85
+ # Folds a receiver token — a `Diagnostic#receiver_type` display
86
+ # string or a message-parsed token — to the class the diagnostics
87
+ # should bucket under, so the selector axis does not fragment one
88
+ # method across every distinct literal receiver. String / integer
89
+ # / float / symbol literals collapse to their class; `singleton(C)`
90
+ # and a bare `C` fold to `C`; a generic `C[...]` keeps the
91
+ # `Array[String]` element form (the AR-relation heuristic keys on
92
+ # it). Returns nil for a token it cannot reduce to a class (a
93
+ # union display, an inferred shape) — the caller keeps the raw
94
+ # string then, never losing the row. Shared with {Catalogue}.
95
+ def normalize_receiver(token)
96
+ return nil if token.nil?
97
+
98
+ t = token.to_s.strip
99
+ return "Integer" if t.match?(/\A-?\d+\z/)
100
+ return "Float" if t.match?(/\A-?\d+\.\d+\z/)
101
+ return "String" if t.start_with?('"', "'")
102
+ return "Symbol" if t.start_with?(":")
103
+
104
+ singleton = t[/\Asingleton\(([\w:]+)\)\z/, 1]
105
+ return singleton if singleton
106
+ return t if t.start_with?("Array[")
107
+
108
+ nominal = t[/\A([\w:]+)\[/, 1]
109
+ return nominal if nominal
110
+ return t if t.match?(/\A[\w:]+\z/)
111
+
112
+ nil
113
+ end
114
+
115
+ def selector_for(receiver, method, group)
116
+ rules = group.group_by { |d| rule_key(d) }
117
+ .transform_values(&:size)
118
+ .sort_by { |rule, count| [-count, rule] }
119
+ .to_h
120
+ Selector.new(receiver: receiver, method_name: method, count: group.size,
121
+ files: group.map(&:path).uniq.size, rules: rules)
122
+ end
123
+
60
124
  def build_hotspots(diagnostics, top)
61
125
  diagnostics.group_by(&:path)
62
126
  .map { |path, group| hotspot_for(path, group) }
@@ -79,6 +143,10 @@ module Rigor
79
143
  "warning" => report.summary.warning, "info" => report.summary.info
80
144
  },
81
145
  "distribution" => report.distribution.map { |r| { "rule" => r.rule, "count" => r.count } },
146
+ "selectors" => report.selectors.map do |s|
147
+ { "receiver" => s.receiver, "method" => s.method_name, "count" => s.count,
148
+ "files" => s.files, "rules" => s.rules }
149
+ end,
82
150
  "hotspots" => report.hotspots.map do |h|
83
151
  { "file" => h.file, "count" => h.count, "by_rule" => h.by_rule }
84
152
  end,