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.
@@ -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