graph_weaver 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1447 -1
  3. data/Gemfile +8 -0
  4. data/Gemfile.lock +151 -2
  5. data/README.md +20 -6
  6. data/docs/alternatives.md +201 -0
  7. data/docs/cassettes.md +17 -1
  8. data/docs/errors.md +382 -17
  9. data/docs/federation.md +469 -63
  10. data/docs/generated_modules.md +231 -15
  11. data/docs/getting_started.md +497 -104
  12. data/docs/i18n.md +234 -0
  13. data/docs/logging.md +160 -24
  14. data/docs/real_world.md +28 -0
  15. data/docs/scalars.md +190 -26
  16. data/docs/testing.md +457 -58
  17. data/docs/transports.md +164 -19
  18. data/docs/upgrading.md +328 -3
  19. data/graph_weaver.gemspec +7 -0
  20. data/lib/generators/graph_weaver/install_generator.rb +138 -4
  21. data/lib/graph_weaver/client.rb +47 -10
  22. data/lib/graph_weaver/codegen/aliases.rb +7 -5
  23. data/lib/graph_weaver/codegen/emit.rb +98 -29
  24. data/lib/graph_weaver/codegen/enum_type.rb +2 -1
  25. data/lib/graph_weaver/codegen/nodes.rb +39 -6
  26. data/lib/graph_weaver/codegen/registry.rb +175 -0
  27. data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
  28. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  29. data/lib/graph_weaver/codegen.rb +404 -197
  30. data/lib/graph_weaver/coerce.rb +155 -26
  31. data/lib/graph_weaver/errors.rb +264 -34
  32. data/lib/graph_weaver/federation.rb +119 -26
  33. data/lib/graph_weaver/graph.rb +315 -0
  34. data/lib/graph_weaver/hints.rb +100 -24
  35. data/lib/graph_weaver/in_process.rb +17 -11
  36. data/lib/graph_weaver/input_struct.rb +119 -32
  37. data/lib/graph_weaver/internal/endpoint.rb +78 -0
  38. data/lib/graph_weaver/internal/headers.rb +51 -0
  39. data/lib/graph_weaver/internal/overrides.rb +67 -5
  40. data/lib/graph_weaver/internal/planner.rb +45 -15
  41. data/lib/graph_weaver/internal/refusal.rb +49 -0
  42. data/lib/graph_weaver/internal/schemas.rb +23 -9
  43. data/lib/graph_weaver/internal/selection.rb +34 -0
  44. data/lib/graph_weaver/internal/server_input.rb +251 -0
  45. data/lib/graph_weaver/internal/test_clients.rb +276 -0
  46. data/lib/graph_weaver/internal/unused.rb +287 -0
  47. data/lib/graph_weaver/internal/values.rb +40 -4
  48. data/lib/graph_weaver/internal.rb +183 -1
  49. data/lib/graph_weaver/log_subscriber.rb +66 -0
  50. data/lib/graph_weaver/logging.rb +136 -12
  51. data/lib/graph_weaver/query_module.rb +36 -3
  52. data/lib/graph_weaver/railtie.rb +237 -17
  53. data/lib/graph_weaver/representation.rb +55 -17
  54. data/lib/graph_weaver/result_struct.rb +90 -0
  55. data/lib/graph_weaver/retry.rb +33 -5
  56. data/lib/graph_weaver/rspec.rb +404 -93
  57. data/lib/graph_weaver/schema_loader.rb +221 -49
  58. data/lib/graph_weaver/tasks.rb +380 -89
  59. data/lib/graph_weaver/testing/cassette.rb +6 -5
  60. data/lib/graph_weaver/testing/endpoint.rb +106 -0
  61. data/lib/graph_weaver/testing/failure.rb +69 -12
  62. data/lib/graph_weaver/testing/fake_client.rb +133 -44
  63. data/lib/graph_weaver/testing/router.rb +58 -11
  64. data/lib/graph_weaver/testing.rb +200 -58
  65. data/lib/graph_weaver/transport/faraday.rb +41 -8
  66. data/lib/graph_weaver/transport/http.rb +46 -4
  67. data/lib/graph_weaver/transport.rb +109 -26
  68. data/lib/graph_weaver/version.rb +1 -1
  69. data/lib/graph_weaver.rb +474 -106
  70. metadata +56 -1
@@ -0,0 +1,276 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module GraphWeaver
5
+ module Internal
6
+ # The client a generated module runs against while a test mode is
7
+ # installed — the slot `graphql: :fake` and its siblings fill, and the one
8
+ # a `graphql_*` helper writes to.
9
+ #
10
+ # A tag used to work by swapping GraphWeaver.client, which is the LAST
11
+ # place a module looks: one generated with `client:` reads its baked
12
+ # DEFAULT_CLIENT first and never got there, so the tag quietly didn't
13
+ # apply. The mode installs itself here instead, and QueryModule asks
14
+ # before it reads that constant — so a tag reaches every module the
15
+ # example runs, bound or not.
16
+ #
17
+ # Keyed by the graph a module was generated from (its baked GRAPH), since
18
+ # the honest answer varies: :fake for a billing module has to fabricate
19
+ # billing's shapes, not the other schema's. A helper names its graphs the
20
+ # same way and lands in the same table, so what an example says applies to
21
+ # the modules it runs.
22
+ #
23
+ # Test-time only. Nothing installs a mode in production, where #for is an
24
+ # ivar read that returns nil.
25
+ module TestClients
26
+ # The app client slot under a mode in an app with several graphs. It
27
+ # fills the duck-typed slot the same way every other client does, and
28
+ # answers the one question asked of it with the reason there is no
29
+ # answer — the alternative is the real endpoint, silently.
30
+ class NoAppClient
31
+ def initialize(mode) = @mode = mode
32
+
33
+ def execute(_query, **)
34
+ graphs = GraphWeaver.graphs
35
+ raise GraphWeaver::Error, "#{@mode.inspect} stands in for a graph's modules, and this " \
36
+ "app has #{graphs.size} graphs (#{graphs.map { |g| g.name.inspect }.join(", ")}) — so " \
37
+ "GraphWeaver.client has no one right answer, and this request would have gone to the " \
38
+ "real endpoint. A generated module runs against its own graph's stand-in; to reach one " \
39
+ "directly, call the client a helper returns (graphql_fake(graph: #{graphs.first.name.inspect}), " \
40
+ "graphql_in_process(graph: #{graphs.first.name.inspect})). Tag the example graphql: :live " \
41
+ "for the app's own client."
42
+ end
43
+
44
+ def inspect = "#<#{self.class} #{@mode.inspect}>"
45
+ end
46
+
47
+ class << self
48
+ # Install `mode` for one example — the example's mode, which is what
49
+ # a graph no helper named runs against.
50
+ #
51
+ # Only the rspec hook installs, once per example after reset!. A
52
+ # helper doesn't: what a helper says is ONE graph's stand-in, written
53
+ # to the table by override!, so no helper can reset another graph's —
54
+ # which is how two graphs run in two modes in one example.
55
+ def install(mode)
56
+ @mode = mode
57
+ @clients = {}
58
+ @context = nil
59
+ end
60
+
61
+ # Back to no mode: every module resolves its own client again.
62
+ def reset!
63
+ @mode = nil
64
+ @clients = nil
65
+ @context = nil
66
+ end
67
+
68
+ # Whether an example is running under a mode — what tells suite setup
69
+ # apart from an example changing it out from under itself.
70
+ def installed? = !@mode.nil?
71
+
72
+ # Whether this example's stand-ins are already built — which is what
73
+ # makes a config setting they were built FROM too late to change.
74
+ def built? = !(@clients.nil? || @clients.empty?)
75
+
76
+ # The GraphQL context every stand-in runs with: this example's, else
77
+ # the suite baseline. graphql_context writes it, and the stand-ins
78
+ # already built take it in place — a :wire example's are built before
79
+ # the example body runs.
80
+ def context = @context || GraphWeaver::Testing.config.context
81
+
82
+ def context=(values)
83
+ @context = values
84
+ @clients&.each_value { |client| client.context = values if client.respond_to?(:context=) }
85
+ end
86
+
87
+ # `client` stands in for every graph in `graphs`, in place of the one
88
+ # the mode would build. This is a helper called in an example saying
89
+ # what the modules it names run against.
90
+ def override!(client, graphs)
91
+ graphs.each { |graph| @clients[graph&.name] = client }
92
+ client
93
+ end
94
+
95
+ # The stand-in for `mod`, or nil when there is nothing to stand in for.
96
+ def for(mod)
97
+ return unless @mode
98
+ # :wire takes no client slot: it serves the resolvers at the
99
+ # endpoint each client already posts to — the transport you ship,
100
+ # running unchanged, is the whole point
101
+ return if @mode == :wire
102
+ # :live is the app's own clients, untouched — so with nothing
103
+ # standing in there is nothing to look up
104
+ return if @mode == :live && !built?
105
+
106
+ # a helper's entry wins whatever the example's mode is, and :live
107
+ # builds nothing of its own, so an untagged example's other graphs
108
+ # still resolve their own clients
109
+ standin(graph_for!(mod))
110
+ end
111
+
112
+ # The stand-in `graph`'s modules run against under the installed mode,
113
+ # built once per example. :wire reaches it too — its clients sit
114
+ # behind the served endpoints rather than in the client slot, but they
115
+ # are the same objects graphql_context has to reach.
116
+ #
117
+ # `mode` is how a helper under a :wire tag asks for the client its own
118
+ # name means rather than the one :wire would have picked.
119
+ def standin(graph, mode = @mode)
120
+ @clients[graph&.name] ||= client_for(mode, graph)
121
+ end
122
+
123
+ # The client `mode` runs `graph` against — the one answer to "what
124
+ # does this tag mean", asked per module here and once per example by
125
+ # the rspec hook. nil for the two modes that take no client slot:
126
+ # :live is the app's own clients, untouched, and :wire serves the
127
+ # resolvers at the endpoint each client already posts to.
128
+ def client_for(mode, graph = app_graph)
129
+ config = GraphWeaver::Testing.config
130
+ case mode
131
+ when :fake
132
+ GraphWeaver::Testing::FakeClient.new(schema: config.reference_schema!(graph),
133
+ registry: graph&.registry)
134
+ when :in_process
135
+ GraphWeaver::InProcess.new(config.schema_class!(graph), context:)
136
+ when :router
137
+ router = config.built_router(graph)
138
+ router.context = context
139
+ # a router is built once per supergraph, so it has to be told
140
+ # where this example starts — the trace, and any faked subgraph's
141
+ # fabricated data. Once per router, not once per graph: two
142
+ # graphs naming one supergraph share it, and resetting again when
143
+ # the second's first module resolved wiped what the first had
144
+ # already accumulated, mid-example.
145
+ router.reset! unless @clients&.value?(router)
146
+ router
147
+ when :wire
148
+ # what sits behind the wire is decided the way the other tags
149
+ # already decide it, per graph — the most faithful thing that
150
+ # graph has, in the order the other tags rank them
151
+ client_for(wire_mode(config, graph), graph)
152
+ end
153
+ end
154
+
155
+ # What :wire serves for `graph`: its router when it is in a composed
156
+ # supergraph, its live schema class when it has one, else a fake of
157
+ # its schema — which is what an app that is a pure client of someone
158
+ # else's API has, and the only mode it could be.
159
+ #
160
+ # The one candidate the other modes have and :wire doesn't is
161
+ # GraphWeaver.client's own schema: reading it introspects the very
162
+ # endpoint :wire is about to stub, so it is a refusal here rather than
163
+ # a request into a stub that doesn't exist yet.
164
+ def wire_mode(config, graph)
165
+ return :router if config.supergraph?(graph)
166
+ return :in_process if config.schema_class?(graph)
167
+ return :fake if config.schema || graph&.named_schema?
168
+
169
+ raise GraphWeaver::Error, ":wire serves your schema at the endpoint your client posts " \
170
+ "to, and #{graph&.name ? "graph #{graph.name.inspect}" : "this app"} has none to " \
171
+ "serve — no live GraphQL::Schema class, no composed supergraph, and no type " \
172
+ "information (nothing at #{GraphWeaver.schema_path}, and " \
173
+ "GraphWeaver::Testing.config.schema is unset). Your client's own schema can't stand " \
174
+ "in here: reading it introspects the endpoint :wire has stubbed. Commit a dump " \
175
+ "(rake graph_weaver:schema:refresh URL=…), or tag the example graphql: :live."
176
+ end
177
+
178
+ # The graph a mode builds for when no module named one: this app's
179
+ # only graph. With several the honest answer varies per module, so
180
+ # there is no app-wide one and each module resolves its own.
181
+ def app_graph
182
+ graphs = GraphWeaver.graphs
183
+ graphs.first if graphs.one?
184
+ end
185
+
186
+ # What GraphWeaver.client holds while a mode is installed, or nil for
187
+ # the modes that leave the app's own there (:live, and :wire, which
188
+ # serves at each client's endpoint instead).
189
+ #
190
+ # One graph has one answer, so the app slot holds the same stand-in
191
+ # its modules resolve. With several there is none — and leaving the
192
+ # app's real client in the slot let a stray GraphWeaver.client.execute
193
+ # reach the production endpoint from an example whose tag promised no
194
+ # request, so the slot refuses by name instead.
195
+ def app_client
196
+ return if @mode.nil? || @mode == :live || @mode == :wire
197
+
198
+ graph = app_graph
199
+ graph ? standin(graph) : NoAppClient.new(@mode)
200
+ end
201
+
202
+ # The graphs a helper stands in for: the one `graph:` names, else the
203
+ # ones `schema` names, else this app's only graph. A helper that
204
+ # reaches no module is the silent pass this slot exists to stop, so
205
+ # nothing to reach is a refusal — and `advice` is what THIS helper
206
+ # does once it knows which graph.
207
+ #
208
+ # `graph:` leads because a graph's name is its identity everywhere
209
+ # else in the gem, and it is the only spelling that reaches every
210
+ # graph: `schema` is matched by object identity, which a graph whose
211
+ # schema is a dump has nothing to match with.
212
+ def targets!(helper, schema, advice, graph: nil)
213
+ return [graph!(helper, graph)] if graph
214
+
215
+ named = named_graphs(schema)
216
+ return named if named.any?
217
+
218
+ graphs = GraphWeaver.graphs
219
+ return graphs if graphs.one?
220
+
221
+ raise GraphWeaver::Error, "#{helper} stands in for the modules of one graph, and " \
222
+ "#{schema ? "#{schema} names none of this app's graphs" : "this app has #{graphs.size}"} " \
223
+ "(#{declared_names}) — say which: #{helper}(graph: #{graphs.first.name.inspect}). #{advice}"
224
+ end
225
+
226
+ private
227
+
228
+ # The graph `graph:` names. Its name, not its schema: that is what
229
+ # `rake graph_weaver:graphs` prints, what codegen bakes into a
230
+ # module's GRAPH, and the one handle a dump-backed graph has.
231
+ def graph!(helper, name)
232
+ found = GraphWeaver.graphs.find { |graph| graph.name == name }
233
+ return found if found
234
+
235
+ near = Util.did_you_mean(GraphWeaver.graphs.map { |graph| graph.name.to_s }, name.to_s)
236
+ raise GraphWeaver::Error, "#{helper}(graph: #{name.inspect}) names none of this app's " \
237
+ "graphs (#{declared_names})#{" — did you mean #{near.to_sym.inspect}?" if near}"
238
+ end
239
+
240
+ # The graphs `schema` names: a schema class is matched against what
241
+ # each graph runs in-process, which is the only thing that ties a
242
+ # class to a graph. A graph named by a dump can't be named this way,
243
+ # and correctly isn't.
244
+ def named_graphs(schema)
245
+ return [] unless schema
246
+
247
+ GraphWeaver.graphs.select { |graph| graph.live_schema.equal?(schema) }
248
+ end
249
+
250
+ def declared_names = GraphWeaver.graphs.map { |graph| graph.name.inspect }.join(", ")
251
+
252
+ # The graph `mod` was generated from, by the name codegen baked in.
253
+ # An app with several graphs and a module that names none was
254
+ # generated before its graph was declared, or by an older release —
255
+ # and guessing would fake one schema's shapes at another's module.
256
+ def graph_for!(mod)
257
+ graphs = GraphWeaver.graphs
258
+ return graphs.first if graphs.one?
259
+
260
+ name = mod.const_defined?(:GRAPH, false) ? mod.const_get(:GRAPH) : nil
261
+ found = graphs.find { |graph| graph.name == name }
262
+ return found if found
263
+
264
+ # Two doors produce a module, so the fix has two spellings: a file
265
+ # gets its GRAPH back by being regenerated, and a GraphWeaver.parse
266
+ # module — which generates no file, so regenerating cannot reach it
267
+ # — is told where it is parsed.
268
+ raise GraphWeaver::Error, "#{mod} doesn't say which of this app's graphs " \
269
+ "(#{declared_names}) it was generated from, so #{@mode.inspect} has nothing to run " \
270
+ "it against — regenerate it (rake graph_weaver:generate), or, if it came from " \
271
+ "GraphWeaver.parse, say which there (graph: #{graphs.first.name.inspect})."
272
+ end
273
+ end
274
+ end
275
+ end
276
+ end
@@ -0,0 +1,287 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "set"
5
+
6
+ require_relative "../internal"
7
+
8
+ module GraphWeaver
9
+ module Internal
10
+ # Which generated props no code in the app reads — the over-fetch that
11
+ # collects when a template stops using a field and nobody edits the
12
+ # .graphql. graphql-client catches it at runtime by masking the data it
13
+ # didn't declare; the structs are checked in here, so it can be recovered
14
+ # without running anything.
15
+ #
16
+ # Name-based on purpose. The generated structs say which props exist; one
17
+ # sweep of the app's own source says which names it mentions. That makes
18
+ # this a lint rather than a proof — #report says so in its own footer,
19
+ # because a finding is a prompt to look, never a verdict.
20
+ class Unused
21
+ # Where a prop shows up when it is READ: a method call, a pattern-match
22
+ # or hash key, a symbol, a string. A bare word in prose matches none of
23
+ # them, which is what keeps comments and locals out.
24
+ READ = /[.:"']([a-z_]\w*)|\b([a-z_]\w*):/
25
+ # Hand a struct to one of these and every prop is read at once, by a
26
+ # call that names none of them. Caught where the sink line carries the
27
+ # module's own name, or a local a line above assigned from it.
28
+ SINKS = /\b(?:to_h|to_json|as_json|serialize|deconstruct_keys)\b|render\s+json:/
29
+ # `result = PersonQuery.execute!(...)` — the local a response lands in.
30
+ # Following one is what lets the sink be on the NEXT line, which is how
31
+ # anyone actually writes a controller. Excludes == and =~.
32
+ ASSIGN = /\b([a-z_]\w*)\s*=[^=~]/
33
+ # A graphql-ruby TYPE class NAMES every field the server offers, as
34
+ # `field :sku` and as a resolver method — which is the server answering,
35
+ # not this app reading a prop back. Without this an app that serves the
36
+ # graph it consumes (graphql_in_process) marks every prop read, and the
37
+ # task reports nothing however much it over-fetches.
38
+ #
39
+ # Type kinds only: GraphQL::Schema::Resolver and ::Mutation hold
40
+ # application logic — in a BFF that is exactly where an upstream graph
41
+ # gets read — and skipping those files lost every read in them. Both
42
+ # spellings, because graphql-ruby's own generator emits the app-owned
43
+ # base class (`< Types::BaseObject`), not the gem's.
44
+ TYPE_KINDS = "Object|Interface|Union|Enum|Scalar|InputObject"
45
+ SCHEMA = /^[ \t]*(?:class \w+ < (?:GraphQL::Schema::|Types::Base)(?:#{TYPE_KINDS})\b|include GraphQL::Schema::Interface\b)/
46
+ # What the sweep can read. A prop read from anywhere else — a .vue, a
47
+ # .json.erb's sibling JS — is a blind spot, and the footer says so.
48
+ # .rake and .builder are Ruby too.
49
+ EXTENSIONS = %w[.rb .rake .builder .erb .slim .haml .jbuilder].freeze
50
+ # Directories that hold no app source. "generated" covers both a graph's
51
+ # own output under the convention and a spec/generated fixture dir; a
52
+ # graph that writes somewhere else is pruned by #outputs.
53
+ SKIP = Set["vendor", "node_modules", "tmp", "log", "generated"].freeze
54
+ # enough of the quoted line to judge it by, without wrapping a terminal
55
+ SNIPPET = 100
56
+
57
+ # Measured against real corpora (actionview, activesupport, graphql and
58
+ # six Rails gems swept together): half to two thirds of genuinely unread
59
+ # selections go unreported, rising with corpus size. Saying so is the
60
+ # difference between a lint and a number somebody trusts.
61
+ FOOTER = "This is a lint, not a proof — it matches prop names as text, so a common name reads " \
62
+ "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."
65
+
66
+ # query: the .graphql that selected it. struct/prop: where it landed.
67
+ # wire: how the query spells that prop, when it differs.
68
+ Selection = Struct.new(:query, :module_name, :struct, :prop, :wire) do
69
+ # The GraphQL-side name, which is what you go and delete: the struct's
70
+ # own name is the response key, so `Person.birthday` reads the way the
71
+ # query does — and a camelCase field, an alias or a reserved rename
72
+ # reads the way the query spells it, not the way the prop does.
73
+ def coordinate = "#{struct.name.split("::").last}.#{wire || prop}"
74
+
75
+ # …and the Ruby side, so the report is greppable both ways.
76
+ def constant = "#{struct.name}##{prop}"
77
+ end
78
+
79
+ # Why a module's props were all counted read, and on what evidence. Via
80
+ # is the local the value was standing in when it reached the serializer,
81
+ # nil when the sink line named the module itself.
82
+ Excuse = Struct.new(:path, :number, :source, :via) do
83
+ def reason
84
+ where = "handed whole to a serializer at #{path}:#{number}"
85
+ via ? "#{where}, as `#{via}`" : where
86
+ end
87
+ end
88
+
89
+ # What one pass over the files answers.
90
+ Sweep = Struct.new(:names, :wholly_used, :files)
91
+
92
+ # paths: the directories to sweep, defaulting to the whole root. Narrowed
93
+ # here rather than by the caller, so an empty PATHS= sweeps everything
94
+ # instead of nothing — nothing would report every prop unread.
95
+ def initialize(graphs: GraphWeaver.graphs, paths: nil)
96
+ @graphs = graphs
97
+ given = Array(paths).map { |path| path.to_s.strip }.reject(&:empty?)
98
+ @roots = (given.empty? ? ["."] : given).map { |path| Util.resolve(path) }
99
+ # A root that isn't there sweeps nothing, and sweeping nothing reports
100
+ # every prop unread — under STRICT, a red build demanding you delete
101
+ # fields you use. `0 files swept` was the only tell, printed beneath
102
+ # the accusations.
103
+ missing = @roots.reject { |root| Dir.exist?(root) }
104
+ return if missing.empty?
105
+
106
+ raise GraphWeaver::Error,
107
+ "no directory at #{missing.map { |root| Util.relative(root) }.join(", ")} — " \
108
+ "PATHS= names directories under #{GraphWeaver.root}"
109
+ end
110
+
111
+ # Every selection nothing reads, grouped the way the report prints them.
112
+ def findings
113
+ @findings ||= selections
114
+ .reject { |selection| read?(selection) }
115
+ .sort_by { |selection| [Util.relative(selection.query), selection.coordinate] }
116
+ end
117
+
118
+ def report
119
+ # The evidence, not just the verdict: name-matching a serializer call
120
+ # is the mushiest thing here, and a suppression that was wrong should
121
+ # be obvious at a glance rather than silently eating the report.
122
+ lines = wholly_used.flat_map do |name, excuse|
123
+ ["#{name}: every prop counted as read — #{excuse.reason}", " #{excuse.source[0, SNIPPET]}"]
124
+ end
125
+ lines += findings.map do |selection|
126
+ "#{Util.relative(selection.query)}: #{selection.coordinate} — selected, never read " \
127
+ "(#{selection.constant})"
128
+ end
129
+ # a run that checked nothing would report "0 unread" whatever the
130
+ # queries said, which is worse than saying so
131
+ lines << nothing_loaded if selections.empty?
132
+ [*lines, "", summary, "", FOOTER].join("\n")
133
+ end
134
+
135
+ # What the summary counts, so the task can phrase its own STRICT abort.
136
+ def summary
137
+ "#{selections.size} selections, #{findings.size} unread — " \
138
+ "#{count(selections.map(&:query).uniq.size, "query", "queries")}, " \
139
+ "#{count(swept, "file", "files")} swept under #{where}"
140
+ end
141
+
142
+ def count(number, one, many) = "#{number} #{(number == 1) ? one : many}"
143
+
144
+ private
145
+
146
+ def read?(selection)
147
+ wholly_used.key?(selection.module_name) || names.include?(selection.prop.to_s)
148
+ end
149
+
150
+ def names = sweep.names
151
+ def wholly_used = sweep.wholly_used
152
+ def swept = sweep.files
153
+
154
+ # One pass over the files for all three answers — the names anything
155
+ # reads, the modules something serializes whole, and how many files that
156
+ # took. Per-prop searching is what makes a tool like this too slow to run.
157
+ def sweep
158
+ @sweep ||= begin
159
+ read = Set.new
160
+ whole = {}
161
+ short = selections.to_h { |selection| [selection.module_name, selection.module_name.split("::").last] }
162
+ files.each do |path|
163
+ # scrub: a stray non-UTF-8 byte in a template is not a reason to
164
+ # refuse to lint the other 500 files
165
+ body = File.read(path).scrub
166
+ next if SCHEMA.match?(body)
167
+
168
+ body.scan(READ) { |method, key| read << (method || key) }
169
+ next unless SINKS.match?(body)
170
+
171
+ # Both substring checks before walking the lines: `to_h` is in
172
+ # most files and a query module's name is in almost none, so this
173
+ # is what keeps the line pass off the other 95%.
174
+ candidates = short.reject { |name, base| whole.key?(name) || !body.include?(base) }
175
+ next if candidates.empty?
176
+
177
+ # ONE line has to carry the module — itself, or a local a line
178
+ # above assigned from it. Anywhere-in-the-file was the first cut
179
+ # and it suppressed this gem's whole report: a doc comment naming
180
+ # PersonQuery three hundred lines above an unrelated to_h counted
181
+ # as serializing it. Following the local is what the line rule
182
+ # missed, and it is the shape every Rails controller has:
183
+ # `result = Q.execute!(...)`, then `render json: result.person`.
184
+ locals = Hash.new { |hash, key| hash[key] = [] }
185
+ body.each_line.with_index(1) do |line, number|
186
+ candidates.each do |name, base|
187
+ locals[name] << Regexp.last_match(1) if line.include?(base) && ASSIGN.match(line)
188
+ end
189
+ next unless SINKS.match?(line)
190
+
191
+ candidates.each do |name, base|
192
+ # the line naming the module is the better evidence; the local
193
+ # is what it falls back to
194
+ via = locals[name].find { |local| line.match?(/\b#{Regexp.escape(local)}\b/) } \
195
+ unless line.include?(base)
196
+ next unless via || line.include?(base)
197
+
198
+ whole[name] ||= Excuse.new(Util.relative(path), number, line.strip, via)
199
+ end
200
+ end
201
+ end
202
+ Sweep.new(read, whole, files.size)
203
+ end
204
+ end
205
+
206
+ # Every generated prop, per query file. Query-driven like everything
207
+ # else: a struct exists because a query selected it.
208
+ def selections
209
+ @selections ||= @graphs.flat_map do |graph|
210
+ Util.query_files(graph.queries).flat_map do |path|
211
+ source = File.read(path)
212
+ name = graph.generated_names(path, source).first
213
+ next [] unless Object.const_defined?(name)
214
+
215
+ result = Object.const_get(name)
216
+ next [] unless result.const_defined?(:Result, false)
217
+
218
+ words = source.scan(/[A-Za-z_]\w*/).uniq
219
+ props(result.const_get(:Result, false))
220
+ .map { |struct, prop| Selection.new(path, name, struct, prop, wire_word(words, prop)) }
221
+ end
222
+ end
223
+ end
224
+
225
+ # How the query spells a prop, when that isn't the prop's own name — a
226
+ # camelCase field, an alias, a reserved rename. Read back off the query
227
+ # text rather than derived from the prop, since no rule inverts an
228
+ # alias; nil when the query spells it the same way, which is most of
229
+ # the time.
230
+ def wire_word(words, prop)
231
+ words.find { |word| word != prop.to_s && GraphWeaver::Codegen.prop_name(word) == prop.to_s }
232
+ end
233
+
234
+ # Nested structs are nested constants, so the props of a whole response
235
+ # are one walk down. Reported flat: a parent nothing reads makes its
236
+ # children unread too, and saying both is the honest count.
237
+ def props(struct, found = [])
238
+ struct.props.each_key { |prop| found << [struct, prop] }
239
+ struct.constants(false).each do |const|
240
+ nested = struct.const_get(const, false)
241
+ props(nested, found) if nested.is_a?(Class) && nested < T::Struct
242
+ end
243
+ found
244
+ end
245
+
246
+ def files
247
+ @files ||= @roots.flat_map { |root| collect(root, []) }.uniq.sort
248
+ end
249
+
250
+ # 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)
253
+ Dir.children(dir).sort.each do |entry|
254
+ path = File.join(dir, entry)
255
+ # lstat, so a symlinked directory can't loop the walk
256
+ stat = File.lstat(path)
257
+ if stat.directory?
258
+ collect(path, found) unless skip_dir?(entry, path)
259
+ elsif stat.file? && EXTENSIONS.include?(File.extname(entry))
260
+ found << path
261
+ end
262
+ end
263
+ found
264
+ rescue SystemCallError
265
+ found
266
+ end
267
+
268
+ def skip_dir?(entry, path) = entry.start_with?(".") || SKIP.include?(entry) || outputs.include?(path)
269
+
270
+ # The generated directories a name check can't catch: a graph that sets
271
+ # `output` somewhere of its own.
272
+ def outputs
273
+ @outputs ||= (Util.generated_dirs + @graphs.map(&:output))
274
+ .flat_map { |pattern| Dir.glob(Util.resolve(pattern), File::FNM_PATHNAME) }
275
+ .to_set
276
+ end
277
+
278
+ def where
279
+ @roots.map { |root| (root == GraphWeaver.root) ? "." : Util.relative(root) }.join(", ")
280
+ end
281
+
282
+ def nothing_loaded
283
+ "nothing to check: no generated module is loaded for any query here (rake graph_weaver:generate)"
284
+ end
285
+ end
286
+ end
287
+ end
@@ -50,6 +50,10 @@ class GraphWeaver::Internal::Values
50
50
  # What Codegen.scalar reports for a scalar nobody registered
51
51
  UNREGISTERED = "T.untyped"
52
52
 
53
+ # What JSON can hold. Anything else a pin offers is a Ruby object the
54
+ # registration has to serialize before it can stand in for a response.
55
+ WIRE = [NilClass, TrueClass, FalseClass, Numeric, String, Symbol, Array, Hash].freeze
56
+
53
57
  # The fallback, for a scalar nobody registered: its prop is T.untyped, so
54
58
  # anything holds and a plausible shape beats a placeholder.
55
59
  NAMED_SHAPES = {
@@ -71,7 +75,13 @@ class GraphWeaver::Internal::Values
71
75
  # pin is resolved against the query, which is the fake's job. Left unsaid
72
76
  # they are the suite's, so a scalar only the app can write for is
73
77
  # fabricable from the cassette anonymizer too.
74
- def initialize(seed: nil, values: nil, pins: nil)
78
+ # schema: which server is being faked. registry: the registrations the
79
+ # values have to satisfy — a Money registered for one graph is not a Money
80
+ # for the next, and only a caller holding the graph can say which. Left
81
+ # unsaid they are read back off the schema, which is the answer for every
82
+ # app with one graph running a live class.
83
+ def initialize(seed: nil, values: nil, pins: nil, schema: nil, registry: nil)
84
+ @registry = registry || GraphWeaver::Internal::Util.registry_for(schema)
75
85
  @rng = Random.new(seed || GraphWeaver::Testing.config.seed || Random.new_seed)
76
86
  @pins = (pins || GraphWeaver::Testing.config.overrides).transform_keys(&:to_s)
77
87
  @style = resolve_style(values)
@@ -86,7 +96,9 @@ class GraphWeaver::Internal::Values
86
96
  # alone. at: where the walk is ("reader.orders.0.total"), for that refusal;
87
97
  # a walk that doesn't track one leaves it unsaid.
88
98
  def scalar(type_name, field_name, coordinate = nil, at: nil)
89
- return GraphWeaver::Internal::Overrides.resolve(@pins[type_name], rng) if @pins.key?(type_name)
99
+ if @pins.key?(type_name)
100
+ return wire(type_name, GraphWeaver::Internal::Overrides.resolve(@pins[type_name], rng), coordinate)
101
+ end
90
102
 
91
103
  registered, shape = resolve(type_name, coordinate)
92
104
  prop = underscore(field_name)
@@ -117,12 +129,31 @@ class GraphWeaver::Internal::Values
117
129
  # the plain-notation string BigDecimal() reads and #to_s("F") writes
118
130
  when :decimal then format("%.2f", @rng.rand(0.0..10_000.0))
119
131
  when :date then (Date.new(2020, 1, 1) + @rng.rand(0..2_000)).iso8601
120
- when :time then Time.at(1_600_000_000 + @rng.rand(0..100_000_000)).utc.iso8601
132
+ # a fraction a quarter of the time: a JS/Apollo server writes milliseconds
133
+ # on every timestamp, and whole seconds alone can't show a lossy round trip
134
+ when :time then fake_time.then { |t| t.iso8601(t.subsec.zero? ? 0 : 3) }
121
135
  when :unregistered then "#{type_name}-#{@sequence += 1}" # nobody registered it: prop is T.untyped
122
136
  else unfakeable!(type_name, field_name, registered, coordinate, at)
123
137
  end
124
138
  end
125
139
 
140
+ # What the wire would carry for a pinned scalar. A pin may be written as the
141
+ # Ruby object an app reads back — a Time, a Money — and the registration
142
+ # says what the server sends for one; a value JSON can already hold stands
143
+ # as written. Shared with the object-pin door, so both read a pin the same
144
+ # way.
145
+ def wire(type_name, value, coordinate = nil)
146
+ return value if WIRE.any? { |klass| value.is_a?(klass) }
147
+
148
+ serialized = @registry.scalar(type_name, coordinate).serialize_value(value)
149
+ return serialized if WIRE.any? { |klass| serialized.is_a?(klass) }
150
+
151
+ article = GraphWeaver::Internal::Util.article(value.class.to_s)
152
+ raise GraphWeaver::Error, "the pin for #{type_name.inspect} is #{article} #{value.class}, and a pin " \
153
+ "is what the wire carries — register_scalar(#{type_name.inspect}) has no serialize: that can run " \
154
+ "against a value (a Proc builds source), so write the pin as the value the server would send"
155
+ end
156
+
126
157
  # same original id => same fake id, so relationships survive anonymization
127
158
  def mapped_id(original)
128
159
  @id_map[original] ||= (@sequence += 1).to_s
@@ -130,11 +161,16 @@ class GraphWeaver::Internal::Values
130
161
 
131
162
  private
132
163
 
164
+ def fake_time
165
+ usec = @rng.rand(0..3).zero? ? @rng.rand(1..999) * 1_000 : 0
166
+ Time.at(1_600_000_000 + @rng.rand(0..100_000_000), usec).utc
167
+ end
168
+
133
169
  # The registration in play and the shape it wants, memoized per scalar (or
134
170
  # per coordinate, where a field-level registration overrides it).
135
171
  def resolve(type_name, coordinate)
136
172
  @resolved[coordinate || type_name] ||= begin
137
- registered = GraphWeaver::Codegen.scalar(type_name, coordinate)
173
+ registered = @registry.scalar(type_name, coordinate)
138
174
  [registered, shape_of(type_name, registered.type)]
139
175
  end
140
176
  end