browsable 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +12 -0
- data/README.md +240 -0
- data/bin/update-bcd-snapshot +65 -0
- data/data/bcd-snapshot.json +341 -0
- data/exe/browsable +6 -0
- data/lib/browsable/analyzers/base.rb +74 -0
- data/lib/browsable/analyzers/css.rb +82 -0
- data/lib/browsable/analyzers/erb.rb +258 -0
- data/lib/browsable/analyzers/html.rb +11 -0
- data/lib/browsable/analyzers/javascript.rb +81 -0
- data/lib/browsable/cli.rb +333 -0
- data/lib/browsable/config.rb +163 -0
- data/lib/browsable/doctor.rb +189 -0
- data/lib/browsable/finding.rb +41 -0
- data/lib/browsable/formatters/github.rb +50 -0
- data/lib/browsable/formatters/human.rb +162 -0
- data/lib/browsable/formatters/json.rb +20 -0
- data/lib/browsable/policy_detector.rb +267 -0
- data/lib/browsable/policy_scanner.rb +73 -0
- data/lib/browsable/railtie.rb +16 -0
- data/lib/browsable/rake_tasks.rb +28 -0
- data/lib/browsable/report.rb +139 -0
- data/lib/browsable/sources/base.rb +45 -0
- data/lib/browsable/sources/builds.rb +10 -0
- data/lib/browsable/sources/importmap.rb +98 -0
- data/lib/browsable/sources/javascripts.rb +10 -0
- data/lib/browsable/sources/public_assets.rb +9 -0
- data/lib/browsable/sources/stylesheets.rb +9 -0
- data/lib/browsable/sources/views.rb +9 -0
- data/lib/browsable/target.rb +175 -0
- data/lib/browsable/version.rb +5 -0
- data/lib/browsable.rb +47 -0
- data/lib/generators/browsable/install/install_generator.rb +82 -0
- data/lib/generators/browsable/install/templates/browsable.yml.tt +51 -0
- metadata +185 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
require "json"
|
|
5
|
+
require "erb"
|
|
6
|
+
require "pastel"
|
|
7
|
+
|
|
8
|
+
module Browsable
|
|
9
|
+
# The `browsable` command-line interface.
|
|
10
|
+
#
|
|
11
|
+
# CLI is a thin shell: it parses flags, runs the audit pipeline (sources →
|
|
12
|
+
# analyzers → report), and hands the Report to a formatter. Every command's
|
|
13
|
+
# real logic lives in the classes it orchestrates.
|
|
14
|
+
class CLI < Thor
|
|
15
|
+
# The analyzer used for each routed file kind.
|
|
16
|
+
ANALYZERS = {
|
|
17
|
+
css: Analyzers::CSS,
|
|
18
|
+
erb: Analyzers::ERB,
|
|
19
|
+
html: Analyzers::HTML,
|
|
20
|
+
js: Analyzers::Javascript
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
SKIP_REASONS = {
|
|
24
|
+
css: "stylelint not found — run `browsable doctor`",
|
|
25
|
+
js: "eslint / eslint-plugin-compat not found — run `browsable doctor`"
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
def self.exit_on_failure? = true
|
|
29
|
+
|
|
30
|
+
class_option :config, type: :string, aliases: "-c", desc: "Path to a config file"
|
|
31
|
+
class_option :format, type: :string, enum: %w[human json github], desc: "Output format"
|
|
32
|
+
class_option :json, type: :boolean, desc: "Shortcut for --format json"
|
|
33
|
+
|
|
34
|
+
desc "audit [PATH]", "Audit a Rails app's frontend for browser-compatibility issues"
|
|
35
|
+
long_desc <<~DESC
|
|
36
|
+
Discovers the project's CSS, ERB/HTML and JavaScript, audits each against
|
|
37
|
+
the browser-support target inferred from ApplicationController's
|
|
38
|
+
allow_browser policy, and reports what your code requires versus what your
|
|
39
|
+
policy permits. Runs with zero configuration.
|
|
40
|
+
DESC
|
|
41
|
+
option :target, type: :string, desc: "Override the inferred browserslist query"
|
|
42
|
+
option :"no-build", type: :boolean, default: false,
|
|
43
|
+
desc: "Scan only what is on disk (browsable never builds assets itself)"
|
|
44
|
+
option :include, type: :array, default: [], desc: "Extra path globs to scan (repeatable)"
|
|
45
|
+
option :exclude, type: :array, default: [], desc: "Path globs to exclude (repeatable)"
|
|
46
|
+
option :"fail-on", type: :string, enum: %w[warning error], default: "error",
|
|
47
|
+
desc: "Exit non-zero when a finding of this severity (or higher) exists"
|
|
48
|
+
def audit(path = ".")
|
|
49
|
+
root = File.expand_path(path)
|
|
50
|
+
config = load_config(root)
|
|
51
|
+
target = resolve_target(config)
|
|
52
|
+
|
|
53
|
+
warn_missing_dependencies
|
|
54
|
+
report = run_audit(root: root, config: config, target: target)
|
|
55
|
+
emit(report)
|
|
56
|
+
exit(report.exit_code(fail_on: options["fail-on"] || "error"))
|
|
57
|
+
end
|
|
58
|
+
default_command :audit
|
|
59
|
+
|
|
60
|
+
desc "doctor", "Check that browsable's system dependencies are installed"
|
|
61
|
+
option :fix, type: :boolean, default: false,
|
|
62
|
+
desc: "Attempt to install missing dependencies via brew/npm"
|
|
63
|
+
def doctor
|
|
64
|
+
doc = Doctor.new
|
|
65
|
+
puts doc.render(color: color?)
|
|
66
|
+
|
|
67
|
+
if options[:fix] && !doc.ok?
|
|
68
|
+
puts
|
|
69
|
+
doc.fix!
|
|
70
|
+
puts
|
|
71
|
+
puts doc.render(color: color?)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
exit(doc.ok? ? 0 : 1)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
desc "check FILE [FILE...]", "Audit specific files (used by editor integrations)"
|
|
78
|
+
option :target, type: :string, desc: "Override the inferred browserslist query"
|
|
79
|
+
def check(*files)
|
|
80
|
+
abort_with("`check` needs at least one file path.") if files.empty?
|
|
81
|
+
|
|
82
|
+
paths = files.map { |file| File.expand_path(file) }
|
|
83
|
+
root = detect_root(paths.first)
|
|
84
|
+
config = load_config(root)
|
|
85
|
+
target = resolve_target(config)
|
|
86
|
+
|
|
87
|
+
report = run_audit(root: root, config: config, target: target, file_list: paths)
|
|
88
|
+
emit(report)
|
|
89
|
+
exit(report.exit_code(fail_on: "error"))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
desc "init", "Generate a .browsable.yml in the current directory"
|
|
93
|
+
long_desc <<~DESC
|
|
94
|
+
Writes a fully-commented .browsable.yml. Rails users should prefer the
|
|
95
|
+
generator: `rails g browsable:install` (writes config/browsable.yml).
|
|
96
|
+
DESC
|
|
97
|
+
option :force, type: :boolean, default: false, desc: "Overwrite an existing file"
|
|
98
|
+
option :minimal, type: :boolean, default: false, desc: "Write section headers only"
|
|
99
|
+
option :target, type: :string, desc: "Pre-populate a manual target query"
|
|
100
|
+
def init
|
|
101
|
+
destination = File.expand_path(".browsable.yml")
|
|
102
|
+
if File.exist?(destination) && !options[:force]
|
|
103
|
+
abort_with("#{destination} already exists. Pass --force to overwrite.")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
File.write(destination, render_config_template)
|
|
107
|
+
puts pastel.green("Created #{destination}")
|
|
108
|
+
puts pastel.dim("This file is optional — delete it and browsable still works. " \
|
|
109
|
+
"Uncomment a line to override a default.")
|
|
110
|
+
puts pastel.dim("Run `browsable audit` to try it out.")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
desc "target [PATH]", "Show the browser-support target browsable inferred"
|
|
114
|
+
def target(path = ".")
|
|
115
|
+
config = load_config(File.expand_path(path))
|
|
116
|
+
resolved = resolve_target(config)
|
|
117
|
+
|
|
118
|
+
unconstrained = options[:target] ? [] : config.unconstrained_browsers
|
|
119
|
+
caveats = []
|
|
120
|
+
unless options[:target]
|
|
121
|
+
caveats << config.policy_note if config.policy_note
|
|
122
|
+
caveats.concat(config.target_notes)
|
|
123
|
+
end
|
|
124
|
+
caveats << resolved.note if resolved.note
|
|
125
|
+
|
|
126
|
+
if json_output?
|
|
127
|
+
puts JSON.pretty_generate(
|
|
128
|
+
resolved.as_json.merge(unconstrained_browsers: unconstrained, notes: caveats)
|
|
129
|
+
)
|
|
130
|
+
else
|
|
131
|
+
puts pastel.bold("Target: #{resolved.query}")
|
|
132
|
+
resolved.browsers.each { |browser, version| puts " #{browser.ljust(9)}>= #{version}" }
|
|
133
|
+
unconstrained.each do |browser|
|
|
134
|
+
puts pastel.dim(" #{browser.ljust(9)}any version (not listed in allow_browser)")
|
|
135
|
+
end
|
|
136
|
+
caveats.each { |note| puts pastel.yellow("! #{note}") }
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
desc "version", "Print the browsable version"
|
|
141
|
+
def version
|
|
142
|
+
puts "browsable #{Browsable::VERSION}"
|
|
143
|
+
end
|
|
144
|
+
map %w[--version -v] => :version
|
|
145
|
+
|
|
146
|
+
private
|
|
147
|
+
|
|
148
|
+
# --- pipeline ------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
def run_audit(root:, config:, target:, file_list: nil)
|
|
151
|
+
available = Doctor.new.available_kinds
|
|
152
|
+
skips = []
|
|
153
|
+
files_by_kind = file_list ? route_files(file_list) : discover_files(root: root, config: config)
|
|
154
|
+
|
|
155
|
+
collect_importmap(root: root, config: config, files_by_kind: files_by_kind, skips: skips) if file_list.nil?
|
|
156
|
+
|
|
157
|
+
findings = []
|
|
158
|
+
ANALYZERS.each do |kind, analyzer_class|
|
|
159
|
+
files = files_by_kind[kind] || []
|
|
160
|
+
next if files.empty?
|
|
161
|
+
|
|
162
|
+
unless available.include?(kind)
|
|
163
|
+
skips << Report::Skip.new(kind: kind, reason: SKIP_REASONS.fetch(kind, "tooling unavailable"))
|
|
164
|
+
next
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
analyzer = analyzer_class.new(target: target, config: config)
|
|
168
|
+
findings.concat(analyzer.analyze(files))
|
|
169
|
+
rescue StandardError => e
|
|
170
|
+
skips << Report::Skip.new(kind: kind, reason: "#{kind} analysis failed: #{e.message}")
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
notes = []
|
|
174
|
+
# Surface target caveats — a policy that could not be inferred, or the
|
|
175
|
+
# Rails rule that browsers absent from an allow_browser hash are
|
|
176
|
+
# unconstrained — unless the user overrode the target on the command line.
|
|
177
|
+
unless options[:target]
|
|
178
|
+
notes << config.policy_note if config.policy_note
|
|
179
|
+
notes.concat(config.target_notes)
|
|
180
|
+
end
|
|
181
|
+
# How the target itself resolved (e.g. a built-in fallback) is relevant
|
|
182
|
+
# even when --target was passed.
|
|
183
|
+
notes << target.note if target.note
|
|
184
|
+
|
|
185
|
+
# The full policy landscape — every allow_browser callsite across the
|
|
186
|
+
# app's controllers, not just ApplicationController. Skipped for `check`,
|
|
187
|
+
# which audits specific files for editor integration.
|
|
188
|
+
policies = file_list ? [] : PolicyScanner.call(root)
|
|
189
|
+
|
|
190
|
+
Report.new(findings: findings, skips: skips, notes: notes, policies: policies,
|
|
191
|
+
target: target, root: root, config_file: config.config_file)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def collect_importmap(root:, config:, files_by_kind:, skips:)
|
|
195
|
+
return unless config.importmap_enabled?
|
|
196
|
+
|
|
197
|
+
importmap = Sources::Importmap.new(root: root)
|
|
198
|
+
return unless importmap.present?
|
|
199
|
+
|
|
200
|
+
if ENV["BROWSABLE_OFFLINE"] == "1"
|
|
201
|
+
skips << Report::Skip.new(kind: :importmap,
|
|
202
|
+
reason: "BROWSABLE_OFFLINE=1 — remote pins were not fetched")
|
|
203
|
+
return
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
(files_by_kind[:js] ||= []).concat(importmap.fetch)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def discover_files(root:, config:)
|
|
210
|
+
src = config.sources
|
|
211
|
+
excludes = Array(options[:exclude]) + config.ignore_files
|
|
212
|
+
|
|
213
|
+
sources = [
|
|
214
|
+
Sources::Stylesheets.new(root: root, globs: src["stylesheets"], excludes: excludes),
|
|
215
|
+
Sources::Builds.new(root: root, globs: src["builds"], excludes: excludes),
|
|
216
|
+
Sources::Views.new(root: root, globs: src["views"], excludes: excludes),
|
|
217
|
+
Sources::Javascripts.new(root: root, globs: src["javascript"], excludes: excludes),
|
|
218
|
+
Sources::PublicAssets.new(root: root, globs: src["public"], excludes: excludes)
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
extra = Array(src["custom"]) + Array(options[:include])
|
|
222
|
+
sources << Sources::Base.new(root: root, globs: extra, excludes: excludes) if extra.any?
|
|
223
|
+
|
|
224
|
+
route_files(sources.flat_map(&:files).uniq)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Route files to an analyzer by extension. `*.html.erb` ends in `.erb`.
|
|
228
|
+
def route_files(files)
|
|
229
|
+
buckets = { css: [], erb: [], html: [], js: [] }
|
|
230
|
+
files.each do |file|
|
|
231
|
+
case File.extname(file).downcase
|
|
232
|
+
when ".css", ".scss" then buckets[:css] << file
|
|
233
|
+
when ".js", ".mjs" then buckets[:js] << file
|
|
234
|
+
when ".erb" then buckets[:erb] << file
|
|
235
|
+
when ".html", ".htm" then buckets[:html] << file
|
|
236
|
+
end
|
|
237
|
+
# TODO(v0.2): route Ruby view components (app/components/**/*.rb).
|
|
238
|
+
end
|
|
239
|
+
buckets
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# --- output --------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
def emit(report)
|
|
245
|
+
puts formatter_class.new(report).render
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def formatter_class
|
|
249
|
+
name = json_output? ? "json" : (options[:format] || "human")
|
|
250
|
+
{ "human" => Formatters::Human, "json" => Formatters::Json,
|
|
251
|
+
"github" => Formatters::Github }.fetch(name)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def json_output?
|
|
255
|
+
options[:json] || options[:format] == "json"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# --- config / target ----------------------------------------------------
|
|
259
|
+
|
|
260
|
+
def load_config(root)
|
|
261
|
+
Config.load(root: root, path: options[:config])
|
|
262
|
+
rescue ConfigError => e
|
|
263
|
+
abort_with(e.message)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def resolve_target(config)
|
|
267
|
+
options[:target] ? Target.new(options[:target]) : config.target
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# --- helpers -------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
def warn_missing_dependencies
|
|
273
|
+
return if ENV.key?("BROWSABLE_DRY_RUN")
|
|
274
|
+
|
|
275
|
+
missing = %i[css js] - Doctor.new.available_kinds
|
|
276
|
+
return if missing.empty?
|
|
277
|
+
|
|
278
|
+
$stderr.puts pastel(io: $stderr).yellow(
|
|
279
|
+
"! #{missing.join(' and ')} analysis is disabled (missing tools). " \
|
|
280
|
+
"Run `browsable doctor` for setup instructions."
|
|
281
|
+
)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Walk up from a file to find the project root.
|
|
285
|
+
def detect_root(start)
|
|
286
|
+
dir = File.directory?(start) ? start : File.dirname(start)
|
|
287
|
+
markers = %w[Gemfile config.ru .browsable.yml config/browsable.yml]
|
|
288
|
+
current = dir
|
|
289
|
+
loop do
|
|
290
|
+
return current if markers.any? { |m| File.exist?(File.join(current, m)) }
|
|
291
|
+
|
|
292
|
+
parent = File.dirname(current)
|
|
293
|
+
return dir if parent == current
|
|
294
|
+
|
|
295
|
+
current = parent
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def render_config_template
|
|
300
|
+
template_path = File.expand_path(
|
|
301
|
+
"../generators/browsable/install/templates/browsable.yml.tt", __dir__
|
|
302
|
+
)
|
|
303
|
+
policy = Config.load(root: Dir.pwd).detected_policy
|
|
304
|
+
comment =
|
|
305
|
+
case policy
|
|
306
|
+
when nil
|
|
307
|
+
"# (No allow_browser call detected — browsable will fall back to browserslist defaults.)"
|
|
308
|
+
when Hash
|
|
309
|
+
"# Detected: ApplicationController declares an explicit allow_browser versions hash."
|
|
310
|
+
else
|
|
311
|
+
"# Detected: ApplicationController uses `allow_browser versions: :#{policy}`"
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
ERB.new(File.read(template_path), trim_mode: "-")
|
|
315
|
+
.result_with_hash(detected_comment: comment,
|
|
316
|
+
manual_query: options[:target],
|
|
317
|
+
minimal: options[:minimal])
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def color?
|
|
321
|
+
$stdout.tty?
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def pastel(io: $stdout)
|
|
325
|
+
Pastel.new(enabled: io.tty?)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def abort_with(message)
|
|
329
|
+
$stderr.puts pastel(io: $stderr).red("Error: #{message}")
|
|
330
|
+
exit(1)
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Browsable
|
|
6
|
+
# Resolves the effective configuration for an audit run.
|
|
7
|
+
#
|
|
8
|
+
# browsable runs fully zero-config: when no config file is present every
|
|
9
|
+
# value below is inferred. A config file only exists to *override* defaults.
|
|
10
|
+
#
|
|
11
|
+
# Resolution precedence (highest wins) is applied by the caller:
|
|
12
|
+
# 1. CLI flags (handled in CLI)
|
|
13
|
+
# 2. config file (loaded here)
|
|
14
|
+
# 3. inferred Rails (allow_browser policy, read here)
|
|
15
|
+
# 4. gem defaults (DEFAULTS below)
|
|
16
|
+
class Config
|
|
17
|
+
DEFAULTS = {
|
|
18
|
+
"target" => {
|
|
19
|
+
"source" => "allow_browsers", # allow_browsers | browserslist | manual
|
|
20
|
+
"manual_query" => "defaults"
|
|
21
|
+
},
|
|
22
|
+
"sources" => {
|
|
23
|
+
"stylesheets" => ["app/assets/stylesheets/**/*.{css,scss}"],
|
|
24
|
+
"builds" => ["app/assets/builds/**/*.css"],
|
|
25
|
+
"views" => ["app/views/**/*.{html.erb,turbo_stream.erb}",
|
|
26
|
+
"app/components/**/*.{rb,html.erb}"],
|
|
27
|
+
"javascript" => ["app/javascript/**/*.{js,mjs}"],
|
|
28
|
+
"importmap" => true,
|
|
29
|
+
"public" => ["public/**/*.{html,css,js}"],
|
|
30
|
+
"custom" => []
|
|
31
|
+
},
|
|
32
|
+
"severity" => {
|
|
33
|
+
"baseline_newly_available" => "warning",
|
|
34
|
+
"baseline_limited" => "error",
|
|
35
|
+
"below_target" => "error"
|
|
36
|
+
},
|
|
37
|
+
"ignore" => {
|
|
38
|
+
"features" => [],
|
|
39
|
+
"files" => []
|
|
40
|
+
}
|
|
41
|
+
}.freeze
|
|
42
|
+
|
|
43
|
+
# Discovery order for an implicit config file, relative to the project root.
|
|
44
|
+
CONFIG_FILENAMES = ["config/browsable.yml", ".browsable.yml"].freeze
|
|
45
|
+
|
|
46
|
+
attr_reader :root, :data, :config_file, :detected_policy, :policy_note
|
|
47
|
+
|
|
48
|
+
# Load and merge configuration for a project rooted at `root`.
|
|
49
|
+
#
|
|
50
|
+
# @param root [String] the Rails app (or project) root
|
|
51
|
+
# @param path [String, nil] an explicit config file path (from --config)
|
|
52
|
+
def self.load(root:, path: nil)
|
|
53
|
+
root = File.expand_path(root)
|
|
54
|
+
config_file = locate_file(root, path)
|
|
55
|
+
file_data = config_file ? parse_file(config_file) : {}
|
|
56
|
+
merged = deep_merge(DEFAULTS, file_data)
|
|
57
|
+
new(root: root, data: merged, config_file: config_file)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.locate_file(root, explicit)
|
|
61
|
+
if explicit
|
|
62
|
+
full = File.expand_path(explicit, root)
|
|
63
|
+
raise ConfigError, "Config file not found: #{explicit}" unless File.file?(full)
|
|
64
|
+
|
|
65
|
+
return full
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
CONFIG_FILENAMES
|
|
69
|
+
.map { |name| File.join(root, name) }
|
|
70
|
+
.find { |candidate| File.file?(candidate) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.parse_file(path)
|
|
74
|
+
loaded = YAML.safe_load_file(path) || {}
|
|
75
|
+
raise ConfigError, "#{path} must contain a YAML mapping" unless loaded.is_a?(Hash)
|
|
76
|
+
|
|
77
|
+
loaded
|
|
78
|
+
rescue Psych::SyntaxError => e
|
|
79
|
+
raise ConfigError, "Could not parse #{path}: #{e.message}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.deep_merge(base, override)
|
|
83
|
+
base.merge(override) do |_key, base_val, override_val|
|
|
84
|
+
if base_val.is_a?(Hash) && override_val.is_a?(Hash)
|
|
85
|
+
deep_merge(base_val, override_val)
|
|
86
|
+
else
|
|
87
|
+
override_val
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def initialize(root:, data:, config_file: nil)
|
|
93
|
+
@root = root
|
|
94
|
+
@data = data
|
|
95
|
+
@config_file = config_file
|
|
96
|
+
# PolicyDetector statically resolves the Rails allow_browser policy.
|
|
97
|
+
# `policy_note` is set when a call was found but could not be resolved.
|
|
98
|
+
result = PolicyDetector.call(root)
|
|
99
|
+
@detected_policy = result.policy
|
|
100
|
+
@policy_note = result.note
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def sources = data.fetch("sources")
|
|
104
|
+
def severity = data.fetch("severity")
|
|
105
|
+
def ignore_features = Array(data.dig("ignore", "features"))
|
|
106
|
+
def ignore_files = Array(data.dig("ignore", "files"))
|
|
107
|
+
def importmap_enabled? = sources.fetch("importmap", true) != false
|
|
108
|
+
|
|
109
|
+
# Resolve the browser-support Target implied by this config.
|
|
110
|
+
def target
|
|
111
|
+
cfg = data.fetch("target")
|
|
112
|
+
case cfg["source"]
|
|
113
|
+
when "manual"
|
|
114
|
+
Target.new(cfg.fetch("manual_query", "defaults"))
|
|
115
|
+
when "browserslist"
|
|
116
|
+
# Defer entirely to the project's browserslist config (.browserslistrc).
|
|
117
|
+
Target.new("defaults")
|
|
118
|
+
else # "allow_browsers" (the default)
|
|
119
|
+
detected_policy ? Target.from_rails_policy(detected_policy) : Target.new("defaults")
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Major browsers an explicit allow_browser *hash* neither pins to a version
|
|
124
|
+
# nor blocks. Rails allows these at any version — it only ever blocks a
|
|
125
|
+
# browser it was given a minimum (or `false`) for — so browsable has no
|
|
126
|
+
# floor to audit them against. Empty unless the policy is an explicit hash.
|
|
127
|
+
def unconstrained_browsers
|
|
128
|
+
return [] unless detected_policy.is_a?(Hash)
|
|
129
|
+
|
|
130
|
+
Target::MODERN.keys - detected_policy.keys
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Informational caveats about the resolved target — so the user is never
|
|
134
|
+
# left guessing why a particular set of browsers is (or isn't) audited.
|
|
135
|
+
def target_notes
|
|
136
|
+
notes = []
|
|
137
|
+
inferring = data.dig("target", "source") == "allow_browsers"
|
|
138
|
+
|
|
139
|
+
# No allow_browser policy at all — explain the browserslist defaults fallback.
|
|
140
|
+
if inferring && detected_policy.nil? && policy_note.nil?
|
|
141
|
+
notes << "No allow_browser policy was found in ApplicationController, so browsable " \
|
|
142
|
+
"is auditing against the browserslist `defaults` baseline. Add an " \
|
|
143
|
+
"allow_browser call, or set `target:` in config/browsable.yml, to pick the " \
|
|
144
|
+
"browsers to audit against explicitly."
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# A partial hash policy — explain the browsers Rails leaves unconstrained.
|
|
148
|
+
if unconstrained_browsers.any?
|
|
149
|
+
pinned = detected_policy.keys.join(", ")
|
|
150
|
+
omitted = unconstrained_browsers.join(", ")
|
|
151
|
+
notes << "Your allow_browser policy pins a version only for #{pinned}. Rails leaves " \
|
|
152
|
+
"every browser you don't list (#{omitted}) allowed at any version, so " \
|
|
153
|
+
"browsable audits only #{pinned}. Add a `target:` block to " \
|
|
154
|
+
"config/browsable.yml to audit the others."
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
notes
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# True when an explicit config file was found and loaded.
|
|
161
|
+
def file_present? = !config_file.nil?
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "shellwords"
|
|
5
|
+
require "rbconfig"
|
|
6
|
+
require "pastel"
|
|
7
|
+
|
|
8
|
+
module Browsable
|
|
9
|
+
# Verifies that the external tools browsable shells out to are installed, and
|
|
10
|
+
# guides the user through installing whatever is missing.
|
|
11
|
+
#
|
|
12
|
+
# Herb is a gem dependency and runs in-process, so it is never checked here —
|
|
13
|
+
# only node, npm, stylelint, eslint and eslint-plugin-compat.
|
|
14
|
+
class Doctor
|
|
15
|
+
# A tool browsable depends on. `binary` is nil for packages that ship no
|
|
16
|
+
# executable (checked via the global npm tree instead). `enables` lists the
|
|
17
|
+
# analyzer kinds the tool unlocks.
|
|
18
|
+
Tool = Data.define(:key, :label, :binary, :npm_package, :purpose, :enables, :required) do
|
|
19
|
+
def binary? = !binary.nil?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The resolved state of a tool on this machine.
|
|
23
|
+
Status = Data.define(:tool, :installed, :detail) do
|
|
24
|
+
def installed? = installed
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
TOOLS = [
|
|
28
|
+
Tool.new(key: :node, label: "node", binary: "node", npm_package: nil,
|
|
29
|
+
purpose: "JavaScript runtime that stylelint and eslint run on",
|
|
30
|
+
enables: %i[css js], required: true),
|
|
31
|
+
Tool.new(key: :npm, label: "npm", binary: "npm", npm_package: nil,
|
|
32
|
+
purpose: "installs the CSS/JS tooling (used by `doctor --fix`)",
|
|
33
|
+
enables: [], required: false),
|
|
34
|
+
Tool.new(key: :stylelint, label: "stylelint", binary: "stylelint",
|
|
35
|
+
npm_package: "stylelint stylelint-no-unsupported-browser-features",
|
|
36
|
+
purpose: "audits CSS for unsupported browser features",
|
|
37
|
+
enables: %i[css], required: true),
|
|
38
|
+
Tool.new(key: :eslint, label: "eslint", binary: "eslint",
|
|
39
|
+
npm_package: "eslint eslint-plugin-compat",
|
|
40
|
+
purpose: "audits JavaScript for unsupported browser features",
|
|
41
|
+
enables: %i[js], required: true),
|
|
42
|
+
Tool.new(key: :eslint_plugin_compat, label: "eslint-plugin-compat", binary: nil,
|
|
43
|
+
npm_package: "eslint-plugin-compat",
|
|
44
|
+
purpose: "the eslint plugin that performs the JS compat checks",
|
|
45
|
+
enables: %i[js], required: true)
|
|
46
|
+
].freeze
|
|
47
|
+
|
|
48
|
+
# Analyzer kinds that need no external tooling at all.
|
|
49
|
+
ALWAYS_AVAILABLE = %i[erb html].freeze
|
|
50
|
+
|
|
51
|
+
def statuses
|
|
52
|
+
@statuses ||= TOOLS.map do |tool|
|
|
53
|
+
present = installed?(tool)
|
|
54
|
+
Status.new(tool: tool, installed: present, detail: detail_for(tool, present))
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# True when every *required* tool is present.
|
|
59
|
+
def ok?
|
|
60
|
+
statuses.select { |s| s.tool.required }.all?(&:installed?)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def missing
|
|
64
|
+
statuses.reject(&:installed?).map(&:tool)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Which analyzer kinds can actually run on this machine right now.
|
|
68
|
+
def available_kinds
|
|
69
|
+
# In dry-run mode the external tools are never invoked, so treat them all
|
|
70
|
+
# as available — this keeps specs and `BROWSABLE_DRY_RUN` audits working.
|
|
71
|
+
return %i[css erb html js] if ENV.key?("BROWSABLE_DRY_RUN")
|
|
72
|
+
|
|
73
|
+
kinds = ALWAYS_AVAILABLE.dup
|
|
74
|
+
%i[css js].each do |kind|
|
|
75
|
+
needed = TOOLS.select { |tool| tool.enables.include?(kind) }
|
|
76
|
+
kinds << kind if needed.all? { |tool| installed?(tool) }
|
|
77
|
+
end
|
|
78
|
+
kinds
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# A formatted, colourised dependency report.
|
|
82
|
+
def render(color: $stdout.tty?)
|
|
83
|
+
pastel = Pastel.new(enabled: color)
|
|
84
|
+
lines = [pastel.bold("browsable doctor — system dependencies"), ""]
|
|
85
|
+
|
|
86
|
+
statuses.each do |status|
|
|
87
|
+
mark = status.installed? ? pastel.green("✓") : pastel.red("✗")
|
|
88
|
+
lines << " #{mark} #{pastel.bold(status.tool.label)} — #{status.tool.purpose}"
|
|
89
|
+
lines << pastel.dim(" #{status.detail}") if status.detail
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
lines << ""
|
|
93
|
+
if ok?
|
|
94
|
+
lines << pastel.green.bold("All required tools are installed. You're ready to audit.")
|
|
95
|
+
else
|
|
96
|
+
lines << pastel.red.bold("Missing required tools — install them with:")
|
|
97
|
+
install_commands.each { |cmd| lines << " #{pastel.cyan(cmd)}" }
|
|
98
|
+
lines << ""
|
|
99
|
+
lines << pastel.dim("Or run `browsable doctor --fix` to install them automatically.")
|
|
100
|
+
end
|
|
101
|
+
lines.join("\n")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Attempt to install everything that is missing. Runnable commands only
|
|
105
|
+
# (npm/brew); anything that needs a manual download is reported, not run.
|
|
106
|
+
def fix!(io: $stdout, input: $stdin, assume_yes: false)
|
|
107
|
+
return true if ok?
|
|
108
|
+
|
|
109
|
+
runnable, manual = install_commands.partition { |cmd| cmd.start_with?("npm ", "brew ") }
|
|
110
|
+
|
|
111
|
+
manual.each { |cmd| io.puts "Manual step required: #{cmd}" }
|
|
112
|
+
return ok? if runnable.empty?
|
|
113
|
+
|
|
114
|
+
io.puts "browsable will run:"
|
|
115
|
+
runnable.each { |cmd| io.puts " #{cmd}" }
|
|
116
|
+
unless assume_yes
|
|
117
|
+
io.print "Proceed? [y/N] "
|
|
118
|
+
answer = input.gets&.strip&.downcase
|
|
119
|
+
return false unless %w[y yes].include?(answer)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
runnable.each do |cmd|
|
|
123
|
+
io.puts "+ #{cmd}"
|
|
124
|
+
system(cmd)
|
|
125
|
+
end
|
|
126
|
+
@statuses = nil
|
|
127
|
+
@installed_cache = nil
|
|
128
|
+
ok?
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def install_commands
|
|
134
|
+
missing.map { |tool| install_command(tool) }.uniq
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def install_command(tool)
|
|
138
|
+
case tool.key
|
|
139
|
+
when :node, :npm
|
|
140
|
+
mac? ? "brew install node" : "install Node.js — see https://nodejs.org/en/download"
|
|
141
|
+
else
|
|
142
|
+
"npm install -g #{tool.npm_package}"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def detail_for(tool, present)
|
|
147
|
+
if present
|
|
148
|
+
tool.binary? ? (tool_version(tool) || "installed") : "installed"
|
|
149
|
+
else
|
|
150
|
+
"not found — #{install_command(tool)}"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def installed?(tool)
|
|
155
|
+
@installed_cache ||= {}
|
|
156
|
+
@installed_cache.fetch(tool.key) do
|
|
157
|
+
@installed_cache[tool.key] = tool.binary? ? binary_on_path?(tool.binary) : npm_package_installed?(tool.npm_package)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def binary_on_path?(binary)
|
|
162
|
+
_out, status = Open3.capture2e("sh", "-c", "command -v #{Shellwords.escape(binary)}")
|
|
163
|
+
status.success?
|
|
164
|
+
rescue StandardError
|
|
165
|
+
false
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# eslint-plugin-compat ships no executable, so check the global npm tree.
|
|
169
|
+
def npm_package_installed?(package)
|
|
170
|
+
_out, status = Open3.capture2e("npm", "ls", "-g", "--depth=0", package.to_s.split.first)
|
|
171
|
+
status.success?
|
|
172
|
+
rescue Errno::ENOENT
|
|
173
|
+
false # npm itself is missing
|
|
174
|
+
rescue StandardError
|
|
175
|
+
false
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def tool_version(tool)
|
|
179
|
+
out, status = Open3.capture2e(tool.binary, "--version")
|
|
180
|
+
status.success? ? out.strip.lines.first&.strip : nil
|
|
181
|
+
rescue StandardError
|
|
182
|
+
nil
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def mac?
|
|
186
|
+
RbConfig::CONFIG["host_os"].to_s.match?(/darwin|mac os/i)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|