Class: Browsable::Doctor

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

Overview

Verifies that the external tools browsable shells out to are installed, and guides the user through installing whatever is missing.

Herb is a gem dependency and runs in-process, so it is never checked here —only node, npm, stylelint, eslint and eslint-plugin-compat.

Defined Under Namespace

Classes: Status, Tool

Constant Summary collapse

TOOLS =
[
  Tool.new(key: :node, label: "node", binary: "node", npm_package: nil,
           purpose: "JavaScript runtime that stylelint and eslint run on",
           enables: %i[css js], required: true),
  Tool.new(key: :npm, label: "npm", binary: "npm", npm_package: nil,
           purpose: "installs the CSS/JS tooling (used by `doctor --fix`)",
           enables: [], required: false),
  Tool.new(key: :stylelint, label: "stylelint", binary: "stylelint",
           npm_package: "stylelint stylelint-no-unsupported-browser-features",
           purpose: "audits CSS for unsupported browser features",
           enables: %i[css], required: true),
  Tool.new(key: :eslint, label: "eslint", binary: "eslint",
           npm_package: "eslint eslint-plugin-compat",
           purpose: "audits JavaScript for unsupported browser features",
           enables: %i[js], required: true),
  Tool.new(key: :eslint_plugin_compat, label: "eslint-plugin-compat", binary: nil,
           npm_package: "eslint-plugin-compat",
           purpose: "the eslint plugin that performs the JS compat checks",
           enables: %i[js], required: true),
  Tool.new(key: :postcss_scss, label: "postcss-scss", binary: nil,
           npm_package: "postcss-scss",
           purpose: "lets stylelint parse SCSS sources (Sprockets apps)",
           enables: %i[scss], required: false)
].freeze
ALWAYS_AVAILABLE =

Analyzer kinds that need no external tooling at all.

%i[erb html].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(root: nil) ⇒ Doctor

Returns a new instance of Doctor.

Parameters:

  • root (String, nil) (defaults to: nil)

    the project root. When provided, optional tools (e.g. postcss-scss) are only flagged as missing if the project actually has files that need them.



58
59
60
# File 'lib/browsable/doctor.rb', line 58

def initialize(root: nil)
  @root = root && File.expand_path(root)
end

Instance Attribute Details

#rootObject (readonly)

Returns the value of attribute root.



62
63
64
# File 'lib/browsable/doctor.rb', line 62

def root
  @root
end

Instance Method Details

#available_kindsObject

Which analyzer kinds can actually run on this machine right now.



108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/browsable/doctor.rb', line 108

def available_kinds
  # In dry-run mode the external tools are never invoked, so treat them all
  # as available — this keeps specs and `BROWSABLE_DRY_RUN` audits working.
  return %i[css erb html js] if ENV.key?("BROWSABLE_DRY_RUN")

  kinds = ALWAYS_AVAILABLE.dup
  %i[css js].each do |kind|
    needed = TOOLS.select { |tool| tool.enables.include?(kind) }
    kinds << kind if needed.all? { |tool| installed?(tool) }
  end
  kinds
end

#fix!(io: $stdout, input: $stdin, assume_yes: false) ⇒ Object

Attempt to install everything that is missing. Runnable commands only (npm/brew); anything that needs a manual download is reported, not run.



147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
# File 'lib/browsable/doctor.rb', line 147

def fix!(io: $stdout, input: $stdin, assume_yes: false)
  return true if ok?

  runnable, manual = install_commands.partition { |cmd| cmd.start_with?("npm ", "brew ") }

  manual.each { |cmd| io.puts "Manual step required: #{cmd}" }
  return ok? if runnable.empty?

  io.puts "browsable will run:"
  runnable.each { |cmd| io.puts "  #{cmd}" }
  unless assume_yes
    io.print "Proceed? [y/N] "
    answer = input.gets&.strip&.downcase
    return false unless %w[y yes].include?(answer)
  end

  runnable.each do |cmd|
    io.puts "+ #{cmd}"
    system(cmd)
  end
  @statuses = nil
  @installed_cache = nil
  ok?
end

#missingObject



76
77
78
# File 'lib/browsable/doctor.rb', line 76

def missing
  statuses.reject(&:installed?).map(&:tool)
end

#needed_optional_missingObject

Optional tools that would be needed by this project but aren’t installed. Used by the audit CLI to surface targeted skips (e.g. postcss-scss missing only when the project has SCSS files).



99
100
101
102
103
104
105
# File 'lib/browsable/doctor.rb', line 99

def needed_optional_missing
  missing.select do |tool|
    next false if tool.required

    needs_tool?(tool)
  end
end

#needs_tool?(tool) ⇒ Boolean

Whether the project at ‘root` actually has files that need this tool. For unconditional tools (e.g. node, stylelint) this is always true; for optional tools (e.g. postcss-scss) it depends on what’s on disk.

Returns:

  • (Boolean)


88
89
90
91
92
93
94
# File 'lib/browsable/doctor.rb', line 88

def needs_tool?(tool)
  return true if tool.required
  return true if tool.enables.empty? # tools that enable nothing are housekeeping
  return true unless root            # no project context: assume needed

  tool.enables.all? { |kind| project_has_kind?(kind) }
end

#ok?Boolean

True when every required tool is present.

Returns:

  • (Boolean)


72
73
74
# File 'lib/browsable/doctor.rb', line 72

def ok?
  statuses.select { |s| s.tool.required }.all?(&:installed?)
end

#postcss_scss_installed?Boolean

Returns:

  • (Boolean)


80
81
82
83
# File 'lib/browsable/doctor.rb', line 80

def postcss_scss_installed?
  tool = TOOLS.find { |t| t.key == :postcss_scss }
  installed?(tool)
end

#render(color: $stdout.tty?) ⇒ Object

A formatted, colourised dependency report.



122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
# File 'lib/browsable/doctor.rb', line 122

def render(color: $stdout.tty?)
  pastel = Pastel.new(enabled: color)
  lines = [pastel.bold("browsable doctor — system dependencies"), ""]

  statuses.each do |status|
    mark = render_mark(pastel, status)
    suffix = render_suffix(pastel, status)
    lines << "  #{mark} #{pastel.bold(status.tool.label)}#{status.tool.purpose}#{suffix}"
    lines << pastel.dim("      #{status.detail}") if status.detail
  end

  lines << ""
  if ok?
    lines << pastel.green.bold("All required tools are installed. You're ready to audit.")
  else
    lines << pastel.red.bold("Missing required tools — install them with:")
    install_commands.each { |cmd| lines << "  #{pastel.cyan(cmd)}" }
    lines << ""
    lines << pastel.dim("Or run `browsable doctor --fix` to install them automatically.")
  end
  lines.join("\n")
end

#statusesObject



64
65
66
67
68
69
# File 'lib/browsable/doctor.rb', line 64

def statuses
  @statuses ||= TOOLS.map do |tool|
    present = installed?(tool)
    Status.new(tool: tool, installed: present, detail: detail_for(tool, present))
  end
end