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
@@ -0,0 +1,48 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "graphql"
5
+
6
+ module GraphWeaver
7
+ # The graphql-ruby schema classes already in this process, and what each
8
+ # one defines. Where {SchemaLoader} *builds* a schema from a source — a
9
+ # path, SDL, an introspection dump — this reads classes the app loaded
10
+ # itself.
11
+ #
12
+ # Two features ask exactly these two questions, and match a schema on the
13
+ # coordinates it defines rather than on its class name: {Testing::Subgraphs}
14
+ # (which schema serves which subgraph) and {Federation::Drift} (has a
15
+ # subgraph changed without a recompose). What they share is this evidence,
16
+ # not the verdict: Subgraphs wants every type AND field, Drift only the
17
+ # types — a schema that lost a field is not a candidate to run against, but
18
+ # is exactly the one Drift has to recognize to report the loss.
19
+ module Schemas
20
+ class << self
21
+ # Every named GraphQL::Schema in the process. An anonymous one is
22
+ # graphql-ruby building from SDL — the router's own view of the
23
+ # supergraph is one — and never an app's subgraph.
24
+ def loaded
25
+ descendants(GraphQL::Schema).select(&:name)
26
+ end
27
+
28
+ # Does this schema carry the coordinate — "Type", or "Type.field"?
29
+ def defines?(schema, coordinate)
30
+ type_name, field_name = coordinate.split(".", 2)
31
+ type = schema.get_type(type_name) or return false
32
+ return true unless field_name
33
+
34
+ return type.fields.key?(field_name) if type.respond_to?(:fields)
35
+ # an input object's members are arguments, not fields
36
+ return type.arguments.key?(field_name) if type.respond_to?(:arguments)
37
+
38
+ false
39
+ end
40
+
41
+ private
42
+
43
+ def descendants(klass)
44
+ klass.subclasses.flat_map { |subclass| [subclass] + descendants(subclass) }
45
+ end
46
+ end
47
+ end
48
+ end
@@ -19,7 +19,19 @@ module GraphWeaver
19
19
  .grep(GraphQL::Language::Nodes::FragmentDefinition)
20
20
  .to_h { |fragment| [fragment.name, fragment] }
21
21
 
22
- doc.definitions.grep(GraphQL::Language::Nodes::OperationDefinition).first
22
+ operations = doc.definitions.grep(GraphQL::Language::Nodes::OperationDefinition)
23
+ # One file, one operation. Requests do carry operationName now, so a
24
+ # second operation would run fine on the wire — what has no answer is
25
+ # naming: a module is named after its FILE (person.graphql =>
26
+ # PersonQuery), and one file can't name two. A convention, not a limit.
27
+ if operations.size > 1
28
+ names = operations.map { |op| op.name ? "'#{op.name}'" : "an anonymous operation" }
29
+ raise GraphWeaver::Error,
30
+ "document defines #{operations.size} operations (#{names.join(", ")}) — " \
31
+ "split them into one file each, since a module is named after its file"
32
+ end
33
+
34
+ operations.first
23
35
  end
24
36
 
25
37
  # The schema type an operation's selections start from.
@@ -32,15 +44,21 @@ module GraphWeaver
32
44
  end
33
45
 
34
46
  # Flatten a selection set as seen by `type`, yielding (result_key,
35
- # field_node) per field: plain fields yield directly; inline fragments
36
- # and named spreads recurse when their type condition applies.
37
- def each_field(type, selections, visiting = Set.new, &block)
47
+ # field_node, conditional) per field: plain fields yield directly; inline
48
+ # fragments and named spreads recurse when their type condition applies.
49
+ # `conditional` is true when any fragment on the way down carried
50
+ # @skip/@include — the whole block may be absent from the response, so
51
+ # everything under it is as optional as a directly-skipped field.
52
+ def each_field(type, selections, visiting = Set.new, conditional: false, &block)
38
53
  selections.each do |selection|
39
54
  case selection
40
55
  when GraphQL::Language::Nodes::Field
41
- yield(selection.alias || selection.name, selection)
56
+ yield(selection.alias || selection.name, selection, conditional)
42
57
  when GraphQL::Language::Nodes::InlineFragment
43
- each_field(type, selection.selections, visiting, &block) if applies?(selection.type&.name, type)
58
+ next unless applies?(selection.type&.name, type)
59
+
60
+ each_field(type, selection.selections, visiting,
61
+ conditional: conditional || conditional?(selection), &block)
44
62
  when GraphQL::Language::Nodes::FragmentSpread
45
63
  fragment = @fragments.fetch(selection.name) do
46
64
  raise ArgumentError, "unknown fragment: #{selection.name}"
@@ -50,7 +68,9 @@ module GraphWeaver
50
68
  end
51
69
 
52
70
  if applies?(fragment.type.name, type)
53
- each_field(type, fragment.selections, visiting | [selection.name], &block)
71
+ # the directive rides on the SPREAD, not the definition it names
72
+ each_field(type, fragment.selections, visiting | [selection.name],
73
+ conditional: conditional || conditional?(selection), &block)
54
74
  end
55
75
  else
56
76
  raise GraphWeaver::Error, "unsupported selection: #{selection.class}"
@@ -64,11 +84,26 @@ module GraphWeaver
64
84
  # last-writer-wins. Codegen relies on this; FakeClient/Anonymizer must too,
65
85
  # or they'd fabricate/keep a shape the generated struct can't cast.
66
86
  def gather(type, selections)
87
+ gather_conditional(type, selections).transform_values { |occurrences| occurrences.map(&:first) }
88
+ end
89
+
90
+ # gather, keeping each occurrence's [field_node, conditional] — the wire
91
+ # key is guaranteed only when SOME occurrence is unconditional.
92
+ def gather_conditional(type, selections)
67
93
  out = {}
68
- each_field(type, selections) { |key, node| (out[key] ||= []) << node }
94
+ each_field(type, selections) { |key, node, conditional| (out[key] ||= []) << [node, conditional] }
69
95
  out
70
96
  end
71
97
 
98
+ # Directives that can drop a selection from the response whatever the
99
+ # schema says — the reason a conditional field's generated type is nilable.
100
+ CONDITIONAL_DIRECTIVES = %w[skip include].freeze
101
+
102
+ # Is this AST node (field, inline fragment, or spread) behind @skip/@include?
103
+ def conditional?(node)
104
+ node.directives.any? { |directive| CONDITIONAL_DIRECTIVES.include?(directive.name) }
105
+ end
106
+
72
107
  # A fragment's type condition applies when it names this type exactly,
73
108
  # or an interface/union this type belongs to (`... on Named { ... }`).
74
109
  def applies?(condition, type)
@@ -5,64 +5,262 @@
5
5
  #
6
6
  # require "graph_weaver/tasks"
7
7
  #
8
- # The tasks use the conventional paths (GraphWeaver.queries_path /
9
- # generated_path / schema_path — override in your Rakefile or an
8
+ # The tasks use the conventional paths (GraphWeaver.queries_paths /
9
+ # generated_paths / schema_path — override in your Rakefile or an
10
10
  # initializer). Register custom scalars before the tasks run — they're
11
11
  # baked into generated source.
12
12
  #
13
- # rake graph_weaver:generate # queries_path -> generated_path
14
- # rake graph_weaver:verify # fail if generated files are stale (CI)
13
+ # Each task names its own subject — generated code, the schema dump, the
14
+ # queries so which question you're asking is the task name:
15
+ #
16
+ # rake graph_weaver:generate # queries_paths -> generated_paths.first
17
+ # rake graph_weaver:verify # fail if generated files are stale (CI)
18
+ # rake graph_weaver:queries:check # fail if a query no longer validates (CI)
19
+ # rake graph_weaver:schema:diff # fail if the server has drifted from the dump
20
+ # rake graph_weaver:schema:refresh # re-introspect and rewrite the dump
21
+ # rake graph_weaver:federation:diff # fail if the supergraph wasn't recomposed
22
+ # rake graph_weaver:federation:coverage # what the local test router can plan
23
+ # rake graph_weaver:federation:subgraphs # which schema serves which subgraph
24
+ # rake graph_weaver:cassettes:check # fail if a recording no longer casts
15
25
  require_relative "../graph_weaver"
16
26
 
17
- # in Rails, run app config first (initializers register scalars/enums/
18
- # helpers, and they're baked into generated source)
19
- GRAPH_WEAVER_DEPS = Rake::Task.task_defined?("environment") ? ["environment"] : []
27
+ module GraphWeaver
28
+ # helpers the rake tasks share; not part of the library's API
29
+ module Tasks
30
+ # The composed supergraph a federation task reads: SUPERGRAPH=, else the
31
+ # conventional dump when that is what it is. Aborts naming the task, so
32
+ # the message says the command to retype.
33
+ def self.supergraph!(task)
34
+ ENV["SUPERGRAPH"] || GraphWeaver::SchemaLoader.locate_path ||
35
+ abort("pass the composed supergraph: rake graph_weaver:federation:#{task} " \
36
+ "SUPERGRAPH=supergraph.graphql")
37
+ end
38
+ end
39
+ end
20
40
 
21
41
  namespace :graph_weaver do
22
- desc "Generate typed query modules (#{GraphWeaver.queries_path} -> #{GraphWeaver.generated_path})"
23
- task generate: GRAPH_WEAVER_DEPS do
42
+ # In Rails, boot the app first — initializers register scalars/enums/
43
+ # helpers and they're baked into generated source. Rails defines
44
+ # :environment *after* every railtie's rake_tasks block (see
45
+ # Rails::Application#run_tasks_blocks), so whether it exists can only be
46
+ # asked when the task runs, not when this file loads.
47
+ task :environment do
48
+ # These tasks read queries, the schema and the registrations, not a
49
+ # generated module. Saying so lets the railtie skip loading them, so a
50
+ # stale generated file can't block the task that repairs it. It's reset
51
+ # below, so cassettes:check — which does read them — loads them itself.
52
+ GraphWeaver.skip_generated_load = true
53
+ Rake::Task["environment"].invoke if Rake::Task.task_defined?("environment")
54
+ ensure
55
+ # only meaningful while booting — leaving it set would silently disable
56
+ # loading for anything that boots later in the same process
57
+ GraphWeaver.skip_generated_load = false
58
+ end
59
+
60
+ desc "Generate typed query modules (#{GraphWeaver.queries_paths.first} -> #{GraphWeaver.generated_paths.first})"
61
+ task generate: :environment do
24
62
  # schema auto-located at GraphWeaver.schema_path, any supported extension
25
63
  GraphWeaver.generate!.each { |path| puts "wrote #{path}" }
64
+ rescue GraphWeaver::Error => e
65
+ # a typo'd query is a user error — the message names file, position and
66
+ # fix, and a rake backtrace through codegen only buries it
67
+ abort e.message
26
68
  end
27
69
 
28
70
  desc "Verify generated query modules are up to date"
29
- task verify: GRAPH_WEAVER_DEPS do
71
+ task verify: :environment do
30
72
  GraphWeaver.verify_generated!
31
73
  puts "generated queries up to date"
74
+ rescue GraphWeaver::Error => e
75
+ abort e.message
32
76
  end
33
77
 
34
78
  namespace :schema do
35
- # both tasks re-introspect from the url recorded in the dump
79
+ # both re-introspect from the url recorded in the dump
36
80
  # (GRAPHWEAVER_AUTH supplies a token for private APIs)
37
81
 
38
82
  desc "Fail when the server's schema has drifted from the local dump"
39
- task :verify do
83
+ task diff: :environment do
40
84
  path = GraphWeaver::SchemaLoader.locate_path or abort "no schema dump at #{GraphWeaver.schema_path}"
41
85
  if GraphWeaver::SchemaLoader.stale?(path)
42
86
  abort "#{path} is stale — the server's schema has drifted (rake graph_weaver:schema:refresh)"
43
87
  end
44
88
 
45
89
  puts "#{path} matches the server"
90
+ rescue GraphWeaver::Error => e
91
+ # e.g. a dump with no recorded url — same clean exit as :refresh
92
+ abort e.message
46
93
  end
47
94
 
48
- desc "Re-introspect the recorded url and rewrite the local dump"
49
- task :refresh do
50
- path = GraphWeaver::SchemaLoader.locate_path or abort "no schema dump at #{GraphWeaver.schema_path}"
51
- meta = GraphWeaver::SchemaLoader.provenance(path) or abort "#{path} records no source url"
95
+ desc "Re-introspect and rewrite the local dump (URL= to bootstrap the first one)"
96
+ task refresh: :environment do
97
+ path, url = GraphWeaver::SchemaLoader.refresh!(url: ENV["URL"])
98
+ puts "refreshed #{path} from #{url}"
99
+ rescue GraphWeaver::Error => e
100
+ abort e.message
101
+ end
102
+ end
103
+
104
+ namespace :queries do
105
+ desc "Report checked-in queries that no longer validate against the server's schema"
106
+ task check: :environment do
107
+ failures = GraphWeaver.check_queries
108
+ failures.each do |path, errors|
109
+ puts path
110
+ errors.each do |error|
111
+ position = [error["line"], error["column"]].compact.join(":")
112
+ puts " #{position.empty? ? "" : "#{position} "}#{error["message"]}"
113
+ end
114
+ puts
115
+ end
116
+
117
+ # abort writes to unbuffered stderr; the detail above went to
118
+ # block-buffered stdout, so a piped CI log shows the verdict first
119
+ $stdout.flush
120
+ abort "#{failures.size} invalid #{(failures.size == 1) ? "query" : "queries"}" if failures.any?
121
+ puts "every query validates against the schema"
122
+ end
123
+ end
124
+
125
+ namespace :federation do
126
+ # Every task here answers "which loaded schema serves which subgraph",
127
+ # and Rails leaves config.rake_eager_load false in every environment —
128
+ # so without this each of them reports on zero subgraphs, and :diff
129
+ # exits 0 having compared nothing. Asked when the task runs, not when
130
+ # this file loads: :environment doesn't exist yet at load time.
131
+ task loaded: :environment do
132
+ Rails.application.eager_load! if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
133
+ end
134
+
135
+ # needs no network, so it gates a PR the way verify does
136
+ desc "Fail when a subgraph here changed and the supergraph wasn't recomposed (SUPERGRAPH=)"
137
+ task diff: :loaded do
138
+ require "graph_weaver/federation"
139
+
140
+ supergraph = GraphWeaver::Tasks.supergraph!("diff")
141
+ drift = GraphWeaver::Federation::Drift.new(supergraph:)
142
+ puts drift.report
143
+
144
+ # A partly-local supergraph is a supported setup, so a subgraph this
145
+ # process doesn't serve isn't a failure — but comparing against NONE
146
+ # of them is: the gate passes whatever the subgraphs say, which is
147
+ # worse than failing.
148
+ $stdout.flush
149
+ abort "the supergraph is out of date — recompose it and commit the result" if drift.drift?
150
+ if drift.vacuous?
151
+ abort "this checked nothing, so it proved nothing. No schema in this process defines what " \
152
+ "the supergraph says any of its subgraphs resolves — load them (in Rails, that is " \
153
+ "config.eager_load / config.rake_eager_load), or, if they all run elsewhere, drop this " \
154
+ "task from CI: there is nothing here for it to gate."
155
+ end
156
+ rescue GraphWeaver::Error => e
157
+ abort e.message
158
+ end
52
159
 
53
- transport = GraphWeaver.new(meta["url"], auth: ENV["GRAPHWEAVER_AUTH"]).transport
54
- GraphWeaver::SchemaLoader.introspect(transport, cache: path, ttl: 0)
55
- puts "refreshed #{path} from #{meta["url"]}"
160
+ desc "Show which loaded schema serves each subgraph, as a paste-ready map (SUPERGRAPH=)"
161
+ task subgraphs: :loaded do
162
+ require "graph_weaver/testing"
163
+
164
+ supergraph = GraphWeaver::Tasks.supergraph!("subgraphs")
165
+
166
+ # Testing::Router derives this map itself; this is for reading what
167
+ # detection sees when it refuses, and for committing the map instead.
168
+ table = GraphWeaver::SchemaLoader.routing_table(supergraph)
169
+ rows = table.subgraphs.map do |name|
170
+ found = GraphWeaver::Testing::Subgraphs.candidates(table, name)
171
+ sought = GraphWeaver::Testing::Subgraphs.expected(table, name)
172
+ [name, found, sought]
173
+ end
174
+ width = rows.map { |name, found, _| %("#{name}" => #{found.first&.name || "nil"},).length }.max
175
+
176
+ puts "subgraphs: {"
177
+ rows.each do |name, found, sought|
178
+ entry = %( "#{name}" => #{found.one? ? found.first.name : "nil"},).ljust(width + 2)
179
+ # fields first: every schema has a Query, so only the fields say why
180
+ evidence = (sought.grep(/\./) | sought).first(3).join(", ")
181
+ note = if found.one?
182
+ "# matched: defines #{evidence}"
183
+ elsif found.any?
184
+ "# AMBIGUOUS: #{found.map(&:name).sort.join(", ")} all match — pick one"
185
+ else
186
+ "# no loaded schema defines #{evidence} — fill this in"
187
+ end
188
+ puts "#{entry} #{note}"
189
+ end
190
+ puts "}"
191
+ rescue GraphWeaver::Error => e
192
+ abort e.message
193
+ end
194
+
195
+ desc "Report how many queries the local test router can plan (SUPERGRAPH=, QUERIES=)"
196
+ task coverage: :loaded do
197
+ require "graph_weaver/testing"
198
+
199
+ puts GraphWeaver::Testing::Coverage.new(
200
+ supergraph: GraphWeaver::Tasks.supergraph!("coverage"),
201
+ queries: ENV["QUERIES"] || GraphWeaver.queries_paths,
202
+ ).report
203
+ rescue GraphWeaver::Error => e
204
+ # a supergraph the routing table can't read fully is itself the answer:
205
+ # nothing is plannable, and the message says which construct
206
+ abort e.message
56
207
  end
57
208
  end
58
209
 
59
210
  namespace :cassettes do
211
+ # The drift the other checks structurally can't see. verify, queries:check
212
+ # and schema:diff all ask about the local side; a cassette is the one
213
+ # artifact recorded from someone else's server, and when that server's
214
+ # answers stop fitting the generated structs the failure surfaces mid-spec
215
+ # as a cast error naming a struct and a sorbet frame — nothing points at
216
+ # the stale file.
217
+ desc "Fail when a recorded response no longer casts into the generated structs"
218
+ task check: :environment do
219
+ require "graph_weaver/testing"
220
+
221
+ # unlike its siblings this task reads generated modules — they are what
222
+ # a recording is checked against
223
+ GraphWeaver.load_generated!
224
+ modules = GraphWeaver.query_files.filter_map do |path|
225
+ name = GraphWeaver.module_name(path, File.read(path))
226
+ Object.const_get(name) if Object.const_defined?(name)
227
+ end
228
+
229
+ # Testing.cassette_dir, not config.cassette_dir: the configured path is
230
+ # relative by default and rake runs from wherever it runs from
231
+ dir = GraphWeaver::Testing.cassette_dir
232
+ checks = Dir[File.join(dir, "*.yml")].sort.map do |path|
233
+ GraphWeaver::Testing::Cassette.new(path).check(modules)
234
+ end
235
+ checks.each { |check| puts check.report }
236
+
237
+ stale = checks.sum { |check| check.stale.size }
238
+ $stdout.flush
239
+ if stale.positive?
240
+ abort "#{stale} stale #{(stale == 1) ? "recording" : "recordings"} — the recorded server's " \
241
+ "answers no longer fit the structs generated from your schema. Re-record " \
242
+ "(GRAPHWEAVER_RECORD=1, with a live client:), or regenerate if it was the schema dump " \
243
+ "that moved: rake graph_weaver:generate."
244
+ end
245
+ if checks.sum(&:checked).zero?
246
+ # a green run that compared nothing is worse than a failure: it would
247
+ # pass whatever the recordings said (see federation:diff)
248
+ abort "this checked nothing, so it proved nothing: no recording in #{dir} carries a query " \
249
+ "any of the #{modules.size} generated modules sends. Drop this task from CI if you " \
250
+ "don't record cassettes, or check that #{dir} is where yours live."
251
+ end
252
+
253
+ puts "every recording still casts"
254
+ end
255
+
60
256
  desc "Anonymize every cassette in Testing.config.cassette_dir (PII-safe to commit)"
61
- task :anonymize do
257
+ task anonymize: :environment do
62
258
  require "graph_weaver/testing"
63
259
 
64
- schema = GraphWeaver::SchemaLoader.load(GraphWeaver.schema_path)
65
- Dir[File.join(GraphWeaver::Testing.config.cassette_dir, "*.yml")].sort.each do |path|
260
+ # locate, not schema_path: the dump is whichever supported extension is
261
+ # actually on disk, and every sibling task asks the same way
262
+ schema = GraphWeaver::SchemaLoader.locate or abort "no schema dump at #{GraphWeaver.schema_path}"
263
+ Dir[File.join(GraphWeaver::Testing.cassette_dir, "*.yml")].sort.each do |path|
66
264
  GraphWeaver::Testing::Cassette.new(path).anonymize!(schema:)
67
265
  puts "anonymized #{path}"
68
266
  end