rigortype 0.1.7 → 0.1.8
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/lib/rigor/analysis/check_rules.rb +3 -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/triage_command.rb +83 -0
- data/lib/rigor/cli/triage_renderer.rb +77 -0
- data/lib/rigor/cli.rb +9 -1
- data/lib/rigor/inference/expression_typer.rb +135 -12
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +33 -8
- data/lib/rigor/inference/method_dispatcher.rb +31 -3
- data/lib/rigor/inference/scope_indexer.rb +156 -6
- data/lib/rigor/inference/statement_evaluator.rb +40 -20
- 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/version.rb +1 -1
- data/sig/rigor/inference.rbs +1 -0
- data/sig/rigor/scope.rbs +6 -0
- metadata +6 -1
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: "Wire the rigor-activesupport-core-ext RBS bundle via " \
|
|
126
|
+
"`signature_paths:` in .rigor.yml."
|
|
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
|
data/lib/rigor/triage.rb
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "triage/hint"
|
|
4
|
+
require_relative "triage/catalogue"
|
|
5
|
+
|
|
6
|
+
module Rigor
|
|
7
|
+
# ADR-23 — diagnostic triage. Aggregates a `rigor check`
|
|
8
|
+
# diagnostic stream into the data behind the `rigor triage`
|
|
9
|
+
# report: a rule-ID distribution, per-file hotspots, and the
|
|
10
|
+
# heuristic hint catalogue ({Triage::Catalogue}).
|
|
11
|
+
#
|
|
12
|
+
# Pure over the diagnostic stream — no second analysis pass, no
|
|
13
|
+
# analyzer internals. `Triage.analyze` is the single entry point;
|
|
14
|
+
# rendering is {CLI::TriageRenderer}'s job.
|
|
15
|
+
module Triage
|
|
16
|
+
UNCATEGORISED = "(uncategorised)"
|
|
17
|
+
|
|
18
|
+
Summary = Data.define(:total, :error, :warning, :info)
|
|
19
|
+
RuleCount = Data.define(:rule, :count)
|
|
20
|
+
Hotspot = Data.define(:file, :count, :by_rule)
|
|
21
|
+
Report = Data.define(:summary, :distribution, :hotspots, :hints)
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# @param diagnostics [Array<Analysis::Diagnostic>]
|
|
26
|
+
# @param top [Integer] hotspot-file cap
|
|
27
|
+
# @param hints [Boolean] run the heuristic catalogue
|
|
28
|
+
# @return [Report]
|
|
29
|
+
def analyze(diagnostics, top: 10, hints: true)
|
|
30
|
+
Report.new(
|
|
31
|
+
summary: build_summary(diagnostics),
|
|
32
|
+
distribution: build_distribution(diagnostics),
|
|
33
|
+
hotspots: build_hotspots(diagnostics, top),
|
|
34
|
+
hints: hints ? Catalogue.recognise(diagnostics) : []
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Diagnostics without a `rule` (parse errors, internal-analyzer
|
|
39
|
+
# errors) bucket under a single sentinel rather than vanishing.
|
|
40
|
+
def rule_key(diagnostic)
|
|
41
|
+
diagnostic.qualified_rule || UNCATEGORISED
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def build_summary(diagnostics)
|
|
45
|
+
by_severity = diagnostics.group_by(&:severity).transform_values(&:size)
|
|
46
|
+
Summary.new(
|
|
47
|
+
total: diagnostics.size,
|
|
48
|
+
error: by_severity.fetch(:error, 0),
|
|
49
|
+
warning: by_severity.fetch(:warning, 0),
|
|
50
|
+
info: by_severity.fetch(:info, 0)
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def build_distribution(diagnostics)
|
|
55
|
+
diagnostics.group_by { |d| rule_key(d) }
|
|
56
|
+
.map { |rule, group| RuleCount.new(rule: rule, count: group.size) }
|
|
57
|
+
.sort_by { |row| [-row.count, row.rule] }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def build_hotspots(diagnostics, top)
|
|
61
|
+
diagnostics.group_by(&:path)
|
|
62
|
+
.map { |path, group| hotspot_for(path, group) }
|
|
63
|
+
.sort_by { |spot| [-spot.count, spot.file] }
|
|
64
|
+
.first(top)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def hotspot_for(path, group)
|
|
68
|
+
by_rule = group.group_by { |d| rule_key(d) }
|
|
69
|
+
.transform_values(&:size)
|
|
70
|
+
.sort_by { |rule, count| [-count, rule] }
|
|
71
|
+
.to_h
|
|
72
|
+
Hotspot.new(file: path, count: group.size, by_rule: by_rule)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def report_to_h(report)
|
|
76
|
+
{
|
|
77
|
+
"summary" => {
|
|
78
|
+
"total" => report.summary.total, "error" => report.summary.error,
|
|
79
|
+
"warning" => report.summary.warning, "info" => report.summary.info
|
|
80
|
+
},
|
|
81
|
+
"distribution" => report.distribution.map { |r| { "rule" => r.rule, "count" => r.count } },
|
|
82
|
+
"hotspots" => report.hotspots.map do |h|
|
|
83
|
+
{ "file" => h.file, "count" => h.count, "by_rule" => h.by_rule }
|
|
84
|
+
end,
|
|
85
|
+
"hints" => report.hints.map(&:to_h)
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
data/lib/rigor/version.rb
CHANGED
data/sig/rigor/inference.rbs
CHANGED
|
@@ -135,6 +135,7 @@ module Rigor
|
|
|
135
135
|
def self?.build_declaration_overrides: (untyped root) -> Hash[untyped, Type::t]
|
|
136
136
|
def self?.record_declarations: (untyped node, Array[String] qualified_prefix, Hash[untyped, Type::t] identity_table, Hash[String, Type::t] discovered) -> void
|
|
137
137
|
def self?.discovered_classes_for_paths: (Array[String] paths, ?buffer: untyped) -> Hash[String, Type::t]
|
|
138
|
+
def self?.discovered_def_index_for_paths: (Array[String] paths, ?buffer: untyped) -> Hash[Symbol, untyped]
|
|
138
139
|
def self?.collect_class_decls: (untyped node, Array[String] qualified_prefix, Hash[String, Type::t] accumulator) -> void
|
|
139
140
|
def self?.qualified_name_for: (untyped constant_path_node) -> String?
|
|
140
141
|
def self?.render_constant_path: (untyped node) -> String
|
data/sig/rigor/scope.rbs
CHANGED
|
@@ -16,6 +16,8 @@ module Rigor
|
|
|
16
16
|
attr_reader discovered_methods: Hash[String, Hash[Symbol, Symbol]]
|
|
17
17
|
attr_reader discovered_def_nodes: Hash[String, Hash[Symbol, untyped]]
|
|
18
18
|
attr_reader discovered_method_visibilities: Hash[String, Hash[Symbol, Symbol]]
|
|
19
|
+
attr_reader discovered_superclasses: Hash[String, String]
|
|
20
|
+
attr_reader discovered_includes: Hash[String, Array[String]]
|
|
19
21
|
attr_reader source_path: String?
|
|
20
22
|
|
|
21
23
|
def self.empty: (?environment: Environment, ?source_path: String?) -> Scope
|
|
@@ -44,6 +46,10 @@ module Rigor
|
|
|
44
46
|
def top_level_def_for: (String | Symbol method_name) -> untyped?
|
|
45
47
|
def with_discovered_method_visibilities: (Hash[String, Hash[Symbol, Symbol]] table) -> Scope
|
|
46
48
|
def discovered_method_visibility: (String | Symbol class_name, String | Symbol method_name) -> Symbol?
|
|
49
|
+
def superclass_of: (String | Symbol class_name) -> String?
|
|
50
|
+
def with_discovered_superclasses: (Hash[String, String] table) -> Scope
|
|
51
|
+
def includes_of: (String | Symbol class_name) -> Array[String]
|
|
52
|
+
def with_discovered_includes: (Hash[String, Array[String]] table) -> Scope
|
|
47
53
|
def with_fact: (Analysis::FactStore::Fact fact) -> Scope
|
|
48
54
|
def with_self_type: (Type::t? type) -> Scope
|
|
49
55
|
def with_declared_types: (Hash[untyped, Type::t] table) -> Scope
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rigortype
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.8
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Rigor contributors
|
|
@@ -266,6 +266,8 @@ files:
|
|
|
266
266
|
- lib/rigor/cli/explain_command.rb
|
|
267
267
|
- lib/rigor/cli/lsp_command.rb
|
|
268
268
|
- lib/rigor/cli/sig_gen_command.rb
|
|
269
|
+
- lib/rigor/cli/triage_command.rb
|
|
270
|
+
- lib/rigor/cli/triage_renderer.rb
|
|
269
271
|
- lib/rigor/cli/type_of_command.rb
|
|
270
272
|
- lib/rigor/cli/type_of_renderer.rb
|
|
271
273
|
- lib/rigor/cli/type_scan_command.rb
|
|
@@ -401,6 +403,9 @@ files:
|
|
|
401
403
|
- lib/rigor/source/node_locator.rb
|
|
402
404
|
- lib/rigor/source/node_walker.rb
|
|
403
405
|
- lib/rigor/testing.rb
|
|
406
|
+
- lib/rigor/triage.rb
|
|
407
|
+
- lib/rigor/triage/catalogue.rb
|
|
408
|
+
- lib/rigor/triage/hint.rb
|
|
404
409
|
- lib/rigor/trinary.rb
|
|
405
410
|
- lib/rigor/type.rb
|
|
406
411
|
- lib/rigor/type/accepts_result.rb
|