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,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browsable
|
|
4
|
+
# A single feature-usage event discovered in the project's frontend code.
|
|
5
|
+
#
|
|
6
|
+
# A Finding records *what* feature was used, *where*, which browser versions
|
|
7
|
+
# it requires, and how that compares against the project's declared target.
|
|
8
|
+
# It is an immutable value object — analyzers produce them, formatters and
|
|
9
|
+
# the LSP server consume them.
|
|
10
|
+
Finding = Data.define(
|
|
11
|
+
:feature_id, # e.g. "html.global_attributes.popover"
|
|
12
|
+
:feature_name, # e.g. "popover"
|
|
13
|
+
:file, # absolute path
|
|
14
|
+
:line, # 1-based
|
|
15
|
+
:column, # 1-based
|
|
16
|
+
:required_browser_versions, # { "firefox" => "125", "safari" => "17" }
|
|
17
|
+
:target_browser_versions, # { "firefox" => "121", "safari" => "17.2" }
|
|
18
|
+
:severity, # :error | :warning | :info
|
|
19
|
+
:message # human-readable explanation
|
|
20
|
+
) do
|
|
21
|
+
def error? = severity == :error
|
|
22
|
+
def warning? = severity == :warning
|
|
23
|
+
def info? = severity == :info
|
|
24
|
+
|
|
25
|
+
# A stable, JSON-friendly hash. This is the wire format the JSON formatter
|
|
26
|
+
# and the LSP server both rely on.
|
|
27
|
+
def as_json
|
|
28
|
+
{
|
|
29
|
+
feature_id: feature_id,
|
|
30
|
+
feature_name: feature_name,
|
|
31
|
+
file: file,
|
|
32
|
+
line: line,
|
|
33
|
+
column: column,
|
|
34
|
+
required_browser_versions: required_browser_versions,
|
|
35
|
+
target_browser_versions: target_browser_versions,
|
|
36
|
+
severity: severity.to_s,
|
|
37
|
+
message: message
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browsable
|
|
4
|
+
module Formatters
|
|
5
|
+
# Emits GitHub Actions workflow commands so findings surface as inline
|
|
6
|
+
# annotations on a pull request. See:
|
|
7
|
+
# https://docs.github.com/actions/reference/workflow-commands-for-github-actions
|
|
8
|
+
class Github
|
|
9
|
+
LEVELS = { error: "error", warning: "warning", info: "notice" }.freeze
|
|
10
|
+
|
|
11
|
+
def initialize(report)
|
|
12
|
+
@report = report
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def render
|
|
16
|
+
lines = @report.findings.map { |finding| annotation(finding) }
|
|
17
|
+
|
|
18
|
+
if (suggestion = @report.suggestion)
|
|
19
|
+
lines << "::notice title=#{escape_property('browsable: suggested allow_browser')}::" \
|
|
20
|
+
"#{escape_data(suggestion.line)}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
lines.join("\n")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def annotation(finding)
|
|
29
|
+
level = LEVELS.fetch(finding.severity, "warning")
|
|
30
|
+
properties = [
|
|
31
|
+
"file=#{finding.file}",
|
|
32
|
+
"line=#{finding.line}",
|
|
33
|
+
"col=#{finding.column}",
|
|
34
|
+
"title=#{escape_property("browsable: #{finding.feature_name}")}"
|
|
35
|
+
].join(",")
|
|
36
|
+
|
|
37
|
+
"::#{level} #{properties}::#{escape_data(finding.message)}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# GitHub requires these characters escaped within workflow commands.
|
|
41
|
+
def escape_data(value)
|
|
42
|
+
value.to_s.gsub("%", "%25").gsub("\r", "%0D").gsub("\n", "%0A")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def escape_property(value)
|
|
46
|
+
escape_data(value).gsub(":", "%3A").gsub(",", "%2C")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
|
|
5
|
+
module Browsable
|
|
6
|
+
module Formatters
|
|
7
|
+
# Pretty terminal output: findings grouped by file, then sorted by position.
|
|
8
|
+
# File paths are emitted as OSC 8 hyperlinks so modern terminals make them
|
|
9
|
+
# clickable; colour is disabled automatically when stdout is not a TTY.
|
|
10
|
+
class Human
|
|
11
|
+
ICONS = { error: "✗", warning: "▲", info: "•" }.freeze
|
|
12
|
+
|
|
13
|
+
def initialize(report, color: $stdout.tty?)
|
|
14
|
+
@report = report
|
|
15
|
+
@pastel = Pastel.new(enabled: color)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def render
|
|
19
|
+
sections = [header, notes, body, skips, summary,
|
|
20
|
+
controller_policies, policy_suggestion].reject(&:empty?)
|
|
21
|
+
sections.join("\n")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
attr_reader :report, :pastel
|
|
27
|
+
|
|
28
|
+
def header
|
|
29
|
+
target = report.target
|
|
30
|
+
lines = [pastel.bold("browsable audit")]
|
|
31
|
+
if target
|
|
32
|
+
browsers = target.browsers.map { |name, version| "#{name} #{version}" }.join(", ")
|
|
33
|
+
lines << pastel.dim("target: #{target.query} (#{browsers})")
|
|
34
|
+
end
|
|
35
|
+
lines << pastel.dim("config: #{report.config_file || 'none (no config file)'}")
|
|
36
|
+
lines.join("\n") + "\n"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Run-level caveats — most importantly, a target that could not be
|
|
40
|
+
# inferred. Shown right under the header so the target line above makes
|
|
41
|
+
# sense.
|
|
42
|
+
def notes
|
|
43
|
+
return "" if report.notes.empty?
|
|
44
|
+
|
|
45
|
+
report.notes.map { |note| pastel.yellow("! #{note}") }.join("\n") + "\n"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def body
|
|
49
|
+
return pastel.green("✓ No browser-compatibility issues found.\n") if report.empty?
|
|
50
|
+
|
|
51
|
+
report.findings_by_file.map { |file, findings| file_section(file, findings) }.join("\n")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def file_section(file, findings)
|
|
55
|
+
lines = [pastel.underline(hyperlink(file))]
|
|
56
|
+
findings.each { |finding| lines << finding_line(finding) }
|
|
57
|
+
lines.join("\n") + "\n"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def finding_line(finding)
|
|
61
|
+
icon = colorize(finding.severity, ICONS.fetch(finding.severity, "•"))
|
|
62
|
+
location = pastel.dim("#{finding.line}:#{finding.column}")
|
|
63
|
+
feature = pastel.cyan(finding.feature_name)
|
|
64
|
+
" #{icon} #{location} #{feature} #{finding.message}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def skips
|
|
68
|
+
return "" if report.skips.empty?
|
|
69
|
+
|
|
70
|
+
lines = [pastel.yellow.bold("Skipped:")]
|
|
71
|
+
report.skips.each do |skip|
|
|
72
|
+
lines << pastel.yellow(" ! #{skip.kind}: #{skip.reason}")
|
|
73
|
+
end
|
|
74
|
+
lines.join("\n") + "\n"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def summary
|
|
78
|
+
e = report.errors.size
|
|
79
|
+
w = report.warnings.size
|
|
80
|
+
i = report.infos.size
|
|
81
|
+
parts = [
|
|
82
|
+
colorize(:error, "#{e} error#{'s' unless e == 1}"),
|
|
83
|
+
colorize(:warning, "#{w} warning#{'s' unless w == 1}"),
|
|
84
|
+
colorize(:info, "#{i} info#{'s' unless i == 1}")
|
|
85
|
+
]
|
|
86
|
+
pastel.bold("#{parts.join(' ')} across #{report.findings_by_file.size} file(s)") + "\n"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# A copy-pasteable allow_browser line that raises the offending browsers
|
|
90
|
+
# to the versions the flagged code requires.
|
|
91
|
+
def policy_suggestion
|
|
92
|
+
suggestion = report.suggestion
|
|
93
|
+
return "" unless suggestion
|
|
94
|
+
|
|
95
|
+
lines = [pastel.bold("Suggested allow_browser policy")]
|
|
96
|
+
lines << pastel.dim(" Raises the minimums so every permitted browser can run the flagged code:")
|
|
97
|
+
lines << ""
|
|
98
|
+
lines << " #{pastel.cyan(suggestion.line)}"
|
|
99
|
+
lines << ""
|
|
100
|
+
suggestion.bumps.each do |browser, change|
|
|
101
|
+
lines << pastel.dim(" #{browser}: #{change[:from]} → #{change[:to]}")
|
|
102
|
+
end
|
|
103
|
+
lines << pastel.dim(" Or address it in the code instead — browsable reports, you decide.")
|
|
104
|
+
lines.join("\n") + "\n"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The policy landscape: every allow_browser callsite found across the
|
|
108
|
+
# app's controllers. Shown only when there is more than one policy, or a
|
|
109
|
+
# policy somewhere other than ApplicationController — otherwise the
|
|
110
|
+
# `target:` line in the header already says everything.
|
|
111
|
+
def controller_policies
|
|
112
|
+
policies = report.policies
|
|
113
|
+
return "" if policies.empty?
|
|
114
|
+
return "" if policies.size == 1 && policies.first.application_controller?
|
|
115
|
+
|
|
116
|
+
lines = [pastel.bold("Browser policies (#{policies.size} allow_browser callsite(s) found)")]
|
|
117
|
+
policies.each { |policy| lines << " #{describe_policy(policy)}" }
|
|
118
|
+
lines << ""
|
|
119
|
+
lines << pastel.dim(" The audit above ran against one target. CSS and importmap JS are served")
|
|
120
|
+
lines << pastel.dim(" globally, so a controller with a broader policy means those assets must")
|
|
121
|
+
lines << pastel.dim(" satisfy it too — re-audit with --target, or set `target:` in config, to check.")
|
|
122
|
+
lines.join("\n") + "\n"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def describe_policy(policy)
|
|
126
|
+
scope = policy.concern ? "#{policy.scope} (concern)" : policy.scope
|
|
127
|
+
scope = scope.ljust(34)
|
|
128
|
+
actions =
|
|
129
|
+
if policy.only then " (only: #{policy.only.join(', ')})"
|
|
130
|
+
elsif policy.except then " (except: #{policy.except.join(', ')})"
|
|
131
|
+
else ""
|
|
132
|
+
end
|
|
133
|
+
"#{scope}#{pastel.cyan(policy_versions_label(policy.result))}#{pastel.dim(actions)}"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def policy_versions_label(result)
|
|
137
|
+
if result.policy.is_a?(Hash)
|
|
138
|
+
"{ #{result.policy.map { |browser, version| "#{browser}: #{version}" }.join(', ')} }"
|
|
139
|
+
elsif result.policy
|
|
140
|
+
":#{result.policy}"
|
|
141
|
+
else
|
|
142
|
+
result.note ? "(could not resolve)" : "(no versions:)"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def colorize(severity, text)
|
|
147
|
+
case severity
|
|
148
|
+
when :error then pastel.red(text)
|
|
149
|
+
when :warning then pastel.yellow(text)
|
|
150
|
+
else pastel.blue(text)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# OSC 8 hyperlink — clickable in modern terminals, plain text elsewhere.
|
|
155
|
+
def hyperlink(path)
|
|
156
|
+
return path unless $stdout.tty?
|
|
157
|
+
|
|
158
|
+
"\e]8;;file://#{path}\e\\#{path}\e]8;;\e\\"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Browsable
|
|
6
|
+
module Formatters
|
|
7
|
+
# Machine-readable formatter. This is the universal interface: the LSP
|
|
8
|
+
# server and any future MCP server consume exactly this structure. The
|
|
9
|
+
# human and github formatters are just alternate presentations of it.
|
|
10
|
+
class Json
|
|
11
|
+
def initialize(report)
|
|
12
|
+
@report = report
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def render
|
|
16
|
+
JSON.pretty_generate(@report.as_json)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Browsable
|
|
6
|
+
# Understands Rails `allow_browser` call sites.
|
|
7
|
+
#
|
|
8
|
+
# Parses controller source with Prism (no Rails boot, no eval) and resolves a
|
|
9
|
+
# call's `versions:` argument — following a constant into the same file, then
|
|
10
|
+
# across the app. It is used two ways:
|
|
11
|
+
#
|
|
12
|
+
# * PolicyDetector.call(root) — ApplicationController's policy, which drives
|
|
13
|
+
# the audit target (see Config).
|
|
14
|
+
# * #scan_calls(source) — every allow_browser call in one file, for the
|
|
15
|
+
# project-wide policy landscape (see PolicyScanner).
|
|
16
|
+
#
|
|
17
|
+
# When an argument cannot be resolved statically the Result carries a `note`
|
|
18
|
+
# instead of a policy.
|
|
19
|
+
class PolicyDetector
|
|
20
|
+
# policy: Symbol (a named policy, e.g. :modern)
|
|
21
|
+
# | Hash ("browser" => "version")
|
|
22
|
+
# | nil
|
|
23
|
+
# note: why an allow_browser call could not be resolved; nil otherwise.
|
|
24
|
+
Result = Data.define(:policy, :note) do
|
|
25
|
+
def resolved? = !policy.nil?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# One allow_browser call: its resolved versions plus any only:/except: scope.
|
|
29
|
+
CallInfo = Data.define(:result, :only, :except)
|
|
30
|
+
|
|
31
|
+
# The "nothing found" result.
|
|
32
|
+
NONE = Result.new(policy: nil, note: nil)
|
|
33
|
+
|
|
34
|
+
# Where to look for a constant defined outside the file being scanned.
|
|
35
|
+
SEARCH_GLOBS = ["app/**/*.rb", "config/**/*.rb", "lib/**/*.rb"].freeze
|
|
36
|
+
MAX_SEARCH_FILES = 600
|
|
37
|
+
|
|
38
|
+
def self.call(root) = new(root).application_policy
|
|
39
|
+
|
|
40
|
+
def initialize(root)
|
|
41
|
+
@root = root
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The policy declared on ApplicationController — the app-wide audit target.
|
|
45
|
+
# When the controller has several allow_browser calls, the first wins.
|
|
46
|
+
def application_policy
|
|
47
|
+
return NONE unless File.file?(controller_path)
|
|
48
|
+
|
|
49
|
+
calls = scan_calls(File.read(controller_path))
|
|
50
|
+
calls.empty? ? NONE : calls.first.result
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
Result.new(policy: nil, note: "could not read the allow_browser policy: #{e.message}")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Every allow_browser call in a single source file, each resolved.
|
|
56
|
+
def scan_calls(source)
|
|
57
|
+
allow_browser_calls(parse(source)).map do |call|
|
|
58
|
+
CallInfo.new(
|
|
59
|
+
result: resolve(versions_argument(call), controller_source: source),
|
|
60
|
+
only: action_names(keyword_argument(call, :only)),
|
|
61
|
+
except: action_names(keyword_argument(call, :except))
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
rescue StandardError
|
|
65
|
+
[]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def controller_path
|
|
71
|
+
@controller_path ||= File.join(@root, "app/controllers/application_controller.rb")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def parse(source)
|
|
75
|
+
Prism.parse(source).value
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# --- locating calls and arguments ----------------------------------------
|
|
79
|
+
|
|
80
|
+
def allow_browser_calls(root_node)
|
|
81
|
+
each_node(root_node).select do |node|
|
|
82
|
+
node.is_a?(Prism::CallNode) && %i[allow_browser allow_browsers].include?(node.name)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def versions_argument(call_node)
|
|
87
|
+
keyword_argument(call_node, :versions) || positional_symbol(call_node)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The value node of a keyword argument, e.g. `only:` in an allow_browser call.
|
|
91
|
+
def keyword_argument(call_node, name)
|
|
92
|
+
Array(call_node.arguments&.arguments).each do |arg|
|
|
93
|
+
next unless arg.is_a?(Prism::KeywordHashNode) || arg.is_a?(Prism::HashNode)
|
|
94
|
+
|
|
95
|
+
assoc = arg.elements.find do |element|
|
|
96
|
+
element.is_a?(Prism::AssocNode) && symbol_name(element.key) == name
|
|
97
|
+
end
|
|
98
|
+
return assoc.value if assoc
|
|
99
|
+
end
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def positional_symbol(call_node)
|
|
104
|
+
Array(call_node.arguments&.arguments).find { |arg| arg.is_a?(Prism::SymbolNode) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# A `:show` or `[:show, :edit]` argument as an array of action-name strings.
|
|
108
|
+
def action_names(node)
|
|
109
|
+
case node
|
|
110
|
+
when Prism::SymbolNode
|
|
111
|
+
[node.value]
|
|
112
|
+
when Prism::ArrayNode
|
|
113
|
+
names = node.elements.filter_map { |e| e.value if e.is_a?(Prism::SymbolNode) }
|
|
114
|
+
names.empty? ? nil : names
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# --- resolving the versions argument -------------------------------------
|
|
119
|
+
|
|
120
|
+
def resolve(node, controller_source:)
|
|
121
|
+
# A literal hash — possibly wrapped in .freeze/.dup — short-circuits here.
|
|
122
|
+
if (hash = hash_node(node))
|
|
123
|
+
return from_hash(hash)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
case node
|
|
127
|
+
when nil
|
|
128
|
+
Result.new(policy: nil, note: "an allow_browser call was found but has no versions: argument")
|
|
129
|
+
when Prism::SymbolNode
|
|
130
|
+
Result.new(policy: node.value.to_sym, note: nil)
|
|
131
|
+
when Prism::ConstantReadNode, Prism::ConstantPathNode
|
|
132
|
+
resolve_constant(node, controller_source: controller_source)
|
|
133
|
+
else
|
|
134
|
+
Result.new(policy: nil, note: unresolved_note(describe(node)))
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The literal HashNode behind a node, unwrapping a no-argument .freeze/.dup/
|
|
139
|
+
# .clone call. Returns nil when there is no literal hash.
|
|
140
|
+
def hash_node(node)
|
|
141
|
+
case node
|
|
142
|
+
when Prism::HashNode
|
|
143
|
+
node
|
|
144
|
+
when Prism::CallNode
|
|
145
|
+
if %i[freeze dup clone].include?(node.name) && node.receiver
|
|
146
|
+
hash_node(node.receiver)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def from_hash(hash_node)
|
|
152
|
+
versions = read_versions_hash(hash_node)
|
|
153
|
+
if versions
|
|
154
|
+
Result.new(policy: versions, note: nil)
|
|
155
|
+
else
|
|
156
|
+
Result.new(policy: nil, note: "the allow_browser versions hash contained no numeric versions")
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def resolve_constant(node, controller_source:)
|
|
161
|
+
name = node.name # the leaf segment, for a namespaced ConstantPathNode
|
|
162
|
+
return Result.new(policy: nil, note: unresolved_note(describe(node))) unless name
|
|
163
|
+
|
|
164
|
+
hash = find_constant_hash(controller_source, name) || search_constant(name)
|
|
165
|
+
return Result.new(policy: hash, note: nil) if hash
|
|
166
|
+
|
|
167
|
+
Result.new(
|
|
168
|
+
policy: nil,
|
|
169
|
+
note: "allow_browser references the constant #{name}, which browsable could not " \
|
|
170
|
+
"resolve to a literal versions hash. Set `target:` in config/browsable.yml " \
|
|
171
|
+
"or pass --target to be explicit."
|
|
172
|
+
)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# --- reading a versions hash ---------------------------------------------
|
|
176
|
+
|
|
177
|
+
def read_versions_hash(hash_node)
|
|
178
|
+
versions = {}
|
|
179
|
+
hash_node.elements.each do |element|
|
|
180
|
+
next unless element.is_a?(Prism::AssocNode)
|
|
181
|
+
|
|
182
|
+
browser = symbol_name(element.key)
|
|
183
|
+
version = numeric_literal(element.value)
|
|
184
|
+
# A browser mapped to false (blocked) or true (any version) has no
|
|
185
|
+
# version floor, so it is left out of the target entirely.
|
|
186
|
+
versions[browser.to_s] = version if browser && version
|
|
187
|
+
end
|
|
188
|
+
versions.empty? ? nil : versions
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def numeric_literal(node)
|
|
192
|
+
node.value.to_s if node.is_a?(Prism::IntegerNode) || node.is_a?(Prism::FloatNode)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# --- constant lookup -----------------------------------------------------
|
|
196
|
+
|
|
197
|
+
def find_constant_hash(source, name)
|
|
198
|
+
write = each_node(parse(source)).find { |node| constant_write?(node, name) }
|
|
199
|
+
return nil unless write
|
|
200
|
+
|
|
201
|
+
hash = hash_node(write.value)
|
|
202
|
+
hash && read_versions_hash(hash)
|
|
203
|
+
rescue StandardError
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def constant_write?(node, name)
|
|
208
|
+
case node
|
|
209
|
+
when Prism::ConstantWriteNode
|
|
210
|
+
node.name == name
|
|
211
|
+
when Prism::ConstantPathWriteNode
|
|
212
|
+
node.target.respond_to?(:name) && node.target.name == name
|
|
213
|
+
else
|
|
214
|
+
false
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Scan the app for the constant's definition. Files are filtered by a cheap
|
|
219
|
+
# string match before the relatively expensive Prism parse.
|
|
220
|
+
def search_constant(name)
|
|
221
|
+
needle = name.to_s
|
|
222
|
+
candidate_files.each do |file|
|
|
223
|
+
text = File.read(file)
|
|
224
|
+
next unless text.include?(needle)
|
|
225
|
+
|
|
226
|
+
hash = find_constant_hash(text, name)
|
|
227
|
+
return hash if hash
|
|
228
|
+
end
|
|
229
|
+
nil
|
|
230
|
+
rescue StandardError
|
|
231
|
+
nil
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def candidate_files
|
|
235
|
+
SEARCH_GLOBS
|
|
236
|
+
.flat_map { |glob| Dir.glob(File.join(@root, glob)) }
|
|
237
|
+
.select { |path| File.file?(path) }
|
|
238
|
+
.reject { |path| path == controller_path }
|
|
239
|
+
.first(MAX_SEARCH_FILES)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# --- helpers -------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
# Depth-first enumeration of every node in a Prism tree.
|
|
245
|
+
def each_node(node, &block)
|
|
246
|
+
return enum_for(:each_node, node) unless block
|
|
247
|
+
|
|
248
|
+
return unless node.is_a?(Prism::Node)
|
|
249
|
+
|
|
250
|
+
block.call(node)
|
|
251
|
+
node.compact_child_nodes.each { |child| each_node(child, &block) }
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def symbol_name(node)
|
|
255
|
+
node.is_a?(Prism::SymbolNode) ? node.value&.to_sym : nil
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def describe(node)
|
|
259
|
+
node.class.name.to_s.split("::").last.sub(/Node\z/, "")
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def unresolved_note(kind)
|
|
263
|
+
"allow_browser's versions: argument is a #{kind} expression that browsable cannot " \
|
|
264
|
+
"evaluate statically. Set `target:` in config/browsable.yml or pass --target."
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browsable
|
|
4
|
+
# Scans every controller and controller-concern for `allow_browser` callsites,
|
|
5
|
+
# so the report can show the full policy landscape — not just the one on
|
|
6
|
+
# ApplicationController that drives the audit target.
|
|
7
|
+
#
|
|
8
|
+
# This is deliberately *discovery only*. browsable does not try to map each
|
|
9
|
+
# frontend asset to the endpoints (and therefore policies) that serve it:
|
|
10
|
+
# CSS and importmap JavaScript are global assets, pulled in by layout helpers
|
|
11
|
+
# on essentially every page, so they have no single owning controller action.
|
|
12
|
+
# The scanner surfaces the policies; the user decides what to audit against.
|
|
13
|
+
class PolicyScanner
|
|
14
|
+
# One discovered allow_browser callsite.
|
|
15
|
+
# scope — the controller/concern it lives in (e.g. "Api::PostsController")
|
|
16
|
+
# file — path relative to the project root
|
|
17
|
+
# result — a PolicyDetector::Result (the resolved versions, or a note)
|
|
18
|
+
# only — action-name strings the policy is limited to, or nil
|
|
19
|
+
# except — action-name strings the policy excludes, or nil
|
|
20
|
+
# concern — true when the callsite is in app/controllers/concerns
|
|
21
|
+
Policy = Data.define(:scope, :file, :result, :only, :except, :concern) do
|
|
22
|
+
def application_controller? = scope == "ApplicationController"
|
|
23
|
+
def scoped? = !only.nil? || !except.nil?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
CONTROLLER_GLOB = "app/controllers/**/*.rb"
|
|
27
|
+
|
|
28
|
+
def self.call(root) = new(root).call
|
|
29
|
+
|
|
30
|
+
def initialize(root)
|
|
31
|
+
@root = File.expand_path(root)
|
|
32
|
+
@detector = PolicyDetector.new(@root)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# => Array<Policy>, in a stable (path-sorted) order.
|
|
36
|
+
def call
|
|
37
|
+
Dir.glob(File.join(@root, CONTROLLER_GLOB)).sort.flat_map { |file| scan_file(file) }
|
|
38
|
+
rescue StandardError
|
|
39
|
+
[]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def scan_file(file)
|
|
45
|
+
@detector.scan_calls(File.read(file)).map do |call|
|
|
46
|
+
Policy.new(
|
|
47
|
+
scope: scope_for(file),
|
|
48
|
+
file: file.sub("#{@root}/", ""),
|
|
49
|
+
result: call.result,
|
|
50
|
+
only: call.only,
|
|
51
|
+
except: call.except,
|
|
52
|
+
concern: file.include?("/concerns/")
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
rescue StandardError
|
|
56
|
+
[]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Derive the controller/concern constant name from the file path — robust
|
|
60
|
+
# and free of AST class-name edge cases. app/controllers/api/posts_controller.rb
|
|
61
|
+
# => "Api::PostsController"; concerns/ is dropped from the name.
|
|
62
|
+
def scope_for(file)
|
|
63
|
+
relative = file.sub(%r{\A.*?app/controllers/}, "")
|
|
64
|
+
.sub(/\.rb\z/, "")
|
|
65
|
+
.sub(%r{\Aconcerns/}, "")
|
|
66
|
+
relative.split("/").map { |segment| camelize(segment) }.join("::")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def camelize(segment)
|
|
70
|
+
segment.split("_").map(&:capitalize).join
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module Browsable
|
|
6
|
+
# Wires browsable into a host Rails application: registers the rake tasks and
|
|
7
|
+
# lets Rails discover the install generator under lib/generators.
|
|
8
|
+
#
|
|
9
|
+
# Loaded only when Rails is present (see the conditional require in
|
|
10
|
+
# lib/browsable.rb).
|
|
11
|
+
class Railtie < Rails::Railtie
|
|
12
|
+
rake_tasks do
|
|
13
|
+
load File.expand_path("rake_tasks.rb", __dir__)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# browsable rake tasks, loaded into a host Rails app by Browsable::Railtie.
|
|
4
|
+
#
|
|
5
|
+
# browsable never precompiles assets on its own — `audit:fresh` is the opt-in
|
|
6
|
+
# task for that. In CI, compose the pipeline explicitly:
|
|
7
|
+
# bundle exec rails assets:precompile && bundle exec browsable audit
|
|
8
|
+
|
|
9
|
+
require "browsable"
|
|
10
|
+
|
|
11
|
+
namespace :browsable do
|
|
12
|
+
desc "Audit the app's frontend for browser-compatibility issues"
|
|
13
|
+
task :audit do
|
|
14
|
+
Browsable::CLI.start(["audit", Rails.root.to_s])
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
namespace :audit do
|
|
18
|
+
desc "Precompile assets first, then audit the fresh build output"
|
|
19
|
+
task fresh: ["assets:precompile"] do
|
|
20
|
+
Browsable::CLI.start(["audit", Rails.root.to_s])
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
desc "Check that browsable's system dependencies are installed"
|
|
25
|
+
task :doctor do
|
|
26
|
+
Browsable::CLI.start(["doctor"])
|
|
27
|
+
end
|
|
28
|
+
end
|