graph_weaver 0.6.1 → 0.7.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +8 -0
  3. data/Gemfile.lock +153 -4
  4. data/README.md +45 -79
  5. data/docs/alternatives.md +195 -0
  6. data/docs/cassettes.md +61 -50
  7. data/docs/editors.md +32 -47
  8. data/docs/errors.md +360 -103
  9. data/docs/federation.md +692 -473
  10. data/docs/generated_modules.md +441 -314
  11. data/docs/getting_started.md +370 -194
  12. data/docs/i18n.md +171 -0
  13. data/docs/logging.md +197 -50
  14. data/docs/real_world.md +42 -27
  15. data/docs/scalars.md +307 -176
  16. data/docs/testing.md +473 -220
  17. data/docs/transports.md +224 -151
  18. data/docs/upgrading.md +258 -305
  19. data/examples/README.md +38 -0
  20. data/examples/countries.rb +39 -0
  21. data/examples/federation.rb +62 -0
  22. data/examples/github/generate.rb +20 -0
  23. data/examples/github/generated/star_mutation.rb +126 -0
  24. data/examples/github/generated/stargazers_query.rb +232 -0
  25. data/examples/github/generated/starred_query.rb +151 -0
  26. data/examples/github/queries/star.graphql +8 -0
  27. data/examples/github/queries/stargazers.graphql +22 -0
  28. data/examples/github/queries/starred.graphql +11 -0
  29. data/examples/github/run.rb +43 -0
  30. data/examples/github/setup.rb +18 -0
  31. data/examples/rick_and_morty.rb +57 -0
  32. data/graph_weaver.gemspec +19 -3
  33. data/lib/generators/graph_weaver/install_generator.rb +138 -4
  34. data/lib/graph_weaver/client.rb +69 -11
  35. data/lib/graph_weaver/codegen/aliases.rb +7 -5
  36. data/lib/graph_weaver/codegen/emit.rb +98 -29
  37. data/lib/graph_weaver/codegen/enum_type.rb +2 -1
  38. data/lib/graph_weaver/codegen/nodes.rb +39 -6
  39. data/lib/graph_weaver/codegen/registry.rb +175 -0
  40. data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
  41. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  42. data/lib/graph_weaver/codegen.rb +406 -195
  43. data/lib/graph_weaver/coerce.rb +155 -26
  44. data/lib/graph_weaver/context_seam.rb +54 -0
  45. data/lib/graph_weaver/errors.rb +284 -46
  46. data/lib/graph_weaver/federation.rb +129 -27
  47. data/lib/graph_weaver/graph.rb +315 -0
  48. data/lib/graph_weaver/hints.rb +100 -24
  49. data/lib/graph_weaver/in_process.rb +27 -15
  50. data/lib/graph_weaver/input_struct.rb +119 -32
  51. data/lib/graph_weaver/internal/endpoint.rb +80 -0
  52. data/lib/graph_weaver/internal/headers.rb +70 -0
  53. data/lib/graph_weaver/internal/overrides.rb +67 -5
  54. data/lib/graph_weaver/internal/planner.rb +45 -15
  55. data/lib/graph_weaver/internal/refusal.rb +49 -0
  56. data/lib/graph_weaver/internal/schemas.rb +23 -9
  57. data/lib/graph_weaver/internal/selection.rb +34 -0
  58. data/lib/graph_weaver/internal/server_input.rb +251 -0
  59. data/lib/graph_weaver/internal/test_clients.rb +276 -0
  60. data/lib/graph_weaver/internal/unused.rb +287 -0
  61. data/lib/graph_weaver/internal/values.rb +40 -4
  62. data/lib/graph_weaver/internal.rb +249 -14
  63. data/lib/graph_weaver/log_subscriber.rb +74 -0
  64. data/lib/graph_weaver/logging.rb +163 -19
  65. data/lib/graph_weaver/query_module.rb +44 -3
  66. data/lib/graph_weaver/railtie.rb +237 -17
  67. data/lib/graph_weaver/representation.rb +55 -17
  68. data/lib/graph_weaver/result_struct.rb +90 -0
  69. data/lib/graph_weaver/retry.rb +45 -13
  70. data/lib/graph_weaver/rspec.rb +404 -93
  71. data/lib/graph_weaver/schema_loader.rb +266 -56
  72. data/lib/graph_weaver/tasks.rb +380 -89
  73. data/lib/graph_weaver/testing/cassette.rb +34 -10
  74. data/lib/graph_weaver/testing/endpoint.rb +107 -0
  75. data/lib/graph_weaver/testing/failure.rb +69 -12
  76. data/lib/graph_weaver/testing/fake_client.rb +164 -45
  77. data/lib/graph_weaver/testing/router.rb +64 -13
  78. data/lib/graph_weaver/testing.rb +200 -58
  79. data/lib/graph_weaver/transport/faraday.rb +41 -8
  80. data/lib/graph_weaver/transport/http.rb +48 -6
  81. data/lib/graph_weaver/transport.rb +134 -27
  82. data/lib/graph_weaver/version.rb +1 -1
  83. data/lib/graph_weaver.rb +495 -106
  84. metadata +71 -3
  85. data/CHANGELOG.md +0 -2355
@@ -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