Class: Guardrails::ClassItis

Inherits:
Object
  • Object
show all
Defined in:
lib/guardrails/class_itis.rb

Overview

Finds repeating “class soup” — the same long class list applied to the same tag in many places. The classic AI-assisted-Rails failure mode: a 6-utility ‘class=“px-4 py-2 text-sm font-medium bg-white rounded-md”` ends up copy-pasted onto 30 buttons because the assistant doesn’t know the codebase already has a ‘ButtonComponent` or `.btn-base` class.

Distinct from CrossCodebasePatterns (structural shape, ignores classes) and PartialSimilarity (whole-partial Jaccard). This audit looks at *single elements* whose class attribute is a repeated high-cardinality literal — the cleanest signal for “extract a ButtonComponent / use @apply / add a semantic class.”

Defined Under Namespace

Classes: Cluster, Occurrence

Constant Summary collapse

DEFAULT_MIN_CLASSES =

Below this many tokens in the class list, repetition is fine —‘<button class=“primary”>` everywhere is intentional, not soup. Soup starts when 5+ classes pile up on one element with no semantic name to anchor them.

5
DEFAULT_MIN_OCCURRENCES =

Same threshold as CrossCodebasePatterns — 2 occurrences are noise, 3+ implies a real recurring pattern worth extracting.

3
DEFAULT_MAX_OCCURRENCES_SHOWN =
10
VIEW_PATTERNS =
[
  "app/views/**/*.html.erb",
  "app/components/**/*.html.erb"
].freeze
IMPLICIT_IGNORE_SEGMENTS =
%w[vendor node_modules tmp public log].freeze
IMPLICIT_IGNORE_PATTERNS =
[/\A(?:\w+_)?mailer\z/].freeze

Instance Method Summary collapse

Constructor Details

#initialize(root:, output: $stdout, min_classes: DEFAULT_MIN_CLASSES, min_occurrences: DEFAULT_MIN_OCCURRENCES, max_occurrences_shown: DEFAULT_MAX_OCCURRENCES_SHOWN) ⇒ ClassItis

Returns a new instance of ClassItis.



52
53
54
55
56
57
58
59
60
61
# File 'lib/guardrails/class_itis.rb', line 52

def initialize(root:, output: $stdout,
               min_classes: DEFAULT_MIN_CLASSES,
               min_occurrences: DEFAULT_MIN_OCCURRENCES,
               max_occurrences_shown: DEFAULT_MAX_OCCURRENCES_SHOWN)
  @root = Pathname(root)
  @output = output
  @min_classes = min_classes
  @min_occurrences = min_occurrences
  @max_occurrences_shown = max_occurrences_shown
end

Instance Method Details

#find_clustersObject



69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/guardrails/class_itis.rb', line 69

def find_clusters
  groups = Hash.new { |h, k| h[k] = [] }

  view_files.each do |file|
    content = File.read(file, encoding: Encoding::UTF_8)
    result = ErbParser.parse(content)
    relative = file.relative_path_from(@root).to_s

    ErbParser.each_node(result.document) do |node|
      next unless node.is_a?(::Herb::AST::HTMLElementNode)

      tag = element_tag_name(node)
      next if tag.nil?

      classes = static_class_tokens(node)
      next if classes.length < @min_classes

      line, column = ErbParser.start_position(node)
      key = [tag, classes]
      groups[key] << Occurrence.new(file: relative, line: line, column: column)
    end
  end

  groups
    .select { |_, occs| occs.length >= @min_occurrences }
    .map { |(tag, classes), occs| Cluster.new(tag: tag, classes: classes, occurrences: occs) }
    .sort_by { |c| [-c.count, -c.class_count] }
end

#runObject



63
64
65
66
67
# File 'lib/guardrails/class_itis.rb', line 63

def run
  clusters = find_clusters
  print_report(clusters)
  clusters
end