graph_weaver 0.7.4 → 0.7.5

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.
@@ -66,15 +66,15 @@ class GraphWeaver::Codegen
66
66
  # defines, defaulting to the operation's own name; default_name: is
67
67
  # parse's container-scoped fallback (file generation stays strict — a
68
68
  # checked-in file deserves a deliberate name). types_namespace: is the shared-types workflow (see
69
- # GraphWeaver.generate!): input types, schema enums, and unions hoisted from
70
- # shared fragments live once in that module and the query module aliases what
71
- # it uses. hoistable_unions: is the set of shared fragment names this query
72
- # may hoist (spreads it inlined, minus any it shadows locally) — a
73
- # whole-union field spread as one of them resolves to a canonical type in the
74
- # shared module (see used_union_names). path: is the file the query was read
69
+ # GraphWeaver.generate!): input types, schema enums, and the types hoisted out
70
+ # of shared fragments live once in that module and the query module aliases
71
+ # what it uses. hoistable_fragments: is the set of shared fragment names this
72
+ # query may hoist (spreads it inlined, minus any it shadows locally) — a whole
73
+ # field spread as one of them resolves to a canonical type in the shared
74
+ # module (see used_fragment_names). path: is the file the query was read
75
75
  # from, named alongside line and column in validation errors.
76
76
  def initialize(schema:, query:, name: nil, default_name: nil,
77
- types_namespace: nil, hoistable_unions: nil, path: nil, module_name: nil,
77
+ types_namespace: nil, hoistable_fragments: nil, path: nil, module_name: nil,
78
78
  graph_name: nil, registry: GraphWeaver::Codegen.registry)
79
79
  renamed!(module_name)
80
80
  @schema = schema
@@ -87,8 +87,8 @@ class GraphWeaver::Codegen
87
87
  @name = name
88
88
  @default_name = default_name
89
89
  @types_namespace = types_namespace
90
- @hoistable_unions = hoistable_unions || []
91
- @used_unions = []
90
+ @hoistable_fragments = hoistable_fragments || []
91
+ @used_fragments = []
92
92
  # scalars this generation had no registration for (see report_untyped_scalars)
93
93
  @untyped_scalars = []
94
94
  # the graph this module belongs to: its client and, under a test mode,
@@ -144,10 +144,10 @@ class GraphWeaver::Codegen
144
144
  { inputs: @variable_inputs.keys, enums: @enums.keys, mapped: @mapped_enums.keys }
145
145
  end
146
146
 
147
- # The shared union fragments this query hoisted, by name — the generate!
148
- # workflow unions these across queries to decide what the shared types module
149
- # must contain.
150
- def used_union_names = @used_unions.dup
147
+ # The shared fragments this query hoisted, by name — the generate! workflow
148
+ # unions these across queries to decide what the shared types module must
149
+ # contain.
150
+ def used_fragment_names = @used_fragments.dup
151
151
 
152
152
  # The custom scalars this walk found no registration for (see
153
153
  # report_untyped_scalars) — the generate! workflow unions these across
@@ -172,26 +172,29 @@ class GraphWeaver::Codegen
172
172
  # T::Enum, or the wire tables for one mapped onto an app enum
173
173
  # (register_enum) — so a value read out of one query's result hands
174
174
  # straight back into another's variable;
175
- # - unions: each named shared fragment a query spread as a whole union field,
176
- # so the same union across queries is one Ruby type family. `fragments` is
177
- # the loaded shared-fragment table (nested spreads resolve through it).
175
+ # - hoisted: each named shared fragment a query spread as a whole field, so
176
+ # the same shape across queries is one Ruby type. `fragments` is the loaded
177
+ # shared-fragment table (nested spreads resolve through it).
178
178
  #
179
- # Unions are built first: a hoisted fragment's own selections are the one
180
- # place a query walk never reaches, so the enums they touch are only known
181
- # once the fragments are built.
182
- def generate_types(inputs:, enums:, unions:, fragments:)
179
+ # Hoisted fragments are built first: their own selections are the one place a
180
+ # query walk never reaches, so the enums they touch are only known once the
181
+ # fragments are built.
182
+ def generate_types(inputs:, enums:, hoisted:, fragments:)
183
183
  validate_module_name!("types module name")
184
184
  reset_walk_state!
185
185
  # nested spreads inside a shared fragment resolve through the whole table
186
186
  @fragments = fragments
187
187
 
188
- union_nodes = unions.uniq.sort.map { |name| hoisted_union(fragments, name) }
188
+ nodes = hoisted.uniq.sort.map { |name| hoisted_fragment(fragments, name) }
189
189
  inputs.sort.each { |name| input_node(@schema.get_type(name)) }
190
190
  enums.uniq.sort.each { |name| variable_core(@schema.get_type(name)) }
191
- check_shared_collisions!(unions)
192
- union_nodes.each { |union| check_shadowing!(union) }
191
+ check_shared_collisions!(hoisted)
192
+ nodes.each do |node|
193
+ check_shadowing!(node)
194
+ check_abstract_mixins!(node)
195
+ end
193
196
 
194
- emit_types_files(union_nodes).tap { report_untyped_scalars }
197
+ emit_types_files(nodes).tap { report_untyped_scalars }
195
198
  end
196
199
 
197
200
  # module-level constants every generated query module defines — a shared
@@ -201,7 +204,7 @@ class GraphWeaver::Codegen
201
204
 
202
205
  # One hoisted shared fragment, built against the schema and named for the
203
206
  # fragment rather than the field that spread it.
204
- def hoisted_union(fragments, name)
207
+ def hoisted_fragment(fragments, name)
205
208
  class_name = camelize(name)
206
209
  # the query module aliases <class_name> = <shared module>::<class_name>; a
207
210
  # name that camelizes to a generated module-level constant (the Result
@@ -213,13 +216,15 @@ class GraphWeaver::Codegen
213
216
 
214
217
  fragment = fragments.fetch(name)
215
218
  type = @schema.get_type(fragment.type.name)
219
+ return object_node(type, fragment.selections, class_name) if type.kind.object?
220
+
216
221
  members = union_members(type, fragment.selections)
217
222
  UnionNode.new(class_name, members, catch_all_member(type, fragment.selections, members))
218
223
  end
219
- private :hoisted_union
224
+ private :hoisted_fragment
220
225
 
221
226
  # Schema type names are unique, so an input and an enum can never land on the
222
- # same name — but a hoisted union is named for its FRAGMENT, which the schema
227
+ # same name — but a hoisted type is named for its FRAGMENT, which the schema
223
228
  # knows nothing about. One shared module means one namespace, so a fragment
224
229
  # named after a type it doesn't describe has to refuse rather than overwrite.
225
230
  def check_shared_collisions!(names)
@@ -250,7 +255,7 @@ class GraphWeaver::Codegen
250
255
  @input_list = false
251
256
  @input_hops = []
252
257
  @mapped_enums = {}
253
- @used_unions = []
258
+ @used_fragments = []
254
259
  # block-built type helpers this walk included — see #block_helpers
255
260
  @block_helpers = []
256
261
  # requires the generated file needs (custom scalars, enum mappings,
@@ -408,6 +413,7 @@ class GraphWeaver::Codegen
408
413
  variables = build_variables(operation)
409
414
  root = object_node(root_type, operation.selections, "Result")
410
415
  check_shadowing!(root)
416
+ check_abstract_mixins!(root)
411
417
 
412
418
  # An anonymous operation takes the module's name — declared in the document
413
419
  # AND sent as operationName, which have to agree (a server rejects an
@@ -719,7 +725,7 @@ class GraphWeaver::Codegen
719
725
  # The shared fragments a query spreads (transitively), excluding any it
720
726
  # shadows with a local definition of the same name — the names
721
727
  # inline_fragments appends, and the set the generate! workflow may hoist
722
- # when they sit on a whole-union field.
728
+ # when they are a field's whole selection.
723
729
  def self.shared_fragment_spreads(query, shared, path = nil)
724
730
  # parsed even with nothing to spread: this is the first look at the document
725
731
  # on the generate! path, so it's where a syntax error gets branded and
@@ -872,59 +878,16 @@ class GraphWeaver::Codegen
872
878
 
873
879
  case (core = field_type.unwrap).kind.name
874
880
  when "OBJECT"
875
- name = pick_name(key, taken)
876
- type_ref(field_type) { object_node(core, sub_selections, name) }
877
- when "UNION", "INTERFACE"
878
- conditions = concrete_conditions(core, sub_selections)
879
- shared = abstract_level_fields(core, sub_selections)
880
-
881
- if conditions.empty?
882
- # abstract-level fields only — every member shares them, so one
883
- # struct suffices and no __typename dispatch is needed (for a
884
- # union that selection can only be __typename)
881
+ if (frag = hoistable_spread(core, sub_selections))
882
+ hoisted_ref(field_type, frag)
883
+ else
885
884
  name = pick_name(key, taken)
886
885
  type_ref(field_type) { object_node(core, sub_selections, name) }
887
- elsif conditions.size == 1 && shared.empty? &&
888
- (member = @schema.get_type(conditions.first)).kind.name == "OBJECT"
889
- # a single `... on X` condition: narrow to X's struct — nil
890
- # when the runtime type doesn't match (narrowing filters).
891
- # With `__typename` selected the match is read off the tag;
892
- # without one there is nothing to read but emptiness, and a
893
- # fragment whose every field hides behind @skip/@include would
894
- # make a real match indistinguishable from a miss ({} either
895
- # way) — refuse rather than guess.
896
- tag = member.graphql_name if dispatchable_typename?(core, sub_selections)
897
- unless tag || unconditional_field?(member, sub_selections)
898
- raise GraphWeaver::Error,
899
- "narrowed `... on #{member.graphql_name}` needs at least one field not under " \
900
- "@skip/@include (or a `__typename` to match on) — an all-conditional selection " \
901
- "makes a match indistinguishable from nil"
902
- end
903
-
904
- name = pick_name(key, taken)
905
- nilable_type_ref(field_type) { NarrowedNode.new(object_node(member, sub_selections, name), typename: tag) }
906
- elsif @types_namespace && (frag = lone_shared_spread(sub_selections)) &&
907
- @hoistable_unions.include?(frag)
908
- # a whole-union field spread as a named shared fragment: hoist to
909
- # the shared types module so the same union across queries is one
910
- # Ruby type family (one exhaustive `case ... T.absurd`).
911
- @used_unions << frag unless @used_unions.include?(frag)
912
- ref = UnionRefNode.new(camelize(frag))
913
- type_ref(field_type) { ref }
914
- else
915
- members = union_members(core, sub_selections)
916
- catch_all = catch_all_member(core, sub_selections, members)
917
- # reuse an identical sibling union — the shared type takes the
918
- # first of the sharing keys alphabetically, not in walk order
919
- signature = union_signature(members, catch_all)
920
- union = union_cache[signature]
921
- if union
922
- rename_union(union, key, taken) if camelize(key) < union.class_name
923
- else
924
- union = union_cache[signature] = UnionNode.new(pick_name(key, taken), members, catch_all)
925
- end
926
- type_ref(field_type) { union }
927
886
  end
887
+ when "UNION", "INTERFACE"
888
+ abstract_field(AbstractField.new(
889
+ type: field_type, selections: sub_selections, key:, taken:, union_cache:,
890
+ ))
928
891
  when "ENUM"
929
892
  # one schema enum is one Ruby type: module-level, named for the enum,
930
893
  # shared by every result field and variable that reaches it (and, on
@@ -953,6 +916,88 @@ class GraphWeaver::Codegen
953
916
  node
954
917
  end
955
918
 
919
+ # An abstract-typed (union or interface) field: what was selected through it,
920
+ # and the two ledgers the struct being built keeps — the names already claimed
921
+ # in its scope, and the unions it has already emitted.
922
+ AbstractField = Data.define(:type, :selections, :key, :taken, :union_cache) do
923
+ def core = type.unwrap
924
+ end
925
+ private_constant :AbstractField
926
+
927
+ # Which of four shapes an abstract-typed field generates. The selection
928
+ # decides, not the schema: what it narrows to, and how it was spread.
929
+ def abstract_field(field)
930
+ conditions = concrete_conditions(field.core, field.selections)
931
+ shared = abstract_level_fields(field.core, field.selections)
932
+
933
+ if conditions.empty?
934
+ abstract_level_struct(field)
935
+ elsif conditions.size == 1 && shared.empty? &&
936
+ (member = @schema.get_type(conditions.first)).kind.name == "OBJECT"
937
+ narrowed_struct(field, member)
938
+ elsif (frag = hoistable_spread(field.core, field.selections))
939
+ hoisted_ref(field.type, frag)
940
+ else
941
+ dispatch_union(field)
942
+ end
943
+ end
944
+
945
+ # Every member carries the abstract-level fields, so one struct answers for
946
+ # all of them and there is nothing to dispatch on — for a union, the only
947
+ # selection that can get here is __typename.
948
+ def abstract_level_struct(field)
949
+ name = pick_name(field.key, field.taken)
950
+ type_ref(field.type) { object_node(field.core, field.selections, name) }
951
+ end
952
+
953
+ # Narrowing to the one member a `... on X` names filters: the field is nil
954
+ # whenever the runtime type doesn't match. With `__typename` selected the
955
+ # match is read off the tag; without one there is nothing to read but
956
+ # emptiness, and a fragment whose every field hides behind @skip/@include
957
+ # would make a real match indistinguishable from a miss ({} either way) —
958
+ # refuse rather than guess.
959
+ def narrowed_struct(field, member)
960
+ tag = member.graphql_name if dispatchable_typename?(field.core, field.selections)
961
+ unless tag || unconditional_field?(member, field.selections)
962
+ raise GraphWeaver::Error,
963
+ "narrowed `... on #{member.graphql_name}` needs at least one field not under " \
964
+ "@skip/@include (or a `__typename` to match on) — an all-conditional selection " \
965
+ "makes a match indistinguishable from nil"
966
+ end
967
+
968
+ name = pick_name(field.key, field.taken)
969
+ nilable_type_ref(field.type) { NarrowedNode.new(object_node(member, field.selections, name), typename: tag) }
970
+ end
971
+
972
+ # A whole field spread as one named shared fragment points at the type
973
+ # hoisted into the shared types module, so the same shape across queries is
974
+ # one Ruby type — for a union, one exhaustive `case ... T.absurd`.
975
+ def hoisted_ref(field_type, frag)
976
+ @used_fragments << frag unless @used_fragments.include?(frag)
977
+ # an abstract type hoists to a dispatch module, an object type to a struct
978
+ core = field_type.unwrap
979
+ node_class = core.kind.object? ? HoistedRefNode : UnionRefNode
980
+ ref = node_class.new(camelize(frag), core.graphql_name)
981
+ type_ref(field_type) { ref }
982
+ end
983
+
984
+ # One member struct per type the selection names, chosen at runtime off
985
+ # __typename. Structurally identical sibling unions share one Ruby type,
986
+ # named for the first of the sharing keys alphabetically so that which one
987
+ # the walk reached first doesn't decide.
988
+ def dispatch_union(field)
989
+ members = union_members(field.core, field.selections)
990
+ catch_all = catch_all_member(field.core, field.selections, members)
991
+ signature = union_signature(members, catch_all)
992
+ union = field.union_cache[signature]
993
+ if union
994
+ rename_union(union, field.key, field.taken) if camelize(field.key) < union.class_name
995
+ else
996
+ union = field.union_cache[signature] = UnionNode.new(pick_name(field.key, field.taken), members, catch_all)
997
+ end
998
+ type_ref(field.type) { union }
999
+ end
1000
+
956
1001
  # A generated class name is only ever a name; Ruby resolves it lexically. So
957
1002
  # a struct nesting `class Date < T::Struct` (from a result key `date`) turns
958
1003
  # a sibling `Date` scalar prop into that struct, and `Date.iso8601` into a
@@ -1066,10 +1111,26 @@ class GraphWeaver::Codegen
1066
1111
  dispatchable_typename?(core, selections) ? keys - ["__typename"] : keys
1067
1112
  end
1068
1113
 
1114
+ # The name a field's whole selection hoists under: exactly one bare spread of
1115
+ # a fragment this query may hoist, written on the field's own type. Only the
1116
+ # generate! workflow has a shared module to hoist into — dynamic `parse`
1117
+ # inlines.
1118
+ def hoistable_spread(core, selections)
1119
+ return unless @types_namespace
1120
+
1121
+ frag = lone_shared_spread(selections)
1122
+ return unless frag && @hoistable_fragments.include?(frag)
1123
+
1124
+ # the shared type is built from the fragment's own type condition, so it is
1125
+ # this field's type only when the two agree — a fragment on a narrower (or
1126
+ # wider) type stays a locally-emitted struct
1127
+ frag if @fragments.fetch(frag).type.name == core.graphql_name
1128
+ end
1129
+
1069
1130
  # The fragment name when a selection is exactly one bare fragment spread
1070
- # (`{ ...F }`) — the shape a union field must have to hoist into the shared
1071
- # unions module. A spread carrying directives (@skip/@include), or mixed with
1072
- # other fields, stays a locally-emitted union.
1131
+ # (`{ ...F }`) — the shape a field must have to hoist into the shared types
1132
+ # module. A spread carrying directives (@skip/@include), or mixed with other
1133
+ # fields, stays locally emitted.
1073
1134
  def lone_shared_spread(selections)
1074
1135
  return unless selections.size == 1
1075
1136
 
@@ -1172,7 +1233,7 @@ class GraphWeaver::Codegen
1172
1233
  # it. It carries what the abstract type itself guarantees, plus anything a
1173
1234
  # `... on SomeInterface` asked for, since an unnamed member may implement it.
1174
1235
  def catch_all_member(type, selections, members)
1175
- node = object_node(type, selections, catch_all_name(members))
1236
+ node = object_node(type, selections, catch_all_name(members.each_value.map(&:class_name)))
1176
1237
  taken = node.fields.map(&:key)
1177
1238
 
1178
1239
  # These are nilable whatever the schema promises: the member that arrives
@@ -1222,9 +1283,9 @@ class GraphWeaver::Codegen
1222
1283
  sibling_conditions(condition, selections, visiting, out)
1223
1284
  end
1224
1285
 
1225
- # "Other", unless a real member already claims that name.
1226
- def catch_all_name(members)
1227
- taken = members.each_value.map(&:class_name)
1286
+ # "Other", unless a real member already claims that name — one rule for a
1287
+ # union's catch-all struct and a generated enum's fallback member.
1288
+ def catch_all_name(taken)
1228
1289
  name = "Other"
1229
1290
  suffix = 2
1230
1291
  while taken.include?(name)
@@ -1251,13 +1312,13 @@ class GraphWeaver::Codegen
1251
1312
  when List then "[#{signature(node.of)}]"
1252
1313
  when NarrowedNode then "?#{signature(node.nested)}"
1253
1314
  when Scalar then "s:#{node.bare_type}"
1254
- when EnumNode then "e:#{node.values.sort.join("|")}"
1315
+ when EnumNode then "e:#{node.values.sort.join("|")}#{"+" if node.fallback?}"
1255
1316
  when MappedEnum then "m:#{node.graphql_name}"
1256
1317
  when ObjectNode
1257
1318
  inner = node.fields.map { |f| "#{f.prop}=#{signature(f.node)}" }.sort.join(",")
1258
1319
  "o:#{node.graphql_type}(#{inner})"
1259
1320
  when UnionNode then "u:(#{union_signature(node.members, node.catch_all)})"
1260
- when UnionRefNode then "ur:#{node.class_name}" # hoisted — identity is its shared name
1321
+ when HoistedRefNode then "hr:#{node.class_name}" # hoisted — identity is its shared name
1261
1322
  else "x:#{node.object_id}" # unknown node kind — never collapse
1262
1323
  end
1263
1324
  end
@@ -1406,8 +1467,12 @@ class GraphWeaver::Codegen
1406
1467
  "constant — map it onto one of yours: register_enum(#{core.graphql_name.inspect}, YourEnum)"
1407
1468
  end
1408
1469
 
1409
- aliases = @registry.enum_registry[core.graphql_name]&.aliases_for(core.values.keys.sort) || {}
1410
- EnumNode.new(class_name, enum_values(core, aliases), aliases)
1470
+ entry = @registry.enum_registry[core.graphql_name]
1471
+ aliases = entry&.aliases_for(core.values.keys.sort) || {}
1472
+ values = enum_values(core, aliases)
1473
+ # named the way a union's catch-all is: Other, or Other2 past a declared OTHER
1474
+ fallback = catch_all_name(values.map { |value| camelize(value.downcase) }) if entry&.generated_fallback?
1475
+ EnumNode.new(class_name, values, aliases, fallback:)
1411
1476
  end
1412
1477
 
1413
1478
  # A schema enum's constant-bearing wire values, sorted so output is
@@ -1479,6 +1544,49 @@ class GraphWeaver::Codegen
1479
1544
  end
1480
1545
  private :abstract_mixin_members
1481
1546
 
1547
+ # An `abstract!` mixin goes into EVERY struct generated from its type, but
1548
+ # only a struct that selected the members it declares can satisfy them — so a
1549
+ # query selecting a subset generated fine and failed in the app's own
1550
+ # `srb tc`, two tools from the query that fell short. Walked from the root the
1551
+ # way shadowing is, so the refusal can name the struct by its path.
1552
+ def check_abstract_mixins!(node, path = [])
1553
+ case node
1554
+ when UnionNode
1555
+ inner = path + [node.class_name]
1556
+ (node.members.each_value.to_a + [node.catch_all]).each { |member| check_abstract_mixins!(member, inner) }
1557
+ when ObjectNode
1558
+ inner = path + [node.class_name]
1559
+ refuse_unsatisfiable_mixin!(node, inner)
1560
+ node.fields.each { |field| check_abstract_mixins!(field.node.nested, inner) if field.node.nested }
1561
+ end
1562
+ end
1563
+
1564
+ def refuse_unsatisfiable_mixin!(node, path)
1565
+ return if node.overrides.empty?
1566
+
1567
+ mixins = @registry.type_registry.dig(node.graphql_type, :mixins) || []
1568
+ provided = (node.fields.map(&:prop) + node.aliases.map(&:name)).to_set
1569
+ # what is still abstract with every registered mixin included: a member one
1570
+ # of them implements for another is already answered
1571
+ probe = Module.new
1572
+ mixins.each { |mixin| probe.include(mixin) }
1573
+ missing = T::AbstractUtils.abstract_methods_for(probe)
1574
+ .map { |method| method.name.to_s }.reject { |member| provided.include?(member) }.sort
1575
+ return if missing.empty?
1576
+
1577
+ mixin = mixins.find { |m|
1578
+ T::AbstractUtils.declared_abstract_methods_for(m).any? { |method| missing.include?(method.name.to_s) }
1579
+ }
1580
+ raise GraphWeaver::Error,
1581
+ "#{[@name, *path].join("::")} includes #{mixin.name}, which declares " \
1582
+ "#{GraphWeaver::Internal::Util.sample(missing.map(&:inspect))} abstract — this selection does " \
1583
+ "not provide #{missing.one? ? "it" : "them"}, and every struct generated from " \
1584
+ "#{node.graphql_type} includes the mixin, so `srb tc` fails on this one. Select " \
1585
+ "#{missing.one? ? "it" : "them"} here, or select #{node.graphql_type} through one shared " \
1586
+ "fragment (`{ ...Frag }`), which hoists one struct for every query to share."
1587
+ end
1588
+ private :check_abstract_mixins!, :refuse_unsatisfiable_mixin!
1589
+
1482
1590
  # The MappedEnum node for a schema enum with a registered app-enum
1483
1591
  # mapping; nil when unregistered — or registered for alias: alone, which
1484
1592
  # says nothing about the Ruby type — falling back to a generated T::Enum.
@@ -255,7 +255,7 @@ module GraphWeaver
255
255
 
256
256
  # the article by the initial, so a registered Ruby class ("an Integer")
257
257
  # reads as well as the scalars ("an Int", "an ID", "a Date")
258
- def expected(scalar) = "expected #{scalar.start_with?(/[AEIOU]/) ? "an" : "a"} #{scalar}"
258
+ def expected(scalar) = "expected #{Internal::Util.article(scalar)} #{scalar}"
259
259
  end
260
260
  end
261
261
  end
@@ -341,15 +341,10 @@ module GraphWeaver
341
341
  *@skipped.sort.map { |name, what| " #{name} (#{evidence(what)})" }]
342
342
  end
343
343
 
344
- # how many coordinates the report names before it says "and N more"
345
- SAMPLE = 5
346
- private_constant :SAMPLE
347
-
348
344
  def evidence(coordinates)
349
345
  return "the supergraph attributes nothing to it alone" if coordinates.empty?
350
- return coordinates.join(", ") if coordinates.size <= SAMPLE
351
346
 
352
- "#{coordinates.first(SAMPLE).join(", ")} and #{coordinates.size - SAMPLE} more"
347
+ Internal::Util.sample(coordinates)
353
348
  end
354
349
 
355
350
  def faked_section
@@ -69,22 +69,37 @@ module GraphWeaver
69
69
  # neither that nor which values exist. Raised bare so the enclosing
70
70
  # Hints.field brands it with the field.
71
71
  # aliases (register_enum alias:) is wire spelling => the value it reads as.
72
- def self.enum(type, value, aliases = nil)
72
+ # fallback is the Other member, when the registration asked for one.
73
+ def self.enum(type, value, aliases = nil, fallback: nil)
73
74
  value = aliases.fetch(value, value) if aliases
75
+ member = type.try_deserialize(value)
76
+ return member if member
77
+ return absorbed(type, value, fallback) if fallback
74
78
 
75
- type.try_deserialize(value) || drifted!(type, value, type.values.map(&:serialize))
79
+ drifted!(type, value, type.values.map(&:serialize), "register_enum fallback: true")
76
80
  end
77
81
 
78
82
  # the same, for an enum mapped onto an app-owned T::Enum, where the wire
79
83
  # table rather than the type knows the accepted values
80
84
  def self.mapped_enum(type, table, value)
81
- table.fetch(value) { drifted!(type, value, table.keys) }
85
+ table.fetch(value) { drifted!(type, value, table.keys, "register_enum fallback:") }
82
86
  end
83
87
 
84
- def self.drifted!(type, value, values)
88
+ # A T::Enum member is a singleton, so Other can't carry the value it
89
+ # swallowed — this line is the only record that anything drifted.
90
+ def self.absorbed(type, value, fallback)
91
+ GraphWeaver::Internal::Log.log(:debug) do
92
+ # the member's bare constant name — a T::Enum member inspects as #<Type::Name>
93
+ "#{type} absorbed #{GraphWeaver::Internal::Redact.shown(value)} into #{fallback.inspect[/::(\w+)>\z/, 1]}"
94
+ end
95
+ fallback
96
+ end
97
+ private_class_method :absorbed
98
+
99
+ def self.drifted!(type, value, values, suggestion)
85
100
  raise KeyError, "#{GraphWeaver::Internal::Redact.shown(value)} is not a #{type} — expected one of: " \
86
101
  "#{values.sort.join(", ")}; a value the server added since you generated " \
87
- "needs a regenerate, or register_enum fallback: to absorb them"
102
+ "needs a regenerate, or #{suggestion} to absorb them"
88
103
  end
89
104
  private_class_method :drifted!
90
105
 
@@ -72,14 +72,12 @@ class GraphWeaver::InProcess
72
72
  "#{GraphWeaver::Internal::Wire.truncate_for_log(query)}"
73
73
  end
74
74
 
75
- result = GraphWeaver::Internal::Log.log_timed(:debug, "in-process #{schema_label} #{tag} completed") do
75
+ GraphWeaver::Internal::Log.log_timed(:debug, "in-process #{schema_label} #{tag} completed") do
76
76
  # a copy per query: graphql-ruby writes a resolver's `context[...] =`
77
77
  # into the hash it is handed, and one client serves every request
78
78
  @schema.execute(query, variables:, operation_name:,
79
79
  context: GraphWeaver::Internal::Util.context!(@context).dup)
80
80
  end
81
-
82
- result
83
81
  rescue GraphWeaver::Error
84
82
  raise
85
83
  rescue => e
@@ -31,12 +31,15 @@ module GraphWeaver
31
31
  # rather than T::Enum.deserialize / the wire table directly: both raise a
32
32
  # bare KeyError naming an anonymous module and none of the values they
33
33
  # would have taken.
34
- def self.enum(type, value, aliases = nil)
35
- return value if value.is_a?(type)
36
-
37
- value = aliases.fetch(value, value) if aliases
38
-
39
- type.try_deserialize(value) || invalid_enum!(type, value, type.values.map(&:serialize))
34
+ # fallback is the generated Other member (register_enum fallback: true).
35
+ # It is the one member input refuses: nothing on the wire means it, so a
36
+ # variable carrying it would send a value the server never declared.
37
+ def self.enum(type, value, aliases = nil, fallback: nil)
38
+ member = value.is_a?(type) ? value : type.try_deserialize(aliases ? aliases.fetch(value, value) : value)
39
+ return member if member && !member.equal?(fallback)
40
+
41
+ accepted = type.values.map(&:serialize) - [fallback&.serialize].compact
42
+ member ? unsendable_enum!(member, accepted) : invalid_enum!(type, value, accepted)
40
43
  end
41
44
 
42
45
  # A list element's index, prepended when something inside it refused —
@@ -110,6 +113,18 @@ module GraphWeaver
110
113
  end
111
114
  private_class_method :invalid_enum!
112
115
 
116
+ # The fallback member is a landing pad for drift, not a value — so it is
117
+ # refused by name rather than listed among the ones you could have meant.
118
+ def self.unsendable_enum!(member, accepted)
119
+ # a T::Enum member inspects as #<Type::Name>
120
+ raise GraphWeaver::Internal::Refusal.brand(
121
+ KeyError.new("#{member.inspect[2..-2]} absorbs values the server added, so " \
122
+ "there is nothing to send for it — expected one of: #{accepted.sort.join(", ")}"),
123
+ :not_a_member, members: accepted.sort,
124
+ )
125
+ end
126
+ private_class_method :unsendable_enum!
127
+
113
128
  def self.included(base)
114
129
  base.extend(ClassMethods)
115
130
  end
@@ -34,9 +34,6 @@ module GraphWeaver
34
34
  # goes through the same check *and refuses at construction* — a swapped
35
35
  # pair fails there rather than as a mystery three fetches later.
36
36
  module Subgraphs
37
- # how many coordinates a message names before it says "and N more"
38
- SAMPLE = 5
39
-
40
37
  # answer this subgraph with fabricated data rather than refusing
41
38
  FAKE = :fake
42
39
 
@@ -117,15 +114,9 @@ module GraphWeaver
117
114
  return schema if gaps.empty?
118
115
 
119
116
  raise GraphWeaver::ConfigurationError, "subgraphs[#{name.inspect}] is " \
120
- "#{schema.name || schema.inspect}, which doesn't define #{sample(gaps)} — the supergraph " \
117
+ "#{schema.name || schema.inspect}, which doesn't define #{Util.sample(gaps)} — the supergraph " \
121
118
  "says #{name} resolves them. Did two entries get swapped?"
122
119
  end
123
-
124
- def sample(list)
125
- return list.join(", ") if list.size <= SAMPLE
126
-
127
- "#{list.first(SAMPLE).join(", ")} and #{list.size - SAMPLE} more"
128
- end
129
120
  end
130
121
  end
131
122
  end
@@ -47,6 +47,11 @@ module GraphWeaver
47
47
  # .json.erb's sibling JS — is a blind spot, and the footer says so.
48
48
  # .rake and .builder are Ruby too.
49
49
  EXTENSIONS = %w[.rb .rake .builder .erb .slim .haml .jbuilder].freeze
50
+ # Ruby that carries no extension to recognise it by. In a non-Rails
51
+ # project the entry points live here, so skipping them skipped the
52
+ # files that read the query.
53
+ SCRIPT_DIRS = Set["bin", "exe"].freeze
54
+ RUBY_SHEBANG = /\A#!.*\bruby\b/
50
55
  # Directories that hold no app source. "generated" covers both a graph's
51
56
  # own output under the convention and a spec/generated fixture dir; a
52
57
  # graph that writes somewhere else is pruned by #outputs.
@@ -60,8 +65,9 @@ module GraphWeaver
60
65
  # difference between a lint and a number somebody trusts.
61
66
  FOOTER = "This is a lint, not a proof — it matches prop names as text, so a common name reads " \
62
67
  "as\nused the moment anything says it. It can't see a prop reached by public_send, or a " \
63
- "read\nin a file type it doesn't sweep (#{EXTENSIONS.join(", ")}). On a real app half to " \
64
- "two\nthirds of genuinely unread selections go unreported; silence is the safe direction."
68
+ "read\nin a file type it doesn't sweep #{EXTENSIONS.join(", ")},\nplus Ruby with no " \
69
+ "extension (any name under bin/ or exe/, a ruby shebang elsewhere). On\na real app half to " \
70
+ "two thirds of genuinely unread selections go unreported; silence is\nthe safe direction."
65
71
 
66
72
  # query: the .graphql that selected it. struct/prop: where it landed.
67
73
  # wire: how the query spells that prop, when it differs.
@@ -244,19 +250,22 @@ module GraphWeaver
244
250
  end
245
251
 
246
252
  def files
247
- @files ||= @roots.flat_map { |root| collect(root, []) }.uniq.sort
253
+ @files ||= @roots
254
+ .flat_map { |root| collect(root, [], SCRIPT_DIRS.include?(File.basename(root))) }
255
+ .uniq.sort
248
256
  end
249
257
 
250
258
  # Pruned as it walks rather than globbed and filtered: node_modules is
251
- # the directory you most want never to descend into.
252
- def collect(dir, found)
259
+ # the directory you most want never to descend into. scripts says we are
260
+ # inside bin/ or exe/, which the walk knows and a path doesn't.
261
+ def collect(dir, found, scripts)
253
262
  Dir.children(dir).sort.each do |entry|
254
263
  path = File.join(dir, entry)
255
264
  # lstat, so a symlinked directory can't loop the walk
256
265
  stat = File.lstat(path)
257
266
  if stat.directory?
258
- collect(path, found) unless skip_dir?(entry, path)
259
- elsif stat.file? && EXTENSIONS.include?(File.extname(entry))
267
+ collect(path, found, scripts || SCRIPT_DIRS.include?(entry)) unless skip_dir?(entry, path)
268
+ elsif stat.file? && ruby?(path, entry, scripts)
260
269
  found << path
261
270
  end
262
271
  end
@@ -265,6 +274,22 @@ module GraphWeaver
265
274
  found
266
275
  end
267
276
 
277
+ # An extension names most of it. A file with none is Ruby if it sits
278
+ # under bin/ or exe/ — that is what those directories are for — or if
279
+ # its first line says so.
280
+ def ruby?(path, entry, scripts)
281
+ return true if EXTENSIONS.include?(File.extname(entry))
282
+ return false unless File.extname(entry).empty?
283
+
284
+ scripts || shebang?(path)
285
+ end
286
+
287
+ def shebang?(path)
288
+ File.open(path) { |file| file.gets(chomp: true) }&.match?(RUBY_SHEBANG) || false
289
+ rescue SystemCallError, ArgumentError
290
+ false
291
+ end
292
+
268
293
  def skip_dir?(entry, path) = entry.start_with?(".") || SKIP.include?(entry) || outputs.include?(path)
269
294
 
270
295
  # The generated directories a name check can't catch: a graph that sets