graph_weaver 0.4.6 → 0.5.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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1442 -0
  3. data/Gemfile.lock +23 -23
  4. data/README.md +115 -96
  5. data/docs/cassettes.md +93 -46
  6. data/docs/editors.md +82 -0
  7. data/docs/errors.md +34 -30
  8. data/docs/federation.md +521 -48
  9. data/docs/generated_modules.md +352 -137
  10. data/docs/getting_started.md +237 -67
  11. data/docs/logging.md +35 -6
  12. data/docs/real_world.md +21 -15
  13. data/docs/scalars.md +49 -154
  14. data/docs/testing.md +300 -52
  15. data/docs/transports.md +129 -30
  16. data/docs/upgrading.md +134 -0
  17. data/graph_weaver.gemspec +19 -3
  18. data/lib/generators/graph_weaver/install_generator.rb +259 -0
  19. data/lib/graph_weaver/client.rb +118 -111
  20. data/lib/graph_weaver/codegen/aliases.rb +223 -0
  21. data/lib/graph_weaver/codegen/emit.rb +283 -261
  22. data/lib/graph_weaver/codegen/enum_type.rb +25 -124
  23. data/lib/graph_weaver/codegen/nodes.rb +72 -13
  24. data/lib/graph_weaver/codegen/scalar_type.rb +69 -66
  25. data/lib/graph_weaver/codegen/type_helpers.rb +140 -0
  26. data/lib/graph_weaver/codegen.rb +672 -336
  27. data/lib/graph_weaver/errors.rb +154 -16
  28. data/lib/graph_weaver/federation.rb +259 -0
  29. data/lib/graph_weaver/hints.rb +9 -1
  30. data/lib/graph_weaver/in_process.rb +90 -0
  31. data/lib/graph_weaver/input_struct.rb +14 -2
  32. data/lib/graph_weaver/logging.rb +29 -0
  33. data/lib/graph_weaver/parsing.rb +59 -0
  34. data/lib/graph_weaver/query_module.rb +55 -0
  35. data/lib/graph_weaver/railtie.rb +23 -1
  36. data/lib/graph_weaver/representation.rb +74 -0
  37. data/lib/graph_weaver/response.rb +7 -0
  38. data/lib/graph_weaver/retry.rb +29 -8
  39. data/lib/graph_weaver/rspec.rb +220 -16
  40. data/lib/graph_weaver/schema_loader.rb +819 -60
  41. data/lib/graph_weaver/schemas.rb +48 -0
  42. data/lib/graph_weaver/selection.rb +43 -8
  43. data/lib/graph_weaver/tasks.rb +220 -22
  44. data/lib/graph_weaver/testing/cassette.rb +249 -81
  45. data/lib/graph_weaver/testing/coverage.rb +160 -0
  46. data/lib/graph_weaver/testing/failure.rb +14 -25
  47. data/lib/graph_weaver/testing/fake_client.rb +182 -22
  48. data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
  49. data/lib/graph_weaver/testing/router.rb +1452 -0
  50. data/lib/graph_weaver/testing/subgraphs.rb +134 -0
  51. data/lib/graph_weaver/testing.rb +209 -13
  52. data/lib/graph_weaver/transport/faraday.rb +28 -10
  53. data/lib/graph_weaver/transport/http.rb +99 -36
  54. data/lib/graph_weaver/transport.rb +67 -14
  55. data/lib/graph_weaver/version.rb +1 -1
  56. data/lib/graph_weaver.rb +416 -172
  57. metadata +25 -9
  58. data/CLAUDE.md +0 -69
  59. data/Makefile +0 -23
  60. data/NOTES.md +0 -182
  61. data/PLAN.md +0 -144
data/CHANGELOG.md CHANGED
@@ -1,3 +1,1445 @@
1
+ ### v0.5.1 (2026-09-07)
2
+ - **A union's catch-all struct keeps the fields an interface fragment asked
3
+ for.** `... on Named { name }` under a union types `name` on every member the
4
+ query named, but the catch-all — the member it didn't name, including one the
5
+ schema grows later — carried only `__typename`, so a `name` the server
6
+ actually sent was dropped on the floor. Those fields are `T.nilable`: the
7
+ member that arrives needn't implement the interface. **Regenerate** to pick
8
+ it up.
9
+ - **`schema_stale?` now recognises the drift a graphql-ruby server reports.**
10
+ It matched Apollo's one flat code plus a message pattern, but graphql-ruby —
11
+ which this library ships as its in-process client — names the rule that fired
12
+ in `extensions.code`. So a renamed type, a removed argument, an argument that
13
+ became required and four other drift shapes all raised without the one
14
+ sentence telling you to refresh the dump and regenerate, and `to_h` reported
15
+ `"schema_stale": false`.
16
+ - **A cast failure keeps the server's own explanation.** A field that came back
17
+ null *with a reason* — a permission rule, a partial outage — raised only
18
+ Sorbet's nil complaint, because the generated envelope built `data` before it
19
+ read `errors`. The reason is now in the message. **Regenerate** to pick it up.
20
+ - **Cassette anonymization missed `errors` and `extensions`.** It walked
21
+ `data` and nothing else, so a rejected request's error message — which
22
+ routinely quotes the input that caused it — and whatever the server hangs
23
+ off `extensions` went to disk verbatim, under a flag that says real data
24
+ never reaches it. **Re-run `rake graph_weaver:cassettes:anonymize` on any
25
+ cassette you've already committed**, or re-record it. Neither key has a
26
+ schema behind it, so both are now walked by shape: keys, nesting, nulls and
27
+ booleans survive and every string and number is replaced, with `path`,
28
+ `locations` and an error's `extensions.code` kept because they describe the
29
+ request rather than the data.
30
+ - **Recording flags a credential that lands in the file.** A cassette gets
31
+ committed as written, and its query and variables can't be anonymized —
32
+ they're the key replay matches on. So the bytes headed for disk are checked
33
+ for the shapes that are unmistakable (a JWT, `AKIA…`, `ghp_…`, `xox…`,
34
+ `sk_live_…`, a PEM block, a `Bearer` header) and named on stderr once per
35
+ cassette. Nothing is rewritten; a password like `hunter2` has no shape, so a
36
+ quiet run still isn't a clean bill of health.
37
+ - **An anonymized cassette no longer drops a key selected twice.** The
38
+ anonymizer kept the last occurrence's sub-selection where codegen merges
39
+ them, so `a { x } a { y }` came back holding only `y` — a recording that
40
+ then failed its own `cassettes:check`.
41
+ - **Three generated types disagreed with the schema.** `srb tc` proves the
42
+ generated code is self-consistent, not that it is true, so each of these was a
43
+ lie the typechecker endorsed. **Regenerate** to pick them up:
44
+ - A field reached only through a `@skip`/`@include`-guarded occurrence, where
45
+ the same key is also selected unguarded, was typed as guaranteed. The server
46
+ legitimately omits it, so `from_h` raised `key not found` on a valid
47
+ response — and a union whose `__typename` arrived that way crashed its own
48
+ dispatch. Those children are nilable now, and the `__typename` case refuses
49
+ at generation as it already did for the plainer spelling.
50
+ - A narrowed abstract inside a non-null list made the *list* nilable
51
+ (`[Thing!]!` with `... on Widget` → `T.nilable(T::Array[…])`). Only the
52
+ elements can be nil; the array is always there.
53
+ - An input field the schema gives a default is optional, but a non-null one
54
+ emitted `const :x, String, default: nil` — so `x.upcase` typechecked and
55
+ was a `NoMethodError`.
56
+ - **`rake graph_weaver:federation:coverage` no longer counts a subgraph as
57
+ served here when two loaded schemas fit it.** `Router.new` refuses that case
58
+ — picking either would be a coin flip — so the report was promising a run
59
+ the suite can't have. It now lands in the "nothing here serves what they
60
+ reach" list, whose advice (name a schema for those subgraphs) is the fix.
61
+ - **A supergraph that renamed the join spec is now refused instead of read as
62
+ empty.** `@link(url: ".../join/v0.3", as: "j")` spells every marker `j__`,
63
+ which the routing table doesn't follow — so it reported a composed graph with
64
+ *no subgraphs* and nothing unsupported, and `Testing::Router` built happily on
65
+ top of that. It now lands in `unsupported`, so the router and
66
+ `federation:coverage` refuse at construction and say why.
67
+ - **`rake graph_weaver:cassettes:anonymize` now finds an SDL schema dump.** It
68
+ opened `GraphWeaver.schema_path` directly instead of locating the dump the
69
+ way every sibling task does, so an app whose committed dump is
70
+ `schema.graphql` (what the generator writes for a supergraph) got a raw
71
+ `Errno::ENOENT`. A missing dump now says so.
72
+ - **`rake graph_weaver:queries:check` now authenticates with the token your
73
+ app uses.** It re-introspects the url the dump records, but read
74
+ `GRAPHWEAVER_AUTH` rather than the ENV var the dump named — so an app
75
+ installed with `--auth MY_TOKEN` reached its private API while the check
76
+ 401'd. Every re-introspection now goes through `SchemaLoader.source_transport`,
77
+ which is also now public.
78
+ - **A `graphql: :router` example now starts from the same fabricated data
79
+ whether it runs alone or after two hundred others.** The router is built once
80
+ for the suite, and a faked subgraph kept fabricating from wherever the last
81
+ example left its sequence — so `rspec spec/foo_spec.rb:12` disagreed with the
82
+ same example in a full run, which is the opposite of what `--seed` promises.
83
+ `Router#reset!` is the example boundary (`reset_trace` still clears only the
84
+ trace); the rspec tag calls it for you.
85
+ - **An override key given as a Symbol now pins.** `graphql_fake(overrides: { name: "Ada" })`
86
+ validated clean and silently fabricated a random value, because lookup is by
87
+ String — the exact silent-green failure the override validation exists to
88
+ prevent.
89
+ - **A helper no longer contradicts `config.default_mode`.** With
90
+ `default_mode = :fake`, an untagged example calling `graphql_in_process`
91
+ raised *"this example is tagged graphql: :fake"* — naming a tag that wasn't
92
+ there, and blocking the untagged form the docs recommend. Only an explicit
93
+ tag can disagree with a helper now.
94
+ - **A variable default that isn't a Boolean no longer reaches a subgraph.** An
95
+ enum or input-object default is a parser AST node; sending one put a
96
+ back-pointer to the lexer on the wire, and an input object raised
97
+ `NoMethodError` outside any rescue. Only `@skip`/`@include` read these, and
98
+ they read Booleans; a subgraph applies its own defaults from the variable
99
+ declaration it already receives.
100
+ - The local router asks the schema for one type by name rather than rebuilding
101
+ the whole type map, at four sites — one of them per response row. On a
102
+ 1200-type supergraph a stitched query over 200 rows was **5x** slower than it
103
+ needed to be; the cost is now flat in schema size.
104
+ - **`graphql: :in_process` ran the committed schema dump instead of your live
105
+ schema class.** A dump loads as an anonymous `GraphQL::Schema` subclass, which
106
+ looks like a runnable class and has no resolvers — so every app that followed
107
+ `getting_started.md` (the generator always commits a dump) got a graphql-ruby
108
+ 500 blaming its own resolver, and the error that was written to explain this
109
+ was unreachable. Nothing to do; if you worked around it with
110
+ `Testing.config.schema = MySchema`, that still works and still wins.
111
+ - **`GraphWeaver.parse(query:)` accepts a `Pathname`.** `schema:` already did,
112
+ so `GraphWeaver.parse(schema: Rails.root.join("schema.graphql"), query:
113
+ Rails.root.join("app/graphql/queries/person.graphql"))` died on the query
114
+ argument with `undefined method 'end_with?'`. Same for `client.parse(path)`.
115
+ - **`rake graph_weaver:cassettes:check` and `:anonymize` now find cassettes
116
+ from any working directory.** Both read `config.cassette_dir` raw, while
117
+ `Cassette.new` resolves it against `Rails.root` — so run from anywhere but
118
+ the app root, `check` aborted with "this checked nothing, so it proved
119
+ nothing" and `anonymize` silently did nothing, both while a spec run found
120
+ the same files fine. They now use the same resolution the recordings do.
121
+ - **An `extend_type(alias:)` name a struct instance already answers to now
122
+ refuses** instead of silently overriding it. `alias: { hash: "…" }` emitted
123
+ `def hash`, which breaks every `Hash` and `Set` holding that struct;
124
+ `inspect`, `to_s`, `method` and `class` were the same story. A *wire field*
125
+ by any of those names was already refused, so this is the same rule reaching
126
+ the same case from the other side. **If generation now refuses an alias you
127
+ had**, rename it — the accessor it generated was overriding a Ruby method.
128
+
129
+ ### v0.5.0 (2026-09-07)
130
+ - **`graphql_in_process(SomeSchema)`** runs one example against that schema
131
+ class's real resolvers — the sibling of `graphql_fake`, and how a federated
132
+ app tests a single subgraph directly rather than through the stitched graph.
133
+ `graphql: :in_process` is that call with no argument, which runs
134
+ `config.schema` when it is a live class, so a non-federated app needs
135
+ nothing. A suite testing several subgraphs names one per example, which no
136
+ suite-wide setting could express.
137
+ - **`Testing::Router` crosses a boundary on a nested `@key` or `@requires`.**
138
+ `@key(fields: "id organization { id }")` and
139
+ `@requires(fields: "origin { lat lon }")` used to refuse; they now plan, to
140
+ any depth, with the object carried in the representation the way the SDL
141
+ spells it (a null inner object included). **Nothing to do** — queries your
142
+ suite marked "run this one against a real router" may now simply run. Where
143
+ a type declares more than one `@key`, the plan takes the first one the
144
+ fetching subgraph can supply.
145
+ - **`:nested_field_set` narrowed rather than disappeared.** It now names only
146
+ a nested field set no one *fetch* can build — `origin` resolving in one
147
+ subgraph and `origin.lat` in another, or a nested `@key` whose object a
148
+ `@requires` would half-fill from somewhere else. Either way the object
149
+ would arrive in pieces, and a representation comes from one fetch. If you
150
+ group refusals by category, that bucket shrinks; its message and
151
+ `docs/federation.md`'s table say what is left. `:chained_requires` is
152
+ unchanged and still a different refusal.
153
+ - **`Testing.config.router` takes `subgraphs:` without `supergraph:`.** It
154
+ raised — "must be the arguments to build one, e.g. `{ supergraph: … }`" —
155
+ even where the committed dump already is the supergraph, which is the case
156
+ the docs call "no config at all" and the single most likely config a
157
+ federated app writes (marking a remote subgraph `:fake`). Either key alone
158
+ is enough now; a missing `supergraph:` derives exactly as it does with no
159
+ `config.router` at all.
160
+ - **`graphql_fake(**options)`** builds the example's fake where the example
161
+ can say what it needs — `graphql_fake(overrides: { "Reader.orders" => [{}, {}] })`
162
+ — and returns it, so `#requests` is in reach. `graphql: :fake` is this call
163
+ with no options. Options had nowhere to go before: the tag builds its client
164
+ in a `config.before(:each)`, which rspec runs ahead of every group hook, so
165
+ `Testing.config.overrides` set in a `before` block was always too late and
166
+ failed silently, as wrong data.
167
+ - **`GraphWeaver.client` is snapshotted and restored around *every* example**,
168
+ not only a tagged one. `graphql: false` used not to restore while
169
+ `graphql: :fake` did, which made "tag `:fake`, then throw the client away"
170
+ the idiom for cleanup. Building your own client is now a plain assignment in
171
+ a `before` block. An example that deliberately leaked a client into later
172
+ examples no longer can.
173
+ - **`graphql: :none` is gone** — a second spelling of `graphql: false`, which
174
+ stays. Change any `:none` tag to `false`.
175
+ - **`GraphWeaver.client!` names the tag** when `graph_weaver/rspec` is loaded:
176
+ `no client configured — tag the example graphql: :fake (or :in_process /
177
+ :router), or build one with graphql_fake`. "Set `GraphWeaver.client=`" was
178
+ advice for the wrong file.
179
+ - **An override pins a subtree by naming only the fields the test is about.**
180
+ `overrides: { "Reader.orders" => [{ "status" => "PAID" }, {}] }` pins the
181
+ list's length and merges each element onto fabricated data — the rest of the
182
+ selection is still generated. It used to *replace*, so pinning one nested
183
+ field meant hand-writing the whole selection set in wire casing, and
184
+ under-supplying died as `key not found: "book"` at cast time. A pinned key
185
+ the query doesn't select is now refused (spellchecked, and naming the
186
+ response keys it could have been), for the same reason a typo'd coordinate
187
+ is. At a union or interface, a pinned object names its `"__typename"` and
188
+ gets that member rather than a random one.
189
+ - **An override of `nil` pins the field null.** It used to read as "no
190
+ override" and fabricate a value.
191
+ - **`Testing::FakeClient#requests`** records every `execute` in order
192
+ (`{ query:, variables:, operation_name: }`) — "did we send the right
193
+ variables", and "did we call it at all", without a hand-rolled spy.
194
+ - **`FakeClient`'s selection-walking internals are private** (`each_field`,
195
+ `gather`, `load_operation`, …). Nothing documented called them; if you did,
196
+ `Object.new.extend(GraphWeaver::Selection)` is the supported host.
197
+ - **The local router refuses a `@requires` whose field set names another
198
+ `@requires` field** (`chained_requires`). It used to answer: a prefetch sends
199
+ the entity's own `@key` and nothing else, so the inner requirement never
200
+ arrived and the field was computed from a representation missing its input —
201
+ the same field then holding two different values in one response. The
202
+ one-hop limit was documented but not enforced.
203
+ - **An error path no longer names an injected `@key`/`@requires` alias.** A
204
+ stitched error came back as `["thing", "_gw_w"]`, a field no schema contains;
205
+ it is `["thing", "w"]` now, as a real router reports it.
206
+ - **`@skip`/`@include` driven by a variable's declared default was ignored on a
207
+ boundary-crossing field**, so `query($show: Boolean = true) { … @include(if: $show) }`
208
+ called with no variables silently dropped the field. graphql-ruby applies an
209
+ operation's defaults; the local router read only what the caller passed.
210
+ - **`Testing::FakeClient` raises a GraphQL validation error for an unknown
211
+ field**, as every other client in the slot does. It used to die with
212
+ `NoMethodError: undefined method 'type' for nil` from inside the fabricator —
213
+ undiagnosable, and the commonest mistake there is: a query drifting ahead of
214
+ the schema dump, or a typo in one written inside an example.
215
+ - **The local test router plans a union or interface at a subgraph boundary.**
216
+ `search { ... on Track { playCount } ... on Artist { upcomingEvents { … } } }`
217
+ — a feed, a search page, any polymorphic list — used to be refused
218
+ (`abstract_boundary`), because a representation names one concrete
219
+ `__typename` and the planner runs before any data exists. It now plans one
220
+ branch per concrete type the supergraph says the subgraph can answer with,
221
+ asks the fetch for `__typename`, and buckets the returned objects by it at
222
+ execution time — one `_entities` fetch per concrete type, which is what a
223
+ real router does. Nothing to change; queries that were refused now run.
224
+ - The `crosses_subgraph` refusal category is **gone**, and
225
+ `abstract_boundary` now means only one thing: the supergraph doesn't record
226
+ which concrete types a subgraph answers an abstract type with (no
227
+ `@join__unionMember`/`@join__implements`, and the type in more than one
228
+ subgraph). Match on `Unplannable#category` for either of those and you must
229
+ change it.
230
+ - A fragment whose type condition can't hold at a position is now dropped
231
+ rather than refused — `... on Note` under a field whose subgraph has no
232
+ Note in that union never matches, and a real router answers `{}` there too.
233
+ - **A query file whose name can't spell a constant now names the file.**
234
+ `01_home_featured.graphql` reported `module_name: must be a constant name,
235
+ got "01HomeFeaturedQuery"` and left you to find which of thirty files it
236
+ meant; it now names the path and says the fix is a rename. It's a
237
+ `GraphWeaver::Error` too, so `rake graph_weaver:generate` aborts on it
238
+ instead of burying it under a backtrace — rescue `ArgumentError` for this
239
+ and you must change it. An explicit `module_name:` argument still raises
240
+ `ArgumentError`.
241
+ - **`rails g graph_weaver:install <supergraph>` recognises a composed
242
+ supergraph** and says what changes because of it: the `federation:*` tasks,
243
+ and that `graphql: :router` runs specs against your real subgraph resolvers.
244
+ The initializer it writes says so too.
245
+ - **Every `rake graph_weaver:federation:*` task was a silent no-op in a stock
246
+ Rails app.** Rails defaults `config.rake_eager_load` to false, and subgraph
247
+ detection only sees *loaded* schema classes — so `federation:subgraphs`
248
+ reported `nil` for every subgraph and `federation:diff` printed "checked 0 of
249
+ 4 subgraphs" and exited **0**, a CI gate permanently green while checking
250
+ nothing. The tasks now eager-load the app first, and detection resolves.
251
+ - **`federation:diff` fails when it compared against nothing** (exit 1, naming
252
+ what to do). Comparing against *some* subgraphs is still a pass — a
253
+ partly-local supergraph is a supported setup — but a comparison against none
254
+ of them proved nothing. If your subgraphs all run elsewhere, drop the task
255
+ from CI; it has nothing there to gate.
256
+ - **The absent-subgraph refusal names the cause.** It advised `subgraphs: { … }`
257
+ on a `Router.new` an rspec `graphql: :router` example never calls. It now
258
+ leads with the usual cause — the schema class isn't loaded yet, so
259
+ eager-load it — and names `GraphWeaver::Testing.config.router = { subgraphs:
260
+ … }` as the way to name one under the tag.
261
+ - **Subgraph-mapping refusals are `GraphWeaver::ConfigurationError` (was
262
+ `ArgumentError`)** — `rescue GraphWeaver::Error` around `Testing::Router.new`
263
+ now catches them, as `docs/errors.md` said it would. Rescue `ArgumentError`
264
+ for these and you must change it. The rule the docs now state: what the
265
+ library *concludes*, having read your schema, is a `GraphWeaver::Error`; an
266
+ argument wrong on its face (`pool_size: must be >= 1`) stays an
267
+ `ArgumentError`. An ambiguous-detection refusal also names each candidate
268
+ schema once, instead of repeating a reloaded constant.
269
+ - **The local test router refused every mutation that stitched below its root**,
270
+ with a message claiming its root fields "span subgraphs" when there was one
271
+ root field in one subgraph — and advice (split it per subgraph) that couldn't
272
+ be followed. It now plans any mutation whose root fields share a subgraph:
273
+ they go over as one document and that subgraph serializes them, and what
274
+ hangs below a root is an ordinary read afterwards. The refusal is left for
275
+ root fields that genuinely span subgraphs, where the message is true.
276
+ - **The local router honoured `@skip`/`@include` on a field but ignored it on a
277
+ fragment spread or inline fragment that crossed a subgraph boundary** — it
278
+ answered a selection the operation had excluded, and ran an extra subgraph
279
+ fetch to do it. Folding a same-type fragment into its parent dropped the
280
+ fragment node, and its directives with it. They now move onto the selections
281
+ they guarded, and a fetch is skipped entirely when everything it was for is
282
+ excluded (so `trace` matches what a real router does). A field and the
283
+ fragment around it both carrying the same directive refuses, as
284
+ `conditional_fragment` — one selection can't hold two conditions of one name.
285
+ - **`rake graph_weaver:federation:diff` reported false drift for every input
286
+ object**, so a correct supergraph failed the CI gate with advice to recompose
287
+ something that wasn't broken. An input object exposes its members as
288
+ `arguments`, not `fields`, and the check only asked for fields.
289
+ - **A `Pathname` works anywhere a schema path does** — `GraphWeaver.new`,
290
+ `generate!(schema:)`, `SchemaLoader.load`. `Rails.root.join("schema.graphql")`
291
+ previously failed as `undefined method 'lstrip' for an instance of Pathname`.
292
+ - **`Transport::HTTP`'s `pool_size:` defaults to `RAILS_MAX_THREADS`** (else 5,
293
+ as before) — the variable Rails already sizes its own connection pool from,
294
+ because it is the same question. A threaded app that raised its thread count
295
+ no longer silently queues behind five sockets.
296
+ - **A saturated pool says so.** The first request that has to wait for a
297
+ connection logs a warning naming the wait and the ceiling; later ones log at
298
+ debug. Queueing was previously indistinguishable from a slow server, which
299
+ mattered most under a fiber scheduler — `async`/Falcon multiplexes fine, but
300
+ nothing sets `RAILS_MAX_THREADS` there, so the default capped it at 5 with no
301
+ signal. See `docs/transports.md`.
302
+
303
+ #### One `execute`, one way to pass a client (**breaking**)
304
+
305
+ Every client answers the same call — `execute(query, variables:,
306
+ operation_name:)`, returning the raw response hash. Three objects a user holds
307
+ used to disagree with that, and each disagreement was a bug waiting.
308
+
309
+ - **`Client#execute` is that contract now**, so `GraphWeaver::Retry.new(client)`,
310
+ `Testing::Sequence.new(client, fake)` and a cassette recorder over a client
311
+ all work. Its one-shot parse-and-run sugar moved to **`Client#run` /
312
+ `#run!`** (and `GraphWeaver.run` / `.run!` for the throwaway-client form):
313
+ **rename `client.execute!(query, **vars)` to `client.run!(query, **vars)`.**
314
+ - **A generated module takes the per-call client as a kwarg**: rename
315
+ `PersonQuery.execute(some_client, id: "1")` to
316
+ `PersonQuery.execute(client: some_client, id: "1")`. Variables are
317
+ unaffected — `$client` was already refused at generation. It is also what
318
+ makes a mistyped variable name itself: `PersonQuery.execute(id: "1")` on a
319
+ query that declares no variables now raises `unknown keyword: :id` instead of
320
+ blaming the client. **Regenerate** (`rake graph_weaver:generate`);
321
+ `rake graph_weaver:verify` fails until you do.
322
+ - **`GraphWeaver.resolve_transport` is gone.** Nothing needs unwrapping any
323
+ more. A client that can't execute is still refused, by the module it was
324
+ passed to — whose name is now in the message.
325
+ - A module parsed from a `Client` bakes that client rather than its transport.
326
+ For a client built from a schema *dump* (no transport), `execute` now says
327
+ `this client has no transport (built from a schema dump)` instead of quietly
328
+ running on `GraphWeaver.client`.
329
+ - `$transport` is a usable GraphQL variable name again — the generated body has
330
+ no such local.
331
+
332
+ #### One reset, one plurality rule (**breaking**)
333
+
334
+ - **`GraphWeaver.reset_scalars!`, `clear_scalars!`, `reset_enums!` and
335
+ `reset_type_helpers!` are gone.** `GraphWeaver.reset_registrations!` is the
336
+ clean slate between tests; to reset one registry, call the same name on
337
+ `GraphWeaver::Codegen`.
338
+ - **Every directory setting is a list — `queries_paths`, `generated_paths`,
339
+ `fragments_paths`** — and *every entry is read*, by `generate!`,
340
+ `verify_generated!`, `check_queries` and `load_queries!` alike. (0.4.x
341
+ dropped a plural `queries_paths` because only `load_queries!` walked it;
342
+ that divergence is what the singular was protecting against, and it is gone.)
343
+ **Rename any `queries_path` / `generated_path` / `fragments_path` you set or
344
+ read** — assigning a String still works (`GraphWeaver.queries_paths =
345
+ "app/graphql/queries"`), and `generated_paths.first` is the one directory
346
+ `generate!` writes into. `schema_path` stays singular: a run reads one
347
+ schema, so a second entry would name a dump nothing opens.
348
+
349
+ - `rake graph_weaver:queries:check` prints an unparseable query's position once
350
+ rather than twice, and `GraphWeaver.check_queries` returns the documented
351
+ `"message"` / `"line"` / `"column"` shape for parse errors too — the position
352
+ is no longer folded into the message.
353
+ - Docs: a spec-local `generated_paths` entry needs an explicit
354
+ `GraphWeaver.load_generated!` (in Rails the Railtie has already run by then),
355
+ and belongs outside `spec/support/`, whose files rspec-rails requires itself
356
+ in sorted order. A registration naming one of your own constants goes in a
357
+ `to_prepare` block — the same rule the in-process client already follows —
358
+ rather than the `require Rails.root.join(...)` dance.
359
+ - **Removing an `extend_type` registration no longer bricks the app.**
360
+ Generated files carry `include GraphWeaver::TypeHelpers::Foo`, so dropping
361
+ the registration made boot fail — and because `rake graph_weaver:generate`
362
+ depends on `:environment`, the regeneration that would repair it failed the
363
+ same way. The graph_weaver tasks now skip loading generated modules (none of
364
+ them reads one), so `rake graph_weaver:generate` repairs the tree. Outside a
365
+ task, the dangling include now raises a `GraphWeaver::Error` naming the
366
+ registration that went missing and how to recover, instead of a bare
367
+ `NameError` pointing into generated code.
368
+ - **A cancelled request no longer leaks its socket.** `Transport::HTTP`
369
+ closed a connection of unknown state with a bare `rescue`, which catches
370
+ only `StandardError`. A fiber scheduler cancels with `Async::Stop`, which
371
+ descends from `Exception`, so cancelling an in-flight request walked past
372
+ the cleanup and left the socket open until GC. Affects any app under
373
+ `async`/Falcon with per-request timeouts. Nothing to do — the fix is
374
+ internal.
375
+ - `rake graph_weaver:queries:check` and `federation:diff` flush stdout before
376
+ aborting, so a piped CI log shows the details before the verdict rather
377
+ than after it.
378
+ - `generate!`, `verify_generated!` and `check_queries` accept a **path or SDL
379
+ string** for `schema:`, like every other schema slot in the library. A String
380
+ used to reach `schema.validate` as itself and die with `undefined method
381
+ 'validate' for an instance of String`.
382
+
383
+ - **`#parse` on anything that holds a schema**, not just `Client`:
384
+ `GraphWeaver::InProcess`, `Testing::FakeClient` and `Testing::Router` gain it
385
+ (and `#load_queries!`) from the new `GraphWeaver::Parsing` mixin. Replace
386
+ `GraphWeaver.parse(schema: router.schema, client: router, query: q)` with
387
+ `router.parse(q)`. `Retry` holds no schema and has no `#parse` — parse from
388
+ what it wraps. Nothing changes for `Client`, and the client contract is
389
+ untouched: a bare `GraphQL::Schema` class still fills the client slot.
390
+ - **`graphql: false` (or `graphql: :none`) opts an example out of
391
+ `config.default_mode`** — no client is installed, so the example can wire its
392
+ own. Previously a default swept up every untagged example with no way out,
393
+ and both spellings raised "is not a mode"; that message now names the opt-out
394
+ alongside the modes.
395
+ - Docs: `graphql_context` is setup, so `docs/testing.md` now shows it in a
396
+ `before` block for a group sharing one identity, with the inline form kept
397
+ for one-offs. The per-example reset runs ahead of group hooks, so a
398
+ group-level `before` re-applies from the same baseline every time.
399
+
400
+ - `examples/federation.rb` — a runnable federated-testing example, and the first
401
+ one that needs no network: three real subgraphs, a boundary-crossing query
402
+ through a generated module, `router.trace`, and a plan-time refusal. Guarded
403
+ by `spec/examples_spec.rb`, which runs it.
404
+ - Docs: the local router moved from `docs/testing.md` to `docs/federation.md`.
405
+ `testing.md` now covers which client an example runs against; `federation.md`
406
+ covers what a federated graph does. No behaviour change — update any bookmark
407
+ to `docs/testing.md#the-in-process-router--graphql-router`, now
408
+ `docs/federation.md#the-local-router`.
409
+ - **`graphql: :in_process` no longer hunts for the live schema class.** It runs
410
+ against `GraphWeaver::Testing.config.schema`, or the schema class your client
411
+ already runs in-process — one sentence, no heuristic. The third fallback
412
+ (`Testing::LiveSchema`, which searched every loaded `GraphQL::Schema` for one
413
+ defining everything the reference schema declares) is **deleted**. It only
414
+ ever applied to an app whose client points at a *different* API, and under
415
+ Zeitwerk it failed on the first `:in_process` example anyway, since an
416
+ autoloaded schema isn't loaded until something names it. Such an app now sets
417
+ `config.schema = MySchema`; when neither source is there, the error says so.
418
+ - **One positive predicate on `Response`, not two.** `#ok?` is gone; `#success?`
419
+ is the survivor, so the pair is `errors?` / `success?`. `success?` is what
420
+ `Process::Status` and `Faraday::Response` call it, and `ok?` reads as HTTP
421
+ 200 — which a GraphQL response carrying errors also is. Semantics unchanged:
422
+ partial data alongside top-level errors is **not** a success.
423
+ - **`Testing::Config#auto_fake` is gone** — it was the pre-tag spelling of
424
+ `config.default_mode = :fake`. Use that.
425
+ - **`rake graph_weaver:federation:diff` no longer loads the test harness.**
426
+ `Federation::Drift` needed one thing from it — the list of loaded schema
427
+ classes — and did `require "graph_weaver/testing"` from inside itself to get
428
+ it, pulling faker into a task that fabricates nothing. That question, and
429
+ "does this schema define this coordinate", now live in `GraphWeaver::Schemas`,
430
+ shared with `Testing::Subgraphs`. Measured over the fixture supergraph, the
431
+ task loads 15 files instead of 323 (253 of them faker's).
432
+ `Testing::Subgraphs.loaded` moved with it: call `GraphWeaver::Schemas.loaded`.
433
+ - Internal: `codegen/enum_type.rb` held `extend_type`, the type-helper and alias
434
+ registries and `GraphWeaver::TypeHelpers` — none of them enums, so a search
435
+ for `extend_type` landed in a file named for something else. Those moved to
436
+ `codegen/type_helpers.rb`; `enum_type.rb` now holds `EnumType` and the enum
437
+ registry, mirroring `scalar_type.rb`. No API change.
438
+ - **`register_enum` and `extend_type` say where to register** when handed a
439
+ constant's *name* instead of the constant. Passing a String is the natural
440
+ workaround for "`uninitialized constant PetKind` in my initializer", and the
441
+ answer is Rails' own: autoloading is set up after `config/initializers` run,
442
+ so register from a `Rails.application.config.to_prepare` block — which
443
+ `rake graph_weaver:generate` also runs before generating. Both registries
444
+ still take the constant itself; a name would be a second spelling that
445
+ couldn't reach `fallback:` or `map:` anyway, since those name enum *members*.
446
+
447
+ #### Scalar coercion is one switch (**breaking**)
448
+
449
+ `coerce:` takes `true`/`false` only — the Symbol form is gone. It was a third
450
+ way to ask one question (*may a variable of this scalar accept loose input?*)
451
+ and the only one that also made you answer *how*, which the scalar already
452
+ knows: `Int`/`Float` convert, anything with a `cast:`/`serialize:` pair parses,
453
+ and a pass-through scalar can't.
454
+
455
+ **What to do:**
456
+
457
+ - `coerce: :to_i` / `coerce: :to_f` — write `coerce: true`. Generated output is
458
+ unchanged.
459
+ - `coerce: :to_s` on `String`/`ID` — drop it and call `.to_s` at the call site.
460
+ Those have nothing to convert from, so `coerce: true` on one raises now
461
+ instead of emitting a no-op.
462
+ - any other Symbol on a custom scalar — give the scalar a `cast:`/`serialize:`
463
+ pair and `coerce: true`.
464
+
465
+ `GraphWeaver.auto_coerce` is unaffected, and `coerce: true` is now exactly what
466
+ it turns on for one scalar instead of all of them.
467
+
468
+ #### One shared module, not three (**breaking** — regenerate)
469
+
470
+ `GraphQLInputs`, `GraphQLUnions` and `GraphQLEnums` are now one `GraphQLTypes`.
471
+ They were three constants, three config knobs and three file shapes for one
472
+ idea — a type shared across query modules — and the rule now fits in a
473
+ sentence: **a type shared across query modules lives in the shared module and is
474
+ aliased in.**
475
+
476
+ **What to do:** run `rake graph_weaver:generate` (`verify` fails until you do,
477
+ naming the stale files). Every generated file changes: a query module opens with
478
+ one `require_relative "types"` instead of up to three, and its aliases read
479
+ `AdoptionInput = GraphQLTypes::AdoptionInput`. On disk, `enums.rb`, `inputs.rb`,
480
+ `inputs/` and `unions.rb` become `types.rb` (the manifest) plus one file per
481
+ type under `types/` — the old files are pruned for you, since pruning keys off
482
+ the generated header. If you referenced `GraphQLInputs::PetFilter` (or the other
483
+ two) by hand, spell it `GraphQLTypes::PetFilter`.
484
+
485
+ `GraphWeaver.inputs_module=` / `unions_module=` / `enums_module=` are now
486
+ `GraphWeaver.types_module=`, and `generate!`/`verify_generated!` take one
487
+ `types_module:` in place of three.
488
+
489
+ One namespace also removes the aliasing *between* the shared artifacts: an input
490
+ struct's props and a union member's selections spell their enums bare now, being
491
+ lexically inside the same module. The manifest requires the enum files first for
492
+ that reason.
493
+
494
+ New: a shared fragment whose name is already a schema type in that module is
495
+ refused at generation, naming both — a fragment is named by you, a type by the
496
+ schema, and one module is one namespace. Previously they lived apart and could
497
+ never meet.
498
+
499
+ #### Has anyone changed a subgraph without recomposing?
500
+
501
+ rake graph_weaver:federation:diff SUPERGRAPH=supergraph.graphql
502
+
503
+ A committed supergraph is a snapshot of a composition, and nothing checked that
504
+ it still described your subgraphs — so it could quietly promise a graph that no
505
+ longer exists. This reads the routing table against the subgraph schemas loaded
506
+ in this process, needs **no network**, and exits non-zero on drift, so it gates
507
+ a PR alongside `graph_weaver:verify`.
508
+
509
+ Both directions, because they mean opposite things: **stale** (the supergraph
510
+ carries `Product.weight`, nothing here defines it — recompose) and **not
511
+ composed in** (a schema here defines `Product.dimensions`, the supergraph
512
+ doesn't carry it — publish the subgraph). Comparison is deliberately looser
513
+ than field-set equality, which would be wrong both ways: a subgraph carries
514
+ federation plumbing no supergraph has, and `@external`/`@shareable` put a field
515
+ in more than one subgraph.
516
+
517
+ A supergraph is routinely only partly local, so the report names three states —
518
+ checked, not here, and answered with fabricated data — and the headline counts
519
+ them. Only drift fails; absence is a supported setup.
520
+ `GraphWeaver::Federation::Drift` is the same thing as data (`#to_h`, `#drift?`).
521
+
522
+ #### Validation errors name the subgraph behind the type
523
+
524
+ When the schema dump is a composed supergraph, `rake graph_weaver:queries:check`
525
+ brands each error with who resolves the type it points at:
526
+
527
+ app/graphql/queries/product.graphql
528
+ 4:5 Field 'dimensions' doesn't exist on type 'Product' (products, reviews)
529
+
530
+ `Product.dimensions` says what broke; `(products, reviews)` says whose code to
531
+ look at. A plain schema has no routing table and is unaffected.
532
+
533
+ - New: `SchemaLoader::RoutingTable#declared_fields`, `#declares?`, `#responsible`.
534
+
535
+ #### A partly-local supergraph now works
536
+
537
+ The testing router serves a supergraph composed from several services when only
538
+ some of them run in this process. A subgraph no loaded schema defines is
539
+ **absent** rather than an error at construction, so the router builds and every
540
+ query that doesn't reach those fields runs normally. A query that does reach
541
+ them is refused at plan time, before anything executes, naming the subgraph and
542
+ the field that reached for it.
543
+
544
+ subgraphs: { "reviews" => :fake } # answer an absent subgraph with fabricated data
545
+
546
+ Faking is opt-in and never silent: a faked fetch is marked `faked: true` in
547
+ `router.trace`, logged at `:warn` per fetch, and listed by `router.faked` and
548
+ `#inspect`. It is deliberately not surfaced as a response error — that would
549
+ make `execute!` raise, defeating the point.
550
+
551
+ - `Testing::Subgraphs.resolve` now returns only the subgraphs this process
552
+ serves instead of raising when one has no candidate. Two candidates still
553
+ refuse, naming both.
554
+
555
+ #### One tag picks what a test runs against
556
+
557
+ `auto_fake` and `config.router` each installed a client for **every** example
558
+ and refused to coexist, so a suite had to choose fakes or real resolvers once,
559
+ for everything — and running in-process against a live schema had no configured
560
+ mode at all. Now an rspec tag says it per example, or per group:
561
+
562
+ it "renders the empty state", graphql: :fake do … end
563
+ it "authorizes drafts", graphql: :in_process do … end
564
+ describe "checkout", graphql: :router do … end
565
+
566
+ `rspec --tag graphql:router` runs one mode's examples. The tag is namespaced
567
+ under one `graphql:` key on purpose: a bare `:fake` or `:router` would collide
568
+ with an app's own metadata and silently change which client an unrelated
569
+ example runs against.
570
+
571
+ **Nothing needs configuring.** Each mode derives what it runs against and
572
+ refuses — naming what it looked for — rather than guessing. The schema is
573
+ `config.schema` if you set one, else the committed dump, else the schema
574
+ `GraphWeaver.client` talks to. `:in_process` finds the live schema *class*: the
575
+ one your client already runs in-process, else the loaded class defining
576
+ everything that schema declares (the rule `subgraphs:` detection already uses).
577
+ `:router` plans against the dump when the dump is a composed supergraph — a
578
+ federated suite whose checked-in dump is the supergraph needs no config at all.
579
+
580
+ - **New:** `graphql_context(current_user: user)` sets the context your
581
+ resolvers see. It merges onto `config.context` and is reset before the next
582
+ example, so an example running as somebody else can't leak into the one
583
+ after. Pass a block to scope it. Under `graphql: :fake` it refuses — there
584
+ are no resolvers to receive it.
585
+ - **New:** `config.default_mode` is what an untagged example runs against
586
+ (`nil`, the default, leaves `GraphWeaver.client` alone). It replaces
587
+ `config.auto_fake`, which still works as the old spelling of
588
+ `default_mode = :fake`.
589
+ - **New:** `config.context` — the baseline every `:in_process` and `:router`
590
+ example starts from. `config.router = { context: … }` now refuses and points
591
+ here; the per-example reset would have overwritten it.
592
+ - `GraphWeaver.execute`, `.new` and `Client.new` now refuse a *client* where a
593
+ schema source belongs — an `InProcess`, `Retry`, transport or fake used to
594
+ crash with `undefined method 'lstrip'`. The message names both ways to say
595
+ what you meant.
596
+
597
+ #### Ruby-keyword field names now generate
598
+
599
+ A result key that underscores to a Ruby keyword — `pageInfo { next }`,
600
+ `filter { in }` — no longer refuses to generate. A prop is only ever read off
601
+ a receiver, so `const :next` is fine; the one bare read, an `alias:`
602
+ delegator's first hop, now spells `self.next`. Output props keep only the ban
603
+ the input side already had: names every `T::Struct` already answers to
604
+ (`class`, `hash`, `serialize`). If you aliased a query around this, you can
605
+ drop the alias and regenerate. `GraphWeaver::Codegen::RESERVED_PROPS` is gone
606
+ — `STRUCT_METHODS` is the whole rule now.
607
+
608
+ - `optional: true` on an `alias:` no longer hides a path segment the schema has
609
+ no field for. It still skips a field this query didn't select — that is what
610
+ it is for — but a typo, or the classic `findPets` where the path is the Ruby
611
+ prop chain, now raises and says which of the two it looks like. If an
612
+ optional alias resolved only through a query-level rename (`{ renamed: meta }`),
613
+ it will now raise on queries that don't select that key.
614
+ - **New:** `GraphWeaver.reset_enums!`, `GraphWeaver.reset_type_helpers!` and
615
+ `GraphWeaver.reset_registrations!` — the registry resets scalars already had.
616
+ `reset_registrations!` is the clean slate to reach for between tests.
617
+ - An alias error no longer names the same type twice when a query module and
618
+ its root type share a name.
619
+
620
+ #### Testing::Router now plans a real query, not just a single-subgraph one
621
+
622
+ `GraphWeaver::Testing::Router` used to hand one operation to one subgraph
623
+ verbatim and refuse anything that crossed a boundary. It now splits at the
624
+ crossing, refetches the entity from its `@key` through
625
+ `_entities(representations:)`, and stitches — batching every node at a level
626
+ into one call, running root query fields that span subgraphs as one fetch
627
+ each, and fetching a `@requires` field set from the subgraph that holds it
628
+ before the field that needs it. On the demo corpus that moves 10/17 queries
629
+ plannable to 17/17. It still refuses, at plan time, every shape it can't
630
+ answer the way a real router would.
631
+
632
+ `subgraphs:` is now **optional**: each subgraph's Ruby schema is derived from
633
+ what the loaded schemas define, and refuses rather than guesses when two
634
+ match or none do. An explicit map (or a partial one) still wins, and is now
635
+ checked the same way — a mis-wired entry fails at construction naming what it
636
+ doesn't define, instead of surfacing three fetches later.
637
+
638
+ - **New:** `config.router = { supergraph: "supergraph.graphql" }` in
639
+ `graph_weaver/rspec` runs every example against your real subgraph
640
+ resolvers.
641
+ - **New:** `rake graph_weaver:federation:subgraphs` prints the subgraph map
642
+ detection sees, with the evidence for each match.
643
+ - **New:** `Testing::Router#context` is settable, so one example can run as a
644
+ different user without rebuilding the router.
645
+ - `Testing::Unplannable`'s `:requires` category is **gone** — the gap it named
646
+ is closed. `:root_fields_span` now applies only to mutations (query roots
647
+ are planned). New categories: `:no_key`, `:abstract_boundary`,
648
+ `:nested_field_set`, `:shadowed_key`.
649
+ - The coverage report's second line now names every subgraph a query touches
650
+ (`accounts+reviews`), not just the one it ran in.
651
+ - `rake graph_weaver:schema:diff`, `schema:refresh` and `cassettes:anonymize`
652
+ now load the Rails environment first, so an initializer's settings apply.
653
+
654
+ **`GraphWeaver::Testing::Router` — a local federation router for tests.** Give
655
+ it a supergraph and your subgraph schema classes and it satisfies the client
656
+ slot, so `GraphWeaver.client = router` runs every generated module against real
657
+ resolvers in-process: no gateway, no node, no sockets. It plans one shape — a
658
+ query whose every field resolves in a single subgraph, passed to that subgraph
659
+ verbatim — and raises `Unplannable` (a `GraphWeaver::Error`) for anything that
660
+ crosses a boundary, at plan time, before any subgraph runs. See
661
+ [docs/testing.md](docs/testing.md#a-local-federation-router).
662
+
663
+ **`rake graph_weaver:federation:coverage SUPERGRAPH=…` says how much of your
664
+ query set that router can plan**, and groups every refusal by what stopped it —
665
+ the number that decides whether wiring it up is worth it. Planning needs the
666
+ supergraph alone, so it runs in CI with no subgraph loadable.
667
+
668
+ **A supergraph's routing table is now readable:
669
+ `GraphWeaver::SchemaLoader.routing_table(supergraph)`.** `load` strips the
670
+ `@join__*` machinery to get the API schema; this keeps it — `owners("Product",
671
+ "shippingEstimate") # => ["reviews"]`, each type's `@key` field sets, and which
672
+ copies are `@external`. A `@join__` directive it hasn't been taught lands in
673
+ `unsupported` rather than being skipped.
674
+
675
+ **`Representations.<entity>` for an entity the query didn't select now says
676
+ what to do.** Builders are query-driven, so `Representations.warehouse(...)`
677
+ raised a bare `NoMethodError` naming nothing. It now names the builders this
678
+ query does have and the selection to add (`... on Warehouse { __typename }`).
679
+
680
+ **Shared-fragment directories are scanned recursively, and `.gql` files count.**
681
+ The scan was `fragments/*.graphql`, so `fragments/person/fields.graphql` — how
682
+ anyone with sixty fragments organizes them — was skipped in silence, and a
683
+ `.gql` file was ignored even though `parse("x.gql")` reads one. A duplicate
684
+ fragment name now names both files that define it.
685
+
686
+ **Query directories are scanned the same way — recursively, `.gql` included.**
687
+ `queries/admin/pets.graphql` produced nothing at all: no file, no error.
688
+ `generate!`, `check_queries` and `client.load_queries!` now walk the tree, and
689
+ `.gql` no longer leaks its extension into the module name. Directories organize
690
+ queries but do not namespace them — `queries/admin/pets.graphql` is still
691
+ `PetsQuery` in `pets_query.rb` — so two files with the same base name are
692
+ refused at generation, naming both, rather than one silently overwriting the
693
+ other's generated file. The scaffolded `graphql.config.yml` matches
694
+ (`**/*.{graphql,gql}`).
695
+
696
+ **`execute` now takes one kwarg per declared variable, always — a single
697
+ required input-object variable is no longer flattened into per-field kwargs.**
698
+ `mutation($input: AdoptionInput!)` generated `execute!(name:, species:, …)`,
699
+ but adding any second variable generated `execute!(input:, …)` instead — so an
700
+ unrelated edit to a query silently reshaped every call site, and the rule
701
+ couldn't be stated without its exception. It also made a schema's own field
702
+ names load-bearing: a field named `client` or `in` can't be a kwarg and can't
703
+ be renamed, so flattening quietly declined and the surface moved again.
704
+ **Rewrite affected call sites to pass the input as one kwarg:**
705
+ `AdoptMutation.execute!(input: { name: "Rex", species: "DOG" })`, or
706
+ `input: AdoptMutation::AdoptionInput.new(name: "Rex", species: Species::Dog)`
707
+ for the field-by-field static check.
708
+
709
+ **An input field named after a Ruby keyword no longer makes a schema
710
+ ungeneratable.** `StringQueryOperatorInput.in` — the standard Hasura/Gatsby
711
+ filter shape — raised "would become prop 'in', which collides with a Ruby
712
+ keyword", with no way out: an input field is the schema's name, not yours, and
713
+ `extend_type alias:` is output-only. But `prop :in` is legal Ruby, and nothing
714
+ reads an input prop bare (`serialize` goes through `public_send`), so the
715
+ refusal was over-broad. Input fields named `in`, `end`, `def`, `nil` and the
716
+ rest now generate. A field colliding with a method every struct defines
717
+ (`serialize`, `to_h`, `class`, `hash`) is still refused — those break at
718
+ require time. Output structs are unchanged: a result key *can* be renamed, in
719
+ the query.
720
+
721
+ **A variable named `$client` no longer generates a file that won't parse.**
722
+ `query($client: ID!)` emitted `def self.execute(client = nil, client:)` — a
723
+ `SyntaxError` raised at app boot from `load_generated!`, arbitrarily far from
724
+ the query that caused it, while `verify_generated!` reported the tree as
725
+ current. Generation now refuses `$client`, `$variables` and `$transport` — the
726
+ three locals the generated `execute` body owns — naming the fix. **Rename such
727
+ a variable in the query (`query($clientId: ID!)`) before regenerating.**
728
+
729
+ **`auto_coerce` no longer erases the typing of String/ID variables.** It mapped
730
+ both to `#to_s`, which widened their kwargs to `T.anything` — the majority of
731
+ real variables, statically unchecked, in exchange for a cast that can't fail.
732
+ `auto_coerce` now covers only the conversions that are conversions (`Int`→`to_i`,
733
+ `Float`→`to_f`) plus scalars with a full cast/serialize pair. **If you relied on
734
+ a String/ID kwarg accepting anything, opt in per scalar:**
735
+ `GraphWeaver.register_scalar("ID", String, coerce: :to_s)`.
736
+
737
+ **An anonymous operation is now named after its module — in the query text and
738
+ in `OPERATION_NAME`.** Requests started carrying `operationName` so servers and
739
+ APMs can attribute traffic, but the constant was only set when the `.graphql`
740
+ document named its operation — and anonymous is what the docs show, so every
741
+ trace arrived `anonymous` and the feature did nothing for the documented happy
742
+ path. `person.graphql` holding `query($id: ID!) { ... }` now emits
743
+ `query PersonQuery($id: ID!) { ... }` with `OPERATION_NAME = "PersonQuery"`.
744
+ Both halves move together: a server rejects an `operationName` its document
745
+ doesn't declare. A document that names its own operation is left untouched.
746
+
747
+ **Cassette files no longer store the request twice — re-record them.** Every
748
+ entry carried a `key:` (the normalized query + variables) *and* a `query:` and
749
+ `variables:` again, and replay matched on `key:` alone: editing the half a
750
+ reviewer reads changed nothing, editing the other half broke replay while the
751
+ file still looked right. The key is now derived from `query`/`variables`/
752
+ `operationName` at load, so the file holds the request once and diffs are real.
753
+ **Existing cassettes must be re-recorded** (`GRAPHWEAVER_RECORD=1`, or delete
754
+ the file) — this also covers cassettes of anonymous operations, which stopped
755
+ matching when entries started keying on `operationName`.
756
+
757
+ **`MissingRecording` now prints the variables — the part that usually differs.**
758
+ It printed the whole query and omitted the variables entirely, so the common
759
+ miss (same query, different variables) showed you 60 lines identical to the
760
+ YAML and nothing about the mismatch. The message now leads with the request's
761
+ variables, says what was recorded for that query (`1 entry recorded for this
762
+ query, with variables {"id" => "1"}`), and prints the query as one truncated
763
+ line.
764
+
765
+ **A first run with no cassette and no `client:` no longer raises
766
+ `MissingRecording`.** There is no request yet, so it raises `GraphWeaver::Error`
767
+ naming the actual situation. **Rescue `GraphWeaver::Error` if you were catching
768
+ `MissingRecording` for this case.**
769
+
770
+ **`Cassette.use` is now `GraphWeaver::Testing.cassette` — rename your calls.**
771
+ It never returned a `Cassette`; it returns a *client* (a recorder or a replayer)
772
+ to hand to `execute`, and the name said otherwise. `Cassette` is now only the
773
+ file — `.new`, `#size`, `#anonymize!`.
774
+
775
+ **Record mode with no `client:` now raises instead of replaying.**
776
+ `GRAPHWEAVER_RECORD=1` on a `Testing.cassette(name)` call with nothing to record
777
+ against quietly served the stale recording, so "re-record everything" produced a
778
+ half-refreshed cassette set with no signal. **Pass `client:` to every call you
779
+ want re-recorded.**
780
+
781
+ **`Recorder.new(..., anonymize:)` is gone.** It was unreachable through the
782
+ factory and duplicated `Testing.config.anonymize`. **Set the config flag** —
783
+ that's the one way to anonymize, with `rake graph_weaver:cassettes:anonymize`
784
+ as the cleanup tool for cassettes recorded before you turned it on.
785
+
786
+ **`FakeClient.new` no longer requires `schema:`.** Every other option fell back
787
+ to `Testing.config`; this one didn't, even though `config.schema` already
788
+ auto-locates the committed dump. `FakeClient.new` now works on its own, and
789
+ says what to set when no schema resolves at all.
790
+
791
+ **`GraphWeaver.queries_paths` (plural) is gone — use `queries_path`.**
792
+ `generate!` and `check_queries` read the singular (the first entry) while
793
+ `load_queries!` walked the whole list, so a second queries directory produced
794
+ modules at runtime that `rake graph_weaver:generate` never generated and
795
+ `verify` never checked — silently. Queries are single-schema by design. **If
796
+ you appended a second queries directory, fold it into the first** (or run a
797
+ second `generate!` with its own `queries:`). `generated_paths` and
798
+ `fragments_paths` stay plural; they genuinely load from several places.
799
+
800
+ **One GraphQL enum is now one Ruby type.** A schema enum a query touches — as
801
+ a variable, in a result, or both — is emitted once per schema into
802
+ `generated/enums.rb` as `GraphQLEnums::<Enum>`, and every query module aliases
803
+ it. Before, an enum read out of a result got a class named for the response key
804
+ and nested in the struct that selected it (`SearchQuery::Result::Search::Pet::Species`),
805
+ while the same enum used as a variable got a module-level one — so whether a
806
+ schema enum was one Ruby type or three depended on what else the query happened
807
+ to reference, and handing a value from one query into another's variable raised
808
+ a `TypeError` that wasn't even a `GraphWeaver::Error`.
809
+
810
+ **Regenerate, and expect enum constants to move.** A nested enum path in app
811
+ code becomes the query module's own alias — `SearchQuery::Species` — or
812
+ `GraphQLEnums::Species`; `srb tc` finds them all. The enums a shared fragment's
813
+ union members select are hoisted too, so `unions.rb` now aliases them rather
814
+ than re-emitting them.
815
+
816
+ **The shared module names no longer depend on your output directory.** They are
817
+ `GraphQLInputs`, `GraphQLUnions` and `GraphQLEnums`, full stop. The old rule
818
+ camelized the parent of `generated/` unless it was on a hardcoded blocklist, so
819
+ `output: "gen2"` gave you `Gen2Inputs` and renaming `app/graphql/generated` to
820
+ `app/gql/generated` renamed a public constant. **A multi-schema layout must now
821
+ name its modules explicitly** — `GraphWeaver.inputs_module=` /
822
+ `unions_module=` / `enums_module=`, or `generate!(inputs_module:, ...)` — in the
823
+ same initializer that already gives each schema its paths. `GraphWeaver.inputs_module`
824
+ and `unions_module` no longer take an output-path argument.
825
+
826
+ **One registration registry, not two.** `Client#register_scalar`,
827
+ `#register_enum`, `#register_enums` and `#extend_type` are **deleted** — a
828
+ client-scoped registration was invisible to `GraphWeaver.generate!` (the rake
829
+ tasks have no client), so the console typed a field richly and the checked-in
830
+ code silently generated `T.untyped`. **Move any `client.register_*` /
831
+ `client.extend_type` call to the `GraphWeaver.` form** (an initializer, next to
832
+ the rest of your config). The one thing client scoping bought — two servers
833
+ disagreeing about a scalar — is what the per-field coordinate form is for:
834
+ `GraphWeaver.register_scalar("User.birthday", Date)`.
835
+
836
+ Also gone with it: `GraphWeaver.register_enums` (bulk) — there was never a
837
+ `register_scalars` to match it, so call `register_enum` per line — and
838
+ `GraphWeaver.reject_positional_map!`, now folded into the one
839
+ `Codegen.register_enum` that every door reaches (so all three doors give the
840
+ same "the value map is a keyword" error instead of a bare arity complaint).
841
+ `Codegen.parse` / `.generate` / `.generate_inputs` / `.generate_unions` no
842
+ longer take `scalars:`/`enums:`/`types:`.
843
+
844
+ **`generate!` now takes a Client where it takes a schema** — `GraphWeaver.generate!(schema: api)`,
845
+ `verify_generated!`, `check_queries` and `parse` all accept one, so the object
846
+ you built in the console is the object the build step wants and no schema dump
847
+ is needed. `client:` still means what it meant (a constant name to bake as
848
+ `DEFAULT_CLIENT`) and still refuses a live object.
849
+ **Rails integration fixes, found by running the gem in a real Rails app.**
850
+
851
+ - **Production boot no longer raises `uninitialized constant
852
+ Generated::PersonQuery`.** The default `generated_path` is
853
+ `app/graphql/generated`, which Zeitwerk claims as an autoload root, while
854
+ the files there define top-level constants. Development (lazy) was fine and
855
+ eager loading was not, so this only showed up in production or
856
+ `rails zeitwerk:check`. The Railtie now hides the generated directory from
857
+ the loader; nothing to configure.
858
+ - **`rake graph_weaver:generate` runs your initializer again.** The tasks
859
+ asked whether Rails' `:environment` task existed at *load* time, but Rails
860
+ defines it after every Railtie's `rake_tasks` block, so the answer was
861
+ always no. Generation and `verify` therefore ran without booting the app —
862
+ silently dropping every `register_scalar` / `register_enum` / `extend_type`
863
+ in `config/initializers`, and generating code that disagreed with the
864
+ running app. **Regenerate**: if you register anything in an initializer,
865
+ your committed generated files are wrong, and `rake graph_weaver:verify`
866
+ will now say so.
867
+ - `generate`, `verify` and `schema:diff` report a `GraphWeaver::Error` the
868
+ way `schema:refresh` already did — the message, and a non-zero exit,
869
+ instead of a rake backtrace through codegen.
870
+
871
+ **`rails g graph_weaver:install` takes any source `GraphWeaver.new` takes.**
872
+ The source is one positional argument — an endpoint, a schema class or an
873
+ existing dump all work the same way:
874
+
875
+ ```sh
876
+ rails g graph_weaver:install https://api.example.com/graphql
877
+ rails g graph_weaver:install MyApp::Schema # in-process, no socket
878
+ rails g graph_weaver:install db/schema.graphql # a dump you already have
879
+ ```
880
+
881
+ The initializer reflects the form chosen: a schema class is resolved in a
882
+ `to_prepare` block (it is autoloaded, so an initializer can not read it, and a
883
+ dev reload replaces the class object), and a dump you already have becomes
884
+ `GraphWeaver.schema_path` rather than being copied. `--auth` and the
885
+ introspection step are url-only; a source that can not use them, a constant
886
+ that does not resolve, and a class that is not a schema are all refused
887
+ before any file is written.
888
+
889
+ **Generated struct names now come from the query's own field names.** A struct
890
+ is named for the response key that selects it — `stargazers` becomes
891
+ `Stargazers`, `edges` becomes `Edges` — so its name is a function of its own
892
+ position in the query and nothing else. Names came from GraphQL *type* names
893
+ before, disambiguated by field name only on collision, which meant **a second
894
+ selection of the same type renamed the first**: a silent break in checked-in
895
+ code your app references. Deep queries could also collide outright and refuse
896
+ to generate.
897
+
898
+ **Regenerate, and expect renames.** Nearly every nested struct changes name
899
+ (`PersonQuery::Result::Person::Pet` becomes `...::Person::Pets`), and app code
900
+ naming one won't typecheck until it's updated — `srb tc` finds them all. The
901
+ payoff: adding, removing, or reordering an unrelated selection can never move
902
+ a name again.
903
+
904
+ - The key is used verbatim, with no pluralization heuristic — a list field
905
+ `pets` generates `Pets`. To pick a different name, alias the field in the
906
+ query: `pet: pets { name }` generates `Pet` (and a `.pet` accessor).
907
+ - Union and interface members keep their type-condition names (`... on Book`
908
+ gives `Book`), inside a container named for the field; a union hoisted from
909
+ a shared fragment is still named for the fragment.
910
+ - Two ties that walk order used to settle now resolve on their own: fields
911
+ sharing one collapsed union type take the first of their keys
912
+ alphabetically, and a name that would shadow the struct it nests in
913
+ (`pet { pet { ... } }`) takes a numeric suffix (`Pet2`).
914
+
915
+ **Requests now send `operationName`** — every graph_weaver request used to be
916
+ anonymous in Apollo Studio, Hasura, and any APM that keys traces, rate limits
917
+ and slow-query reports on it. Generated modules emit their operation name as
918
+ `OPERATION_NAME` beside `QUERY` and send it on the wire; a raw query string
919
+ handed to a transport falls back to the name in the document. In-process
920
+ execution passes it to `Schema.execute(operation_name:)`, which also makes a
921
+ multi-operation document selectable there.
922
+
923
+ To get the benefit, **name your operations** — `query Person($id: ID!)`, not
924
+ `query($id: ID!)` — and regenerate. An anonymous operation still works and
925
+ sends no `operationName`.
926
+
927
+ Three breaking changes come with it:
928
+ - **The client-slot contract widened to
929
+ `execute(query, variables:, operation_name: nil)`.** If you wrote your own
930
+ transport, client, or test double, add the kwarg — a client that doesn't
931
+ accept it now raises `ArgumentError: unknown keyword: :operation_name`. A
932
+ graphql-ruby `Schema` class already takes it, so bare schemas in the client
933
+ slot are unaffected. Subclasses of `GraphWeaver::Transport` only implement
934
+ `post(body)` and need no change.
935
+ - **Cassettes are keyed on `operationName` too**, so two operations in one
936
+ document can't collide. Cassettes recorded from a *named* operation before
937
+ this release no longer match — re-record them
938
+ (`GRAPHWEAVER_RECORD=1 bundle exec rspec`). Anonymous ones are unaffected.
939
+ - **`GraphWeaver::Transport.log_tag` takes an operation name, not a query
940
+ string** (`log_tag(query)` → `log_tag(operation_name)`); the constant
941
+ `Transport::OPERATION_NAME` is now `Transport::OPERATION_NAME_PATTERN`, since
942
+ generated modules define an `OPERATION_NAME` of their own.
943
+
944
+ Codegen bug fixes from the library review (all with regression coverage):
945
+ - Narrowing (`... on X` and nothing else) now reads the match off `__typename`
946
+ when the selection carries it, instead of off "the object came back empty".
947
+ Selecting `__typename` guaranteed a non-empty object, so **every non-matching
948
+ member was cast into `X`'s struct** — loudly when it had a non-null field,
949
+ silently when all its fields were nullable. Regenerate: any query mixing
950
+ `__typename` with a single type condition (the `_entities { __typename
951
+ ... on Widget { … } }` federation shape) was mistyped and now filters
952
+ correctly.
953
+ - A dispatched union/interface now requires its `__typename` to be unaliased and
954
+ free of `@skip`/`@include` — `from_h` reads it unguarded, so either would have
955
+ raised at runtime. Fix the selection if generation now refuses it.
956
+ - **Unions and interfaces generate per named condition, plus one catch-all
957
+ `Other`** — not one struct per schema member. A two-condition query against
958
+ GitHub's `Node` (278 implementations) went from 5,386 lines / 279 structs to
959
+ 162 lines / 4. **Regenerate, and expect member names to move**: a type your
960
+ query names no fields on is now `Other` rather than its own struct, so a
961
+ `case` over the members needs an `Other` branch (`T.absurd` will tell you).
962
+ In exchange, a `__typename` the query doesn't name — including a **member the
963
+ schema grows after you generate** — deserializes into `Other` instead of
964
+ raising `unexpected __typename`, so adding a union member upstream stays the
965
+ non-breaking change GraphQL says it is.
966
+ - `@skip`/`@include` on an inline fragment or a named spread now makes the
967
+ fields under it nilable, as it always did for a directly-marked field —
968
+ previously they kept non-null typing and a `data.fetch`, so a skipped block
969
+ raised `key not found`. The narrowing guard sees the fragment's own directive
970
+ too. Conversely, a field selected both conditionally and unconditionally is no
971
+ longer over-nilable: one unguaranteed selection doesn't unmake the guarantee.
972
+ - List variables coerce per element, so an enum inside a list accepts its wire
973
+ value the way a scalar enum already did (`sort: ["POPULARITY_DESC"]` used to
974
+ raise `NoMethodError: undefined method 'serialize' for String`). Input-object
975
+ and custom-scalar elements coerce in lists too.
976
+
977
+ - Federation schemas that previously wouldn't load now do:
978
+ - a supergraph whose `schema` definition carries a non-`@link` directive
979
+ (`@tag`, `@composeDirective`, a composed custom one) no longer dies with a
980
+ `GraphQL::ParseError` pointing into a document you never wrote.
981
+ - **raw subgraph SDL loads** — what `rover subgraph fetch`, `_service { sdl }`,
982
+ or your service repo's `.graphql` gives you. The federation directives a
983
+ subgraph applies but doesn't declare (`@key`, `@external`, `@shareable`, …)
984
+ are supplied on load, for both fed-1 and `@link`-style subgraphs. Note the
985
+ `@inaccessible` subtraction stays supergraph-only: a subgraph keeps those
986
+ fields, because it is not the public contract.
987
+ - A schema that won't build now raises `GraphWeaver::Error` naming the artifact
988
+ we took the source for (supergraph / subgraph / plain SDL / introspection),
989
+ instead of whatever graphql-ruby's internals happened to raise — a
990
+ `NoMethodError`, a `ParseError` pointing into a document you never wrote, a
991
+ bare `RuntimeError`. **Rescuing the raw graphql-ruby classes no longer
992
+ catches these.** The `@inaccessible` cascade also prunes a directive
993
+ definition's own arguments.
994
+ - **Single-line SDL loads.** `SchemaLoader.load("type Query { hi: String }")` —
995
+ the shape you type in a console — was rejected as "unsupported schema format",
996
+ because a string had to contain a newline to count as content rather than a
997
+ path.
998
+ - Rejecting a schema source is branded too, so the error class no longer depends
999
+ on which branch rejected it: an unsupported format and an unreadable file both
1000
+ raise `GraphWeaver::Error` (were `ArgumentError` and `Errno::ENOENT`). A bare
1001
+ host now says so — `"graphql.anilist.co" looks like a host; did you mean
1002
+ "https://graphql.anilist.co"?` — instead of pointing at the file system.
1003
+ - Cassette recording accepts a `GraphWeaver::Client` — the call
1004
+ `docs/cassettes.md` shows (`Testing.cassette("github", client: live)`),
1005
+ which failed with `ArgumentError: missing keywords`. And a client that can't
1006
+ `execute` is now rejected on the spot, with its class named, rather than
1007
+ surfacing later as `NoMethodError … for an instance of Hash`.
1008
+ - Generated structs answer `respond_to?` the way `method_missing` behaves, so
1009
+ `struct.method(:nmae)` gets the same "did you mean" hint the direct call does.
1010
+ - `@oneOf` input objects enforce exactly one field. The schema can't express it
1011
+ — every `@oneOf` field is nullable — so the struct accepted zero or many and
1012
+ the server rejected the round trip; supplying the wrong number now raises
1013
+ `GraphWeaver::InputError` naming the type and the keys. **Regenerate** to pick
1014
+ it up.
1015
+ - An enum whose values differ only in case (`enum E { active ACTIVE }`) is
1016
+ refused at generation naming both wire values, instead of emitting two
1017
+ `Active` constants and raising `RuntimeError: Enum values must be assigned to
1018
+ constants` when the file loads. **Map such an enum onto one of yours**
1019
+ (`register_enum`). `AB`/`A_B` and `IN_PROGRESS`/`INPROGRESS` still generate
1020
+ fine — they name distinct constants.
1021
+ - A `.graphql` file that won't parse raises `GraphWeaver::ValidationError`
1022
+ **naming the file**, instead of a bare `GraphQL::ParseError` whose `[6, 1]`
1023
+ pointed into a document you never wrote — fragment inlining parses on the
1024
+ `generate!` path before `Codegen#generate`'s rescue could brand it. Fragment
1025
+ files get the same treatment.
1026
+ - Generated `from_response` shape-checks the envelope, so a malformed one stays
1027
+ under `GraphWeaver::Error`. A non-object `data`, a `Hash` (or an array of
1028
+ strings) for `errors`, and non-object `extensions` all escaped as a raw Sorbet
1029
+ `TypeError` — the `data` one from `from_h`'s sig, before the struct's own
1030
+ rescue could see it. A body that isn't an object at all deserialized to an
1031
+ empty envelope (`String#[]` answers `"data"` with nil); it now raises.
1032
+ - The generated `from_h` rescues `StandardError`, not just
1033
+ `TypeError`/`ArgumentError`/`KeyError` — a registered scalar whose cast raises
1034
+ anything else (`JSON::ParserError`, `URI::InvalidURIError`, your
1035
+ `Money::ParseError`) escaped the umbrella. **Regenerate** to pick both up.
1036
+ - A document holding more than one operation is refused at generation. Only the
1037
+ first was ever typed, and the whole document went on the wire with no
1038
+ `operationName`, so the request came back "Must provide operation name" —
1039
+ **split multi-operation files into one operation each.**
1040
+ - Result keys are checked before they become props, so generation refuses what
1041
+ used to be an unloadable file. Two keys that underscore to the same prop
1042
+ (`{ name Name: name }` — a plain alias, no exotic schema needed) raised
1043
+ `ArgumentError: Attempted to redefine prop :name` at require time; so did a
1044
+ field named `class`, `hash`, `send` or `frozen?`, which `T::Props` won't let a
1045
+ struct redefine. **Alias the field in the query** (`classValue: class`) — the
1046
+ error names the key and the spelling. The same reserved set now covers input
1047
+ fields, which only checked Ruby keywords and `serialize`/`to_h` before.
1048
+ - **Global registrations are validated against the schema**, like client-scoped
1049
+ ones always were: `GraphWeaver.extend_type("Medai", …)` (or `register_scalar` /
1050
+ `register_enum`) used to be a silent no-op, which is the failure mode
1051
+ `docs/getting_started.md` step 3 walks you straight into — it now raises at
1052
+ generation with the spellchecked hint. Registrations are global (see above),
1053
+ so **drop any that names a type the schema you generate against doesn't
1054
+ have**. The built-in scalars are exempt — a schema with no `Date` isn't a
1055
+ mistake.
1056
+ - `extend_type(requires:)` and `register_enum(requires:)` check each path is
1057
+ loadable at registration, as `register_scalar(requires:)` already did and
1058
+ `docs/scalars.md` already promised — a typo fails now, not in the generated
1059
+ file.
1060
+ - Docs: `docs/testing.md` passed the client to generated `execute` as a `client:`
1061
+ kwarg — it's positional. `README.md` had module naming backwards for the
1062
+ documented path (a file's module comes from the **file** name, not the
1063
+ operation name). `docs/federation.md` covers subgraph SDL, federation v1
1064
+ supergraphs, and that `@inaccessible` is subtracted only on the supergraph
1065
+ path. `docs/cassettes.md` names `MissingRecording` correctly.
1066
+ - **Federation namespaces are derived from the schema's own `@link`/`@core`
1067
+ declarations** instead of a hardcoded `join__`/`link__`/`core__` list — the
1068
+ spec URL's name segment gives the namespace, `as:` renames it, and `import:`
1069
+ binds names into the root namespace (`{name: "@key", as: "@myKey"}` included).
1070
+ Four things this fixes:
1071
+ - a graph using fed-2.5+ auth (`@requiresScopes`/`@policy`/`@context`) no
1072
+ longer leaks `federation__Scope`, `federation__Policy` or
1073
+ `context__ContextFieldValue` into `schema.types`;
1074
+ - a supergraph that renamed a spec (`@link(url: ".../join/v0.3", as: "j")`)
1075
+ strips its `j__*` machinery — it previously failed to load at all;
1076
+ - **a renamed `@inaccessible`** (`import: [{name: "@inaccessible", as:
1077
+ "@private"}]`, or `as:` on the inaccessible spec) hides what it marks. It
1078
+ was missed entirely before, so the derived API schema kept fields the
1079
+ router does not serve and codegen over-permitted them. **Regenerate** if
1080
+ your supergraph renames it.
1081
+ - a `@core`-only fed-1 schema, and any composed graph carrying no `@join__`
1082
+ marker, is now recognized as composed rather than loaded as plain SDL
1083
+ (`core__Purpose` used to survive, and `@inaccessible` went unsubtracted).
1084
+
1085
+ - **Subgraph SDL loads with the entity resolver it serves.** No published
1086
+ subgraph SDL contains `_entities`/`_service` — `rover subgraph fetch` and
1087
+ `_service { sdl }` both print the schema, where the plumbing is implicit — so
1088
+ the one query only a subgraph can describe couldn't be typed against the
1089
+ artifact you have. Weaver now supplies `_Any`, `_Service` and an `_Entity`
1090
+ union over the file's own `@key`'d types, alongside the `@key`/`@external`
1091
+ definitions it already supplied. Supergraphs and plain SDL are untouched;
1092
+ a file declaring its own `_entities` keeps it.
1093
+ - **Typed `_entities` representations.** A query selecting entities now
1094
+ generates a `Representations` builder per entity it can resolve, typed from
1095
+ the `@key(fields:)` directives the subgraph SDL carries:
1096
+ `UserQuery::Representations.user(id: "1")` → `{"__typename" => "User", "id"
1097
+ => "1"}`. `__typename` is injected, key fields are typed from the schema, and
1098
+ a single `@key` makes them **required kwargs** — so an incomplete
1099
+ representation is an `srb tc` error, not a round trip. Compound (`"upc sku"`)
1100
+ and nested (`"organization { id }"`) key sets are parsed as the selection
1101
+ sets they are; a type with two alternative keys takes them optionally and
1102
+ raises `GraphWeaver::InputError` naming the type and what's missing when
1103
+ neither is satisfied. Builders are emitted only for entities the query
1104
+ actually reaches, and a key marked `resolvable: false` gets none.
1105
+ **`Representations` joins `Result`/`QUERY` as a reserved module-level name**
1106
+ — a shared fragment hoisting to it is now refused.
1107
+
1108
+ Transport improvements from the same review:
1109
+ - **`Transport::HTTP` pools its connections** (`pool_size:`, default 5) instead
1110
+ of serializing every request behind one socket and one mutex. The mutex was
1111
+ held across the whole network round trip, so one transport — which is what
1112
+ `GraphWeaver.client = api` gives a Rails app — allowed exactly one request in
1113
+ flight process-wide. Against a 10 ms-latency server, 8 threads × 10 calls:
1114
+ 1059 ms before, 281 ms with the default pool of 5 (~3.8×). Sockets still open
1115
+ lazily, stay keep-alive, and are dropped on any error so the next call
1116
+ reconnects. **Lower `pool_size:` if your server counts connections per
1117
+ client**; raise it to match a threaded web server's thread count.
1118
+ - Both transports now send `Accept: application/graphql-response+json,
1119
+ application/json;q=0.9` — the media type GraphQL-over-HTTP requires a
1120
+ conforming client to accept, so a spec-conformant server can finally use the
1121
+ newer status-code semantics — and `User-Agent: graph_weaver/<version>`, so
1122
+ server operators can attribute the traffic. Previously the only header sent
1123
+ was `Content-Type`, and net/http supplied `Accept: */*`. `headers:` still
1124
+ overrides both; a prebuilt `Faraday::Connection` keeps whatever it carries.
1125
+ - **`Transport::Faraday` takes `open_timeout:`/`read_timeout:` and defaults them
1126
+ to 10s/30s**, the same as `Transport::HTTP`. It had no timeout knobs at all,
1127
+ so it inherited net/http's 60s/60s — 6× and 2× the documented defaults. Both
1128
+ timeouts now also thread through the client: `GraphWeaver.new(url,
1129
+ read_timeout: 5)` works whichever transport is picked. Passing a timeout
1130
+ alongside a prebuilt `Faraday::Connection` raises, as `headers:` already did.
1131
+ The Faraday transport also logs its adapter at `:info` — the default
1132
+ `net_http` one opens a connection per request, which was invisible.
1133
+ - **New `GraphWeaver::InProcess`**, wrapping a live graphql-ruby schema class —
1134
+ `GraphWeaver.new(MySchema, context: { current_user: user })`. In-process
1135
+ execution worked but was blind in three ways: nothing supplied a `context:`,
1136
+ so a resolver reading `context[:current_user]` got nil (surfacing as "Cannot
1137
+ return null for non-nullable field Query.me"); all logging lived in
1138
+ `Transport#execute`, which an in-process schema bypasses, so not one line at
1139
+ DEBUG; and a resolver raise came out as a bare `RuntimeError` where the same
1140
+ failure over HTTP is a `ServerError`, so `rescue GraphWeaver::Error` caught
1141
+ one and missed the other. A resolver raise is now a `ServerError` (status
1142
+ 500) with the original kept as `#cause` — in-process, the real backtrace is
1143
+ the point. **A bare schema class still works in any client slot**; the
1144
+ wrapper is an upgrade, not a requirement.
1145
+ - **`ServerError` carries the response `#headers`** (names downcased), plus
1146
+ `#retry_after` (seconds or HTTP-date, per RFC 9110) and `#rate_limited?`. The
1147
+ `Net::HTTPResponse` was always in hand and thrown away, so recovering
1148
+ `x-ratelimit-remaining` or a request id meant monkey-patching the transport.
1149
+ A `post` override may now return a third element, the headers; returning the
1150
+ documented `[status, body]` pair stays correct.
1151
+ - **`Retry` honours `Retry-After`** — the server's delay wins over the
1152
+ configured backoff, clamped to `max:` and not jittered. Related: **408 and
1153
+ 429 now retry by default.** They were treated as ordinary 4xx ("your bug,
1154
+ retrying won't fix it"), which for the one status that exists to say "come
1155
+ back later" was exactly backwards, and left `Retry` incorrect against GitHub
1156
+ and Shopify. Pass `retry_if:` to restore the old behaviour.
1157
+ - **A throttling predicate, spelled the same everywhere**: `ServerError#throttled?`
1158
+ (429, or a 503 that says when to come back) and `QueryError#throttled?` /
1159
+ `Response#throttled?` (a throttle code in the errors array). An API says "slow
1160
+ down" with an HTTP status or with a code in a 200 body, and callers shouldn't
1161
+ have to know which. The codes are `GraphWeaver::GraphQLError::THROTTLE_CODES`
1162
+ — Shopify's `THROTTLED`, GitHub's `RATE_LIMITED`, and the common Apollo/Hasura
1163
+ spellings — so `retry_codes:` takes the constant instead of a hand-written
1164
+ string. `QueryError#to_h` gains `"throttled"` alongside `"schema_stale"`.
1165
+ - `Transport::HTTP` takes `ca_file:`/`ca_path:`/`cert:`/`key:`/`verify_mode:`,
1166
+ forwarded to `Net::HTTP.start` — a private CA or mTLS no longer means
1167
+ switching to Faraday, which was the real but undiscoverable answer. Passing
1168
+ one to an `http://` url raises instead of quietly doing nothing.
1169
+ - **An instrumentation seam**: `GraphWeaver.instrumenter = ->(event, payload,
1170
+ &block) { ... }`, a no-op until set, wrapping every request — over the wire
1171
+ and in-process, one seam for both. `ActiveSupport::Notifications` becomes a
1172
+ two-line adapter. The one event is `GraphWeaver::EXECUTE_EVENT`; its payload
1173
+ carries `:url`, `:schema`, `:operation` and `:status`, and deliberately not
1174
+ the query or variables (those are PII, and belong at debug on the logger
1175
+ where the level gates them). See `docs/logging.md`.
1176
+ Developer-experience fixes (all with regression coverage):
1177
+ - **FakeClient override keys are validated against the schema.** A typo'd key
1178
+ (`"Person.nmae" => "Daniel"`) pinned nothing, and the example passed against
1179
+ random fake data — a test that had quietly stopped checking what it claims to.
1180
+ Keys now raise, spellchecked, at `FakeClient.new` and at `Testing.configure`
1181
+ when a schema is already set. Bare field-name keys (`"name"`) still work;
1182
+ **fix or drop any key that doesn't name a field in your schema.**
1183
+ - Codegen validation errors name the position they already captured: each
1184
+ message is prefixed `4:5`, and `queries/typo.graphql:4:5` when the file is
1185
+ known (`Codegen.new`/`Codegen.generate` take it as `path:`), instead of
1186
+ leaving a project of thirty query files to search by hand.
1187
+ - A strict `alias:` whose path doesn't fit a query now names the query that
1188
+ failed and ends with `— pass optional: true to skip selections that don't
1189
+ fit`, the documented way out.
1190
+ - Generation lists the custom scalars it had no registration for at `info`
1191
+ (`3 unregistered custom scalars → T.untyped: …`). Informational — a scalar
1192
+ without a codec is a legitimate choice, just no longer a silent one.
1193
+ - `Response#ok?` (and `#success?`) — the positive form of `errors?`.
1194
+ - `FakeClient#schema` reads back the schema responses are fabricated against,
1195
+ which is how to reach it under `auto_fake`, where `GraphWeaver.client` is the
1196
+ fake; `Testing.config.schema` reads back too.
1197
+ New:
1198
+ - **`rake graph_weaver:queries:check` — which of your queries a schema change
1199
+ broke.** Re-introspects the url the dump records (leaving the dump alone) and
1200
+ validates every checked-in query against the server as it is now,
1201
+ reporting file plus line:col plus message and exiting non-zero on any
1202
+ failure, so it drops into CI. `GraphWeaver.check_queries` returns the same
1203
+ thing as data (`{path => [{"message", "line", "column"}]}`, empty when
1204
+ everything validates); pass `schema:` to check a schema you already have
1205
+ without touching the network. Complements `graph_weaver:verify`, which asks
1206
+ the different question of whether the committed Ruby is stale.
1207
+ - `verify_generated!` (and `rake graph_weaver:verify`) compares generated files
1208
+ with line endings normalized, so a checkout under git's `autocrlf` no longer
1209
+ reports every generated file as stale.
1210
+ - New [editor support](docs/editors.md) doc: the `graphql.config.yml` that gives
1211
+ VS Code and RubyMine validation, autocomplete and hover docs in your
1212
+ `.graphql` files — no JS project, no gem code, five lines of YAML.
1213
+ - **Byte-identical generation is now a stated guarantee**, not just a property:
1214
+ the same schema and queries produce the same files on any machine, in any
1215
+ order (`docs/generated_modules.md`). It was already true and spec-enforced;
1216
+ it was documented nowhere.
1217
+
1218
+ **Faraday is no longer auto-selected — `GraphWeaver.new(url)` always builds
1219
+ `Transport::HTTP`.** Selection used to be `defined?(::Faraday)`, and faraday
1220
+ rides into most bundles transitively (stripe, octokit, ...), so adding an
1221
+ unrelated gem silently swapped your transport, its timeouts, and its connection
1222
+ behaviour. The accidental default was also the slower one: `Transport::HTTP`
1223
+ pools persistent sockets (1 TCP connection for 10 requests) where Faraday's
1224
+ default `net_http` adapter reconnects per request (10 for 10) — a full TLS
1225
+ handshake each time over HTTPS.
1226
+
1227
+ **What you must do:** if you were relying on the auto-pick, ask for Faraday
1228
+ explicitly — `GraphWeaver.new(url, transport: :faraday)`. A middleware block
1229
+ still implies it (`GraphWeaver.new(url) { |conn| ... }`), since the block is
1230
+ Faraday's. Faraday is otherwise unchanged and fully supported. Alongside a url,
1231
+ `transport:` now takes `:http` (the default) or `:faraday` rather than a
1232
+ built transport object — passing an object there used to raise "pass a url or
1233
+ transport:, not both" and now raises naming the two symbols. Alongside a schema
1234
+ source it still takes a built transport, and now rejects a Symbol. The client
1235
+ logs which transport it built at `info`.
1236
+
1237
+ `docs/transports.md` gains the recipe for giving Faraday the connection reuse
1238
+ `Transport::HTTP` has by default: the `:net_http_persistent` adapter, the two
1239
+ gems it needs, and the version pairing (Faraday 2.x requires
1240
+ `faraday-net_http_persistent` **2.x**; 1.2.0 raises `NoMethodError: undefined
1241
+ method 'dependency'` at load). graph_weaver depends on neither and never
1242
+ selects it for you.
1243
+
1244
+ **Generated files are pruned when their query disappears.** Renaming or
1245
+ deleting a `.graphql` used to leave its `.rb` behind forever: `load_generated!`
1246
+ kept requiring it, its module kept resolving against a query that no longer
1247
+ existed, and `verify_generated!` stayed silent — the pruning only covered
1248
+ `inputs/*.rb` and `unions.rb`. `generate!` now deletes any generated file the
1249
+ plan no longer produces, and `verify_generated!` reports it as stale.
1250
+
1251
+ Only files carrying the `# Generated by GraphWeaver — do not edit.` header are
1252
+ ever deleted, so a hand-written file in the output directory survives. **What
1253
+ you must do:** nothing, unless you were relying on a lingering module — the
1254
+ next `generate!` removes it, and CI's `rake graph_weaver:verify` will name it
1255
+ first.
1256
+
1257
+ **Mutations now generate `…Mutation` modules, not `…Query`.**
1258
+ `save_list_entry.graphql` holding a `mutation` produces
1259
+ `SaveListEntryMutation` in `save_list_entry_mutation.rb`;
1260
+ `SaveListEntryQuery.execute!` read wrong for a write. Queries are unchanged.
1261
+ The rule is one rule — the camelized file name plus the operation the file
1262
+ defines — and all three naming sites follow it: `generate!`,
1263
+ `GraphWeaver.parse(path)`, and `client.load_queries!`. The operation name
1264
+ written *inside* the file still names nothing; it goes on the wire as
1265
+ `operationName`.
1266
+
1267
+ **What you must do:** regenerate (`rake graph_weaver:generate`) and rename the
1268
+ call sites of any mutation module — `AdoptQuery` → `AdoptMutation`, including
1269
+ nested constants like `AdoptQuery::AdoptionInput`. Regeneration prunes the old
1270
+ `*_query.rb` files, and `rake graph_weaver:verify` names anything missed.
1271
+ Changing a file's `query` to `mutation` from here on renames its constant the
1272
+ same way, which CI now catches rather than letting it drift.
1273
+
1274
+ **Generated modules get their client plumbing from
1275
+ `GraphWeaver::QueryModule`.** `client`/`client=` carry no per-query type
1276
+ information, so every generated file repeated the same fifteen untyped lines;
1277
+ they now live in the gem, beside the input-struct runtime, and a module says
1278
+ `extend GraphWeaver::QueryModule` instead. `execute`, `execute!`,
1279
+ `from_response` and `from_response!` stay generated — their sigs are your
1280
+ query's types. A baked `client:` constant is emitted as `DEFAULT_CLIENT`,
1281
+ still resolved on first use so a module can load before the initializer that
1282
+ builds its client, and resolution is unchanged: per call → per module → baked
1283
+ constant → `GraphWeaver.client`.
1284
+
1285
+ **What you must do:** regenerate (`rake graph_weaver:generate`). The files
1286
+ change; nothing about how you call them does.
1287
+ Error-message and console ergonomics from the same review:
1288
+ - **Validation errors name the query file and render one per line**, compiler
1289
+ style — `invalid query in app/graphql/queries/person.graphql:` followed by an
1290
+ indented `4:5 Field 'nmae' doesn't exist on type 'Person'` per error. They
1291
+ arrived as one joined line with no file at all, because `generate!` had the
1292
+ path in hand and never passed it to codegen, so thirty query files left you
1293
+ hunting for a bare `4:5`. `ValidationError#errors` and `#to_h` keep the shape
1294
+ `rake graph_weaver:queries:check` reads; only the message text changed, and
1295
+ **it is multi-line now** — update anything matching on it.
1296
+ - **`register_enum("Species", PetKind, {"DOG" => :dog})` says the value map is a
1297
+ keyword**, and shows the call with `map:` in it. Guessing the map as a third
1298
+ positional argument used to get Ruby's `wrong number of arguments (given 3,
1299
+ expected 2)`, which never mentions `map:`.
1300
+ - **`load_queries!` logs when it replaces an already-loaded module**, at
1301
+ `:info`, before swapping the constant: `replacing PersonQuery — objects built
1302
+ from the previous module stay instances of it`. Reloading is unchanged and
1303
+ still what the method is for; it just isn't silent about the structs it
1304
+ orphans, which is how a console session ends up with an `is_a?` that fails
1305
+ for no visible reason.
1306
+ **Rails install generator.**
1307
+ `rails g graph_weaver:install https://api.example.com/graphql` writes
1308
+ `config/initializers/graph_weaver.rb`, the `app/graphql/queries` and
1309
+ `app/graphql/generated` directories, `graphql.config.yml` (schema autocomplete
1310
+ and validation for `.graphql` files in VS Code / RubyMine) and the schema dump
1311
+ — replacing the console step the getting-started guide used to open with.
1312
+ `--auth` names the ENV var holding the token (default `GRAPHWEAVER_AUTH`),
1313
+ `--no-schema` skips the introspection. Re-running prompts on conflict like any
1314
+ Rails generator.
1315
+
1316
+ **`rake graph_weaver:schema:refresh` can now create the first dump.** It read
1317
+ its url from an existing dump's provenance stamp, so it couldn't bootstrap one
1318
+ — pass `URL=https://api.example.com/graphql` and it will, and both the
1319
+ no-dump and no-provenance messages now name that fix. The same logic is
1320
+ `GraphWeaver::SchemaLoader.refresh!(url:, auth:)`, which is what the generator
1321
+ calls.
1322
+
1323
+ **Pointing a client at a url that isn't a GraphQL endpoint now says so.** A
1324
+ REST base url, a GraphiQL page or a proxy that ate the path answers 200 with
1325
+ well-formed JSON, and `.schema` raised a bare `KeyError`/`NoMethodError` out of
1326
+ graphql-ruby — unbranded, no url, and it escaped `rescue GraphWeaver::Error`
1327
+ (a 404 on the same path was already branded and clear). Introspection now
1328
+ checks for `data.__schema` and raises `GraphWeaver::Error` naming the endpoint
1329
+ and the first 200 characters of what came back.
1330
+
1331
+ **A subgraph's own `FieldSet` / `Scope` / `Policy` type no longer collides with
1332
+ weaver's.** Loading subgraph SDL injects the federation directive definitions
1333
+ the file applies but doesn't declare, and the scalars they reference went in
1334
+ unnamespaced — so a subgraph that owns a type by one of those names either had
1335
+ it shadowed or failed to build, with advice pointing at the wrong file. Those
1336
+ three are now `federation__FieldSet` / `federation__Scope` /
1337
+ `federation__Policy`. `_Any` / `_Entity` / `_Service` keep their names — those
1338
+ are spec-mandated and queryable.
1339
+
1340
+ **`rake graph_weaver:queries:check` no longer compares an in-process app's
1341
+ schema against itself.** For an app whose schema is its own graphql-ruby class
1342
+ there is no server to re-introspect, so the check degraded to re-reading the
1343
+ committed dump — reporting phantom errors about the app's own schema, a field
1344
+ you just added reading as "doesn't exist". When `GraphWeaver.client` executes
1345
+ in-process (a `Client` wrapping a schema class, or the class itself), the check
1346
+ now validates against the live class. Network clients are unchanged.
1347
+
1348
+ **The two dead-end "records no source url" messages now say what to do.** A
1349
+ dump taken from a schema class is rebuilt from code, not re-fetched — both
1350
+ `schema:refresh` and `schema:diff` say that instead of naming a `URL=` that
1351
+ doesn't exist for you.
1352
+
1353
+ **Two rake tasks are renamed so each one names its own subject.** There were
1354
+ three checks and two of them were called `verify`, while the one people run
1355
+ most — "did schema drift break my queries?" — lived under `schema:` and doesn't
1356
+ check the schema. **Update your CI:**
1357
+
1358
+ | Was | Now | Asks |
1359
+ |---|---|---|
1360
+ | `graph_weaver:schema:check` | `graph_weaver:queries:check` | do my checked-in queries still validate? |
1361
+ | `graph_weaver:schema:verify` | `graph_weaver:schema:diff` | has the server drifted from the dump? |
1362
+
1363
+ `graph_weaver:verify` (is the committed Ruby fresh?) and
1364
+ `graph_weaver:schema:refresh` are unchanged. No aliases — the old names are
1365
+ gone.
1366
+
1367
+ **The instrumentation payload now carries `:status` in-process too.** `InProcess`
1368
+ brands a resolver raise as `ServerError(500)` precisely so callers needn't
1369
+ branch on which side of the seam a query ran — but the payload had no `:status`
1370
+ in-process and no `:schema` over the wire, so a subscriber had to branch
1371
+ anyway. A successful in-process execute now sets `:status` to 200; a failure
1372
+ still rides the exception the hook already sees.
1373
+
1374
+ **`extend_type` and `alias:` moved from `docs/scalars.md` to
1375
+ `docs/generated_modules.md`** — they decorate a generated struct, and now sit
1376
+ next to what a generated struct looks like. **Update any bookmark to
1377
+ `scalars.md#type-helpers-your-logic-on-generated-structs`**; it is
1378
+ `generated_modules.md#type-helpers` now. `scalars.md` still owns
1379
+ `register_scalar` and `register_enum`.
1380
+
1381
+ - **`Testing::Router#trace` accumulates across executes and is reset
1382
+ explicitly** — `router.reset_trace`. It used to clear itself at the top of
1383
+ every `execute`, which made it answer about the *last* query rather than the
1384
+ code path: a service object running two queries reported only the second's
1385
+ fetches, and an example that ran nothing read the previous example's, so an
1386
+ assertion could pass on another example's work and fail under `--order rand`.
1387
+ The rspec `graphql: :router` tag resets it per example. **An example that
1388
+ asserts on the trace after more than one `execute` now sees both**, and
1389
+ wants a `reset_trace` in between if it meant only the last one.
1390
+ - **The absent-subgraph refusal leads with the half that applies.** It opened
1391
+ with "Rails autoloads, so the class is probably just not loaded yet" — right
1392
+ often enough to lead with, except when eager loading is already on, and then
1393
+ the library can *ask* rather than send you to a setting you already have. It
1394
+ now checks `config.eager_load` / `config.rake_eager_load` and, when either is
1395
+ on, says the subgraph runs elsewhere and puts `=> :fake` first instead of at
1396
+ the end of a 60-word sentence.
1397
+ - **A refusal spells a nested `@key`/`@requires` field set the way your schema
1398
+ does** — `"origin { lat lon }"`, not `"origin.lat", "origin.lon"`. The dotted
1399
+ form is this library's parse of it and matches nothing you can grep for.
1400
+ - **One `@interfaceObject` no longer disables the whole router.** It refused at
1401
+ construction, for the entire supergraph, so a single directive made
1402
+ `Testing::Router` unusable even for queries that never touch the type — one
1403
+ corpus had to be split into two graphs over it. It is now a per-query refusal
1404
+ (`Unplannable#category` `:interface_object`) keyed on the types the query
1405
+ actually reaches, and `federation:coverage` counts it as one refusal among
1406
+ others rather than aborting. Routing an `@interfaceObject` is still not
1407
+ implemented; this only makes the refusal proportionate.
1408
+ `RoutingTable#unsupported` no longer lists them —
1409
+ `RoutingTable#interface_objects` does, as `{"Media" => ["catalog"]}`.
1410
+ - `docs/federation.md`'s refusal table now lists **every** `Unplannable`
1411
+ category, and a spec keeps it that way. Five were missing, `chained_requires`
1412
+ and `conditional_fragment` among them.
1413
+ - **`federation:coverage` counts what your suite can *run*, not only what
1414
+ plans.** `5/5 queries plannable locally (100%)` was optimistic in exactly the
1415
+ partly-local shape the docs call the usual migration one: a query resolving
1416
+ in a subgraph another service serves plans fine and a spec still can't run
1417
+ it. The headline now reads `…, 2 servable here`, and the queries reaching
1418
+ past what's loaded are listed with the subgraph each needs. Plan-only is
1419
+ still the design — with no subgraph loaded (the SDL-alone CI run) the second
1420
+ number is dropped and the report says it counted planning only.
1421
+ `Coverage#servable` and `#elsewhere` are the programmatic side, and
1422
+ `Coverage::Result` gained `absent` / `servable?`.
1423
+ - **`Testing::Failure.stale_schema` drops its `schema:` / `seed:` sampling.**
1424
+ It picked a random real type/field so a fabricated error string would look
1425
+ plausible — but no assertion can depend on which one it picks, so it was
1426
+ decoration with three kwargs and an RNG behind it. `stale_schema(type:,
1427
+ field:)` names the casualty when the message matters, and the bare call still
1428
+ trips `schema_stale?`. Passing `schema:`/`seed:` now raises `ArgumentError`.
1429
+ - **`rake graph_weaver:cassettes:check`** — replays every recording through the
1430
+ generated modules and fails when one no longer casts. A cassette is the only
1431
+ artifact recorded from a foreign server, and nothing else here notices when
1432
+ that server's answers drift out of the shape the structs were generated for:
1433
+ `verify`, `queries:check` and `schema:diff` all ask about the local side. It
1434
+ needs no network, so it belongs beside `verify` in a PR run. A recording no
1435
+ generated module sends is skipped and counted, and checking *none* of them
1436
+ fails, like `federation:diff`. `Testing::Cassette#check` is the programmatic
1437
+ side.
1438
+ - **A cast failure no longer prints sorbet-runtime's `Caller:` frame.**
1439
+ `GraphWeaver::TypeError`'s message ended with `Caller:
1440
+ .../sorbet-runtime/.../call_validation.rb:331` — a path into the gem, never
1441
+ into the code with the problem, and the only location the message offered.
1442
+
1
1443
  ### v0.4.6 (2026-07-30)
2
1444
  Bug fixes from a full-library review (all with regression coverage):
3
1445
  - alias: a nested-object/enum leaf (`meta.sub`) now qualifies its constant