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,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module Browsable
|
|
7
|
+
module Analyzers
|
|
8
|
+
# Base class for analyzers. An analyzer turns a list of files into Findings.
|
|
9
|
+
#
|
|
10
|
+
# browsable owns no parsing or compat-data logic of its own: analyzers are
|
|
11
|
+
# thin adapters over Herb (in-process) or stylelint/eslint (shelled out).
|
|
12
|
+
class Base
|
|
13
|
+
attr_reader :target, :config
|
|
14
|
+
|
|
15
|
+
def initialize(target:, config:)
|
|
16
|
+
@target = target
|
|
17
|
+
@config = config
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @param files [Array<String>] absolute paths to analyze
|
|
21
|
+
# @return [Array<Finding>]
|
|
22
|
+
def analyze(_files)
|
|
23
|
+
raise NotImplementedError, "#{self.class} must implement #analyze"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# External binaries this analyzer needs on PATH. Empty for in-process ones.
|
|
27
|
+
def required_tools = []
|
|
28
|
+
|
|
29
|
+
# The bundled MDN browser-compat-data subset, parsed once and shared.
|
|
30
|
+
def self.compat_data
|
|
31
|
+
@compat_data ||= JSON.parse(
|
|
32
|
+
File.read(File.join(Browsable.data_dir, "bcd-snapshot.json"))
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def compat_data = self.class.compat_data
|
|
39
|
+
|
|
40
|
+
# Translate a severity category into a concrete Finding severity symbol,
|
|
41
|
+
# honouring the user's `severity:` config block.
|
|
42
|
+
def severity_for(category)
|
|
43
|
+
(config.severity[category.to_s] || "warning").to_sym
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def ignored_feature?(feature_id)
|
|
47
|
+
config.ignore_features.include?(feature_id)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def dry_run? = ENV.key?("BROWSABLE_DRY_RUN")
|
|
51
|
+
|
|
52
|
+
# Run an external tool and return its stdout.
|
|
53
|
+
#
|
|
54
|
+
# In dry-run mode the process is never spawned: BROWSABLE_DRY_RUN_<KEY>
|
|
55
|
+
# supplies the output instead, either as inline JSON or as a path to a
|
|
56
|
+
# JSON file. This is the seam specs use to inject fake stylelint/eslint
|
|
57
|
+
# output without those tools installed.
|
|
58
|
+
def shell_out(argv, dry_run_key:)
|
|
59
|
+
if dry_run?
|
|
60
|
+
injected = ENV[dry_run_key]
|
|
61
|
+
return File.read(injected) if injected && File.file?(injected)
|
|
62
|
+
return injected if injected && !injected.empty?
|
|
63
|
+
|
|
64
|
+
return "[]"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# stylelint and eslint exit non-zero whenever they report problems, so
|
|
68
|
+
# the exit status is deliberately ignored — only stdout matters here.
|
|
69
|
+
stdout, _stderr, _status = Open3.capture3(*argv)
|
|
70
|
+
stdout
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
module Browsable
|
|
7
|
+
module Analyzers
|
|
8
|
+
# Audits CSS by shelling out to stylelint with the
|
|
9
|
+
# `stylelint-no-unsupported-browser-features` plugin configured for the
|
|
10
|
+
# project's target. browsable supplies the config; stylelint (and its
|
|
11
|
+
# bundled caniuse data) does the actual compatibility reasoning.
|
|
12
|
+
class CSS < Base
|
|
13
|
+
def required_tools = ["stylelint"]
|
|
14
|
+
|
|
15
|
+
def analyze(files)
|
|
16
|
+
return [] if files.empty?
|
|
17
|
+
|
|
18
|
+
argv = ["stylelint", "--config", write_stylelintrc,
|
|
19
|
+
"--formatter", "json", *files]
|
|
20
|
+
parse(shell_out(argv, dry_run_key: "BROWSABLE_DRY_RUN_CSS"))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# Write a throwaway .stylelintrc.json scoped to the current target.
|
|
26
|
+
#
|
|
27
|
+
# TODO(v0.2): pass --resolve-plugins-relative-to so a globally-installed
|
|
28
|
+
# stylelint-no-unsupported-browser-features resolves reliably regardless
|
|
29
|
+
# of where the temp config lives.
|
|
30
|
+
def write_stylelintrc
|
|
31
|
+
dir = Dir.mktmpdir("browsable-stylelint")
|
|
32
|
+
path = File.join(dir, ".stylelintrc.json")
|
|
33
|
+
File.write(path, JSON.pretty_generate(stylelint_config))
|
|
34
|
+
path
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def stylelint_config
|
|
38
|
+
{
|
|
39
|
+
"plugins" => ["stylelint-no-unsupported-browser-features"],
|
|
40
|
+
"rules" => {
|
|
41
|
+
"plugin/no-unsupported-browser-features" => [
|
|
42
|
+
true,
|
|
43
|
+
{ "browsers" => target.to_browserslist, "severity" => "warning" }
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def parse(raw)
|
|
50
|
+
data = JSON.parse(raw)
|
|
51
|
+
return [] unless data.is_a?(Array)
|
|
52
|
+
|
|
53
|
+
data.flat_map do |result|
|
|
54
|
+
Array(result["warnings"]).filter_map do |warning|
|
|
55
|
+
finding_from_warning(result["source"], warning)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
rescue JSON::ParserError
|
|
59
|
+
[]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def finding_from_warning(file, warning)
|
|
63
|
+
text = warning["text"].to_s
|
|
64
|
+
# stylelint phrases the feature as a quoted token, e.g. "css-has".
|
|
65
|
+
feature = text[/"([^"]+)"/, 1] || warning["rule"].to_s
|
|
66
|
+
return nil if ignored_feature?(feature)
|
|
67
|
+
|
|
68
|
+
Finding.new(
|
|
69
|
+
feature_id: "css.#{feature}",
|
|
70
|
+
feature_name: feature,
|
|
71
|
+
file: file,
|
|
72
|
+
line: warning["line"] || 1,
|
|
73
|
+
column: warning["column"] || 1,
|
|
74
|
+
required_browser_versions: {}, # stylelint does not expose exact versions
|
|
75
|
+
target_browser_versions: target.browsers,
|
|
76
|
+
severity: warning["severity"] == "error" ? severity_for("below_target") : :warning,
|
|
77
|
+
message: text
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browsable
|
|
4
|
+
module Analyzers
|
|
5
|
+
# Audits ERB templates (and plain HTML) by parsing them in-process with the
|
|
6
|
+
# Herb gem, then looking up every HTML element and global attribute against
|
|
7
|
+
# the bundled MDN browser-compat-data snapshot.
|
|
8
|
+
#
|
|
9
|
+
# No external tools are needed — Herb is a gem dependency — so ERB/HTML
|
|
10
|
+
# analysis works on a machine with nothing else installed.
|
|
11
|
+
class ERB < Base
|
|
12
|
+
Usage = Data.define(:kind, :name, :line, :column)
|
|
13
|
+
|
|
14
|
+
def required_tools = [] # Herb is in-process
|
|
15
|
+
|
|
16
|
+
def analyze(files)
|
|
17
|
+
files.flat_map do |file|
|
|
18
|
+
analyze_source(File.read(file), file: file)
|
|
19
|
+
rescue StandardError
|
|
20
|
+
[]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Analyze one template's source text. Exposed directly for the LSP server,
|
|
25
|
+
# which audits unsaved, in-memory buffer contents.
|
|
26
|
+
def analyze_source(source, file:)
|
|
27
|
+
extract_usages(source)
|
|
28
|
+
.uniq { |usage| [usage.kind, usage.name, usage.line] }
|
|
29
|
+
.filter_map { |usage| build_finding(usage, file: file) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# --- feature extraction --------------------------------------------------
|
|
35
|
+
|
|
36
|
+
def extract_usages(source)
|
|
37
|
+
# Trust Herb's result, including an empty one: a template with no HTML
|
|
38
|
+
# (all-ERB partials, comment-only files) legitimately yields no usages.
|
|
39
|
+
# The coarse scan runs *only* when Herb actually raises — e.g. the gem
|
|
40
|
+
# is somehow missing — never because Herb returned nothing.
|
|
41
|
+
herb_usages(source)
|
|
42
|
+
rescue StandardError
|
|
43
|
+
scan_usages(source)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def herb_usages(source)
|
|
47
|
+
require "herb"
|
|
48
|
+
|
|
49
|
+
result = ::Herb.parse(source)
|
|
50
|
+
root = result.respond_to?(:value) ? result.value : result
|
|
51
|
+
usages = []
|
|
52
|
+
walk(root) { |node| collect_usage(node, usages) }
|
|
53
|
+
usages
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def walk(node, &block)
|
|
57
|
+
return unless node
|
|
58
|
+
|
|
59
|
+
block.call(node)
|
|
60
|
+
children_of(node).each { |child| walk(child, &block) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def children_of(node)
|
|
64
|
+
%i[child_nodes compact_child_nodes children].each do |accessor|
|
|
65
|
+
next unless node.respond_to?(accessor)
|
|
66
|
+
|
|
67
|
+
kids = node.public_send(accessor)
|
|
68
|
+
return Array(kids).compact if kids
|
|
69
|
+
end
|
|
70
|
+
[]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def collect_usage(node, usages)
|
|
74
|
+
class_name = node.class.name.to_s
|
|
75
|
+
|
|
76
|
+
if class_name.include?("HTMLOpenTag") && node.respond_to?(:tag_name)
|
|
77
|
+
name = token_text(node.tag_name)
|
|
78
|
+
line, column = node_position(node)
|
|
79
|
+
usages << Usage.new(kind: :element, name: name.to_s.downcase, line: line, column: column) if name
|
|
80
|
+
elsif class_name.include?("HTMLAttribute") &&
|
|
81
|
+
!class_name.include?("Name") && !class_name.include?("Value")
|
|
82
|
+
name = token_text(node.respond_to?(:name) ? node.name : node)
|
|
83
|
+
line, column = node_position(node)
|
|
84
|
+
usages << Usage.new(kind: :attribute, name: name.to_s.downcase, line: line, column: column) if name
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Extract the text of a Herb token or name node. Herb represents a tag or
|
|
89
|
+
# attribute name as a Token (#value), a LiteralNode (#content), or a
|
|
90
|
+
# composite name node that wraps a LiteralNode in its children.
|
|
91
|
+
def token_text(obj)
|
|
92
|
+
return nil if obj.nil?
|
|
93
|
+
return obj if obj.is_a?(String)
|
|
94
|
+
return obj.value if obj.respond_to?(:value) && obj.value.is_a?(String)
|
|
95
|
+
return obj.content if obj.respond_to?(:content) && obj.content.is_a?(String)
|
|
96
|
+
|
|
97
|
+
if obj.respond_to?(:child_nodes)
|
|
98
|
+
Array(obj.child_nodes).compact.each do |child|
|
|
99
|
+
text = token_text(child)
|
|
100
|
+
return text if text
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def node_position(node)
|
|
107
|
+
loc = node.location if node.respond_to?(:location)
|
|
108
|
+
start = nil
|
|
109
|
+
start = loc.start if loc.respond_to?(:start)
|
|
110
|
+
start ||= loc.start_position if loc.respond_to?(:start_position)
|
|
111
|
+
line = start.respond_to?(:line) ? start.line : 1
|
|
112
|
+
column = start.respond_to?(:column) ? start.column.to_i + 1 : 1
|
|
113
|
+
[line, column]
|
|
114
|
+
rescue StandardError
|
|
115
|
+
[1, 1]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Degraded extraction: a line-by-line scan for opening tags and bare
|
|
119
|
+
# attribute names. Noise is mostly harmless — only names present in the
|
|
120
|
+
# BCD snapshot survive the lookup in #build_finding — but ERB tags must be
|
|
121
|
+
# blanked first so Ruby code and comment prose are never read as markup.
|
|
122
|
+
# Blanking (rather than deleting) preserves line and column numbers.
|
|
123
|
+
def scan_usages(source)
|
|
124
|
+
usages = []
|
|
125
|
+
erb_blanked(source).each_line.with_index(1) do |line, number|
|
|
126
|
+
line.scan(/<([a-zA-Z][a-zA-Z0-9-]*)/) do
|
|
127
|
+
usages << Usage.new(kind: :element, name: Regexp.last_match(1).downcase,
|
|
128
|
+
line: number, column: (Regexp.last_match.begin(1) || 0))
|
|
129
|
+
end
|
|
130
|
+
line.scan(/[\s"']([a-zA-Z][a-zA-Z0-9-]*)(?==|>|\s|\z)/) do
|
|
131
|
+
usages << Usage.new(kind: :attribute, name: Regexp.last_match(1).downcase,
|
|
132
|
+
line: number, column: (Regexp.last_match.begin(1) || 0) + 1)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
usages
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Replace every ERB tag (<% %>, <%= %>, <%# %>) with spaces, keeping
|
|
139
|
+
# newlines so line/column positions are unchanged. The contents of an ERB
|
|
140
|
+
# tag — Ruby code or comment text — are never HTML.
|
|
141
|
+
def erb_blanked(source)
|
|
142
|
+
source.gsub(/<%.*?%>/m) { |tag| tag.gsub(/[^\n]/, " ") }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# --- compat lookup -------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
def build_finding(usage, file:)
|
|
148
|
+
compat = compat_for(usage)
|
|
149
|
+
return nil unless compat
|
|
150
|
+
|
|
151
|
+
feature_id = feature_id_for(usage)
|
|
152
|
+
return nil if ignored_feature?(feature_id)
|
|
153
|
+
|
|
154
|
+
support = compat["support"] || {}
|
|
155
|
+
required = required_versions(support)
|
|
156
|
+
below = browsers_below_target(required, unsupported_browsers(support))
|
|
157
|
+
baseline = compat.dig("status", "baseline")
|
|
158
|
+
|
|
159
|
+
category = categorize(below, baseline)
|
|
160
|
+
return nil unless category # widely available — nothing worth reporting
|
|
161
|
+
|
|
162
|
+
Finding.new(
|
|
163
|
+
feature_id: feature_id,
|
|
164
|
+
feature_name: usage.name,
|
|
165
|
+
file: file,
|
|
166
|
+
line: usage.line,
|
|
167
|
+
column: usage.column,
|
|
168
|
+
required_browser_versions: required,
|
|
169
|
+
target_browser_versions: target.browsers,
|
|
170
|
+
severity: severity_for(category),
|
|
171
|
+
message: message_for(usage, category, below, required)
|
|
172
|
+
)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def compat_for(usage)
|
|
176
|
+
table =
|
|
177
|
+
if usage.kind == :element
|
|
178
|
+
compat_data.dig("html", "elements")
|
|
179
|
+
else
|
|
180
|
+
compat_data.dig("html", "global_attributes")
|
|
181
|
+
end
|
|
182
|
+
entry = table&.dig(usage.name)
|
|
183
|
+
entry && entry["__compat"]
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def feature_id_for(usage)
|
|
187
|
+
segment = usage.kind == :element ? "elements" : "global_attributes"
|
|
188
|
+
"html.#{segment}.#{usage.name}"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def categorize(below, baseline)
|
|
192
|
+
return "below_target" if below.any?
|
|
193
|
+
return "baseline_limited" if baseline == false
|
|
194
|
+
return "baseline_newly_available" if baseline == "low"
|
|
195
|
+
|
|
196
|
+
nil
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def required_versions(support)
|
|
200
|
+
support.each_with_object({}) do |(browser, info), out|
|
|
201
|
+
added = support_version(info)
|
|
202
|
+
out[browser] = added if added.is_a?(String)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def support_version(info)
|
|
207
|
+
entry = info.is_a?(Array) ? info.first : info
|
|
208
|
+
entry && entry["version_added"]
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def unsupported_browsers(support)
|
|
212
|
+
support.select { |_browser, info| support_version(info) == false }.keys
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def browsers_below_target(required, unsupported)
|
|
216
|
+
target.browsers.filter_map do |browser, floor|
|
|
217
|
+
if unsupported.include?(browser)
|
|
218
|
+
browser
|
|
219
|
+
elsif (req = required[browser]) && version_gt?(req, floor)
|
|
220
|
+
browser
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def version_gt?(left, right)
|
|
226
|
+
Gem::Version.new(left.to_s) > Gem::Version.new(right.to_s)
|
|
227
|
+
rescue ArgumentError
|
|
228
|
+
false
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def message_for(usage, category, below, required)
|
|
232
|
+
label = usage.kind == :element ? "<#{usage.name}>" : "the '#{usage.name}' attribute"
|
|
233
|
+
|
|
234
|
+
case category
|
|
235
|
+
when "below_target"
|
|
236
|
+
clauses = below.map do |browser|
|
|
237
|
+
name = titleize(browser)
|
|
238
|
+
required_version = required[browser]
|
|
239
|
+
if required_version
|
|
240
|
+
"needs #{name} #{required_version}+, but your target allows #{name} #{target.minimum_version(browser)}"
|
|
241
|
+
else
|
|
242
|
+
"is not supported by #{name} at any version"
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
"#{label} #{clauses.join('; ')}."
|
|
246
|
+
when "baseline_limited"
|
|
247
|
+
"#{label} has limited availability (not Baseline) — provide a fallback."
|
|
248
|
+
else
|
|
249
|
+
"#{label} is newly available (Baseline low) — confirm it covers the browsers you support."
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def titleize(browser)
|
|
254
|
+
browser.split("_").first.capitalize
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browsable
|
|
4
|
+
module Analyzers
|
|
5
|
+
# Audits static .html files. Herb parses HTML and ERB with the same API, so
|
|
6
|
+
# the only difference from the ERB analyzer is intent — this exists as a
|
|
7
|
+
# distinct class so the orchestrator can route plain-HTML sources explicitly.
|
|
8
|
+
class HTML < ERB
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
module Browsable
|
|
7
|
+
module Analyzers
|
|
8
|
+
# Audits JavaScript by shelling out to eslint with eslint-plugin-compat
|
|
9
|
+
# configured for the project's target. As with CSS, browsable supplies the
|
|
10
|
+
# config and eslint (with its bundled compat data) does the reasoning.
|
|
11
|
+
class Javascript < Base
|
|
12
|
+
def required_tools = ["eslint"]
|
|
13
|
+
|
|
14
|
+
def analyze(files)
|
|
15
|
+
return [] if files.empty?
|
|
16
|
+
|
|
17
|
+
# TODO(v0.2): emit an eslint 9 flat config (eslint.config.mjs) and
|
|
18
|
+
# detect which config format the installed eslint expects.
|
|
19
|
+
argv = ["eslint", "--no-eslintrc", "--config", write_eslintrc,
|
|
20
|
+
"--format", "json", *files]
|
|
21
|
+
parse(shell_out(argv, dry_run_key: "BROWSABLE_DRY_RUN_JS"))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def write_eslintrc
|
|
27
|
+
dir = Dir.mktmpdir("browsable-eslint")
|
|
28
|
+
path = File.join(dir, ".eslintrc.json")
|
|
29
|
+
File.write(path, JSON.pretty_generate(eslint_config))
|
|
30
|
+
path
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def eslint_config
|
|
34
|
+
{
|
|
35
|
+
"root" => true,
|
|
36
|
+
"parserOptions" => { "ecmaVersion" => "latest", "sourceType" => "module" },
|
|
37
|
+
"env" => { "browser" => true, "es2024" => true },
|
|
38
|
+
"plugins" => ["compat"],
|
|
39
|
+
"extends" => ["plugin:compat/recommended"],
|
|
40
|
+
"settings" => { "browsers" => target.to_browserslist }
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def parse(raw)
|
|
45
|
+
data = JSON.parse(raw)
|
|
46
|
+
return [] unless data.is_a?(Array)
|
|
47
|
+
|
|
48
|
+
data.flat_map do |result|
|
|
49
|
+
Array(result["messages"]).filter_map do |message|
|
|
50
|
+
finding_from_message(result["filePath"], message)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
rescue JSON::ParserError
|
|
54
|
+
[]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def finding_from_message(file, message)
|
|
58
|
+
rule = message["ruleId"]
|
|
59
|
+
return nil unless rule # syntax errors etc. carry no ruleId
|
|
60
|
+
|
|
61
|
+
text = message["message"].to_s
|
|
62
|
+
# eslint-plugin-compat reports every feature under the `compat/compat`
|
|
63
|
+
# rule; the feature itself is the leading token of the message.
|
|
64
|
+
feature = text[/\A(\S+)/, 1] || rule
|
|
65
|
+
return nil if ignored_feature?(feature)
|
|
66
|
+
|
|
67
|
+
Finding.new(
|
|
68
|
+
feature_id: "javascript.#{feature}",
|
|
69
|
+
feature_name: feature,
|
|
70
|
+
file: file,
|
|
71
|
+
line: message["line"] || 1,
|
|
72
|
+
column: message["column"] || 1,
|
|
73
|
+
required_browser_versions: {},
|
|
74
|
+
target_browser_versions: target.browsers,
|
|
75
|
+
severity: message["severity"] == 2 ? severity_for("below_target") : :warning,
|
|
76
|
+
message: text
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|