browsable 0.1.0 → 0.2.0.pre.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8fbd1c4932fbe740cf4d0560fbeb4738c51c3129efdb3c7a3e48b86bc47a743d
4
- data.tar.gz: f6d98e30cc6e54c6c5fe643dbf3eada45a08af171e2632561c8d91f1b49c74b6
3
+ metadata.gz: 9484cb575cb9dec80631552a5fad005ad12c8cec353c61d139d9a299072a4f05
4
+ data.tar.gz: 5c1c08df4256e57348aa46c381e81a2bc628cc2bf4cd3ba8a808eef56c67b425
5
5
  SHA512:
6
- metadata.gz: 5f21b860c35d76a1011a2152a40ab9cbb232e7378d93f61fec3c75f4cea1cd5b69829ea4e77ffd2ae5f7406334e436d4997a17ad41fb769f253362e579d5baa6
7
- data.tar.gz: 9e996ecdb1dded29f78c2b30c3b5c37178bd7c339800680bca51947934c6addf965534d09a1b3b03b5358b2134b542b056eab354b9582ba9864dc2dc806f57d7
6
+ metadata.gz: 97f7dff9786a2b8d697e7043c8a8f61000ef5a44e3e7826e2c9e5de1639b370a7e3908bb8581813dbefdfeda304bfb561f232d287f4c82adaae58782b36d6f90
7
+ data.tar.gz: d2d0002312062a738b2179126c32bf958a9997c722d7ec46903de6b9e8af535a2ebe2847c09f56873d330a75e0d0c9fedabc2d48bbbbb71731b33c3223d6667b
data/CHANGELOG.md CHANGED
@@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ### Added — v0.2: Runtime response auditing
11
+
12
+ - **Runtime mode.** A Rack middleware (`Browsable::Middleware`) observes HTML
13
+ responses during a test run, records the controller#action, the effective
14
+ `allow_browser` policy, and every asset the response loaded, into a
15
+ thread-safe `Browsable::AuditLog`. Test-suite integration is opt-in via a
16
+ single `require`: `browsable/rspec` or `browsable/minitest`.
17
+ - **End-of-suite analysis (`Browsable::TestReport`).** The middleware records;
18
+ it never analyzes. At suite end, stylelint and eslint are invoked **once**
19
+ each over the deduplicated union of every asset loaded across the suite —
20
+ not per request. A 500-spec suite that loads 10 unique CSS files spawns
21
+ exactly one stylelint process.
22
+ - **Per-endpoint policy resolution (`Browsable::PolicyResolver`).** Walks the
23
+ controller's ancestor chain, applies each `allow_browser` call's
24
+ `only:`/`except:` filter to the action, and the last matching call wins —
25
+ matching Rails' filter-callback semantics.
26
+ - **Asset URL → on-disk path resolution (`Browsable::AssetResolver`).** Strips
27
+ digests, honors the configured asset host, and walks Propshaft and Sprockets
28
+ search paths with a public/ fallback. The ≥95% resolution bar is enforced by
29
+ the new `bin/benchmark-asset-resolution` script.
30
+ - **`browsable replay PATH`** — re-renders a JSON audit dump through any
31
+ formatter, suitable for emitting GitHub annotations in CI from a saved
32
+ test-suite report.
33
+ - **Dependencies.** Adds `nokogiri` (response HTML parsing), `concurrent-ruby`
34
+ (thread-safe `AuditLog`), and `rack` (Rack body handling) as runtime gem
35
+ dependencies. **The user's Rails app still has no `package.json` and no
36
+ `node_modules`.** Runtime mode shells out to the same globally-installed
37
+ `stylelint` and `eslint` as static mode.
38
+
39
+ ### Unchanged
40
+
41
+ - Static `browsable audit` and the v0.1 CLI surface are fully backwards
42
+ compatible. Runtime mode is purely additive.
43
+
10
44
  ## [0.1.0]
11
45
 
12
46
  - Initial release.
data/README.md CHANGED
@@ -1,32 +1,56 @@
1
- # browsable
1
+ <div align="center">
2
2
 
3
- **Rails-aware browser-compatibility auditing for your frontend code.**
3
+ # Browsable
4
4
 
5
- `browsable` audits a Rails application's CSS, HTML, ERB, and JavaScript and
6
- reports which browsers can actually render and run it — then compares that
7
- against the project's declared `allow_browser` policy.
5
+ **Rails-aware browser-compatibility auditing for your frontend.**
8
6
 
9
- The name is a play on Rails 8's `allow_browser` controller API. Instead of
10
- *declaring* which browsers you allow, `browsable` tells you which browsers your
11
- code is actually **browsable by**.
7
+ Find out which browsers your Rails app is actually *browsable by* before your users do.
12
8
 
13
- > This is the core gem of the [`browsable` monorepo](https://github.com/romanhood/browsable).
14
- > See also [`browsable-lsp`](../browsable-lsp) (editor diagnostics) and
15
- > [`browsable.nvim`](../browsable.nvim) (Neovim plugin).
9
+ [![Gem Version](https://img.shields.io/gem/v/browsable.svg)](https://rubygems.org/gems/browsable)
10
+ [![CI](https://github.com/romanhood/browsable/actions/workflows/ci.yml/badge.svg)](https://github.com/romanhood/browsable/actions/workflows/ci.yml)
11
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](../LICENSE)
12
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.2-CC342D.svg)](https://www.ruby-lang.org/)
16
13
 
17
- ## Philosophy
14
+ </div>
18
15
 
19
- - **The gem owns no parsing or compat-data logic.** It shells out to mature
20
- tools that already do this well — [Herb](https://github.com/marcoroth/herb)
21
- for ERB, [stylelint](https://stylelint.io/) for CSS,
22
- [eslint-plugin-compat](https://github.com/amilajack/eslint-plugin-compat) for
23
- JavaScript. The gem's value is the Rails-aware glue.
24
- - **No `package.json`, no `node_modules` in your Rails repo.** The external
25
- tools live globally on your machine. `browsable doctor` detects them and
26
- guides installation.
27
- - **It reports, it doesn't decide.** It tells you what your code requires and
28
- what your config permits. You decide what to do.
29
- - **Configuration is optional.** `browsable` runs with zero config.
16
+ ---
17
+
18
+ Browsable audits a Rails application's CSS, HTML, ERB, and JavaScript and reports which browsers can actually render and run it — then compares the answer against the `allow_browser` policy you've declared.
19
+
20
+ The name is a play on Rails 8's `allow_browser` controller API. Instead of *declaring* which browsers you allow, `browsable` tells you which browsers your code is actually browsable by.
21
+
22
+ > 📦 This is the core gem of the [`browsable` monorepo][monorepo].
23
+ > See also [`browsable-lsp`][lsp] for editor diagnostics and [`browsable.nvim`][nvim] for Neovim.
24
+
25
+ ## Table of contents
26
+
27
+ - [Why Browsable?](#why-browsable)
28
+ - [Installation](#installation)
29
+ - [Quick start](#quick-start)
30
+ - [System dependencies](#system-dependencies)
31
+ - [CLI reference](#cli-reference)
32
+ - [Configuration](#configuration)
33
+ - [How it works](#how-it-works)
34
+ - [Runtime auditing (test-suite mode)](#runtime-auditing-test-suite-mode)
35
+ - [Per-controller policies](#per-controller-and-per-action-policies)
36
+ - [Suggested policy fixes](#suggested-allow_browser-fix)
37
+ - [Rake tasks](#rake-tasks)
38
+ - [Contributing](#contributing)
39
+ - [License](#license)
40
+
41
+ ## Why Browsable?
42
+
43
+ Rails 8 made browser support a first-class concern with `allow_browser`. But the framework has no opinion on whether your CSS actually works in the browsers you allowed. You can declare `allow_browser :modern` and silently ship `:has()` selectors that break in Safari 15. There was no tool that closed that loop — until now.
44
+
45
+ Browsable closes it by:
46
+
47
+ - 🔍 **Reading your `allow_browser` policy** straight from `ApplicationController`
48
+ - 🎯 **Translating it** into a precise browserslist query
49
+ - 📂 **Discovering** your stylesheets, views, JavaScript, and importmap pins
50
+ - ✅ **Auditing each** against best-in-class compat databases (MDN BCD, caniuse)
51
+ - 📋 **Reporting** by file, with exact lines and suggested fixes
52
+
53
+ No `package.json`. No `node_modules`. No build-system pollution in your Rails repo.
30
54
 
31
55
  ## Installation
32
56
 
@@ -44,49 +68,84 @@ Then `bundle install`. Or install it standalone:
44
68
  gem install browsable
45
69
  ```
46
70
 
47
- ## System dependenciesthe `doctor` workflow
71
+ > 💡 **Heads up:** Browsable shells out to `stylelint` and `eslint` for CSS and JS analysis. These live globally on your machine *not* in your Rails repo. Run `browsable doctor` to check and install them.
48
72
 
49
- `browsable` shells out to `stylelint` and `eslint` (and the `node` runtime they
50
- need). Those are *not* gem dependencies — they live globally on your machine.
51
- Check what you have:
73
+ ## Quick start
52
74
 
53
75
  ```bash
54
- bundle exec browsable doctor
76
+ bundle exec browsable audit
55
77
  ```
56
78
 
57
- `doctor` prints, for each missing tool, the exact command to install it. Let it
58
- do the work for you:
79
+ That's it. With zero configuration, Browsable will:
80
+
81
+ 1. **Read** `ApplicationController`'s `allow_browser` policy to learn your target
82
+ 2. **Discover** your stylesheets, views, JavaScript, and importmap pins
83
+ 3. **Audit** each against that target
84
+ 4. **Report** the findings, grouped by file
85
+
86
+ ### Example output
59
87
 
60
- ```bash
61
- bundle exec browsable doctor --fix # installs missing tools via brew / npm
62
88
  ```
89
+ $ bundle exec browsable audit
63
90
 
64
- ERB and HTML analysis needs nothing extra — the `herb` gem is a dependency and
65
- runs in-process.
91
+ Target inferred from ApplicationController.allow_browser :modern
92
+ chrome 120, edge 120, firefox 121, safari 17.2, opera 106
66
93
 
67
- ## Quick start
94
+ app/assets/stylesheets/cards.css
95
+ 42:3 :has() selector requires Safari 15.4+ (policy allows 17.2 ✓)
96
+ 87:5 @container query requires Firefox 110+ (policy allows 121 ✓)
97
+
98
+ ✗ app/views/legacy/embed.html.erb
99
+ 14:22 <dialog> element requires Safari 15.4+, but the LegacyController
100
+ policy allows Safari 12.0
101
+
102
+ Browser policies (2 found)
103
+ ApplicationController :modern
104
+ LegacyController { safari: 12, chrome: 60 } (only: embed)
105
+
106
+ 1 error, 0 warnings — exit 1
107
+ ```
108
+
109
+ ## System dependencies
110
+
111
+ Browsable shells out to a few external tools that live globally on your machine:
112
+
113
+ | Tool | Purpose | Required? |
114
+ | --- | --- | --- |
115
+ | `node` | JavaScript runtime for `stylelint` & `eslint` | Yes |
116
+ | `stylelint` | CSS compatibility analysis | Yes (CSS audits) |
117
+ | `eslint` + `eslint-plugin-compat` | JavaScript compatibility analysis | Yes (JS audits) |
118
+ | `browserslist` | Live resolution of `defaults` queries | Optional |
119
+ | `herb` | ERB parsing | Bundled (gem dep) |
120
+
121
+ ### The `doctor` workflow
68
122
 
69
123
  ```bash
70
- bundle exec browsable audit
124
+ bundle exec browsable doctor
71
125
  ```
72
126
 
73
- That's it. With no configuration, `browsable`:
127
+ For each missing tool, `doctor` prints the exact command to install it. Or let it do the work:
74
128
 
75
- 1. reads `ApplicationController`'s `allow_browser` policy to learn your target,
76
- 2. discovers your stylesheets, views, JavaScript, and importmap pins,
77
- 3. audits each against that target, and
78
- 4. prints a report grouped by file.
129
+ ```bash
130
+ bundle exec browsable doctor --fix
131
+ ```
132
+
133
+ This installs missing tools via `brew` or `npm` — opt-in, never automatic.
79
134
 
80
135
  ## CLI reference
81
136
 
137
+ ### Commands
138
+
82
139
  | Command | Purpose |
83
140
  | --- | --- |
84
- | `browsable` / `browsable audit [PATH]` | Full project audit |
141
+ | `browsable` *(or `browsable audit`)* | Full project audit |
142
+ | `browsable audit [PATH]` | Audit a specific directory |
143
+ | `browsable check FILE [FILE...]` | Audit specific files *(used by editors)* |
85
144
  | `browsable doctor` | Check system dependencies |
86
- | `browsable doctor --fix` | Install missing dependencies (opt-in) |
87
- | `browsable check FILE [FILE...]` | Audit specific files (used by editors) |
145
+ | `browsable doctor --fix` | Install missing dependencies |
88
146
  | `browsable target [PATH]` | Show the inferred browser-support target |
89
- | `browsable init` | Generate a `.browsable.yml` (non-Rails projects) |
147
+ | `browsable replay PATH` | Reformat a JSON audit dump *(test-suite mode → GitHub annotations)* |
148
+ | `browsable init` | Generate `.browsable.yml` *(non-Rails projects)* |
90
149
  | `browsable version` | Print the version |
91
150
 
92
151
  ### Flags
@@ -94,147 +153,281 @@ That's it. With no configuration, `browsable`:
94
153
  | Flag | Effect |
95
154
  | --- | --- |
96
155
  | `--target QUERY` | Override the inferred browserslist query |
97
- | `--json` | Emit findings as JSON (shortcut for `--format json`) |
156
+ | `--json` | Emit findings as JSON *(shortcut for `--format json`)* |
98
157
  | `--format human\|json\|github` | Choose the output formatter |
99
- | `--no-build` | Scan only what is on disk (`browsable` never builds assets itself) |
100
- | `--include GLOB` | Add a path glob to the audit (repeatable) |
101
- | `--exclude GLOB` | Exclude a path glob (repeatable) |
158
+ | `--no-build` | Scan only what's on disk *(Browsable never builds assets itself)* |
159
+ | `--include GLOB` | Add a path glob *(repeatable)* |
160
+ | `--exclude GLOB` | Exclude a path glob *(repeatable)* |
102
161
  | `--fail-on warning\|error` | Exit-code policy for CI |
103
162
  | `--config PATH` | Override the config file location |
104
163
 
105
- The `--json` output is the universal interface: the LSP server (and any future
106
- MCP server) consume exactly that structure. The human and GitHub formatters are
107
- just alternate presentations of the same data.
164
+ > 💡 The `--json` output is the universal interface. The LSP server and any future MCP server consume that exact structure. The `human` and `github` formatters are just alternate presentations of the same data.
108
165
 
109
- ## Rails generator
166
+ ## Configuration
167
+
168
+ **Browsable needs no config file.** Configuration is for overrides only.
169
+
170
+ When a file is present, it's discovered in this order:
171
+
172
+ 1. The path passed to `--config`
173
+ 2. `config/browsable.yml` *(preferred in Rails apps)*
174
+ 3. `.browsable.yml` in the working directory
175
+
176
+ Resolution precedence (highest wins):
177
+
178
+ ```
179
+ CLI flags → config file → inferred Rails config → gem defaults
180
+ ```
181
+
182
+ ### Generating a config file
110
183
 
111
184
  ```bash
112
185
  rails g browsable:install
113
186
  ```
114
187
 
115
- This writes a fully-commented `config/browsable.yml` — every option present,
116
- commented out, set to its default. It is a self-documenting reference: uncomment
117
- a line to override it. Flags: `--minimal`, `--target QUERY`, `--force`.
188
+ This writes a fully-commented `config/browsable.yml` — every option present, commented out, set to its default. It's a self-documenting reference: uncomment a line to override it.
189
+
190
+ | Flag | Effect |
191
+ | --- | --- |
192
+ | `--minimal` | Section headers only, no option reference |
193
+ | `--target QUERY` | Pre-populate the target |
194
+ | `--force` | Overwrite an existing config |
118
195
 
119
196
  Non-Rails projects use `browsable init`, which writes `.browsable.yml` instead.
120
197
 
121
- ## Configuration
198
+ ## How it works
122
199
 
123
- `browsable` needs no config file. When one is present it is discovered in this
124
- order:
200
+ ### The inference chain
125
201
 
126
- 1. the path passed to `--config`
127
- 2. `config/browsable.yml` (preferred in Rails apps)
128
- 3. `.browsable.yml` in the working directory
202
+ ```
203
+ ApplicationController.allow_browser → Target
204
+ :modern chrome 120, safari 17.2, ...
205
+
206
+
207
+ config/importmap.rb ─┐ Sources
208
+ app/assets/** ─┼─→ discovered files ─→ │
209
+ app/views/** ─┤ │
210
+ app/javascript/** ─┘ ▼
211
+ Analyzers
212
+
213
+ CSS → stylelint
214
+ ERB → Herb + MDN BCD
215
+ HTML → Herb + MDN BCD
216
+ JS → eslint + eslint-plugin-compat
217
+
218
+
219
+ Report → Formatter
220
+ ```
221
+
222
+ Browsable's job is the **glue between Rails-land and browserslist-land**. It reads `allow_browser :modern`, expands it to concrete browser versions, configures stylelint and eslint with that target, and runs Herb against a bundled MDN browser-compat-data snapshot for ERB and HTML.
223
+
224
+ ### Partial `allow_browser` policies
225
+
226
+ If your `allow_browser` policy is a hash that pins only some browsers — say `versions: { safari: 16.4, firefox: 121 }` — Rails leaves every browser you *don't* list allowed at any version. It only blocks a browser it was explicitly given a minimum (or `false`) for.
129
227
 
130
- Resolution precedence (highest wins): **CLI flags config file inferred Rails
131
- config → gem defaults**. See the generated `config/browsable.yml` for the full,
132
- commented option reference.
228
+ Browsable audits exactly the browsers you pinned and prints a note naming the rest. To audit against more, set an explicit `target:` in `config/browsable.yml`. The same note-and-fall-back-to-`defaults` behavior applies when Browsable can't resolve your policy statically.
229
+
230
+ ### Where `defaults` comes from
133
231
 
134
- ## How it works — the inference chain
232
+ When there's no `allow_browser` policy at all, Browsable audits against the [browserslist `defaults`][browserslist] query — the "reasonable broad support" baseline the wider frontend ecosystem uses.
233
+
234
+ - **With `browserslist` installed** *(`npm install -g browserslist`)*: resolved live from caniuse data
235
+ - **Without it**: a small built-in approximation, with a note saying so
236
+
237
+ Either way, these versions are *not* a Rails concept — Rails blocks nothing unless you call `allow_browser` — and they aren't derived from stylelint or eslint. For a precise, stable target, set `target:` in `config/browsable.yml`.
238
+
239
+ ## Suggested `allow_browser` fix
240
+
241
+ When an audit finds errors that are purely a version conflict — your code needs a browser version newer than your policy permits — Browsable prints a ready-to-paste `allow_browser` line that raises *only* the offending browsers to the minimum those features require:
135
242
 
136
243
  ```
137
- ApplicationController.allow_browser → Target (browserslist query) → Analyzers
138
- :modern chrome 120, safari 17.2 │
139
-
140
- config/importmap.rb ─┐ CSS → stylelint
141
- app/assets/** ─┼─→ Sources ─→ files by kind ───────── ERB → Herb + BCD
142
- app/views/** ─┤ HTML Herb + BCD
143
- app/javascript/** ─┘ JS → eslint
144
-
145
-
146
- Report → Formatter
244
+ 💡 Suggested allow_browser policy
245
+
246
+ allow_browser versions: {
247
+ chrome: 120,
248
+ edge: 120,
249
+ firefox: 125, # was 121
250
+ safari: 17.2,
251
+ opera: 106
252
+ }
147
253
  ```
148
254
 
149
- `browsable` translates between Rails-land and browserslist-land: it reads
150
- `allow_browser :modern`, expands it to concrete browser versions, configures
151
- stylelint/eslint with that target, and runs Herb against the bundled MDN
152
- browser-compat-data snapshot for ERB/HTML.
255
+ It's a suggestion, not an instruction. Tightening the policy is one fix; changing the code (a fallback, a `@supports` rule) is another. **Browsable reports you decide.**
153
256
 
154
- ### Partial `allow_browser` policies
257
+ The suggestion is derived from HTML/ERB findings, which carry exact version data. It also appears in `--json` output as `suggested_policy` and as a GitHub Actions notice.
155
258
 
156
- If your `allow_browser` policy is a hash that pins only some browsers — say
157
- `versions: { safari: 16.4, firefox: 121 }` — Rails leaves every browser you
158
- *don't* list allowed at **any** version (it only blocks a browser it was given a
159
- minimum, or `false`, for). browsable audits exactly the browsers you pinned and
160
- prints a note naming the rest. To audit against more, set an explicit `target:`
161
- in `config/browsable.yml`. The same note-and-fall-back-to-`defaults` behaviour
162
- applies when browsable cannot resolve your policy statically.
259
+ ## Runtime auditing (test-suite mode)
163
260
 
164
- ### Where `defaults` comes from
261
+ Static mode answers the question *“does my codebase satisfy a single browser-support target?”*. Runtime mode answers a sharper one: *“for every endpoint in my app, does the HTML it actually renders satisfy that endpoint's policy?”* It does this without trying to build a static asset → endpoint graph — instead, it lets Rails itself say what each endpoint renders during a test run, and audits *that*.
262
+
263
+ Runtime mode uses **the same machine-level tools as static mode** — `node`, `stylelint`, `eslint`. No `package.json`, no `node_modules` in your Rails app. The middleware records during the suite; analysis happens **once**, at the end, with one stylelint and one eslint invocation regardless of how many request specs you ran.
165
264
 
166
- When there is no `allow_browser` policy at all, browsable audits against the
167
- [browserslist](https://github.com/browserslist/browserslist) `defaults` query —
168
- the "reasonable broad support" baseline the wider frontend ecosystem uses. It is
169
- resolved **live** from caniuse data when the `browserslist` CLI is installed
170
- (`npm install -g browserslist`); otherwise browsable uses a small **built-in
171
- approximation** and says so in a note. Either way these versions are *not* a
172
- Rails concept — Rails blocks nothing unless you call `allow_browser` — and they
173
- are not derived from stylelint or eslint. For a precise, stable target, set
174
- `target:` in `config/browsable.yml`.
175
-
176
- ### Suggested `allow_browser` fix
177
-
178
- When an audit finds errors that are purely a version conflict — your code needs
179
- a browser version newer than your policy permits — browsable prints a ready-to-paste
180
- `allow_browser` line that raises *only* the offending browsers to the minimum
181
- those features require, leaving every other browser untouched:
182
-
183
- ```
184
- Suggested allow_browser policy
185
- allow_browser versions: { chrome: 120, edge: 120, firefox: 125, safari: 17.2, opera: 106 }
186
- firefox: 121 → 125
187
- ```
188
-
189
- It is a suggestion, not an instruction: tightening the policy is one fix, changing
190
- the code (a fallback, a `@supports` rule) is another. browsable reports; you
191
- decide. The suggestion is derived from HTML/ERB findings, which carry exact
192
- version data; it also appears in `--json` (`suggested_policy`) and as a GitHub
193
- Actions notice.
194
-
195
- ### Per-controller and per-action policies
196
-
197
- Rails lets any controller override `allow_browser`, and scope the override to
198
- certain actions with `only:`/`except:`. browsable scans every file under
199
- `app/controllers/` (including `concerns/`) and lists each `allow_browser` call
200
- it finds its versions and any action scope — under **Browser policies** in the
201
- report.
202
-
203
- The audit itself runs against a single target (ApplicationController's policy,
204
- or your `config/browsable.yml`). browsable does **not** try to map each frontend
205
- asset to the exact endpoints — and policies — that serve it. CSS and importmap
206
- JavaScript are global assets, included via layout helpers on nearly every page,
207
- so they have no single owning controller action; a per-asset policy graph would
208
- be guesswork. Instead, browsable shows you the whole policy landscape: if a
209
- controller serves shared assets to a broader range of browsers than
210
- ApplicationController, audit against that policy explicitly with `--target` or
211
- `config/browsable.yml`. Per-action auditing of `app/views/<controller>/`
212
- templates against their controller's policy is a planned refinement.
265
+ ### Adoption
266
+
267
+ ```ruby
268
+ # Gemfile
269
+ group :development, :test do
270
+ gem "browsable"
271
+ end
272
+ ```
273
+
274
+ ```bash
275
+ bundle install
276
+ bundle exec browsable doctor # one-time, installs stylelint / eslint if missing
277
+ ```
278
+
279
+ ```ruby
280
+ # spec/rails_helper.rb (RSpec)
281
+ require "browsable/rspec"
282
+
283
+ # OR test/test_helper.rb (Minitest)
284
+ require "browsable/minitest"
285
+ ```
286
+
287
+ Then run your suite as you normally would:
288
+
289
+ ```bash
290
+ bundle exec rspec # or: bundle exec rails test
291
+ ```
292
+
293
+ At end-of-suite Browsable prints a report grouped by `Controller#action`, with each finding evaluated against that endpoint's effective `allow_browser` policy.
294
+
295
+ ### How it works
296
+
297
+ ```
298
+ ┌──────────────────────┐
299
+ │ Rack middleware │ per request: parse HTML, resolve asset URLs,
300
+ │ (records only) │ look up policy, push to AuditLog. NO subprocesses.
301
+ └──────────┬───────────┘
302
+
303
+
304
+ ┌──────────────────────┐
305
+ │ AuditLog │ thread-safe accumulator of
306
+ │ (in-memory) │ (endpoint, policy, html, asset_paths)
307
+ └──────────┬───────────┘
308
+ │ end of suite
309
+
310
+ ┌──────────────────────┐
311
+ │ TestReport │ deduplicated asset universe
312
+ │ (one stylelint, │ stylelint × 1, eslint × 1
313
+ │ one eslint call) │ findings ➜ attributed back to endpoints
314
+ └──────────────────────┘
315
+ ```
316
+
317
+ - **The middleware never analyzes.** Per-request work is a Nokogiri parse plus URL resolution — under a few milliseconds on a typical page.
318
+ - **The middleware never runs in production.** It raises at construction if `Rails.env.production?`.
319
+ - **Analysis is one batch.** A 500-spec suite that hits 50 unique HTML pages loading the same 10 CSS files spawns **two Node processes**, total — not 500.
320
+ - **Endpoint-level policies.** The `PolicyResolver` walks each controller's ancestor chain, applies each `allow_browser` call's `only:`/`except:` filter, and picks the last matching one — matching Rails' own filter-callback semantics.
321
+
322
+ ### Configuration
323
+
324
+ The drivers ship with sensible defaults. Override per-suite:
325
+
326
+ ```ruby
327
+ Browsable::RSpec.configure do |c|
328
+ c.fail_on = :error # :error | :warning | :never
329
+ c.format = :human # :human | :json | :github
330
+ c.output = "tmp/browsable_report.json"
331
+ end
332
+ ```
333
+
334
+ For CI: dump the report as JSON during the test run, then re-render it as GitHub annotations:
335
+
336
+ ```bash
337
+ bundle exec rspec
338
+ bundle exec browsable replay tmp/browsable_report.json --format github
339
+ ```
340
+
341
+ ### Example output
342
+
343
+ ```
344
+ browsable audit
345
+ target: runtime-union (chrome 100, firefox 121, safari 17.2)
346
+
347
+ [response] PostsController#show
348
+ ✗ 14:22 popover the 'popover' attribute needs Safari 17+, but PostsController#show
349
+ policy allows Safari 15
350
+
351
+ app/assets/builds/application.css
352
+ ▲ 42:3 css-has ":has()" is not a known feature
353
+
354
+ Browser policies (2 found)
355
+ ApplicationController :modern
356
+ LegacyController { safari: 15, chrome: 100 } (only: embed)
357
+
358
+ 1 error, 1 warning across 2 file(s)
359
+ ```
360
+
361
+ `[response] Controller#action` lines are findings against an endpoint's rendered HTML; ordinary file paths are findings against assets the endpoint loaded — the JSON output (`browsable replay … --format json`) preserves the full endpoint-to-finding mapping so dashboards can reconstruct it.
362
+
363
+ ### Compatibility
364
+
365
+ - Rails 7.1+ (middleware reads `env["action_controller.instance"]`)
366
+ - Ruby 3.2+
367
+ - Propshaft (preferred), with a Sprockets + filesystem fallback
368
+ - RSpec 3.10+ or Minitest 5.15+
369
+
370
+ ## Per-controller and per-action policies
371
+
372
+ Rails lets any controller override `allow_browser` and scope the override to certain actions with `only:` / `except:`. Browsable scans every file under `app/controllers/` (including `concerns/`) and lists each `allow_browser` call it finds — with its versions and any action scope — under **Browser policies** in the report.
373
+
374
+ In **static mode**, the audit runs against a single target. CSS and importmap JavaScript are global assets, included via layout helpers on nearly every page, so they have no single owning controller action — and a static asset → endpoint graph would be guesswork.
375
+
376
+ In **runtime mode** (v0.2+), this is solved properly: the middleware sees the actual HTML each endpoint renders during a test run, so findings are attached to the endpoints that *actually loaded* the asset, against each endpoint's *specific* policy. See [Runtime auditing](#runtime-auditing-test-suite-mode) above.
213
377
 
214
378
  ## Rake tasks
215
379
 
216
- Inside a Rails app, the railtie registers:
380
+ Inside a Rails app, the railtie registers three tasks:
217
381
 
218
- - `rake browsable:audit` audit `app/assets/builds/` as it stands
219
- - `rake browsable:audit:fresh` run `assets:precompile` first, then audit
220
- - `rake browsable:doctor` run the dependency check
382
+ | Task | Behavior |
383
+ | --- | --- |
384
+ | `rake browsable:audit` | Audit `app/assets/builds/` as it stands |
385
+ | `rake browsable:audit:fresh` | Run `assets:precompile` first, then audit |
386
+ | `rake browsable:doctor` | Run the dependency check |
221
387
 
222
- `browsable` never precompiles assets on its own. In CI, compose the pipeline
223
- explicitly: `bundle exec rails assets:precompile && bundle exec browsable audit`.
388
+ > ⚠️ **Browsable never precompiles assets on its own.** In CI, compose the pipeline explicitly:
389
+ > ```bash
390
+ > bundle exec rails assets:precompile && bundle exec browsable audit
391
+ > ```
224
392
 
225
393
  ## Contributing
226
394
 
227
- This gem lives in the `browsable/` subdirectory of the
228
- [monorepo](https://github.com/romanhood/browsable). To work on it:
395
+ This gem lives in the `browsable/` subdirectory of the [monorepo][monorepo]. To work on it:
229
396
 
230
397
  ```bash
231
- cd browsable
398
+ git clone https://github.com/romanhood/browsable
399
+ cd browsable/browsable
232
400
  bundle install
233
401
  bundle exec rspec
234
402
  ```
235
403
 
236
- Refresh the bundled compat data with `ruby bin/update-bcd-snapshot`.
404
+ Refresh the bundled MDN browser-compat-data snapshot:
405
+
406
+ ```bash
407
+ ruby bin/update-bcd-snapshot
408
+ ```
409
+
410
+ Bug reports and pull requests welcome. The monorepo has a [CONTRIBUTING.md][contributing] with the broader workflow.
237
411
 
238
412
  ## License
239
413
 
240
- MIT — see the [LICENSE](../LICENSE) at the monorepo root.
414
+ [MIT][license] — see the LICENSE file at the monorepo root.
415
+
416
+ ---
417
+
418
+ <div align="center">
419
+
420
+ Made with care for Rails developers who refuse to add a `package.json` to their app. 🛤️
421
+
422
+ [Monorepo][monorepo] · [LSP server][lsp] · [Neovim plugin][nvim] · [Report an issue][issues]
423
+
424
+ </div>
425
+
426
+ [monorepo]: https://github.com/romanhood/browsable
427
+ [lsp]: https://github.com/romanhood/browsable/tree/main/browsable-lsp
428
+ [nvim]: https://github.com/romanhood/browsable/tree/main/browsable.nvim
429
+ [roadmap]: https://github.com/romanhood/browsable/blob/main/ROADMAP.md
430
+ [contributing]: https://github.com/romanhood/browsable/blob/main/CONTRIBUTING.md
431
+ [license]: https://github.com/romanhood/browsable/blob/main/LICENSE
432
+ [issues]: https://github.com/romanhood/browsable/issues
433
+ [browserslist]: https://github.com/browserslist/browserslist