permittable 0.10.0 → 0.11.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e135a1605d2d98e7c3a3952e2f9dc47394a656249f52d22b7018ed5dc15b23cb
4
- data.tar.gz: f3748c6014a1f74099719fc8c42af289910c9f94d36687837cbe409fd67538a1
3
+ metadata.gz: be27cc7405432910133e85a3fb187c0147f14a7a28d98f960c58bc7190fc0271
4
+ data.tar.gz: e70282fd762f9a4c57d1b43e0c01b4306193997930b57015efc28b0af576564a
5
5
  SHA512:
6
- metadata.gz: 12881e40e20e390c7eb93963a5ca89aa402ed58d79eb0bfa7383d7a2ea6c5b93f6d61d1b35c94ed27b3b75774ddc75256e1d176f9cea5470d06200f64aaecb7e
7
- data.tar.gz: 312bd2b824147af2821dc318c0a66a250d65bd7626d80574e91a0bfb5cc06159a0e22428e30b6d463bfe57ece1fe72c56b4ca8b513b7cdbde1419eb637cd0533
6
+ metadata.gz: 61ec3dadad1f5fbc42c1c0403d228dff1d51c9ccaff13b8d455fb5d1ef795a01ddf7b64c0fa5e6dac76984bb22ad208b38ee50a1e6593326eb40704dbeecc8a6
7
+ data.tar.gz: '0443649c00422967f30d26d3e5982735433756c2aa1a669e26c10eaeaec707d48c2b4f2992805c3544e0bf68d502e0d9879776e78da2d497a7beacb18949dcb9'
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 0.11.0 (2026-10-05)
4
+ <!-- title: an audit ignore list, route-order-stable operationIds, and routes read the way Rails dispatches them -->
5
+
6
+ One new setting and six fixes, closing every open issue. `Permittable.audit_ignore` lets `permittable:audit[strict]` pass in an app that uses ActiveStorage or a catch-all 404 route, and controllers from engines and gems are now listed apart from the app's own rather than counted against it. The OpenAPI export and the audit now read the route set the way Rails dispatches it: an earlier unconstrained route wins its verb and path over a later one (including a later `via: :all`), optional segments expand in Rails' own match order with constraints taken into account, and two routes no longer export paths that differ only in variable names. Colliding operationIds are ranked by the operation itself, so reordering `routes.rb` no longer renames a generated client method. The audit stops reporting a contract on a shared base controller as stale, and the wrapper-key exemption from 0.10.0 now holds whatever order ParamsWrapper and Permittable are included in, and no longer probes the request when no contract uses its answer. The compatibility matrix now runs the Railtie specs on every supported Rails line.
7
+
8
+ Minor rather than patch: there is new public API, and three changes are visible from outside. Controllers whose source is outside `Rails.root` no longer count towards `[strict]` by default (set `Permittable.audit_ignore_outside_root = false` to keep counting them). Some existing operationIds change once on upgrade: see the operationId entry under **Changed** for which. And a route that an earlier unconstrained route already answers for the same verb and path is no longer described, so a contract left on its action is now reported as stale.
9
+
10
+ ### Added
11
+ - **`Permittable.audit_ignore`: leave controllers and actions out of `permittable:audit` and `permittable:generate`.** Two endpoints failed `[strict]` in nearly every app without being a risk the app could fix: ActiveStorage's direct uploads controller (a POST with no contract, which `permittable:generate` also drafted), and a catch-all 404 route, whose `via: :all` reads as accepting a body on POST, PUT and PATCH. `Permittable.audit_ignore = ["active_storage/direct_uploads", "application#not_found"]` takes controller paths as Rails routes them and `controller#action` strings, validated on assignment: a class name raises with the path it meant, and a Class or a Regexp is refused. Controllers whose source is outside `Rails.root` (engines and gems: ActiveStorage, ActionMailbox, Devise, Rails' own `rails/*`) are ignored by default, including gems installed under the app's own `vendor/bundle`; `Permittable.audit_ignore_outside_root = false` audits them too. Ignored endpoints leave the counts and never fail `[strict]`, but the report still lists them in a section per reason, along with any ignore entry that matches no routed action. The generator's sweep skips ignored controllers and leaves an ignored action out of its draft; naming a controller still drafts it whole. (#69)
12
+
13
+ ### Changed
14
+ - **Colliding operationIds are ranked by the operation itself, not by load and route order.** The rank is routed first, then controller path and action, then the shallowest path, then GET, POST, PATCH, PUT, DELETE. Reordering `routes.rb`, or a controller loading earlier, therefore never renames a generated client method. **This can change some existing operationIds once on upgrade**: two controllers that fold to one id (`admin/users` and `admin_users`) whose load order disagreed with string order, one action reached by separate routes at several paths or verbs (`post "admin/legacy"` before `post "legacy"`, `via: [:post, :get]`), and routes with nested optional groups. `resources` PATCH/PUT pairs, a single optional scope, and ids that never collided are unchanged. Adding or removing a colliding route can still rename the other members of its group. (#68)
15
+
16
+ ### Fixed
17
+ - **The export and the audit described routes Rails never dispatches to.** A route that an earlier, unconstrained route already answers for the same verb and path is no longer described. A `match ..., via: :all` route keeps only the verbs no earlier route answers on that path, so `get "hooks"` → `index` drawn before it no longer produces a phantom `GET /hooks receive` audit row or hands that slot to `receive` in the export. A route hidden this way is dropped, and a contract left on its action is reported as stale. A constrained route (a segment requirement, a subdomain or host, a constraint object or lambda) never hides the routes behind it, because Rails passes on to them every request the constraint turns away: the audit is a gate, and it errs towards listing a route rather than hiding an unguarded one. Only identical path templates are compared. (#68)
18
+ - **Optional route segments were expanded by string, not as Rails matches them, and two routes could export paths that differ only in variable names.** `scope "(:locale)"` put its root at `/{locale}` and its `get ":slug"` at `/{slug}`, two templates OpenAPI forbids side by side; and since Rails sends `GET /foo` to the root, the second never reached `pages#show` unless a constraint on `:locale` turned the request away. Such paths are now one path, spelled the way the route Rails tries first spells it: a later route under another verb is placed there with its own spelling in `x-permittable-path`, and one under the same verb is listed in the earlier operation's `x-permittable-shadows` instead of being emitted (its operation lands in `x-permittable-controllers` if it has no other path). Expansion now follows Rails' own match order, constraints included: `get "x(/:a)(/new)"` no longer documents `/x/new`, which Rails reads as `a = "new"` (unless `:a`'s constraint rejects it), and `x(/:a)(/:b)` with `a: /\d+/`, where Rails binds `/x/foo` to `:b`, lists `/x/{b}` on `/x/{a}` rather than dropping it silently. The route is read from the parsed Journey tree rather than its printed string, once per route rather than once per verb; a Journey-shaped stand-in with a String spec now honours escaped parentheses and raises on unbalanced ones instead of truncating the path. (#67)
19
+ - **`Audit.stale` reported a contract inherited from a shared base controller as stale.** `permittable_contracts` is a `class_attribute`, so a base controller with no routes of its own, and every subclass routed to a different slice of its actions, was listed under "Contracts declared for actions no route reaches". A contract's action is now stale only when no controller that carries the contract (the declaring class or any of its subclasses) reaches it, and it is listed once, under the class that declared it. The audit summary also agrees in number when a count is one ("1 of those accepts a request body", "1 covered action declares no model:, so no schema-drift guard runs for it"). (#49)
20
+ - **The wrapper-key exemption switched off when ParamsWrapper came before Permittable in the ancestor chain.** It recorded ParamsWrapper's key in `process_action`, relying on running before ParamsWrapper wrapped the body. A controller that includes `ActionController::ParamsWrapper` after Permittable, such as a Metal subclass, ran ParamsWrapper first, and every wrapped JSON request to a rootless `unknown: :error` contract was a 422 again. The key is now read in `process`, which every `process_action` runs inside, so module order no longer matters. The probe also no longer touches the request unless some contract on the controller acts on its answer: a rootless one that checks unknown keys or declares a field of the wrapper's name. (#70)
21
+
22
+ ### Test suite
23
+ - **The compatibility matrix never ran the Railtie specs.** None of the `gemfiles/activesupport_*.gemfile` variants bundled `railties`, so `spec/railtie_spec.rb` skipped itself in every job but the root `Gemfile`'s: the `filter_parameters` wiring, its reach into ActiveRecord's `filter_attributes`, the late-bound registry and rake-task loading went untested on every supported Rails line but the newest. Each gemfile now pins `railties` to its own line, and the root `Gemfile`'s floor moves to 6.1 alongside `actionpack`/`activerecord`. Running the spec on old Rails surfaced two problems in its probe app, none in the gem: the probe booted without loading `logger` first (a NameError on activesupport 6.1), and asserted `precompile_filter_parameters`, which arrived in Rails 7.1. The gem itself needed no change on any version. (#50)
24
+ - **The conformance spec read exported patterns as Ruby regexps, so `^` and `$` anchored a line.** `spec/support/tiny_json_schema.rb` compiled each `pattern` with `Regexp.new`, but the document holds ECMA-262, where `^` and `$` without the m flag anchor the whole input. `format: /\Aabc\z/` exports `^abc$`; the server and every real validator reject `"abc\n"` against it, and the instrument accepted it — a spurious "schema looser than server" divergence in the one direction the spec says cannot happen, which also hid a real anchor regression. The instrument now undoes the anchor translation before compiling (`^` → `\A`, `$` → `\z`), tokenizing escape pairs and character classes as units the way `EcmaPattern` does, and `spec/ecma_pattern_spec.rb` checks it against node over every exported pattern. No gem behaviour changes. (#48)
25
+
3
26
  ## 0.10.0 (2026-09-30)
4
27
  <!-- title: canonical numeric strings, the app's own CSRF param, and defaults that agree with requests -->
5
28
 
data/README.md CHANGED
@@ -195,7 +195,7 @@ Adopting on an existing API with live traffic? Skip ahead to [Adopting on a live
195
195
  - **[Adopting on a live API](#adopting-on-a-live-api)**
196
196
  - [Monitor mode](#monitor-mode-roll-out-without-rejecting)
197
197
  - [Generating draft contracts](#generating-draft-contracts-permittablegenerate)
198
- - [Auditing coverage](#auditing-coverage-permittableaudit)
198
+ - [Auditing coverage](#auditing-coverage-permittableaudit) · [Ignoring controllers and actions](#ignoring-controllers-and-actions)
199
199
  - **[Beyond the controller](#beyond-the-controller)**
200
200
  - [Testing contracts](#testing-contracts-rspec-matchers)
201
201
  - [Standalone contracts](#standalone-contracts-no-controller)
@@ -832,6 +832,8 @@ bin/rails permittable:generate # every controller without a
832
832
  bin/rails "permittable:generate[UsersController]" # one controller, even if covered
833
833
  ```
834
834
 
835
+ The sweep skips what the audit [ignores](#ignoring-controllers-and-actions): controllers whose source is outside `Rails.root` (so ActiveStorage's direct uploads controller is not drafted), and anything in `Permittable.audit_ignore`. A `controller#action` entry leaves only that action out of the controller's draft. Naming a controller drafts it regardless, the same way it drafts a covered one.
836
+
835
837
  For each controller the task infers the model from `controller_name` (columns give types, NOT NULL gives `required`), scans the controller source for `params.require(...).permit(...)` and `params.expect(...)` calls (permitted keys give the field list and the `root:`), and prints a paste-ready draft:
836
838
 
837
839
  ```ruby
@@ -921,9 +923,9 @@ Three things it tells you that nothing else does:
921
923
 
922
924
  - **Which write actions are unguarded.** A `GET` without a contract is usually fine; a `POST` without one is untrusted input reaching the action unchecked. That count is the number `[strict]` fails on, which makes the task a CI gate: *no new unguarded write endpoint*.
923
925
  - **Which contracts aren't enforcing yet.** The audit runs inside the app, so unlike the exported OpenAPI it resolves the **effective** mode — a rule's own `mode:` first, then your app-wide `Permittable.mode`. This is the [monitor-mode](#monitor-mode-roll-out-without-rejecting) rollout dashboard.
924
- - **Which contracts have gone stale.** A contract declared for an action no route reaches, or for a routed action Rails would 404, is a renamed or deleted action that left its contract behind.
926
+ - **Which contracts have gone stale.** A contract declared for an action no route reaches, or for a routed action Rails would 404, is a renamed or deleted action that left its contract behind. A contract on a shared base controller is stale only when no controller that inherits it reaches the action, and it is listed once, under the class that declares it.
925
927
 
926
- A route that lists several verbs is audited once per verb. A `match ... via: :all` route is expanded into exactly GET, POST, PUT, PATCH and DELETE, and listed once for each. `resources` routes all seven actions whether or not they exist. A route that Rails would 404 reads `action not found`: no method (inherited ones count), no `action_missing`, and no template to render implicitly. It is not counted against `[strict]` or as coverage. It stays in the table rather than disappearing. The template check uses the class-level view paths and the default lookup details. So a template that is only found at request time reads `action not found`, for example one behind a `prepend_view_path` in a `before_action`, or one that exists only as a variant.
928
+ A route that lists several verbs is audited once per verb. A `match ... via: :all` route is expanded into exactly GET, POST, PUT, PATCH and DELETE, and listed once for each. Rails sends a request to the first route that matches its path and verb, and the audit lists what Rails dispatches. With `get "hooks", to: "webhooks#index"` before `match "hooks", to: "webhooks#receive", via: :all`, `GET /hooks` is listed under `index` only, and `receive` gets the other four verbs. Turn the two around and the `get` route is never reached, so it is not listed, and a contract on `index` shows as stale. The same rule removes a route declared twice, and the OpenAPI export applies it too. Only an unconstrained route hides the routes behind it, and only on the identical path. A route with a constraint can pass a request on: a segment requirement (`id: /\d+/`), a subdomain or host, or a constraint object or lambda. So the routes after it stay listed, even when the constraint happens to admit every request. With `constraints(AdminConstraint) { post "settings", ... }` ahead of `post "settings", to: "settings#update"`, every non-admin `POST` reaches `settings#update`, so its row stays and `[strict]` still counts it. The audit errs towards an extra row rather than a hidden route. Paths that differ at all, even only in a variable's name (`users/:id` and `users/:slug`), are both listed. `resources` routes all seven actions whether or not they exist. A route that Rails would 404 reads `action not found`: no method (inherited ones count), no `action_missing`, and no template to render implicitly. It is not counted against `[strict]` or as coverage. It stays in the table rather than disappearing. The template check uses the class-level view paths and the default lookup details. So a template that is only found at request time reads `action not found`, for example one behind a `prepend_view_path` in a `before_action`, or one that exists only as a variant.
927
929
 
928
930
  A catch-all 404 route (`match "*path", to: "application#not_found", via: :all`) shows its POST, PUT and PATCH rows as accepting a body. They do accept one: every stray body reaches the controller. Route only GET to the controller (Rails answers HEAD from it). Send the other verbs to a plain Rack endpoint, which never parses the body and which the audit does not list:
929
931
 
@@ -934,11 +936,60 @@ match "*path", to: ->(_env) { [404, { "content-type" => "text/plain" }, ["Not Fo
934
936
 
935
937
  A `config.exceptions_app = routes` setup (`match "/404", to: "errors#not_found", via: :all`) shows the same rows. It needs only `via: :get`, because on 6.1 and later `ShowExceptions` re-dispatches the error request as a GET.
936
938
 
937
- Under `[strict]` the task aborts on these rows, so the only choices today are to route the catch-all GET-only, as above, or to run the audit without `[strict]` until the ignore list ([#69](https://github.com/VSN2015/permittable/issues/69)) lands. Don't declare a contract on the catch-all to silence the gate. Under monitor mode it validates eagerly, so a malformed JSON POST answers 400 instead of 404. The OpenAPI export would also gain a fake `/{path}` endpoint.
939
+ Under `[strict]` the task aborts on these rows. Either route the catch-all GET-only, as above, or add `application#not_found` to the [ignore list](#ignoring-controllers-and-actions). Don't declare a contract on the catch-all to silence the gate. Under monitor mode it validates eagerly, so a malformed JSON POST answers 400 instead of 404. The OpenAPI export would also gain a fake `/{path}` endpoint.
938
940
 
939
941
  The table lists a row for every verb on every path; the summary counts routes. A route with an optional segment, such as anything under `scope "(:locale)"`, lists each path it expands to (`/users` and `/{locale}/users`), but it is one route, so one unguarded `POST` counts once and the summary line says `N routed actions in M rows`. Rows are collapsed by controller, action, route index and verb; the index is a position within one `rails_routes` call, so audit concatenated route lists (an app's and an engine's) separately, or give them distinct `route:` values. Two separate routes to the same action (`post "/users"` and `post "/admin/users"`) count as two, because each one is a way in.
940
942
 
941
- Controllers that never included `Permittable` are audited too — those are the ones worth finding. Everything is plain Ruby over the frozen registry plus route descriptors, so `Permittable::Audit.entries(controllers:, routes:)` works without Rails.
943
+ Controllers that never included `Permittable` are audited too — those are the ones worth finding. Everything is plain Ruby over the frozen registry plus route descriptors, so `Permittable::Audit.entries(controllers:, routes:)` works without Rails. It takes `ignore:` and `root:` too, which default to `Permittable.audit_ignore` and `Rails.root` (see below).
944
+
945
+ #### Ignoring controllers and actions
946
+
947
+ Some routed endpoints are not yours to guard. Two are common enough to fail almost every `[strict]` run: ActiveStorage's direct uploads controller (a `POST` with no contract), and the catch-all 404 above, whose `via: :all` adds `POST`, `PUT` and `PATCH` rows. List them in an initializer:
948
+
949
+ ```ruby
950
+ # config/initializers/permittable.rb
951
+ Permittable.audit_ignore = [
952
+ "active_storage/direct_uploads", # every action of the controller
953
+ "application#not_found" # one action, on every verb it is routed on
954
+ ]
955
+ ```
956
+
957
+ An entry is a controller path the way Rails routes it (the controller half of `bin/rails routes`, such as `admin/users`), or `controller#action`. Entries must be strings, checked when you assign the list:
958
+
959
+ - A class name raises `ArgumentError` naming the path you meant: `"ActiveStorage::DirectUploadsController"` → did you mean `"active_storage/direct_uploads"`?
960
+ - A class raises too. Naming one in an initializer would autoload the controller during boot, which Zeitwerk refuses for a reloadable controller.
961
+ - A `Regexp` raises. A pattern would also ignore controllers added after it, and catching a new unguarded endpoint is the point of the gate.
962
+
963
+ Assign the whole list. It is frozen, so add to it with `Permittable.audit_ignore += ["webhooks#receive"]`, which is validated too.
964
+
965
+ **Controllers outside `Rails.root` are ignored by default.** These are engines and gems: ActiveStorage, ActionMailbox, Devise, and Rails' own `rails/*` controllers. An app can't keep a contract in a gem's controller. The audit locates a controller by the file that defines its class (`Module#const_source_location`). A gem installed inside the app still counts as a gem; `bundle config path vendor/bundle` does this, and so do CI caches. A controller whose source can't be located is audited. To audit gem controllers like your own:
966
+
967
+ ```ruby
968
+ Permittable.audit_ignore_outside_root = false
969
+ ```
970
+
971
+ That default already covers ActiveStorage. Naming it, as above, keeps it ignored if you turn the default off. To put a gem's endpoint under the gate, subclass its controller in `app/controllers` (for Devise, `devise_for :users, controllers: { sessions: "users/sessions" }`). The subclass is the app's, so it is audited.
972
+
973
+ Ignored endpoints leave the counts and never fail `[strict]`, but they stay in the report. Each reason gets its own section, with one line per action and the verbs it is routed on. An entry that matches no routed action (a typo, or a route since removed) is listed as well. Otherwise a typo would ignore nothing, and nothing would say so. With the initializer above plus a mistyped `"admin/report"`, the end of the report reads:
974
+
975
+ ```
976
+ Ignored by Permittable.audit_ignore (left out of the counts above):
977
+ active_storage/direct_uploads#create POST
978
+ application#not_found DELETE, GET, PATCH, POST, PUT
979
+
980
+ Ignored as outside the app root (set Permittable.audit_ignore_outside_root = false to audit them):
981
+ active_storage/blobs/redirect#show GET
982
+ active_storage/disk#show GET
983
+ active_storage/disk#update PUT
984
+ rails/health#show GET
985
+
986
+ Permittable.audit_ignore entries that match no routed action (a typo, or a route since removed?):
987
+ admin/report
988
+ ```
989
+
990
+ An unmatched entry is reported, not failed: a mistyped `application#not_foud` leaves the catch-all's rows in the count, so `[strict]` fails on those, and this list says why.
991
+
992
+ `permittable:generate` honours the same list (see [generating draft contracts](#generating-draft-contracts-permittablegenerate)).
942
993
 
943
994
  ---
944
995
 
@@ -1037,7 +1088,7 @@ bin/rails permittable:openapi # JSON to stdout
1037
1088
  bin/rails "permittable:openapi[openapi/api.json]" # write to a file
1038
1089
  ```
1039
1090
 
1040
- The task eager-loads the app (also exercising the drift guard), collects every controller with contracts, and maps documented actions onto `paths` via the route set. `OPENAPI_TITLE` / `OPENAPI_VERSION` override the `info` block. Pipe the output through Swagger UI, Redoc, Postman, or [`openapi-typescript`](https://github.com/openapi-ts/openapi-typescript) and your frontend gets compile-time types for every request body.
1091
+ The task eager-loads the app (also exercising the drift guard), collects every controller with contracts, and maps documented actions onto `paths` via the route set. Each path and verb is documented under the action Rails dispatches it to, which is the first route that matches. So an explicit `get` before a `via: :all` route on the same path keeps `GET`, and a route behind an earlier, unconstrained one with the same path and verb is not documented under that verb. A constrained route hides nothing ([the audit](#auditing-coverage-permittableaudit) explains why). When two routes then claim one path and verb, the second operation lands under `x-permittable-controllers`. `OPENAPI_TITLE` / `OPENAPI_VERSION` override the `info` block. Pipe the output through Swagger UI, Redoc, Postman, or [`openapi-typescript`](https://github.com/openapi-ts/openapi-typescript) and your frontend gets compile-time types for every request body.
1041
1092
 
1042
1093
  Or build fragments programmatically — no Rails required:
1043
1094
 
@@ -1050,7 +1101,9 @@ Permittable::OpenAPI.document(controllers: [...], info: { "title" => "My API" })
1050
1101
 
1051
1102
  Every operation references shared components for the [error envelope](#violations-and-error-responses): a `422` response always, plus a `400` when the contract declares a `root:`. So consumers get typed *errors*, not just typed inputs.
1052
1103
 
1053
- **What is honestly unrepresentable stays visible instead of guessed.** A `format:` regexp using a construct with no faithful ECMA-262 spelling is exported as `x-permittable-pattern` rather than a mistranslated `pattern`: a Ruby-only escape or flag, one ECMA-262 reads differently or refuses to compile at all (`{,3}`, `&&` in a class, a backreference, the word boundary `\b`), or one that cannot be carried once folded into the rest of its own character class (`\S` beside a member other than `\s`) — but **not** a hyphen right after a completed range (`[a-c-e]`), which both dialects read the same way and export unchanged. This also covers a regexp anchored with `^`/`$`, which in Ruby anchor a **line** and in ECMA-262 anchor the whole string, so `/^\d{5}$/` accepts `"evil\n12345"` at runtime and publishing that source would promise a stricter rule than the server enforces (use `\A`/`\z`, which translate exactly); `validate:`/`transform:` are flagged `x-permittable-custom-validation`/`x-permittable-transformed`; actions covered only by a catch-all rule on a plain-Ruby host appear under `"*"` with `x-permittable-catch-all`; operations whose rule runs in [monitor mode](#monitor-mode-roll-out-without-rejecting) carry `x-permittable-mode: "monitor"`; operations with no matching route — or whose path-and-verb slot another controller already claimed, which one document cannot represent twice — land in `x-permittable-controllers` instead of being dropped. A templated path segment is declared as a path `parameter` of type `string`, because the route set doesn't say what an `:id` is and the exporter won't invent it. The schema documents the canonical JSON encoding — the runtime additionally accepts string-encoded scalars (`"42"`, `"true"`) for form/query payloads.
1104
+ **What is honestly unrepresentable stays visible instead of guessed.** A `format:` regexp using a construct with no faithful ECMA-262 spelling is exported as `x-permittable-pattern` rather than a mistranslated `pattern`: a Ruby-only escape or flag, one ECMA-262 reads differently or refuses to compile at all (`{,3}`, `&&` in a class, a backreference, the word boundary `\b`), or one that cannot be carried once folded into the rest of its own character class (`\S` beside a member other than `\s`) — but **not** a hyphen right after a completed range (`[a-c-e]`), which both dialects read the same way and export unchanged. This also covers a regexp anchored with `^`/`$`, which in Ruby anchor a **line** and in ECMA-262 anchor the whole string, so `/^\d{5}$/` accepts `"evil\n12345"` at runtime and publishing that source would promise a stricter rule than the server enforces (use `\A`/`\z`, which translate exactly); `validate:`/`transform:` are flagged `x-permittable-custom-validation`/`x-permittable-transformed`; actions covered only by a catch-all rule on a plain-Ruby host appear under `"*"` with `x-permittable-catch-all`; operations whose rule runs in [monitor mode](#monitor-mode-roll-out-without-rejecting) carry `x-permittable-mode: "monitor"`; operations with no matching route — or whose path-and-verb slot another controller already claimed, which one document cannot represent twice, or whose route an earlier one shadows (below) — land in `x-permittable-controllers` instead of being dropped. A templated path segment is declared as a path `parameter` of type `string`, because the route set doesn't say what an `:id` is and the exporter won't invent it. The schema documents the canonical JSON encoding — the runtime additionally accepts string-encoded scalars (`"42"`, `"true"`) for form/query payloads.
1105
+
1106
+ **Paths are the URLs Rails dispatches.** An optional segment expands into each path it stands for, read the way Rails matches it: present before absent, left to right, constraints included. So `get "x(/:a)(/new)"` documents no `/x/new`, because Rails reads that URL as `a = "new"`, unless a constraint on `:a` rejects `"new"`. OpenAPI forbids two paths that differ only in their variable names, and two routes can produce them: under `scope "(:locale)"` a root is `/{locale}` and a `get ":slug"` is `/{slug}`. Such paths are one path, spelled as the route Rails tries first spells it. A later route under another verb is placed there too, and carries its own spelling in `x-permittable-path`. One under the same verb is shadowed: Rails sends that URL to the earlier route, and reaches the later one only when the earlier route's constraints turn the request away. It is listed in the earlier operation's `x-permittable-shadows` instead of being emitted as a second path, and its operation lands in `x-permittable-controllers` if it has no other path. A variant of one route that a constraint keeps reachable is listed the same way: `x(/:a)(/:b)` with `a: /\d+/` sends `/x/foo` to `:b`, so `/x/{a}` lists `/x/{b}`.
1054
1107
 
1055
1108
  **Every `operationId` is unique across the document, and only a collision is ever renamed.** An operation's id is its controller path with `/` folded to `_`, then its action: `users_create`, `admin_users_index`. Client generators name a method after the id, so the scheme itself never changes. Where two places in the document would carry one id, the exporter renames all but one of them:
1056
1109
 
@@ -1059,10 +1112,14 @@ Every operation references shared components for the [error envelope](#violation
1059
1112
  | One operation under two verbs (the separate PATCH and PUT routes `resources` draws to `update`, or one `match ..., via: [:patch, :put]` route) | `users_update` on PATCH, `users_update_put` on PUT |
1060
1113
  | The pair again at a second path (`resources :orgs { resources :users }`) | `users_update_2` on the second PATCH, `users_update_3` on the second PUT |
1061
1114
  | One operation under every verb (with `via: :all` routes, which the exporter documents under each verb) | `webhooks_receive` on GET, then `webhooks_receive_post`, `_put`, `_patch`, `_delete` |
1062
- | One operation at two paths under one verb (with the optional-segment expansion: `(/:locale)/posts` is documented at `/posts` and `/{locale}/posts`) | `posts_create` on the first path in route order, `posts_create_2` on the other |
1063
- | Two controllers that fold to one id (`admin/users` and `admin_users`, both GET) | `admin_users_index` on the first controller, `admin_users_index_2` on the second |
1115
+ | One operation at two paths under one verb (with the optional-segment expansion: `(/:locale)/posts` is documented at `/posts` and `/{locale}/posts`) | `posts_create` on the shallower path, `posts_create_2` on the other |
1116
+ | Two controllers that fold to one id (`admin/users` and `admin_users`, both GET) | `admin_users_index` on `admin/users`, whose controller path sorts first, and `admin_users_index_2` on `admin_users` |
1117
+
1118
+ One place keeps the plain id. The exporter ranks the places by what they are, never by the order it finds them in. A routed operation comes before one under `x-permittable-controllers`, which takes part because it is in the same document. Then operations are ranked by controller path, then action, compared as strings, whatever their verbs. Within one operation, the shallowest path comes first (the fewest segments, then alphabetical order), then the verb: GET, POST, PATCH, PUT, DELETE. So PATCH comes before PUT whichever the route lists first, and `match via: [:put, :patch]` and `resources` name the PATCH method the same way. Every other place gets its verb appended when that verb differs from the plain id's verb and the result is free. Otherwise it gets the next free number in rank order, from `_2`. So a second PATCH is `users_update_2`, not `users_update_patch`, and a second POST is `posts_create_2`. A route declared twice is placed once, and a controller passed twice is documented once, so neither collides with itself.
1119
+
1120
+ **The stability rules.** An id that only one operation would carry never changes, even when a suffix elsewhere would spell it; that suffix is numbered instead. Reordering `routes.rb`, or a controller that loads earlier, renames nothing, because the rank never reads that order. (The exception is two routes that answer the same path and verb. Their order decides which one Rails dispatches to, and the export follows Rails. When the first of them is constrained, both are kept, controller order picks which operation takes the slot, and the other lands under `x-permittable-controllers`.)
1064
1121
 
1065
- One place keeps the plain id. A routed operation comes before one under `x-permittable-controllers`, which takes part because it is in the same document. After that, controller, action and route order decide. Within one operation, PATCH comes before PUT whichever the route lists first, so `match via: [:put, :patch]` and `resources` name the PATCH method the same way. Between two operations only the order counts, whatever the verbs. Every other place gets its verb appended when that verb differs from the plain id's verb and the result is free. Otherwise it gets the next free number, from `_2`. So a second PATCH is `users_update_2`, not `users_update_patch`, and a second POST is `posts_create_2`. **The stability rule:** an id that only one operation would carry never changes, even when a suffix elsewhere would spell it; that suffix is numbered instead. So a change to routes or controllers can rename only operations that collide, never one that stands alone. A route declared twice is placed once, and a controller passed twice is documented once, so neither collides with itself.
1122
+ **What can still rename a generated method: a change to a colliding group.** No scheme can keep every id when a new collider arrives, because one of them must take a suffix. A new route, action or controller whose id collides gets a suffix. If it ranks first, it takes the plain id from the place that had it instead. Every numbered place ranked after it moves along by one. Removing or renaming a collider moves the others the same way. Only the members of the colliding group can be renamed, never an id that stands alone. If a client must never change, remove the collision: give the operations distinct controller paths or action names.
1066
1123
 
1067
1124
  Output is deterministic (fixed key order, declaration-order properties), so the generated file can be committed and reviewed as a diff — a contract change shows up in the same PR as its documentation change.
1068
1125
 
@@ -1144,12 +1201,14 @@ A `format:` regexp that does not translate to ECMA-262 is looser in the same way
1144
1201
  | `Permittable.error_format` / `=` | `:envelope` (default) or `:problem` — see [RFC 9457 problem+json](#rfc-9457-problemjson) |
1145
1202
  | `Permittable.problem_base_uri` / `=` | Base URI for problem `type` members |
1146
1203
  | `Permittable.check_column_types` / `=` | Opt in to the [type half of the drift guard](#checking-types-too-opt-in) (default `false`) |
1204
+ | `Permittable.audit_ignore` / `=` | Controller paths and `controller#action` strings the audit and the generator [leave out](#ignoring-controllers-and-actions) (default `[]`) |
1205
+ | `Permittable.audit_ignore_outside_root` / `=` | Also leave out controllers whose source is outside `Rails.root` (default `true`) |
1147
1206
  | `Permittable.fields(&block)` | A reusable [field group](#reusing-fields-permittablefields-and-use) — splice it into a contract with `use` |
1148
1207
  | `Permittable::InvalidParameters` | Raised on violation; carries `#details` and `#status` |
1149
1208
  | `Permittable::JsonSchema` | Contract data → JSON Schema fragments (`.rule`, `.object`, `.field`) |
1150
1209
  | `Permittable::OpenAPI` | OpenAPI 3.1 assembly (`.document`, `.operations_for`, `.request_body_for`, `.components`) |
1151
- | `Permittable::Generator` | Contract drafting (`.draft`, `.for_controller`, `.scan`) — see [generating draft contracts](#generating-draft-contracts-permittablegenerate) |
1152
- | `Permittable::Audit` | Coverage across the route set (`.entries`, `.summary`, `.stale`, `.format`) — see [auditing coverage](#auditing-coverage-permittableaudit) |
1210
+ | `Permittable::Generator` | Contract drafting (`.draft`, `.for_controller`, `.scan`, `.targets`) — see [generating draft contracts](#generating-draft-contracts-permittablegenerate) |
1211
+ | `Permittable::Audit` | Coverage across the route set (`.entries`, `.summary`, `.stale`, `.format`, `.ignore_reason`, `.unmatched_ignores`) — see [auditing coverage](#auditing-coverage-permittableaudit) |
1153
1212
  | `Permittable::Contract` | [Standalone contracts](#standalone-contracts-no-controller) (`.define`, `#call`, `#call!`, `#json_schema`, `#rule`, `#fields`) |
1154
1213
  | `Permittable::FieldGroup` | A [reusable field list](#reusing-fields-permittablefields-and-use) (`#fields`, `#names`) — built by `Permittable.fields` |
1155
1214
  | `Permittable::Matchers` | RSpec matchers via `require "permittable/rspec"` — `permit_param` for the declaration, `accept_params`/`reject_params` for the behaviour. See [testing contracts](#testing-contracts-rspec-matchers) |
@@ -15,6 +15,11 @@ module Permittable
15
15
  # bin/rails permittable:audit[strict] # ...and exit 1 if a write action
16
16
  # # is unguarded (a CI gate)
17
17
  #
18
+ # What is not the app's to guard — ActiveStorage's direct uploads, a
19
+ # catch-all 404 — is left out with Permittable.audit_ignore, and every
20
+ # controller whose source is outside Rails.root is left out by default.
21
+ # Either way it leaves the counts and [strict], never the report.
22
+ #
18
23
  # Plain Ruby over the frozen registry and a list of route descriptors (the
19
24
  # same `{ controller:, action:, verb:, path: }` shape OpenAPI.rails_routes
20
25
  # produces, plus the optional `route:` it tags each one with), so it is
@@ -30,6 +35,14 @@ module Permittable
30
35
  # is usually fine; a POST without one is the finding.
31
36
  BODY_VERBS = %w[post put patch].freeze
32
37
 
38
+ # Why an entry is left out of the counts, keyed as Entry#ignored holds it,
39
+ # with the heading its section of the report carries. Listed in this
40
+ # order: what the app chose to ignore first, then what it got by default.
41
+ IGNORE_REASONS = {
42
+ configured: "Ignored by Permittable.audit_ignore (left out of the counts above):",
43
+ outside_root: "Ignored as outside the app root (set Permittable.audit_ignore_outside_root = false to audit them):"
44
+ }.freeze
45
+
33
46
  # One routed action, paired with the rule a request for it would resolve
34
47
  # through (nil when nothing covers it — including when the controller
35
48
  # never included the concern, which is exactly the case worth finding).
@@ -37,8 +50,10 @@ module Permittable
37
50
  # `missing_action` marks a route to an action the controller does not
38
51
  # define — see Audit.missing_action?. `route` identifies the route the
39
52
  # path came from (nil for a hand-built descriptor, which is then its own
40
- # route); see `summary`.
41
- Entry = Struct.new(:controller, :action, :verb, :path, :rule, :missing_action, :route, keyword_init: true) do
53
+ # route); see `summary`. `ignored` is why the entry is left out of the
54
+ # counts — a key of IGNORE_REASONS — or nil when it is audited.
55
+ Entry = Struct.new(:controller, :action, :verb, :path, :rule, :missing_action, :route, :ignored,
56
+ keyword_init: true) do
42
57
  def covered?
43
58
  !rule.nil?
44
59
  end
@@ -47,6 +62,10 @@ module Permittable
47
62
  missing_action == true
48
63
  end
49
64
 
65
+ def ignored?
66
+ !ignored.nil?
67
+ end
68
+
50
69
  # The mode this action would actually run in, rule-level declaration
51
70
  # first and the app-wide default behind it.
52
71
  def mode
@@ -69,25 +88,137 @@ module Permittable
69
88
  # Every routed action of every given controller, sorted for stable output.
70
89
  # Pass ALL controllers, not just the ones including Permittable — a
71
90
  # controller that never included it is unguarded, which is the point.
72
- def entries(controllers:, routes:)
91
+ #
92
+ # Ignored actions are returned too, marked with why (Entry#ignored):
93
+ # `ignore:` is the Permittable.audit_ignore list, and `root:` the app
94
+ # root a controller's source must be inside (see app_root; nil ignores
95
+ # nothing on that ground). Both default to the app's own configuration,
96
+ # as `mode` does.
97
+ def entries(controllers:, routes:, ignore: Permittable.audit_ignore, root: app_root)
73
98
  routes = routes.to_a
74
- controllers.flat_map { |controller| controller_entries(controller, routes) }
99
+ controllers.flat_map { |controller| controller_entries(controller, routes, ignore: ignore, root: root) }
75
100
  .sort_by { |entry| [entry.controller, entry.path, entry.verb.to_s] }
76
101
  end
77
102
 
78
- def controller_entries(controller, routes)
103
+ # The app's own choice outranks the default: a controller that is both
104
+ # configured and outside the root is reported as configured, so its entry
105
+ # counts as matched (see unmatched_ignores) — the README's own example
106
+ # names ActiveStorage, which the default already covers. The location is
107
+ # looked up once per controller, not once per route.
108
+ def controller_entries(controller, routes, ignore: Permittable.audit_ignore, root: app_root)
79
109
  key = OpenAPI.controller_key(controller) || controller.inspect
80
110
  missing = missing_lookup(controller)
111
+ outside = :outside_root if outside_root?(controller, root)
81
112
  routes.select { |route| route[:controller].to_s == key }.map do |route|
82
113
  action = route[:action].to_s
83
114
  Entry.new(controller: key, action: action, verb: route[:verb], path: route[:path],
84
115
  rule: rule_for(controller, action), missing_action: missing[action],
85
- route: route[:route])
116
+ route: route[:route], ignored: configured_ignore(key, action, ignore) || outside)
117
+ end
118
+ end
119
+
120
+ # Why `controller` — or one of its actions — is left out of the audit and
121
+ # of permittable:generate: :configured (Permittable.audit_ignore names the
122
+ # controller, or "controller#action"), :outside_root, or nil. The
123
+ # generator asks per action; see Generator.targets.
124
+ def ignore_reason(controller, action = nil, ignore: Permittable.audit_ignore, root: app_root)
125
+ key = OpenAPI.controller_key(controller) || controller.inspect
126
+ configured_ignore(key, action, ignore) || (:outside_root if outside_root?(controller, root))
127
+ end
128
+
129
+ def configured_ignore(key, action, ignore)
130
+ :configured if ignore.include?(key) || (action && ignore.include?("#{key}##{action}"))
131
+ end
132
+
133
+ # The ignore entries no routed action answers to — a controller that is
134
+ # not routed, an action it does not route, or a typo of either. Ignored
135
+ # entries count as matches: the question is whether the entry names
136
+ # something, not whether it was the only reason that something was
137
+ # ignored.
138
+ def unmatched_ignores(entries, ignore: Permittable.audit_ignore)
139
+ ignore.reject do |item|
140
+ key, action = item.split("#", 2)
141
+ entries.any? { |entry| entry.controller == key && (action.nil? || entry.action == action) }
142
+ end
143
+ end
144
+
145
+ # The root a controller's source must be inside to be audited:
146
+ # Rails.root, unless Permittable.audit_ignore_outside_root is off — or nil
147
+ # without Rails, where there is no app to be outside of.
148
+ def app_root
149
+ return nil unless Permittable.audit_ignore_outside_root
150
+ return nil unless defined?(::Rails) && ::Rails.respond_to?(:root)
151
+
152
+ ::Rails.root&.to_s
153
+ end
154
+
155
+ # Whether `controller` is an engine's or a gem's rather than the app's:
156
+ # its source is outside `root`, or inside a directory gems are installed
157
+ # in — `bundle config path vendor/bundle`, which CI caches and Docker
158
+ # images commonly use, puts every gem inside the app's root, and
159
+ # ActiveStorage's controller is no more the app's for being there. A gem
160
+ # directory that holds the root itself (a GEM_HOME set to the app, or to
161
+ # a parent of it) says nothing about which side a file is on, so it is
162
+ # not consulted — otherwise every app controller would read as a gem's
163
+ # and [strict] would pass on nothing. A controller that cannot be located
164
+ # is audited: for a gate, a false alarm beats a missed endpoint.
165
+ def outside_root?(controller, root)
166
+ return false if root.nil?
167
+
168
+ path = source_path(controller)
169
+ return false if path.nil?
170
+
171
+ !within?(path, root) || Gem.path.any? { |dir| within?(path, dir) && !within?(root, dir) }
172
+ end
173
+
174
+ # Where `controller` is defined: its constant's own definition (the
175
+ # `class` line — reopening a gem's controller in app/ does not move it),
176
+ # or, for a class whose constant cannot be found, the first of its own
177
+ # methods that has a location. nil when neither says.
178
+ #
179
+ # A constant that is registered for autoload but not loaded reports the
180
+ # `autoload` call instead — never the case here, since only loaded
181
+ # classes have descendants to audit.
182
+ def source_path(controller)
183
+ name = controller.name if controller.respond_to?(:name)
184
+ located = begin
185
+ # [] for a constant defined in C, so `first` is nil there too.
186
+ Object.const_source_location(name)&.first if name.is_a?(String) && !name.empty?
187
+ rescue NameError
188
+ nil
189
+ end
190
+ located || method_source_path(controller)
191
+ end
192
+
193
+ def method_source_path(controller)
194
+ return nil unless controller.is_a?(Module)
195
+
196
+ methods = controller.instance_methods(false) + controller.private_instance_methods(false)
197
+ methods.lazy.filter_map { |name| controller.instance_method(name).source_location&.first }.first
198
+ end
199
+
200
+ # Whether `path` is `dir` or inside it, compared both as written and with
201
+ # symlinks resolved: Rails.root is a realpath, and on macOS a tmpdir under
202
+ # /var is really under /private/var — either side alone can disagree with
203
+ # the other, and an app controller misread as outside the root would drop
204
+ # out of [strict]. A form that cannot be resolved (a path that does not
205
+ # exist) is compared as written only.
206
+ def within?(path, dir)
207
+ [[File.expand_path(path), File.expand_path(dir)], [realpath(path), realpath(dir)]].any? do |file, base|
208
+ file && base && (file == base || file.start_with?(File.join(base, "")))
86
209
  end
87
210
  end
88
211
 
212
+ def realpath(path)
213
+ File.realpath(path)
214
+ rescue SystemCallError
215
+ nil
216
+ end
217
+
89
218
  # action => missing?, answered once per action: a `via: :all` route is
90
- # five entries for one action, and each resolver call builds a controller.
219
+ # up to five entries for one action (fewer when an earlier, unconstrained
220
+ # route already answers some of its verbs on that path, see
221
+ # OpenAPI.drop_shadowed), and each resolver call builds a controller.
91
222
  # The action list is read once per controller, not once per route.
92
223
  def missing_lookup(controller)
93
224
  listed = listed_actions(controller)
@@ -144,20 +275,64 @@ module Permittable
144
275
  # kind is in no summary bucket, so without this it would vanish from the
145
276
  # report's conclusions. Catch-all rules declare no actions, so they never
146
277
  # appear here.
278
+ #
279
+ # Judged per RULE, not per class: permittable_contracts is a
280
+ # class_attribute, so a subclass carries every rule its parent declared,
281
+ # and judging each class alone listed a shared base (routed to nothing)
282
+ # and every subclass routed to a different slice of its actions. A rule's
283
+ # action is stale only when NO controller carrying that rule reaches it —
284
+ # the class that declared it, or any descendant among `controllers:` — and
285
+ # it is listed once, under the declaring class, where the fix is made.
286
+ # Counting only the rules a class added itself would not do: the shared
287
+ # base still has no routes. Shadowing is not judged: an action some
288
+ # carrier reaches is not stale, even if that carrier declares its own rule
289
+ # for it, just as a rule redeclared within one class never was.
147
290
  def stale(controllers:, routes:)
148
291
  routes = routes.to_a
149
- controllers.each_with_object({}) do |controller, found|
292
+ reaches = Hash.new { |memo, controller| memo[controller] = reach_lookup(controller, routes) }
293
+ carriers(controllers).each_with_object({}) do |(rule, held_by), found|
294
+ declarer = declaring_class(held_by.first, rule)
295
+ holders = held_by | [declarer]
296
+ left = rule[:actions].reject { |action| holders.any? { |controller| reaches[controller][action] } }
297
+ next if left.empty?
298
+
299
+ key = OpenAPI.controller_key(declarer) || declarer.inspect
300
+ found[key] = (found.fetch(key, []) + left).uniq
301
+ end
302
+ end
303
+
304
+ # rule => the given controllers carrying it, in first-seen order. Keyed by
305
+ # IDENTITY: permit_params hands a subclass its parent's rule objects, while
306
+ # two classes declaring the same fields build equal but distinct rules.
307
+ def carriers(controllers)
308
+ controllers.each_with_object({}.compare_by_identity) do |controller, held|
150
309
  next unless controller.respond_to?(:permittable_contracts)
151
310
 
152
- key = OpenAPI.controller_key(controller) || controller.inspect
153
- routed = routes.select { |route| route[:controller].to_s == key }.map { |route| route[:action].to_s }
154
- declared = controller.permittable_contracts.flat_map { |rule| rule[:actions] }.uniq
155
- missing = missing_lookup(controller)
156
- left = declared.select { |action| !routed.include?(action) || missing[action] }
157
- found[key] = left unless left.empty?
311
+ controller.permittable_contracts.each { |rule| (held[rule] ||= []) << controller }
158
312
  end
159
313
  end
160
314
 
315
+ # action => whether a request for it reaches `controller`: routed there,
316
+ # and not to an action Rails would 404. Lazy, so the resolver is asked
317
+ # only about actions some contract declares.
318
+ def reach_lookup(controller, routes)
319
+ key = OpenAPI.controller_key(controller) || controller.inspect
320
+ routed = routes.select { |route| route[:controller].to_s == key }.to_set { |route| route[:action].to_s }
321
+ missing = missing_lookup(controller)
322
+ Hash.new { |memo, action| memo[action] = routed.include?(action) && !missing[action] }
323
+ end
324
+
325
+ # The highest ancestor still carrying `rule` — the class whose
326
+ # permit_params declared it, whether or not it was among `controllers:`.
327
+ def declaring_class(controller, rule)
328
+ controller = controller.superclass while controller.respond_to?(:superclass) && carries?(controller.superclass, rule)
329
+ controller
330
+ end
331
+
332
+ def carries?(controller, rule)
333
+ controller.respond_to?(:permittable_contracts) && controller.permittable_contracts.any? { |held| held.equal?(rule) }
334
+ end
335
+
161
336
  # The numbers worth putting in a CI log. `uncovered_with_body` is the one
162
337
  # that should be zero; `unguarded_models` counts covered actions whose
163
338
  # rule declares no `model:`, so no schema-drift guard runs for them.
@@ -172,8 +347,11 @@ module Permittable
172
347
  # An action the controller does not define takes no body, and a contract
173
348
  # left on one guards nothing, so it is counted as `missing_actions` and in
174
349
  # no other bucket — the buckets stay disjoint and still add up to `actions`.
350
+ #
351
+ # An ignored entry (Entry#ignored) is in none of the counts, so it never
352
+ # fails [strict]; `format` lists it in a section of its own instead.
175
353
  def summary(entries)
176
- entries = routed(entries)
354
+ entries = routed(entries.reject(&:ignored?))
177
355
  found, missing = entries.partition { |e| !e.missing_action? }
178
356
  {
179
357
  actions: entries.length,
@@ -196,13 +374,19 @@ module Permittable
196
374
  end
197
375
 
198
376
  # The human-readable report: one block per controller, then the summary,
199
- # then anything stale.
200
- def format(entries, stale: {})
377
+ # then anything stale. The table and the summary cover the audited
378
+ # entries; what was ignored follows in sections of its own, then any
379
+ # ignore entry that matched nothing — `unmatched` defaults to checking
380
+ # Permittable.audit_ignore, the list `entries` defaults to.
381
+ def format(entries, stale: {}, unmatched: unmatched_ignores(entries))
201
382
  return "Permittable audit: no routed actions to report.\n" if entries.empty?
202
383
 
384
+ ignored, entries = entries.partition(&:ignored?)
203
385
  out = entries.group_by(&:controller).map { |key, group| controller_block(key, group) }
204
386
  out << summary_lines(summary(entries), rows: entries.length)
205
387
  out << stale_lines(stale) unless stale.empty?
388
+ out << ignored_lines(ignored) unless ignored.empty?
389
+ out << unmatched_lines(unmatched) unless unmatched.empty?
206
390
  "#{out.join("\n")}\n"
207
391
  end
208
392
 
@@ -242,9 +426,14 @@ module Permittable
242
426
  # say WHY they differ: an optional segment's variants are the usual
243
427
  # reason, but two concatenated route lists sharing a controller, action
244
428
  # and index collapse the same way.
429
+ #
430
+ # Each line agrees with its own count: "1 of those accepts", "1 covered
431
+ # action declares ... for it". `rows` needs no singular — it only prints
432
+ # when it exceeds the route count, so it is at least 2.
245
433
  def summary_lines(counts, rows: counts[:actions])
246
434
  body = counts[:uncovered_with_body]
247
435
  missing = counts[:missing_actions]
436
+ models = counts[:unguarded_models]
248
437
  # Its own bucket, so the head line still adds up to the route count.
249
438
  not_found = ", #{missing} not found (Rails 404s #{missing == 1 ? 'it' : 'them'})" unless missing.zero?
250
439
  listed = " in #{rows} rows" unless rows == counts[:actions]
@@ -253,9 +442,10 @@ module Permittable
253
442
  "#{counts[:actions]} routed action#{'s' unless counts[:actions] == 1}#{listed}: " \
254
443
  "#{counts[:enforced]} enforced, #{counts[:monitored]} in monitor mode, " \
255
444
  "#{counts[:uncovered]} without a contract#{not_found}",
256
- " #{body} of those accept a request body#{' — untrusted input reaches the action unchecked' unless body.zero?}",
257
- " #{counts[:unguarded_models]} covered action#{'s' unless counts[:unguarded_models] == 1} " \
258
- "declare no model:, so no schema-drift guard runs for them"
445
+ " #{body} of those accept#{'s' if body == 1} a request body" \
446
+ "#{' — untrusted input reaches the action unchecked' unless body.zero?}",
447
+ " #{models} covered action#{models == 1 ? ' declares' : 's declare'} no model:, " \
448
+ "so no schema-drift guard runs for #{models == 1 ? 'it' : 'them'}"
259
449
  ].join("\n")
260
450
  end
261
451
 
@@ -264,5 +454,26 @@ module Permittable
264
454
  "\nContracts declared for actions no route reaches or Rails would 404 (renamed or deleted?):\n" \
265
455
  "#{pairs.map { |pair| " #{pair}" }.join("\n")}"
266
456
  end
457
+
458
+ # One section per reason, in IGNORE_REASONS order, and one line per
459
+ # controller#action with every verb it is routed on: a `via: :all`
460
+ # catch-all is one line rather than five rows, so what was left out stays
461
+ # visible without crowding the table it was left out of.
462
+ def ignored_lines(ignored)
463
+ IGNORE_REASONS.filter_map do |reason, heading|
464
+ group = ignored.select { |entry| entry.ignored == reason }
465
+ next if group.empty?
466
+
467
+ verbs = group.group_by { |entry| "#{entry.controller}##{entry.action}" }
468
+ .transform_values { |list| list.map { |entry| entry.verb.to_s.upcase }.uniq.join(", ") }
469
+ width = verbs.keys.map(&:length).max
470
+ "\n#{heading}\n#{verbs.map { |pair, list| " #{pair.ljust(width)} #{list}" }.join("\n")}"
471
+ end.join("\n")
472
+ end
473
+
474
+ def unmatched_lines(unmatched)
475
+ "\nPermittable.audit_ignore entries that match no routed action (a typo, or a route since removed?):\n" \
476
+ "#{unmatched.map { |item| " #{item}" }.join("\n")}"
477
+ end
267
478
  end
268
479
  end