rigortype 0.1.7 → 0.1.9

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +186 -513
  3. data/lib/rigor/analysis/check_rules.rb +23 -1
  4. data/lib/rigor/analysis/diagnostic.rb +17 -3
  5. data/lib/rigor/analysis/runner.rb +178 -3
  6. data/lib/rigor/analysis/worker_session.rb +14 -3
  7. data/lib/rigor/cli/annotate_command.rb +224 -0
  8. data/lib/rigor/cli/baseline_command.rb +36 -16
  9. data/lib/rigor/cli/prism_colorizer.rb +111 -0
  10. data/lib/rigor/cli/triage_command.rb +83 -0
  11. data/lib/rigor/cli/triage_renderer.rb +77 -0
  12. data/lib/rigor/cli.rb +71 -5
  13. data/lib/rigor/environment.rb +9 -1
  14. data/lib/rigor/inference/builtins/method_catalog.rb +17 -1
  15. data/lib/rigor/inference/builtins/time_catalog.rb +10 -1
  16. data/lib/rigor/inference/expression_typer.rb +300 -18
  17. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +109 -0
  18. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +173 -10
  19. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +53 -1
  20. data/lib/rigor/inference/method_dispatcher/math_folding.rb +149 -0
  21. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +20 -1
  22. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +33 -8
  23. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +81 -0
  24. data/lib/rigor/inference/method_dispatcher/set_folding.rb +81 -0
  25. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +316 -2
  26. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +126 -0
  27. data/lib/rigor/inference/method_dispatcher/time_folding.rb +56 -0
  28. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +67 -0
  29. data/lib/rigor/inference/method_dispatcher.rb +179 -4
  30. data/lib/rigor/inference/method_parameter_binder.rb +67 -10
  31. data/lib/rigor/inference/narrowing.rb +29 -10
  32. data/lib/rigor/inference/scope_indexer.rb +156 -6
  33. data/lib/rigor/inference/statement_evaluator.rb +43 -21
  34. data/lib/rigor/plugin/base.rb +39 -0
  35. data/lib/rigor/plugin/loader.rb +22 -1
  36. data/lib/rigor/plugin/manifest.rb +73 -10
  37. data/lib/rigor/plugin/protocol_contract.rb +185 -0
  38. data/lib/rigor/plugin/registry.rb +66 -0
  39. data/lib/rigor/scope.rb +46 -0
  40. data/lib/rigor/triage/catalogue.rb +296 -0
  41. data/lib/rigor/triage/hint.rb +27 -0
  42. data/lib/rigor/triage.rb +89 -0
  43. data/lib/rigor/type/constant.rb +29 -2
  44. data/lib/rigor/version.rb +1 -1
  45. data/sig/rigor/inference.rbs +1 -0
  46. data/sig/rigor/scope.rbs +6 -0
  47. metadata +16 -1
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ # ADR-28 declaration: "every instance/singleton method named
6
+ # `method_name`, defined in a source file matching `path_glob`,
7
+ # is implicitly required to satisfy the declared parameter +
8
+ # return-type protocol."
9
+ #
10
+ # Authored on a plugin manifest:
11
+ #
12
+ # manifest(
13
+ # id: "web",
14
+ # version: "0.1.0",
15
+ # protocol_contracts: [
16
+ # Rigor::Plugin::ProtocolContract.new(
17
+ # path_glob: "lib/controller/**/*.rb",
18
+ # method_name: :get,
19
+ # param_types: [{ index: 0, type_name: "Rack::Request" }],
20
+ # return_type_name: "Rack::Response"
21
+ # )
22
+ # ]
23
+ # )
24
+ #
25
+ # The contract drives two distinct engine behaviours (ADR-28
26
+ # § "provide-and-check"):
27
+ #
28
+ # - **provide** — when the inference engine binds the parameter
29
+ # list of a matching `def`, {Rigor::Inference::MethodParameterBinder}
30
+ # substitutes the declared `param_types` for the usual
31
+ # `Dynamic[Top]` fallback, so the method body is analysed as
32
+ # if the parameter carried its protocol type.
33
+ # - **check** — the contributing plugin's `#diagnostics_for_file`
34
+ # hook confirms the method exists and its inferred return type
35
+ # conforms to `return_type_name`.
36
+ #
37
+ # ## Fields
38
+ #
39
+ # - `path_glob` — `File.fnmatch` glob (String) selecting the
40
+ # source files the contract applies to, relative to the
41
+ # analysed project root (e.g. `"lib/controller/**/*.rb"`).
42
+ # - `method_name` — Symbol; the instance (or singleton) method
43
+ # the contract constrains.
44
+ # - `singleton` — Boolean; `true` constrains `def self.<name>`,
45
+ # `false` (default) constrains instance methods.
46
+ # - `param_types` — Array of `ParamType` (positional index →
47
+ # fully-qualified type name). The type names resolve against
48
+ # the analysed project's environment lazily, at consumption
49
+ # time, so the contract value object stays independent of
50
+ # environment construction order.
51
+ # - `return_type_name` — fully-qualified type name (String) the
52
+ # method's inferred return type must conform to.
53
+ # - `severity` — Symbol diagnostic severity for contract
54
+ # violations (`:error` default).
55
+ #
56
+ # ## Ractor-shareability
57
+ #
58
+ # Every field is frozen at construction (ADR-15 Phase 1); the
59
+ # nested `ParamType` is a frozen `Data`. `Ractor.shareable?`
60
+ # returns true after `#initialize`, so the contract survives
61
+ # `Plugin::Registry.materialize` into a worker Ractor.
62
+ class ProtocolContract
63
+ VALID_SEVERITIES = %i[error warning info].freeze
64
+
65
+ # One positional-parameter provision: the zero-based index of
66
+ # the parameter and the fully-qualified name of the type it
67
+ # carries under the protocol.
68
+ ParamType = Data.define(:index, :type_name)
69
+
70
+ attr_reader :path_glob, :method_name, :singleton, :param_types, :return_type_name, :severity
71
+
72
+ def initialize(path_glob:, method_name:, return_type_name: nil, param_types: [], singleton: false,
73
+ severity: :error)
74
+ validate_path_glob!(path_glob)
75
+ validate_method_name!(method_name)
76
+ validate_return_type_name!(return_type_name)
77
+ validate_severity!(severity)
78
+
79
+ @path_glob = path_glob.dup.freeze
80
+ @method_name = method_name.to_sym
81
+ @singleton = singleton ? true : false
82
+ @param_types = coerce_param_types(param_types)
83
+ @return_type_name = return_type_name.nil? ? nil : return_type_name.dup.freeze
84
+ @severity = severity.to_sym
85
+ freeze
86
+ end
87
+
88
+ # Returns a copy with `path_glob` replaced. Plugins use this to
89
+ # honour a per-project config override of the convention path
90
+ # without rebuilding the whole contract by hand.
91
+ def with_path_glob(glob)
92
+ ProtocolContract.new(
93
+ path_glob: glob,
94
+ method_name: method_name,
95
+ return_type_name: return_type_name,
96
+ param_types: param_types.map { |pt| { index: pt.index, type_name: pt.type_name } },
97
+ singleton: singleton,
98
+ severity: severity
99
+ )
100
+ end
101
+
102
+ def to_h
103
+ {
104
+ "path_glob" => path_glob,
105
+ "method_name" => method_name.to_s,
106
+ "singleton" => singleton,
107
+ "param_types" => param_types.map { |pt| { "index" => pt.index, "type_name" => pt.type_name } },
108
+ "return_type_name" => return_type_name,
109
+ "severity" => severity.to_s
110
+ }
111
+ end
112
+
113
+ def ==(other)
114
+ other.is_a?(ProtocolContract) && to_h == other.to_h
115
+ end
116
+ alias eql? ==
117
+
118
+ def hash
119
+ to_h.hash
120
+ end
121
+
122
+ private
123
+
124
+ def validate_path_glob!(value)
125
+ return if value.is_a?(String) && !value.empty?
126
+
127
+ raise ArgumentError,
128
+ "Plugin::ProtocolContract#path_glob must be a non-empty String, got #{value.inspect}"
129
+ end
130
+
131
+ def validate_method_name!(value)
132
+ return if value.is_a?(Symbol) || (value.is_a?(String) && !value.empty?)
133
+
134
+ raise ArgumentError,
135
+ "Plugin::ProtocolContract#method_name must be a Symbol/non-empty String, got #{value.inspect}"
136
+ end
137
+
138
+ def validate_return_type_name!(value)
139
+ return if value.nil?
140
+ return if value.is_a?(String) && !value.empty?
141
+
142
+ raise ArgumentError,
143
+ "Plugin::ProtocolContract#return_type_name must be a non-empty String or nil, got #{value.inspect}"
144
+ end
145
+
146
+ def validate_severity!(value)
147
+ return if VALID_SEVERITIES.include?(value.to_sym)
148
+
149
+ raise ArgumentError,
150
+ "Plugin::ProtocolContract#severity must be one of #{VALID_SEVERITIES.inspect}, got #{value.inspect}"
151
+ rescue NoMethodError
152
+ raise ArgumentError,
153
+ "Plugin::ProtocolContract#severity must be one of #{VALID_SEVERITIES.inspect}, got #{value.inspect}"
154
+ end
155
+
156
+ def coerce_param_types(param_types)
157
+ unless param_types.is_a?(Array)
158
+ raise ArgumentError,
159
+ "Plugin::ProtocolContract#param_types must be an Array, got #{param_types.inspect}"
160
+ end
161
+
162
+ param_types.map { |entry| coerce_param_type(entry) }.freeze
163
+ end
164
+
165
+ def coerce_param_type(entry)
166
+ return entry if entry.is_a?(ParamType)
167
+
168
+ unless entry.is_a?(Hash)
169
+ raise ArgumentError,
170
+ "Plugin::ProtocolContract param_types entry must be a Hash or ParamType, got #{entry.inspect}"
171
+ end
172
+
173
+ index = entry[:index] || entry["index"]
174
+ type_name = entry[:type_name] || entry["type_name"]
175
+ unless index.is_a?(Integer) && index >= 0 && type_name.is_a?(String) && !type_name.empty?
176
+ raise ArgumentError,
177
+ "Plugin::ProtocolContract param_types entry needs an Integer index >= 0 and a " \
178
+ "non-empty String type_name, got #{entry.inspect}"
179
+ end
180
+
181
+ ParamType.new(index: index, type_name: type_name.dup.freeze)
182
+ end
183
+ end
184
+ end
185
+ end
@@ -104,7 +104,73 @@ module Rigor
104
104
  Inference::HktRegistry.new(registrations: registrations, definitions: definitions)
105
105
  end
106
106
 
107
+ # ADR-25 — flat, ordered list of every loaded plugin's
108
+ # resolved RBS signature directories (absolute paths), in
109
+ # plugin registration order. `Environment.for_project`
110
+ # merges these into the signature-path set fed to
111
+ # `RbsLoader`, alongside the configuration's `signature_paths:`
112
+ # and the `bundler:` / `rbs_collection:` discovery output.
113
+ def signature_paths
114
+ plugins.flat_map(&:signature_paths)
115
+ end
116
+
117
+ # ADR-26 — the aggregate set of "open" receiver class names
118
+ # declared across loaded plugins (manifest `open_receivers:`).
119
+ # A class is open when a plugin vouches that it responds
120
+ # beyond its RBS-declared method surface. `open_receiver?`
121
+ # is the membership predicate `Analysis::CheckRules` consults
122
+ # to skip the `call.undefined-method` rule for such a class.
123
+ def open_receivers
124
+ plugins.flat_map { |plugin| plugin.manifest.open_receivers }
125
+ end
126
+
127
+ def open_receiver?(class_name)
128
+ return false if class_name.nil?
129
+
130
+ open_receivers.include?(class_name.to_s)
131
+ end
132
+
133
+ # ADR-28 — flat, ordered list of every loaded plugin's
134
+ # path-scoped method-protocol contracts, in plugin
135
+ # registration order. Read from each plugin's
136
+ # `#protocol_contracts` (which the manifest backs by default
137
+ # but a plugin MAY override to fold in per-project config).
138
+ # Consumed by `Inference::MethodParameterBinder` (the
139
+ # parameter-type provision) and by contributing plugins'
140
+ # `#diagnostics_for_file` hooks (the presence + return-type
141
+ # check).
142
+ def protocol_contracts
143
+ plugins.flat_map(&:protocol_contracts)
144
+ end
145
+
146
+ # ADR-28 — the subset of `protocol_contracts` whose
147
+ # `path_glob` matches `path`. Contract globs are authored
148
+ # project-root-relative (`lib/controller/**/*.rb`); the
149
+ # analyzer may hand this method either a project-relative
150
+ # path (`rigor check` run from the project root) or an
151
+ # absolute one (run from elsewhere, or a spec tmpdir), so the
152
+ # glob is matched both directly and as a `**/`-prefixed path
153
+ # suffix. `File::FNM_PATHNAME` keeps `*` from crossing `/`;
154
+ # `File::FNM_EXTGLOB` enables `{a,b}` groups. Returns `[]` for
155
+ # a nil path so the binder can call this unconditionally.
156
+ def contracts_for_path(path)
157
+ return [] if path.nil?
158
+
159
+ path_s = path.to_s
160
+ protocol_contracts.select { |contract| path_matches_glob?(contract.path_glob, path_s) }
161
+ end
162
+
163
+ FNMATCH_FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB
164
+ private_constant :FNMATCH_FLAGS
165
+
107
166
  EMPTY = new.freeze
167
+
168
+ private
169
+
170
+ def path_matches_glob?(glob, path)
171
+ File.fnmatch?(glob, path, FNMATCH_FLAGS) ||
172
+ File.fnmatch?(File.join("**", glob), path, FNMATCH_FLAGS)
173
+ end
108
174
  end
109
175
  end
110
176
  end
data/lib/rigor/scope.rb CHANGED
@@ -21,6 +21,7 @@ module Rigor
21
21
  :class_ivars, :class_cvars, :program_globals,
22
22
  :discovered_classes, :in_source_constants, :discovered_methods,
23
23
  :discovered_def_nodes, :discovered_method_visibilities,
24
+ :discovered_superclasses, :discovered_includes,
24
25
  :source_path
25
26
 
26
27
  EMPTY_DECLARED_TYPES = {}.compare_by_identity.freeze
@@ -51,6 +52,8 @@ module Rigor
51
52
  discovered_methods: EMPTY_CLASS_BINDINGS,
52
53
  discovered_def_nodes: EMPTY_CLASS_BINDINGS,
53
54
  discovered_method_visibilities: EMPTY_CLASS_BINDINGS,
55
+ discovered_superclasses: EMPTY_CLASS_BINDINGS,
56
+ discovered_includes: EMPTY_CLASS_BINDINGS,
54
57
  source_path: nil
55
58
  )
56
59
  @environment = environment
@@ -69,6 +72,8 @@ module Rigor
69
72
  @discovered_methods = discovered_methods
70
73
  @discovered_def_nodes = discovered_def_nodes
71
74
  @discovered_method_visibilities = discovered_method_visibilities
75
+ @discovered_superclasses = discovered_superclasses
76
+ @discovered_includes = discovered_includes
72
77
  @source_path = source_path
73
78
  freeze
74
79
  end
@@ -284,6 +289,41 @@ module Rigor
284
289
  rebuild(discovered_def_nodes: table)
285
290
  end
286
291
 
292
+ # ADR-24 slice 2 — per-class table mapping a fully
293
+ # qualified user-class name to its superclass name AS
294
+ # WRITTEN at the `class Foo < Bar` declaration (`"Bar"`,
295
+ # possibly a qualified `"A::B"`). Populated by `ScopeIndexer`
296
+ # — per-file plus the cross-file project pre-pass — and
297
+ # consumed by `ExpressionTyper#try_user_method_inference`
298
+ # to walk the superclass chain when an implicit-self call
299
+ # does not resolve against the enclosing class's own defs.
300
+ # The as-written name is resolved to a qualified class at
301
+ # walk time against the call's lexical nesting.
302
+ def superclass_of(class_name)
303
+ @discovered_superclasses[class_name.to_s]
304
+ end
305
+
306
+ def with_discovered_superclasses(table)
307
+ rebuild(discovered_superclasses: table)
308
+ end
309
+
310
+ # ADR-24 slice 2 — per-class/module table mapping a fully
311
+ # qualified user class or module to the list of module
312
+ # names it `include`s / `prepend`s, AS WRITTEN at the
313
+ # mixin call. Populated by `ScopeIndexer` (per-file plus
314
+ # the cross-file pre-pass) and consumed by
315
+ # `ExpressionTyper#resolve_user_def_through_ancestors` so an
316
+ # implicit-self call resolves against an included module's
317
+ # `def`s, not just the superclass chain. As-written names
318
+ # are resolved to qualified classes at walk time.
319
+ def includes_of(class_name)
320
+ @discovered_includes[class_name.to_s] || []
321
+ end
322
+
323
+ def with_discovered_includes(table)
324
+ rebuild(discovered_includes: table)
325
+ end
326
+
287
327
  # v0.1.2 — per-class table mapping `method_name (Symbol) →
288
328
  # :public | :private | :protected`. Populated by
289
329
  # `ScopeIndexer` for every `def` it sees inside a class
@@ -372,6 +412,8 @@ module Rigor
372
412
  discovered_classes: @discovered_classes, in_source_constants: @in_source_constants,
373
413
  discovered_methods: @discovered_methods, discovered_def_nodes: @discovered_def_nodes,
374
414
  discovered_method_visibilities: @discovered_method_visibilities,
415
+ discovered_superclasses: @discovered_superclasses,
416
+ discovered_includes: @discovered_includes,
375
417
  source_path: @source_path
376
418
  )
377
419
  self.class.new(
@@ -386,6 +428,8 @@ module Rigor
386
428
  discovered_methods: discovered_methods,
387
429
  discovered_def_nodes: discovered_def_nodes,
388
430
  discovered_method_visibilities: discovered_method_visibilities,
431
+ discovered_superclasses: discovered_superclasses,
432
+ discovered_includes: discovered_includes,
389
433
  source_path: source_path
390
434
  )
391
435
  end
@@ -413,6 +457,8 @@ module Rigor
413
457
  discovered_methods: discovered_methods,
414
458
  discovered_def_nodes: discovered_def_nodes,
415
459
  discovered_method_visibilities: discovered_method_visibilities,
460
+ discovered_superclasses: discovered_superclasses,
461
+ discovered_includes: discovered_includes,
416
462
  source_path: source_path
417
463
  )
418
464
  end
@@ -0,0 +1,296 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "hint"
4
+
5
+ module Rigor
6
+ module Triage
7
+ # ADR-23 § "Heuristic catalogue" — the six v1 recognisers.
8
+ #
9
+ # {.recognise} runs them in order over the diagnostic stream.
10
+ # Each recogniser sees only the diagnostics not yet claimed by
11
+ # an earlier one, so a `5.minutes` diagnostic counted by H1
12
+ # (ActiveSupport) is not re-counted by H2 (monkey-patch).
13
+ #
14
+ # WD3 / slice 4: recognisers key on the structured
15
+ # `qualified_rule` first; where they additionally need the
16
+ # receiver type or method name they read the structured
17
+ # `Diagnostic#receiver_type` / `#method_name` fields, falling
18
+ # back to parsing the diagnostic message only when those are
19
+ # absent. A parse failure degrades to "skip this diagnostic" —
20
+ # never a crash.
21
+ module Catalogue # rubocop:disable Metrics/ModuleLength
22
+ module_function
23
+
24
+ UNDEFINED_METHOD_RULE = "call.undefined-method"
25
+
26
+ # `undefined method `foo' for <receiver>`
27
+ UNDEF_METHOD = /\Aundefined method [`'"]([^`'"]+)['"`] for (.+)\z/
28
+
29
+ # ActiveSupport `core_ext` selectors, grouped by the core
30
+ # class they extend. Survey-grounded (the dominant clusters
31
+ # from the five-project survey + the Mastodon measurement).
32
+ AS_NUMERIC = %w[
33
+ day days hour hours minute minutes second seconds week weeks
34
+ fortnight fortnights month months year years
35
+ byte bytes kilobyte kilobytes megabyte megabytes gigabyte gigabytes
36
+ terabyte terabytes petabyte petabytes exabyte exabytes
37
+ ago since from_now in_milliseconds
38
+ ].freeze
39
+ AS_STRING = %w[
40
+ squish squish! strip_heredoc html_safe underscore camelize camelcase
41
+ pluralize singularize titleize titlecase humanize dasherize
42
+ parameterize tableize classify constantize safe_constantize
43
+ demodulize deconstantize foreign_key indent indent! truncate
44
+ truncate_words to_datetime to_date to_time exclude? at from
45
+ remove remove! mb_chars upcase_first downcase_first
46
+ ].freeze
47
+ AS_HASH = %w[
48
+ deep_dup deep_merge deep_merge! symbolize_keys symbolize_keys!
49
+ stringify_keys stringify_keys! deep_symbolize_keys deep_stringify_keys
50
+ deep_transform_keys deep_transform_keys! deep_transform_values
51
+ except! with_indifferent_access assert_valid_keys
52
+ reverse_merge reverse_merge! extract!
53
+ ].freeze
54
+ AS_ARRAY = %w[
55
+ to_sentence in_groups_of in_groups second third fourth fifth
56
+ forty_two extract_options! wrap deep_dup
57
+ ].freeze
58
+ AS_TIMEDATE = %w[
59
+ zone current beginning_of_day end_of_day beginning_of_week
60
+ end_of_week beginning_of_month end_of_month beginning_of_year
61
+ end_of_year next_week prev_week next_month prev_month
62
+ tomorrow yesterday all_day all_week all_month advance
63
+ ago since change to_fs
64
+ ].freeze
65
+ AS_BY_CLASS = {
66
+ "Integer" => AS_NUMERIC, "Float" => AS_NUMERIC, "Numeric" => AS_NUMERIC,
67
+ "String" => AS_STRING, "Symbol" => AS_STRING,
68
+ "Hash" => AS_HASH, "Array" => AS_ARRAY,
69
+ "Time" => AS_TIMEDATE, "Date" => AS_TIMEDATE,
70
+ "DateTime" => AS_TIMEDATE, "ActiveSupport::TimeWithZone" => AS_TIMEDATE
71
+ }.freeze
72
+ private_constant :AS_NUMERIC, :AS_STRING, :AS_HASH, :AS_ARRAY, :AS_TIMEDATE
73
+
74
+ # ActiveRecord query-builder methods. When flagged on an
75
+ # `Array[...]` receiver they signal a relation misinference.
76
+ AR_QUERY_METHODS = %w[
77
+ where joins includes preload eager_load references select
78
+ order reorder distinct group having limit offset pluck
79
+ find_by find_each find_in_batches in_batches none rewhere
80
+ unscope merge except_query extending
81
+ ].freeze
82
+ private_constant :AR_QUERY_METHODS
83
+
84
+ SYSTEMIC_THRESHOLD = 8 # (file, rule) count → "systemic"
85
+ MONKEY_PATCH_MIN_FILES = 3 # same (method, receiver) across N files
86
+ GENUINE_BUG_MAX_COUNT = 5 # rule total ≤ N → "likely genuine bug"
87
+ private_constant :SYSTEMIC_THRESHOLD, :MONKEY_PATCH_MIN_FILES, :GENUINE_BUG_MAX_COUNT
88
+
89
+ # @param diagnostics [Array<Analysis::Diagnostic>]
90
+ # @return [Array<Hint>]
91
+ def recognise(diagnostics)
92
+ claimed = {}.compare_by_identity
93
+ recognisers.filter_map do |recogniser|
94
+ pool = diagnostics.reject { |d| claimed[d] }
95
+ hint, matched = send(recogniser, pool)
96
+ next unless hint
97
+
98
+ matched.each { |d| claimed[d] = true }
99
+ hint
100
+ end
101
+ end
102
+
103
+ # H4 (ActiveRecord query methods) runs before H2 (generic
104
+ # monkey-patch): a known AR method on `Array[...]` deserves
105
+ # the precise relation-misinference hint, not the generic
106
+ # "project core-ext" guess H2 would otherwise claim it for.
107
+ def recognisers
108
+ %i[h1_activesupport h4_ar_relation h3_gem_without_rbs
109
+ h2_monkey_patch h5_systemic_cluster h6_genuine_bugs]
110
+ end
111
+
112
+ # --- H1 — likely ActiveSupport core_ext --------------------
113
+ def h1_activesupport(pool)
114
+ matched = pool.select do |d|
115
+ parsed = parse_undefined_method(d)
116
+ parsed && activesupport?(parsed[:receiver], parsed[:method])
117
+ end
118
+ return nil if matched.empty?
119
+
120
+ [Hint.new(
121
+ id: "activesupport-core-ext", confidence: :likely,
122
+ diagnostic_count: matched.size,
123
+ summary: "undefined-method on core classes (#{top_methods(matched)}) — " \
124
+ "ActiveSupport monkey-patches these",
125
+ action: "Add rigor-activesupport-core-ext to `plugins:` in .rigor.yml " \
126
+ "(it is an RBS-bundle plugin — ADR-25)."
127
+ ), matched]
128
+ end
129
+
130
+ # --- H2 — likely a project monkey-patch / refinement -------
131
+ def h2_monkey_patch(pool)
132
+ groups = undefined_method_groups(pool).select do |(_method, _recv), diags|
133
+ diags.map(&:path).uniq.size >= MONKEY_PATCH_MIN_FILES
134
+ end
135
+ return nil if groups.empty?
136
+
137
+ matched = groups.values.flatten(1)
138
+ [Hint.new(
139
+ id: "project-monkey-patch", confidence: :possible,
140
+ diagnostic_count: matched.size,
141
+ summary: "same method undefined across many files " \
142
+ "(#{describe_groups(groups)}) — likely a project core-ext / refinement",
143
+ action: "Register the defining file via `pre_eval:` (ADR-17), " \
144
+ "or add an RBS overlay for the method."
145
+ ), matched]
146
+ end
147
+
148
+ # --- H3 — gem ships no RBS ---------------------------------
149
+ def h3_gem_without_rbs(pool)
150
+ notice = pool.find { |d| d.message.match?(/gem\(s\).*have no RBS available/) }
151
+ return nil unless notice
152
+
153
+ count = notice.message[/\A(\d+) gem/, 1] || "some"
154
+ [Hint.new(
155
+ id: "gem-without-rbs", confidence: :likely, diagnostic_count: 1,
156
+ summary: "#{count} Gemfile.lock gem(s) ship no RBS — undefined-method " \
157
+ "diagnostics on their classes are expected, not bugs",
158
+ action: "`rbs collection install`, ship `sig/` in the gem, or opt the " \
159
+ "gem into `dependencies.source_inference:` (ADR-10)."
160
+ ), [notice]]
161
+ end
162
+
163
+ # --- H4 — possible ActiveRecord relation misinference ------
164
+ def h4_ar_relation(pool)
165
+ matched = pool.select do |d|
166
+ parsed = parse_undefined_method(d)
167
+ parsed && AR_QUERY_METHODS.include?(parsed[:method]) &&
168
+ parsed[:receiver].start_with?("Array[")
169
+ end
170
+ return nil if matched.empty?
171
+
172
+ [Hint.new(
173
+ id: "activerecord-relation-misinference", confidence: :possible,
174
+ diagnostic_count: matched.size,
175
+ summary: "ActiveRecord query methods (#{top_methods(matched)}) flagged " \
176
+ "on an `Array[...]` receiver",
177
+ action: "Enable rigor-activerecord; if it persists the receiver is an " \
178
+ "engine misinference (an AR relation read as Array) — worth a Rigor issue."
179
+ ), matched]
180
+ end
181
+
182
+ # --- H5 — systemic single-file cluster ---------------------
183
+ def h5_systemic_cluster(pool)
184
+ bucket = pool.group_by { |d| [d.path, rule_of(d)] }
185
+ .select { |_key, diags| diags.size >= SYSTEMIC_THRESHOLD }
186
+ .max_by { |_key, diags| diags.size }
187
+ return nil unless bucket
188
+
189
+ (path, rule), matched = bucket
190
+ [Hint.new(
191
+ id: "systemic-file-cluster", confidence: :likely,
192
+ diagnostic_count: matched.size,
193
+ summary: "#{matched.size}× `#{rule}` concentrated in #{path}",
194
+ action: "Likely systemic in this file — one fix may clear many; " \
195
+ "or a strong baseline candidate (ADR-22)."
196
+ ), matched]
197
+ end
198
+
199
+ # --- H6 — low-count scattered rules = likely genuine bugs --
200
+ def h6_genuine_bugs(pool)
201
+ small = pool.group_by { |d| rule_of(d) }
202
+ .select { |rule, diags| rule && diags.size.between?(1, GENUINE_BUG_MAX_COUNT) }
203
+ return nil if small.empty?
204
+
205
+ matched = small.values.flatten(1)
206
+ rules = small.map { |rule, diags| "#{rule}×#{diags.size}" }.sort.join(", ")
207
+ [Hint.new(
208
+ id: "genuine-bugs", confidence: :likely,
209
+ diagnostic_count: matched.size,
210
+ summary: "low-count, scattered rules (#{rules})",
211
+ action: "Review these first — low-count diagnostics are usually the " \
212
+ "localised bugs Rigor caught, not systemic noise."
213
+ ), matched]
214
+ end
215
+
216
+ # --- shared helpers ----------------------------------------
217
+
218
+ # WD3 / slice 4: prefer the structured `receiver_type` /
219
+ # `method_name` fields the `call.undefined-method` rule now
220
+ # populates; fall back to parsing the message only when they
221
+ # are absent (older diagnostics, plugin-emitted rules). Either
222
+ # way the receiver token is normalised through `receiver_class`.
223
+ def parse_undefined_method(diag)
224
+ return nil unless rule_of(diag) == UNDEFINED_METHOD_RULE
225
+
226
+ method, receiver_token = structured_undefined_method(diag) ||
227
+ message_undefined_method(diag)
228
+ return nil unless method
229
+
230
+ receiver = receiver_class(receiver_token)
231
+ return nil unless receiver
232
+
233
+ { method: method, receiver: receiver }
234
+ end
235
+
236
+ def structured_undefined_method(diag)
237
+ return nil unless diag.method_name && diag.receiver_type
238
+
239
+ [diag.method_name, diag.receiver_type]
240
+ end
241
+
242
+ def message_undefined_method(diag)
243
+ m = UNDEF_METHOD.match(diag.message)
244
+ m && [m[1], m[2]]
245
+ end
246
+
247
+ # Normalises a message receiver token to a class name.
248
+ # Integer / string / symbol literals fold to their class;
249
+ # `Foo[...]` keeps the `Array[...]` form (H4 needs it);
250
+ # `singleton(Foo)` and bare `Foo` fold to `Foo`.
251
+ def receiver_class(token)
252
+ t = token.strip
253
+ return "Integer" if t.match?(/\A-?\d+\z/)
254
+ return "Float" if t.match?(/\A-?\d+\.\d+\z/)
255
+ return "String" if t.start_with?('"', "'")
256
+ return "Symbol" if t.start_with?(":")
257
+
258
+ singleton = t[/\Asingleton\(([\w:]+)\)\z/, 1]
259
+ return singleton if singleton
260
+ return t if t.start_with?("Array[")
261
+
262
+ nominal = t[/\A([\w:]+)\[/, 1]
263
+ return nominal if nominal
264
+ return t if t.match?(/\A[\w:]+\z/)
265
+
266
+ nil
267
+ end
268
+
269
+ def activesupport?(receiver, method)
270
+ AS_BY_CLASS[receiver]&.include?(method) || false
271
+ end
272
+
273
+ def undefined_method_groups(pool)
274
+ pairs = pool.filter_map do |d|
275
+ parsed = parse_undefined_method(d)
276
+ parsed ? [[parsed[:method], parsed[:receiver]], d] : nil
277
+ end
278
+ pairs.group_by(&:first).transform_values { |group| group.map(&:last) }
279
+ end
280
+
281
+ def describe_groups(groups)
282
+ groups.keys.first(3).map { |method, recv| "`#{method}` on #{recv}" }.join(", ")
283
+ end
284
+
285
+ def top_methods(diagnostics, limit: 5)
286
+ diagnostics.filter_map { |d| parse_undefined_method(d)&.fetch(:method) }
287
+ .tally.sort_by { |method, count| [-count, method] }
288
+ .first(limit).map { |method, count| "#{method}×#{count}" }.join(" ")
289
+ end
290
+
291
+ def rule_of(diag)
292
+ diag.qualified_rule
293
+ end
294
+ end
295
+ end
296
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Triage
5
+ # ADR-23 — one heuristic finding produced by the {Catalogue}.
6
+ #
7
+ # - `id` — stable kebab-case identifier (`activesupport-core-ext`, …).
8
+ # - `confidence` — `:likely` or `:possible`. Surfaced in the
9
+ # `[likely …]` / `[possible …]` report framing; a hint is
10
+ # signal, never a verdict.
11
+ # - `diagnostic_count` — size of the matched cluster.
12
+ # - `summary` — one-line evidence string (what was matched).
13
+ # - `action` — the suggested next step, phrased imperatively
14
+ # for a human / agent (ADR-23 WD4: triage never acts itself).
15
+ Hint = Data.define(:id, :confidence, :diagnostic_count, :summary, :action) do
16
+ def to_h
17
+ {
18
+ "id" => id,
19
+ "confidence" => confidence.to_s,
20
+ "diagnostic_count" => diagnostic_count,
21
+ "summary" => summary,
22
+ "action" => action
23
+ }
24
+ end
25
+ end
26
+ end
27
+ end