Class: MaquinaComponents::Doctor

Inherits:
Object
  • Object
show all
Defined in:
lib/maquina_components/doctor.rb

Overview

Migration scanner behind rake maquina:doctor.

Run inside a consuming app, it looks for the app's own CSS/view/JS patterns that a maquina release makes redundant or outright breaks, and prints file:line plus a suggested replacement for each. Advisory only: it never edits anything and never fails a build.

Findings carry the release that introduced them, so the report stays useful across upgrades rather than describing a single migration.

Plain Ruby on purpose - no Rails, no extra gems - so it can also be run against a directory from the engine's own repo.

Defined Under Namespace

Classes: Finding

Constant Summary collapse

SEVERITIES =
{
  breaking: "BREAKING     - stops working after upgrading",
  review: "REVIEW       - still works, but there is now a token for it",
  cleanup: "CLEANUP      - probably unnecessary now"
}.freeze
CSS_GLOBS =
[
  "app/assets/stylesheets/**/*.css",
  "app/assets/tailwind/**/*.css",
  "app/assets/**/*.css",
  "app/javascript/**/*.css",
  "app/views/**/*.css"
].freeze
VIEW_GLOBS =
[
  "app/views/**/*.erb",
  "app/views/**/*.html",
  "app/components/**/*.erb",
  "app/helpers/**/*.rb"
].freeze
JS_GLOBS =
[
  "app/javascript/**/*.js"
].freeze
EXCLUDED =
%r{/(node_modules|tmp|vendor|coverage)/|/app/assets/builds/}
COMPONENT_SELECTOR =

A component-owned selector: data-component or any data-*-part hook.

/\[data-(?:component|[a-z]+(?:-[a-z]+)*-part)\s*[~|^$*]?=/
UNIVERSAL_SELECTOR =

A rule whose every selector is the universal one, e.g. * or *, ::before, ::after — the shape of a Tailwind-v3-style preflight shim.

/\A\*(\s*,\s*(\*|::?[a-z-]+))*\z/
PRESENCE_ACTIVE =
/\[data-active\](?!\s*[~|^$*]?=)/
TAILWIND_ACTIVE_VARIANT =
/data-\[active\]/
SVG_DATA_URI =
/data:image\/svg\+xml/
MARK_TOKEN_ASSIGNMENT =

Assigning a data URI to one of the five mark properties IS the 0.6.0 pattern, so it must not be reported as a restated rule.

/--(?:checkbox-mark|checkbox-indeterminate|radio-mark|switch-thumb|select-chevron)-image\s*:/
RADIUS_DECL =
/(?:border-radius\s*:|@apply[^;{}]*\brounded(?:-[a-z0-9\[\].\/-]+)?\b)/
SHADOW_DECL =
/(?:box-shadow\s*:|@apply[^;{}]*\b(?:shadow|ring)(?:-[a-z0-9\[\].\/-]+)?\b)/
FOCUS_SELECTOR =
/:focus(-visible|-within)?\b/
DESTRUCTIVE_DECL =

0.7.1 ----------------------------------------------------------------- The destructive pair plus the surface it lands on, read out of a host theme. Lightness alone cannot separate the two shipped palette conventions -- a tint palette's dark block has the same shape as a shadcn palette's light block -- so the check measures the symptom (error text disappearing into its own card) instead. See docs/theming.md.

/\A--(destructive|destructive-foreground|card|background)\s*:\s*(.+)\z/m
OKLCH_LIGHTNESS =
/oklch\(\s*([0-9.]+)(%?)/
CONTRAST_FLOOR =

Oklch lightness gap below which text is effectively invisible on its surface. A proxy for a contrast ratio, deliberately conservative: this rule is BREAKING, so it must not fire on a merely low-contrast palette.

0.25
THEME_SCOPE =

Only :root / .dark / @theme declare a palette; a component-level override of the same name is not a statement about the app's convention.

/\A(:root|html|:where\(:root\)|\.dark|html\.dark|\[data-theme[^\]]*\]|)\s*\z/
ERROR_PART =
/data-form-part=["']error["']|form_part:\s*[:"']error/
FIELD_WITH_ERRORS =
/field_with_errors/
ARIA_INVALID =
/aria-invalid|aria:\s*\{[^}]*\binvalid\b|\baria_invalid\b/
HANDROLLED_ERROR_COLOR =
/\btext-destructive\b|\btext-red-\d/
FIELD_OPENER =

A form field, raw or through a Rails helper. Matched against a bounded window rather than a line: helper calls routinely span five or six lines, and a per-line regex would silently miss almost every real one.

/<(?:input|textarea)\b|\bf\.(?:text_field|text_area|email_field|password_field|number_field|url_field|telephone_field|phone_field|search_field|date_field)\b/
FIELD_COMPONENT =
/data-component=["'](?:input|textarea)["']|component:\s*[:"'](?:input|textarea)["']?/
FIELD_REQUIRED =
/\brequired\b/
FIELD_PLACEHOLDER =
/\bplaceholder\b/
/dataset\.side|["']data-side["']|setAttribute\(\s*["']data-side["']/

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(root) ⇒ Doctor

Returns a new instance of Doctor.



94
95
96
97
98
99
100
101
102
103
# File 'lib/maquina_components/doctor.rb', line 94

def initialize(root)
  @root = File.expand_path(root.to_s)
  @findings = []
  @scanned_files = 0
  # Cross-file state: some 0.7.1 rules are conclusions about the whole app,
  # not about one line, so they are evaluated after the scan.
  @destructive_tokens = {}
  @aria_invalid_seen = false
  @error_sites = []
end

Instance Attribute Details

#findingsObject (readonly)

Returns the value of attribute findings.



92
93
94
# File 'lib/maquina_components/doctor.rb', line 92

def findings
  @findings
end

#rootObject (readonly)

Returns the value of attribute root.



92
93
94
# File 'lib/maquina_components/doctor.rb', line 92

def root
  @root
end

#scanned_filesObject (readonly)

Returns the value of attribute scanned_files.



92
93
94
# File 'lib/maquina_components/doctor.rb', line 92

def scanned_files
  @scanned_files
end

Instance Method Details

#findings_for(severity) ⇒ Object



120
121
122
# File 'lib/maquina_components/doctor.rb', line 120

def findings_for(severity)
  findings.select { |finding| finding.severity == severity }
end

#reportObject



124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
# File 'lib/maquina_components/doctor.rb', line 124

def report
  out = []
  out << "maquina_components doctor"
  out << "Scanned #{scanned_files} file#{"s" unless scanned_files == 1} under #{root}"
  out << ""

  if findings.empty?
    out << "No at-risk patterns found. Nothing to migrate."
    out << ""
    return out.join("\n")
  end

  SEVERITIES.each do |severity, heading|
    group = findings_for(severity)
    next if group.empty?

    out << "#{heading} (#{group.size})"
    out << "-" * 72
    group.each do |finding|
      out << "  #{relative(finding.path)}:#{finding.line}  [#{finding.rule}] (#{finding.version})"
      out << "    #{finding.source}"
      finding.suggestion.each_line { |line| out << "    -> #{line.chomp}" }
      out << ""
    end
  end

  out << "Summary: " + SEVERITIES.keys.map { |s| "#{findings_for(s).size} #{s}" }.join(", ")
  out << "Advisory only - nothing was changed. See docs/upgrading.md for the release notes behind each rule."
  out << ""
  out.join("\n")
end

#runObject



105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/maquina_components/doctor.rb', line 105

def run
  each_file(CSS_GLOBS) { |path| scan_css(path) }
  each_file(VIEW_GLOBS) { |path|
    scan_markup(path)
    scan_form_fields(path)
  }
  each_file(JS_GLOBS) { |path|
    scan_markup(path)
    scan_javascript(path)
  }
  check_destructive_palette
  check_invalid_without_aria
  self
end