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:



42
43
44
45
46
47
48
49
50
51
52
# File 'lib/rigor/triage.rb', line 42

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



70
71
72
73
74
# File 'lib/rigor/triage.rb', line 70

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



123
124
125
126
127
128
# File 'lib/rigor/triage.rb', line 123

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.



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

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



60
61
62
63
64
65
66
67
68
# File 'lib/rigor/triage.rb', line 60

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



130
131
132
133
134
135
136
# File 'lib/rigor/triage.rb', line 130

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(C) and a bare C fold to C; a generic C[...] keeps the Array[String] 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.



94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
# File 'lib/rigor/triage.rb', line 94

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



138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
# File 'lib/rigor/triage.rb', line 138

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.



56
57
58
# File 'lib/rigor/triage.rb', line 56

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

.selector_for(receiver, method, group) ⇒ Object



114
115
116
117
118
119
120
121
# File 'lib/rigor/triage.rb', line 114

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