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.
- checksums.yaml +4 -4
- data/README.md +186 -513
- data/lib/rigor/analysis/check_rules.rb +23 -1
- data/lib/rigor/analysis/diagnostic.rb +17 -3
- data/lib/rigor/analysis/runner.rb +178 -3
- data/lib/rigor/analysis/worker_session.rb +14 -3
- data/lib/rigor/cli/annotate_command.rb +224 -0
- data/lib/rigor/cli/baseline_command.rb +36 -16
- data/lib/rigor/cli/prism_colorizer.rb +111 -0
- data/lib/rigor/cli/triage_command.rb +83 -0
- data/lib/rigor/cli/triage_renderer.rb +77 -0
- data/lib/rigor/cli.rb +71 -5
- data/lib/rigor/environment.rb +9 -1
- data/lib/rigor/inference/builtins/method_catalog.rb +17 -1
- data/lib/rigor/inference/builtins/time_catalog.rb +10 -1
- data/lib/rigor/inference/expression_typer.rb +300 -18
- data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +109 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +173 -10
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +53 -1
- data/lib/rigor/inference/method_dispatcher/math_folding.rb +149 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +20 -1
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +33 -8
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +81 -0
- data/lib/rigor/inference/method_dispatcher/set_folding.rb +81 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +316 -2
- data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +126 -0
- data/lib/rigor/inference/method_dispatcher/time_folding.rb +56 -0
- data/lib/rigor/inference/method_dispatcher/uri_folding.rb +67 -0
- data/lib/rigor/inference/method_dispatcher.rb +179 -4
- data/lib/rigor/inference/method_parameter_binder.rb +67 -10
- data/lib/rigor/inference/narrowing.rb +29 -10
- data/lib/rigor/inference/scope_indexer.rb +156 -6
- data/lib/rigor/inference/statement_evaluator.rb +43 -21
- data/lib/rigor/plugin/base.rb +39 -0
- data/lib/rigor/plugin/loader.rb +22 -1
- data/lib/rigor/plugin/manifest.rb +73 -10
- data/lib/rigor/plugin/protocol_contract.rb +185 -0
- data/lib/rigor/plugin/registry.rb +66 -0
- data/lib/rigor/scope.rb +46 -0
- data/lib/rigor/triage/catalogue.rb +296 -0
- data/lib/rigor/triage/hint.rb +27 -0
- data/lib/rigor/triage.rb +89 -0
- data/lib/rigor/type/constant.rb +29 -2
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/inference.rbs +1 -0
- data/sig/rigor/scope.rbs +6 -0
- 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
|