graph_weaver 0.7.5 → 0.7.7

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +43 -43
  3. data/docs/errors.md +7 -3
  4. data/docs/generated_modules.md +66 -16
  5. data/docs/getting_started.md +39 -19
  6. data/docs/logging.md +79 -35
  7. data/docs/migrating.md +11 -4
  8. data/docs/scalars.md +12 -3
  9. data/docs/testing.md +75 -13
  10. data/docs/upgrading.md +43 -3
  11. data/examples/README.md +4 -2
  12. data/examples/github/generate.rb +22 -8
  13. data/examples/github/generated/star_mutation.rb +2 -2
  14. data/examples/github/generated/stargazers_query.rb +2 -2
  15. data/examples/github/generated/starred_query.rb +2 -2
  16. data/examples/github/run.rb +1 -0
  17. data/examples/github/setup.rb +16 -8
  18. data/graph_weaver.gemspec +15 -6
  19. data/lib/generators/graph_weaver/install_generator.rb +20 -2
  20. data/lib/graph_weaver/client.rb +0 -23
  21. data/lib/graph_weaver/codegen/aliases.rb +15 -3
  22. data/lib/graph_weaver/codegen/emit.rb +7 -6
  23. data/lib/graph_weaver/codegen/enum_type.rb +26 -2
  24. data/lib/graph_weaver/codegen/nodes.rb +21 -2
  25. data/lib/graph_weaver/codegen.rb +106 -54
  26. data/lib/graph_weaver/graph.rb +67 -6
  27. data/lib/graph_weaver/in_process.rb +1 -1
  28. data/lib/graph_weaver/input_struct.rb +29 -4
  29. data/lib/graph_weaver/internal/overrides.rb +126 -14
  30. data/lib/graph_weaver/internal/test_clients.rb +29 -7
  31. data/lib/graph_weaver/internal/unused.rb +30 -11
  32. data/lib/graph_weaver/internal/values.rb +17 -5
  33. data/lib/graph_weaver/internal.rb +23 -6
  34. data/lib/graph_weaver/log_subscriber.rb +27 -17
  35. data/lib/graph_weaver/logging.rb +89 -53
  36. data/lib/graph_weaver/parsing.rb +32 -3
  37. data/lib/graph_weaver/query_module.rb +48 -8
  38. data/lib/graph_weaver/railtie.rb +250 -11
  39. data/lib/graph_weaver/rspec.rb +41 -17
  40. data/lib/graph_weaver/schema_diff.rb +24 -5
  41. data/lib/graph_weaver/schema_loader.rb +22 -9
  42. data/lib/graph_weaver/tasks.rb +71 -44
  43. data/lib/graph_weaver/testing/fake_client.rb +24 -21
  44. data/lib/graph_weaver/testing.rb +27 -7
  45. data/lib/graph_weaver/transport.rb +1 -1
  46. data/lib/graph_weaver/version.rb +1 -1
  47. data/lib/graph_weaver.rb +76 -43
  48. metadata +2 -2
@@ -85,6 +85,12 @@ class GraphWeaver::Railtie < Rails::Railtie
85
85
  # calling ignore again — check_generated_ignored! refuses instead.
86
86
  attr_accessor :ignored_dirs
87
87
 
88
+ # The outputs left to Zeitwerk, resolved — the trees whose every module is
89
+ # the constant Zeitwerk infers from its path, so hiding them would be
90
+ # hiding code that already works. Decided beside the sweep, because it is
91
+ # what the sweep must not hide.
92
+ attr_accessor :autoloaded_dirs
93
+
88
94
  # Whether hiding an output right now would actually hide it: false until
89
95
  # the initializer below has swept (it picks up anything declared so far),
90
96
  # and false again from the moment Zeitwerk sets the main autoloader up.
@@ -108,14 +114,24 @@ class GraphWeaver::Railtie < Rails::Railtie
108
114
  GraphWeaver::Railtie.hide_generated!
109
115
  end
110
116
 
117
+ # ONE rule: a generated tree Zeitwerk would name correctly is left to
118
+ # Zeitwerk; any other is hidden from it and required at boot.
119
+ #
111
120
  # generated/person_query.rb defines ::PersonQuery, but Zeitwerk infers
112
121
  # Generated::PersonQuery from the path — and app/graphql/generated is inside
113
122
  # an autoload root by default, so eager loading raised "uninitialized
114
123
  # constant Generated::PersonQuery" in production while development (lazy) was
115
- # fine. prepare_generated! requires them instead.
124
+ # fine. prepare_generated! requires those instead. A graph whose `namespace:`
125
+ # is the constant its output path spells has no such mismatch, and requiring
126
+ # it at boot would define its whole parent chain before a query is ever used.
116
127
  def self.hide_generated!
117
128
  self.ignored_dirs = []
129
+ self.autoloaded_dirs = []
118
130
  self.hiding_outputs = true
131
+ # the aligned trees first: they are what the sweep must NOT hide, and
132
+ # generated_paths' own globs cover a graph's output (app/graphql/*/generated
133
+ # matches app/graphql/billing/generated)
134
+ GraphWeaver.graphs.each { |graph| consider_output!(graph) }
119
135
  GraphWeaver::Internal::Util.generated_dirs.each { |path| ignore_output!(path) }
120
136
 
121
137
  # Zeitwerk reads its ignore list once, at setup, and nothing in Rails
@@ -124,12 +140,189 @@ class GraphWeaver::Railtie < Rails::Railtie
124
140
  # check_generated_ignored! has to be the one to say so.
125
141
  main = Rails.autoloaders.main if Rails.autoloaders.respond_to?(:main)
126
142
  if main.respond_to?(:on_setup)
127
- main.on_setup { GraphWeaver::Railtie.hiding_outputs = false }
143
+ main.on_setup do
144
+ GraphWeaver::Railtie.hiding_outputs = false
145
+ GraphWeaver::Railtie.check_alignment!
146
+ end
128
147
  else
129
148
  self.hiding_outputs = false
130
149
  end
131
150
  end
132
151
 
152
+ # Which side of the rule one graph's output falls, and say so in a line at
153
+ # debug: a user who set `namespace:` and expected laziness learns here why
154
+ # they didn't get it. True when the tree is Zeitwerk's to load.
155
+ def self.consider_output!(graph)
156
+ dir = autoload_path(graph.output)
157
+ return true if autoloaded_dirs.include?(dir)
158
+ # already hidden by an earlier pass, and an ignore can't be taken back
159
+ return false if ignored_dirs.include?(dir)
160
+
161
+ reason = misalignment(graph, dir)
162
+ subject, = describe_output(dir)
163
+ if reason
164
+ GraphWeaver::Internal::Log.log(:debug) do
165
+ "#{subject} is hidden from Zeitwerk and required at boot — #{reason}"
166
+ end
167
+ # here rather than back in ignore_output!, so the decision is recorded
168
+ # the moment it is made and the sweep doesn't reach it a second time
169
+ hide!(dir)
170
+ return false
171
+ end
172
+
173
+ autoloaded_dirs << dir
174
+ # types.rb requires its own directory — one file per type, in the order the
175
+ # runtime needs — so Zeitwerk never autoloads those files one at a time.
176
+ # Only eager loading walks them, and it would want a constant name for each
177
+ # that the generator's own inflection doesn't promise: HTTPHeaderInput
178
+ # writes httpheader_input.rb, which Zeitwerk reads as HttpheaderInput.
179
+ # Loading types.rb is what loads them, and types.rb is eager loaded.
180
+ Rails.autoloaders.each { |loader| loader.do_not_eager_load(File.join(dir, "types")) }
181
+ GraphWeaver::Internal::Log.log(:debug) do
182
+ "#{subject} is left to Zeitwerk — every module it generates is the constant Zeitwerk reads " \
183
+ "off the path, so it autoloads on first reference and reloads with the app"
184
+ end
185
+ true
186
+ end
187
+
188
+ # Why Zeitwerk would NOT name what this graph generates the way its files do
189
+ # — nil when it would, which is the aligned case. The first disagreement is
190
+ # the whole answer: one namespace settles every file, so the fix for one is
191
+ # the fix for all of them.
192
+ def self.misalignment(graph, dir)
193
+ main = Rails.autoloaders.main if Rails.respond_to?(:autoloaders) && Rails.autoloaders.respond_to?(:main)
194
+ # 2.6.2 is where cpath_expected_at arrives, and with it the only answer
195
+ # that can confirm this one at setup — without it nothing is left to
196
+ # Zeitwerk, which is what every app got before.
197
+ unless main.respond_to?(:cpath_expected_at)
198
+ return "no autoloader here answers cpath_expected_at (Zeitwerk 2.6.2 and up does)"
199
+ end
200
+
201
+ if ignored_by_app?(dir)
202
+ return "it is already hidden from autoloading — a GraphWeaver.generated_paths pattern reaches " \
203
+ "it, or the app ignored it itself"
204
+ end
205
+
206
+ namespace = expected_cpath(dir)
207
+ return "it is not under an autoload path, so nothing but this would load it" unless namespace
208
+ unless namespace == graph.namespace
209
+ return "Zeitwerk reads that directory as #{namespace}, and this graph's constants are " \
210
+ "#{graph.namespace || "top-level"} (namespace #{namespace.inspect} makes them agree)"
211
+ end
212
+
213
+ types = expected_cpath(File.join(dir, "types.rb"))
214
+ return "types.rb defines #{graph.types_module}, Zeitwerk expects #{types}" unless types == graph.types_module
215
+
216
+ first_mismatch(graph, dir)
217
+ end
218
+
219
+ # The first query module whose name isn't the one Zeitwerk reads off its
220
+ # file. Only reached once the namespace agrees, so what is left is a file
221
+ # name the app's inflector camelizes differently from the generator.
222
+ def self.first_mismatch(graph, dir)
223
+ GraphWeaver::Internal::Util.query_files(graph.queries).each do |query|
224
+ name, filename = graph.generated_names(query, File.read(query))
225
+ expected = expected_cpath(File.join(dir, filename))
226
+ next if expected == name
227
+
228
+ return "#{filename} defines #{name}, Zeitwerk expects #{expected}"
229
+ end
230
+ nil
231
+ end
232
+
233
+ # The constant Zeitwerk will expect at an absolute path, or nil when nothing
234
+ # will autoload it.
235
+ #
236
+ # Zeitwerk answers this itself, with cpath_expected_at — but only once Rails
237
+ # has pushed the main loader's roots, and Rails does that INSIDE
238
+ # :setup_main_autoloader, whose start is the deadline for `ignore`. So the
239
+ # roots here are the list Rails is about to push, and every constant name
240
+ # comes from the loader's own inflector, which an app's `inflect`/`acronym`
241
+ # is already on. check_alignment! re-asks Zeitwerk the moment it can answer.
242
+ def self.expected_cpath(path)
243
+ main = Rails.autoloaders.main
244
+ roots = autoload_roots
245
+ cnames = []
246
+ dir = path
247
+ if path.end_with?(".rb")
248
+ cnames << main.inflector.camelize(File.basename(path, ".rb"), path)
249
+ dir = File.dirname(path)
250
+ end
251
+ until roots.key?(dir)
252
+ parent = File.dirname(dir)
253
+ return if parent == dir || ignored_by_app?(dir)
254
+
255
+ cnames << main.inflector.camelize(File.basename(dir), dir)
256
+ dir = parent
257
+ end
258
+ return if ignored_by_app?(dir)
259
+
260
+ [roots[dir], *cnames.reverse].compact.join("::")
261
+ end
262
+
263
+ # Every directory the main autoloader will have as a root, and the name of
264
+ # the namespace each sits under (nil for Object): the paths Rails is about to
265
+ # push, plus any the app pushed itself — which is the only way a root gets a
266
+ # namespace, and the one thing Rails leaves alone when it pushes the rest.
267
+ def self.autoload_roots
268
+ roots = {}
269
+ if defined?(ActiveSupport::Dependencies) && ActiveSupport::Dependencies.respond_to?(:autoload_paths)
270
+ once = Rails.autoloaders.respond_to?(:once) ? Rails.autoloaders.once.dirs : []
271
+ ActiveSupport::Dependencies.autoload_paths.each do |path|
272
+ path = path.to_s
273
+ # through autoload_path, so a root and an output under it are spelled
274
+ # the same way — a symlinked checkout (Capistrano's current/) otherwise
275
+ # has the root as Rails wrote it and the output as realpath saw it
276
+ roots[autoload_path(path)] = nil unless once.include?(path)
277
+ end
278
+ end
279
+ Rails.autoloaders.main.dirs(namespaces: true).each do |dir, namespace|
280
+ roots[autoload_path(dir)] = (namespace == Object) ? nil : namespace.name
281
+ end
282
+ roots
283
+ end
284
+
285
+ # An app that hid the directory itself has a tree Zeitwerk will not load
286
+ # however it is named — so it is one to require at boot, exactly as before.
287
+ def self.ignored_by_app?(dir)
288
+ asked = IGNORES.find { |name| Rails.autoloaders.main.respond_to?(name) }
289
+ asked && Rails.autoloaders.main.public_send(asked, dir)
290
+ end
291
+
292
+ # The prediction above, against the one answer that is authoritative. A
293
+ # `collapse` between the autoload root and the output, or a root pushed under
294
+ # its own namespace after this railtie looked, changes the name — and neither
295
+ # is readable before setup. Left unsaid, that surfaces as Zeitwerk's own
296
+ # error on the first eager load in production.
297
+ def self.check_alignment!
298
+ main = Rails.autoloaders.main
299
+ Array(autoloaded_dirs).each do |dir|
300
+ # the top level only: types/ is excluded from eager loading, so what
301
+ # Zeitwerk reads off those files never comes up
302
+ Dir[File.join(dir, "*.rb")].sort.each do |file|
303
+ expected = main.cpath_expected_at(file)
304
+ # nil is "I don't see that file" — which is what Zeitwerk answers for a
305
+ # path spelled through a symlink its own roots aren't, so it is not an
306
+ # answer to compare against
307
+ next if expected.nil? || expected == expected_cpath(file)
308
+
309
+ subject, short = describe_output(dir)
310
+ raise GraphWeaver::Error,
311
+ "#{subject} was left to Zeitwerk because the constants it generates are the ones its path " \
312
+ "spells — but Zeitwerk reads #{GraphWeaver::Internal::Util.relative(file)} as " \
313
+ "#{expected.inspect}, not #{expected_cpath(file).inspect}. A `collapse`, or an autoload root " \
314
+ "pushed under its own namespace, over #{short} changes that name: move the output out from " \
315
+ "under it, or set namespace: to what Zeitwerk reads."
316
+ end
317
+ end
318
+ end
319
+
320
+ # Whether this generated directory is one Zeitwerk is loading — what
321
+ # Internal::Util.required_dirs asks, so the boot-time require skips it.
322
+ def self.left_to_zeitwerk?(path)
323
+ Array(autoloaded_dirs).include?(autoload_path(path))
324
+ end
325
+
133
326
  # Hide one output from Zeitwerk, the moment it is named — a graph declared in
134
327
  # config/initializers says where it writes long after the sweep above ran.
135
328
  # Waiting for it instead is what `after: :load_config_initializers` used to
@@ -141,9 +334,17 @@ class GraphWeaver::Railtie < Rails::Railtie
141
334
  def self.ignore_output!(path)
142
335
  return unless hiding_outputs
143
336
 
144
- # patterns, not paths — generated_paths may be globs, and Zeitwerk expands
145
- # its own at setup, which is what the window stays open until
146
- dir = autoload_path(path)
337
+ # a graph saying where it writes is also the moment the rule can be applied
338
+ # to it: config/initializers is later than the sweep above, so this is
339
+ # where an app's second graph is first heard of
340
+ graph = GraphWeaver.graphs.find { |candidate| autoload_path(candidate.output) == autoload_path(path) }
341
+ return if graph && consider_output!(graph)
342
+
343
+ hidden_for(path).each { |dir| hide!(dir) }
344
+ end
345
+
346
+ # Hide one resolved directory, once.
347
+ def self.hide!(dir)
147
348
  return if ignored_dirs.include?(dir)
148
349
 
149
350
  check_autoload_once!([dir])
@@ -151,6 +352,25 @@ class GraphWeaver::Railtie < Rails::Railtie
151
352
  Rails.autoloaders.each { |loader| loader.ignore(dir) }
152
353
  end
153
354
 
355
+ # What hiding one generated path means. Normally the pattern itself —
356
+ # generated_paths may be globs, and Zeitwerk expands its own at setup, so a
357
+ # directory a pattern will match is hidden however late it appears; that is
358
+ # what the window stays open until. The exception is a pattern that also
359
+ # covers a tree Zeitwerk is keeping (app/graphql/*/generated matches a
360
+ # namespaced graph's own output), since a glob can't say "all but this one":
361
+ # then it is expanded here and the aligned directories left out.
362
+ def self.hidden_for(path)
363
+ dir = autoload_path(path)
364
+ kept = Array(autoloaded_dirs)
365
+ return [dir] if kept.empty?
366
+
367
+ expanded = Dir[GraphWeaver::Internal::Util.resolve(path)]
368
+ .select { |found| File.directory?(found) }.map { |found| autoload_path(found) }
369
+ return [dir] if (expanded & kept).empty?
370
+
371
+ expanded - kept
372
+ end
373
+
154
374
  # The `once` autoloader is set up in bootstrap, before any of this — and
155
375
  # Zeitwerk reads its ignore list only at setup, so `loader.ignore` hides
156
376
  # nothing from it however the output is spelled. The generic advice ("name it
@@ -211,7 +431,9 @@ class GraphWeaver::Railtie < Rails::Railtie
211
431
  def self.describe_output(dir)
212
432
  graph = GraphWeaver.graphs.find { autoload_path(_1.output) == dir }
213
433
  short = GraphWeaver::Internal::Util.relative(GraphWeaver::Internal::Util.resolve(graph&.output || dir))
214
- ["#{graph ? "graph :#{graph.name}'s output" : "generated path"} #{short}", short]
434
+ # the default graph has no name to say, and "graph :'s output" is worse
435
+ # than the path on its own
436
+ ["#{graph&.name ? "graph #{graph.name.inspect}'s output" : "generated path"} #{short}", short]
215
437
  end
216
438
 
217
439
  # A graph declared from to_prepare — what the docs say to do when its block
@@ -219,16 +441,20 @@ class GraphWeaver::Railtie < Rails::Railtie
219
441
  # Zeitwerk reads its ignore list only then. So an output that arrives that
220
442
  # late can't be hidden: its files load as ordinary autoloads and raise on the
221
443
  # constant they don't define, in a Zeitwerk error that blames a dropped
222
- # extend_type. Refuse, and name what actually happened.
444
+ # extend_type. Refuse, and name what actually happened — unless the tree is
445
+ # one Zeitwerk names correctly, which needed no hiding at any point and is as
446
+ # true declared late as declared early.
223
447
  def self.check_generated_ignored!
224
448
  # no autoloaders, no Zeitwerk, nothing to refuse
225
449
  return unless Rails.respond_to?(:autoloaders)
226
450
 
227
- late = GraphWeaver::Internal::Util.generated_dirs.map { autoload_path(_1) } - Array(ignored_dirs)
451
+ late = GraphWeaver::Internal::Util.generated_dirs.map { autoload_path(_1) } -
452
+ Array(ignored_dirs) - Array(autoloaded_dirs)
228
453
  return if late.empty?
229
454
 
230
455
  late.each do |dir|
231
456
  next unless Rails.autoloaders.any? { |loader| autoloaded?(loader, dir) }
457
+ next if aligned_late?(dir)
232
458
 
233
459
  subject, short = describe_output(dir)
234
460
  raise GraphWeaver::Error,
@@ -239,6 +465,17 @@ class GraphWeaver::Railtie < Rails::Railtie
239
465
  end
240
466
  end
241
467
 
468
+ # A graph declared after setup, whose tree Zeitwerk already names correctly:
469
+ # nothing had to be hidden, so nothing was missed. Recorded on the way past,
470
+ # so the boot-time require skips it too.
471
+ def self.aligned_late?(dir)
472
+ graph = GraphWeaver.graphs.find { autoload_path(_1.output) == dir }
473
+ return false unless graph && misalignment(graph, dir).nil?
474
+
475
+ self.autoloaded_dirs = Array(autoloaded_dirs) | [dir]
476
+ true
477
+ end
478
+
242
479
  # The two auto-wires an app gets for free, and the only two it can turn off
243
480
  # by assigning nil.
244
481
  #
@@ -408,8 +645,10 @@ class GraphWeaver::Railtie < Rails::Railtie
408
645
  # reloaded what it wrote.
409
646
  return if watcher&.execute_if_updated
410
647
 
411
- # entries may be globs, so Dir[] rather than Dir.exist?
412
- generated = GraphWeaver::Internal::Util.generated_dirs.any? do |dir|
648
+ # entries may be globs, so Dir[] rather than Dir.exist?. required_dirs, not
649
+ # generated_dirs: a tree left to Zeitwerk loads on first reference, and not
650
+ # requiring its whole parent chain here is the point of leaving it there.
651
+ generated = GraphWeaver::Internal::Util.required_dirs.any? do |dir|
413
652
  Dir[GraphWeaver::Internal::Util.resolve(dir)].any?
414
653
  end
415
654
  return unless generated
@@ -422,7 +661,7 @@ class GraphWeaver::Railtie < Rails::Railtie
422
661
  # request 500s on "uninitialized constant Accounts::PersonQuery" until a
423
662
  # .graphql edit happens to trigger the watcher. An un-namespaced module
424
663
  # defines a top-level constant Zeitwerk never manages, so it survives.
425
- if GraphWeaver.graphs.any?(&:namespace)
664
+ if GraphWeaver.graphs.any? { |graph| graph.namespace && !left_to_zeitwerk?(graph.output) }
426
665
  GraphWeaver.reload_generated!
427
666
  else
428
667
  GraphWeaver.load_generated!
@@ -161,7 +161,9 @@ module GraphWeaver
161
161
  # suite's WebMock setup is touched.
162
162
  def self.serve!
163
163
  webmock!
164
- wire_targets.map do |url, graph|
164
+ targets, above = wire_targets
165
+ above.each { |graph| disclose_above!(graph) }
166
+ targets.map do |url, graph|
165
167
  # built here, so a graph with nothing to serve refuses before the
166
168
  # example runs rather than from inside its first request
167
169
  disclose!(GraphWeaver::Internal::TestClients.standin(graph), url, graph)
@@ -210,6 +212,17 @@ module GraphWeaver
210
212
  end
211
213
  end
212
214
 
215
+ # Say which graph ran above the wire. Its stand-in is built when one of
216
+ # its modules first runs, not here — a graph the example never touches
217
+ # must not refuse it — so this names the graph rather than what is
218
+ # behind it.
219
+ def self.disclose_above!(graph)
220
+ GraphWeaver::Internal::Log.log(:info) do
221
+ ":wire has no endpoint for #{graph.name ? "graph #{graph.name.inspect}" : "this app"} — " \
222
+ "its client posts to none, so its modules run above the wire, as #{TAG}: :in_process would"
223
+ end
224
+ end
225
+
213
226
  # What the stand-in IS, read off the object rather than re-deciding —
214
227
  # one answer, and it can't drift from what was built. A fake of a dump
215
228
  # has no name to give: the dump loads as an anonymous class, and
@@ -254,21 +267,24 @@ module GraphWeaver
254
267
  # inside the cleanup.
255
268
  def self.unserve!(stub) = WebMock::StubRegistry.instance.request_stubs.delete(stub)
256
269
 
257
- # Every endpoint an example's modules can post to, each with the graph
258
- # whose resolvers belong behind it: the client each graph names, or
259
- # GraphWeaver.client for a graph naming none. One graph per endpoint —
260
- # an app whose graphs all name clients needs no app default at all.
270
+ # What :wire does with each graph, in two lists: the endpoints to stub,
271
+ # each with the graph whose resolvers belong behind it, and the graphs
272
+ # there is no endpoint for. One graph per endpoint — an app whose graphs
273
+ # all name clients needs no app default at all.
274
+ #
275
+ # A graph whose client posts to no url has no wire to be served at, so
276
+ # it runs above one instead of refusing the example — including the
277
+ # examples that never touch it. An example where NO graph posts anywhere
278
+ # is refused, since a :wire that serves nothing tests no transport.
261
279
  def self.wire_targets
262
- targets = GraphWeaver.graphs.filter_map do |graph|
263
- client = graph.client || GraphWeaver.client
264
- [endpoint!(client, graph), graph] if client
265
- end
280
+ targets, above = GraphWeaver.graphs.map { |graph| [graph.client_url, graph] }.partition(&:first)
266
281
  refuse_shared_endpoint!(targets)
267
- return targets if targets.any?
282
+ return [targets, above.map(&:last)] if targets.any?
268
283
 
269
- # nothing bakes a client and the app has none: the endpoint refusal
270
- # names the empty slot, which is the thing to fix
271
- endpoint!(GraphWeaver.client)
284
+ # the endpoint refusal names the client that posts to none, which is
285
+ # the thing to fix
286
+ graph = GraphWeaver.graphs.first
287
+ endpoint!(graph&.client || GraphWeaver.client, graph)
272
288
  end
273
289
 
274
290
  # One stub per url, so two graphs on one endpoint used to mean the
@@ -343,7 +359,8 @@ module GraphWeaver
343
359
  end
344
360
 
345
361
  private_class_method :wire_targets, :refuse_shared_endpoint!, :whose_client,
346
- :webmock!, :webmock_enabled?, :disclose!, :served, :unnamed_schemas, :loaded_schemas
362
+ :webmock!, :webmock_enabled?, :disclose!, :disclose_above!, :served, :unnamed_schemas,
363
+ :loaded_schemas
347
364
 
348
365
  # Included into every example group, so graphql_context is there
349
366
  # whether or not this example took a client from the hook.
@@ -570,10 +587,17 @@ module GraphWeaver
570
587
  raise GraphWeaver::Error, "graphql_context needs resolvers to receive it, and a " \
571
588
  "#{TAG}: :fake example runs against fabricated data — tag it #{TAG}: :in_process or " \
572
589
  "#{TAG}: :router (or pin the data itself: " \
573
- "graphql_fake(overrides: { \"Person.name\" => \"Ada\" }))"
590
+ "graphql_fake(\"Person.name\" => \"Ada\"))"
574
591
  else
575
- raise GraphWeaver::Error, "graphql_context needs an example running against your " \
576
- "resolvers — tag it #{TAG}: :in_process or #{TAG}: :router"
592
+ # :live, which is what an untagged example is. The app's own client
593
+ # stays in the slot holding the context it was built with, so there
594
+ # is nothing here to merge onto — and saying "you need resolvers"
595
+ # reads as false to an app whose own client is an InProcess, which
596
+ # is running them
597
+ raise GraphWeaver::Error, "graphql_context says what THIS example's resolvers see, " \
598
+ "and #{TAG}: :live leaves your app's own client exactly as it is — carrying the " \
599
+ "context it was built with. Tag the example #{TAG}: :in_process or #{TAG}: :router, " \
600
+ "which build a client per example."
577
601
  end
578
602
  end
579
603
 
@@ -122,6 +122,8 @@ module GraphWeaver
122
122
  return change(name, "#{kind(old)} -> #{kind(new)}", breaking: true)
123
123
  end
124
124
 
125
+ compare_description(name, old, new)
126
+
125
127
  case new.kind.name
126
128
  when "OBJECT", "INTERFACE"
127
129
  compare_fields(name, old, new)
@@ -151,6 +153,7 @@ module GraphWeaver
151
153
  breaking: breaks_output?(old.type, new.type))
152
154
  end
153
155
  compare_deprecation(coordinate, old, new)
156
+ compare_description(coordinate, old, new)
154
157
  compare_arguments(coordinate, old.arguments, new.arguments)
155
158
  end
156
159
 
@@ -192,6 +195,7 @@ module GraphWeaver
192
195
  change(coordinate, "#{prefix}#{signature(old)} -> #{signature(new)}", breaking:)
193
196
  end
194
197
  compare_deprecation(coordinate, old, new)
198
+ compare_description(coordinate, old, new)
195
199
  end
196
200
 
197
201
  def compare_enum(name, old, new)
@@ -200,7 +204,10 @@ module GraphWeaver
200
204
 
201
205
  (before.keys - after.keys).each { |value| change("#{name}.#{value}", "enum value removed", breaking: true) }
202
206
  (after.keys - before.keys).each { |value| change("#{name}.#{value}", "enum value added") }
203
- (before.keys & after.keys).each { |value| compare_deprecation("#{name}.#{value}", before[value], after[value]) }
207
+ (before.keys & after.keys).each do |value|
208
+ compare_deprecation("#{name}.#{value}", before[value], after[value])
209
+ compare_description("#{name}.#{value}", before[value], after[value])
210
+ end
204
211
  end
205
212
 
206
213
  # A dropped member silently stops matching a `... on X` fragment, which
@@ -233,10 +240,22 @@ module GraphWeaver
233
240
  change(coordinate, now ? "deprecated: #{now}" : "no longer deprecated")
234
241
  end
235
242
 
236
- # The walk names what a client breaks on. A description, a directive
237
- # definition, an argument default moves the SDL without appearing
238
- # above — still drift, and a gate that went green on it would be worse
239
- # than one that admits it can't name it.
243
+ # A description breaks nothing and reaches no generated code, but it is
244
+ # the likeliest thing to differ between a hand-maintained dump and the
245
+ # server — and unnamed it fell through to note_unnamed_drift, which is a
246
+ # permanent red with nothing to act on. The text itself isn't printed: it
247
+ # can be paragraphs, and the coordinate is what you go and look at.
248
+ def compare_description(coordinate, old, new)
249
+ return unless old.respond_to?(:description)
250
+ return if old.description == new.description
251
+
252
+ change(coordinate, "description changed")
253
+ end
254
+
255
+ # The walk names what a client breaks on. A directive definition or an
256
+ # argument default moves the SDL without appearing above — still drift,
257
+ # and a gate that went green on it would be worse than one that admits
258
+ # it can't name it.
240
259
  def note_unnamed_drift(before, after)
241
260
  return unless @changes.empty?
242
261
  return if before.to_definition == after.to_definition
@@ -199,10 +199,21 @@ module GraphWeaver::SchemaLoader
199
199
  COMPOSITION_SPEC = %r{@(?:link\s*\(\s*url|core\s*\(\s*feature):\s*"https://specs\.apollo\.dev/(?:join|core)/}
200
200
  private_constant :COMPOSITION_SPEC
201
201
 
202
- # A composed Fed2 supergraph is marked by @join__* directives (every merged
203
- # type carries them); a plain schema has none.
202
+ # A composed Fed2 supergraph is marked by @join__* directives APPLIED to
203
+ # its types (every merged type carries them); a plain schema applies none.
204
+ #
205
+ # Applied, not merely declared: introspect a router and graphql-ruby hands
206
+ # back the API schema with the @join__* directive DEFINITIONS still in it
207
+ # and every application gone — so a bare substring test answers "composed"
208
+ # about the one artifact whose routing table has been removed.
209
+ JOIN_DEFINITION = /\bdirective\s+@join__\w+/
210
+ private_constant :JOIN_DEFINITION
211
+
204
212
  def self.federation_sdl?(sdl)
205
- sdl.match?(/@join__\w/) || sdl.match?(COMPOSITION_SPEC)
213
+ return true if sdl.match?(COMPOSITION_SPEC)
214
+ return false unless sdl.match?(/@join__\w/)
215
+
216
+ sdl.gsub(JOIN_DEFINITION, "").match?(/@join__\w/)
206
217
  end
207
218
 
208
219
  # The federation spec a fed-2 subgraph @links, and the directives a fed-1
@@ -720,12 +731,13 @@ module GraphWeaver::SchemaLoader
720
731
  schema = build_introspection(result)
721
732
 
722
733
  if cache
723
- # the extension picks the format: .json is the verbatim wire
724
- # artifact; .graphql/.gql is SDL — human-readable, PR-reviewable
725
- # diffs (both generate byte-identical code)
734
+ # the extension picks the format: .json is the wire artifact, pretty-
735
+ # printed so a refresh diffs line by line (graphql-client's dump is
736
+ # too); .graphql/.gql is SDL — human-readable, PR-reviewable diffs
737
+ # (both generate byte-identical code)
726
738
  meta = stamp(transport, auth_env)
727
739
  content = if cache.end_with?(".json")
728
- JSON.generate(meta ? result.merge("graph_weaver" => meta) : result)
740
+ JSON.pretty_generate(meta ? result.merge("graph_weaver" => meta) : result)
729
741
  else
730
742
  header = meta && "# graph_weaver: #{JSON.generate(meta)}\n\n"
731
743
  "#{header}#{schema.to_definition}"
@@ -909,13 +921,14 @@ module GraphWeaver::SchemaLoader
909
921
  # Composition is the only thing that rebuilds a supergraph: introspection
910
922
  # answers with the API schema, which is the merged shape minus the routing
911
923
  # table, so refreshing one from a url replaces the contract with a strictly
912
- # smaller artifact and reports success.
924
+ # smaller artifact and reports success. Public so `schema:refresh` can step
925
+ # over such a graph in these words rather than reaching the overwrite and
926
+ # relying on the guard to catch it.
913
927
  def self.recompose_hint(path)
914
928
  "#{GraphWeaver::Internal::Util.relative(path)} is a composed supergraph; introspection returns " \
915
929
  "the API schema, not the @join__* routing table — recompose it (rover supergraph compose) " \
916
930
  "and check the result in, instead of refreshing it"
917
931
  end
918
- private_class_method :recompose_hint
919
932
 
920
933
  # A transport to the dump's recorded url, authenticated from whichever ENV
921
934
  # var the dump named (else DEFAULT_AUTH_ENV). The single way to reach a