Module: Rigor::Triage

Defined in:
lib/rigor/triage.rb,
lib/rigor/triage/hint.rb,
lib/rigor/triage/catalogue.rb

Overview

ADR-23 — diagnostic triage. Aggregates a ‘rigor check` diagnostic stream into the data behind the `rigor triage` report: a rule-ID distribution, per-file hotspots, and the heuristic hint catalogue (Catalogue).

Pure over the diagnostic stream — no second analysis pass, no analyzer internals. ‘Triage.analyze` is the single entry point; rendering is CLI::TriageRenderer’s job.

Defined Under Namespace

Modules: Catalogue Classes: Hint, Hotspot, Report, RuleCount, Selector, Summary

Constant Summary collapse

UNCATEGORISED =
"(uncategorised)"

Class Method Summary collapse

Class Method Details

.analyze(diagnostics, top: 10, hints: true, include_info: false) ⇒ Report

WD6 (ADR-23): the volume views — distribution / selectors / hotspots — route only the actionable diagnostics (error + warning) by default. Plugin-emitted ‘:info` diagnostics are overwhelmingly recognition trace (`plugin.activerecord.model-call`, `plugin.rails-routes.helper`, …) — positive “Rigor resolved this call” records, not problems — and on a real Rails app they swamp the genuine error/warning signal (the field trip: 257 of 267 diagnostics were such trace) and invert the hotspot ranking towards the files with the *most working* code. The summary still reports the full info count, and `include_info: true` (the `–include-info` flag) restores the pre-v0.2.3 behaviour. Hints always see the full stream so the `gem-without-rbs` notice (an info-severity `rbs.coverage.missing-gem`) survives; the count-based H5/H6 recognisers guard against info themselves so recognition trace never reads as a bug.

Parameters:

  • diagnostics (Array<Analysis::Diagnostic>)
  • top (Integer) (defaults to: 10)

    hotspot-file cap

  • hints (Boolean) (defaults to: true)

    run the heuristic catalogue

  • include_info (Boolean) (defaults to: false)

    route info into the volume views

Returns:



54
55
56
57
58
59
60
61
62
63
64
# File 'lib/rigor/triage.rb', line 54

def analyze(diagnostics, top: 10, hints: true, include_info: false)
  routed = include_info ? diagnostics : diagnostics.reject { |d| d.severity == :info }
  Report.new(
    summary: build_summary(diagnostics),
    distribution: build_distribution(routed),
    selectors: build_selectors(routed),
    hotspots: build_hotspots(routed, top),
    hints: hints ? Catalogue.recognise(diagnostics, include_info: include_info) : [],
    include_info: include_info
  )
end

.build_distribution(diagnostics) ⇒ Object



82
83
84
85
86
# File 'lib/rigor/triage.rb', line 82

def build_distribution(diagnostics)
  diagnostics.group_by { |d| rule_key(d) }
             .map { |rule, group| RuleCount.new(rule: rule, count: group.size) }
             .sort_by { |row| [-row.count, row.rule] }
end

.build_hotspots(diagnostics, top) ⇒ Object



143
144
145
146
147
148
# File 'lib/rigor/triage.rb', line 143

def build_hotspots(diagnostics, top)
  diagnostics.group_by(&:path)
             .map { |path, group| hotspot_for(path, group) }
             .sort_by { |spot| [-spot.count, spot.file] }
             .first(top)
end

.build_selectors(diagnostics) ⇒ Object

The class/method aggregation axis (ADR-23 follow-up). Groups every diagnostic that carries a ‘method_name` by its `(receiver_type, method_name)` pair so a consumer can answer “which method / class concentrates the diagnostics?” with a `jq` query over the JSON instead of parsing message text. Method-only diagnostics (nil `receiver_type`) keep a null `receiver` and still group by method. The full list is returned uncapped — the JSON is the agent-facing surface; the text renderer caps its own rows.



97
98
99
100
101
102
# File 'lib/rigor/triage.rb', line 97

def build_selectors(diagnostics)
  diagnostics.select(&:method_name)
             .group_by { |d| [normalize_receiver(d.receiver_type) || d.receiver_type, d.method_name.to_s] }
             .map { |(receiver, method), group| selector_for(receiver, method, group) }
             .sort_by { |s| [-s.count, s.receiver.to_s, s.method_name] }
end

.build_summary(diagnostics) ⇒ Object



72
73
74
75
76
77
78
79
80
# File 'lib/rigor/triage.rb', line 72

def build_summary(diagnostics)
  by_severity = diagnostics.group_by(&:severity).transform_values(&:size)
  Summary.new(
    total: diagnostics.size,
    error: by_severity.fetch(:error, 0),
    warning: by_severity.fetch(:warning, 0),
    info: by_severity.fetch(:info, 0)
  )
end

.hotspot_for(path, group) ⇒ Object



150
151
152
153
154
155
156
# File 'lib/rigor/triage.rb', line 150

def hotspot_for(path, group)
  by_rule = group.group_by { |d| rule_key(d) }
                 .transform_values(&:size)
                 .sort_by { |rule, count| [-count, rule] }
                 .to_h
  Hotspot.new(file: path, count: group.size, by_rule: by_rule)
end

.normalize_receiver(token) ⇒ Object

Folds a receiver token — a ‘Diagnostic#receiver_type` display string or a message-parsed token — to the class the diagnostics should bucket under, so the selector axis does not fragment one method across every distinct literal receiver. String / integer / float / symbol literals collapse to their class; `singleton©` and a bare `C` fold to `C`; a generic `C` keeps the `Array` element form (the AR-relation heuristic keys on it). Returns nil for a token it cannot reduce to a class (a union display, an inferred shape) — the caller keeps the raw string then, never losing the row. Shared with Catalogue.



114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
# File 'lib/rigor/triage.rb', line 114

def normalize_receiver(token)
  return nil if token.nil?

  t = token.to_s.strip
  return "Integer" if t.match?(/\A-?\d+\z/)
  return "Float"   if t.match?(/\A-?\d+\.\d+\z/)
  return "String"  if t.start_with?('"', "'")
  return "Symbol"  if t.start_with?(":")

  singleton = t[/\Asingleton\(([\w:]+)\)\z/, 1]
  return singleton if singleton
  return t if t.start_with?("Array[")

  nominal = t[/\A([\w:]+)\[/, 1]
  return nominal if nominal
  return t if t.match?(/\A[\w:]+\z/)

  nil
end

.report_to_h(report) ⇒ Object



158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
# File 'lib/rigor/triage.rb', line 158

def report_to_h(report)
  {
    "summary" => {
      "total" => report.summary.total, "error" => report.summary.error,
      "warning" => report.summary.warning, "info" => report.summary.info
    },
    "distribution" => report.distribution.map { |r| { "rule" => r.rule, "count" => r.count } },
    "selectors" => report.selectors.map do |s|
      { "receiver" => s.receiver, "method" => s.method_name, "count" => s.count,
        "files" => s.files, "rules" => s.rules }
    end,
    "hotspots" => report.hotspots.map do |h|
      { "file" => h.file, "count" => h.count, "by_rule" => h.by_rule }
    end,
    "hints" => report.hints.map(&:to_h),
    # WD6: false means distribution / selectors / hotspots above
    # exclude `:info` (their counts will not sum to summary.total);
    # the summary's `info` field still reports the full count.
    "include_info" => report.include_info
  }
end

.rule_key(diagnostic) ⇒ Object

Diagnostics without a ‘rule` (parse errors, internal-analyzer errors) bucket under a single sentinel rather than vanishing.



68
69
70
# File 'lib/rigor/triage.rb', line 68

def rule_key(diagnostic)
  diagnostic.qualified_rule || UNCATEGORISED
end

.selector_for(receiver, method, group) ⇒ Object



134
135
136
137
138
139
140
141
# File 'lib/rigor/triage.rb', line 134

def selector_for(receiver, method, group)
  rules = group.group_by { |d| rule_key(d) }
               .transform_values(&:size)
               .sort_by { |rule, count| [-count, rule] }
               .to_h
  Selector.new(receiver: receiver, method_name: method, count: group.size,
               files: group.map(&:path).uniq.size, rules: rules)
end