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) ⇒ Report

Parameters:

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

    hotspot-file cap

  • hints (Boolean) (defaults to: true)

    run the heuristic catalogue

Returns:



37
38
39
40
41
42
43
44
45
# File 'lib/rigor/triage.rb', line 37

def analyze(diagnostics, top: 10, hints: true)
  Report.new(
    summary: build_summary(diagnostics),
    distribution: build_distribution(diagnostics),
    selectors: build_selectors(diagnostics),
    hotspots: build_hotspots(diagnostics, top),
    hints: hints ? Catalogue.recognise(diagnostics) : []
  )
end

.build_distribution(diagnostics) ⇒ Object



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

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



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

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.



78
79
80
81
82
83
# File 'lib/rigor/triage.rb', line 78

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



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

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



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

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.



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

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



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

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)
  }
end

.rule_key(diagnostic) ⇒ Object

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



49
50
51
# File 'lib/rigor/triage.rb', line 49

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

.selector_for(receiver, method, group) ⇒ Object



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

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