holdfast 0.1.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.
@@ -0,0 +1,1506 @@
1
+ # Drafts sequence definitions for a Rails app: an action
2
+ # per write route, Grape endpoint and job, a pool per model they take, a world
3
+ # built from the factories, the fakes the specs install, and each fake's
4
+ # failure modes. A short random walk (the probe) records which tables each
5
+ # action writes and which fakes it calls; areas are grouped by it.
6
+ #
7
+ # The hunt stage runs it from the app root with the app's bundle:
8
+ # ruby propose.rb --all [--force] one file per write area in SEQUENCES_OUT, plus invariants.rb
9
+ # ruby propose.rb AREA [AREA...] one draft on stdout; an AREA is a regexp over paths and job names
10
+ ENV["RAILS_ENV"] = "test" # the probe needs the test database and the factories
11
+ $LOAD_PATH.unshift(File.expand_path("spec", Dir.pwd))
12
+ require "rspec/core"
13
+ require "rails_helper"
14
+ require_relative "sequences"
15
+ require "prism"
16
+
17
+ module Propose
18
+ MAX_ACTIONS = 10
19
+ FAILURE_NAME = /fail|time_?out|declin|refus|reject|unavailable|error/i
20
+
21
+ Draft = Data.define(:name, :params, :body, :kind, :resource, :file)
22
+ Failure = Data.define(:name, :code, :service)
23
+
24
+ module_function
25
+
26
+ # The model a param or path word names: `loan_id` is a Loan,
27
+ # `pickup_branch_id` a Branch (by a belongs_to of that name), `request_id`
28
+ # an InterlibraryRequest (the one model whose name ends in Request).
29
+ def model_for(word)
30
+ stem = word.to_s.sub(/_id\z/, "")
31
+ found = stem.classify.safe_constantize
32
+ return found if found.is_a?(Class) && found < ActiveRecord::Base
33
+
34
+ by_assoc = models.flat_map { it.reflect_on_all_associations(:belongs_to) }.find { it.name.to_s == stem && !it.polymorphic? }
35
+ return by_assoc.klass if by_assoc
36
+
37
+ suffix = models.select { it.name.underscore.end_with?("_#{stem}") }
38
+ suffix.first if suffix.one?
39
+ end
40
+
41
+ def pool_name(model) = model.model_name.plural.to_sym
42
+
43
+ # A string param named like an identifying column (a code, a barcode, an
44
+ # ISBN) reads it from a record the world has: `barcode` from a Copy.
45
+ # A status or a reason is free text instead: copying one from an unrelated
46
+ # table draws from an empty pool.
47
+ IDENTIFIER = /code|barcode|isbn|token|email|ref\z|reference\z/
48
+ def column_source(key, models)
49
+ # A param named like a record (`branch`, `pickup_branch`) sends its identifying column.
50
+ if (model = model_for(key)) && (column = model.columns.find { it.type == :string && it.name.match?(IDENTIFIER) })
51
+ return [model, column.name]
52
+ end
53
+ # `carnet_code` sends a carnet's code.
54
+ if (m = key.to_s.match(/\A(\w+?)_(\w+)\z/)) && m[2].match?(IDENTIFIER) && (model = model_for(m[1])) && model.columns_hash[m[2]]&.type == :string
55
+ return [model, m[2]]
56
+ end
57
+ return unless key.to_s.match?(IDENTIFIER)
58
+
59
+ seeded = models.sort_by { FactoryBot.factories.registered?(it.model_name.singular) ? 0 : 1 }
60
+ seeded.each { |m| return [m, key.to_s] if m.column_names.include?(key.to_s) && m.columns_hash[key.to_s].type == :string }
61
+ nil
62
+ end
63
+
64
+ # Guessed draws for an untyped numeric param, marked in the draft.
65
+ PERCENTS = [0, 50, 100, 101, -1].freeze
66
+ COUNTS = { /minutes|duration/ => [0, 15, 30, 60, 120, 240, 600], /size|count|attendees|seats|quantity|number|days/ => [0, 1, 2, 4, 12, -1],
67
+ /amount|price|total|fee|cost/ => ["0", "1", "12.50", "100", "-5", "0.01"] }.freeze
68
+
69
+ def models
70
+ @models ||= ActiveRecord::Base.descendants.reject { it.abstract_class? || it.name.nil? }.select(&:table_exists?)
71
+ end
72
+
73
+ # What a controller action reads from params: key => [model, attribute],
74
+ # [:date], [:nested, keys] for `require(:k).permit(...)`, [:upload], or [:value].
75
+ def controller_params(controller, action)
76
+ file = controller.instance_method(action).source_location&.first or return {}
77
+ found = {}
78
+ nest = lambda do |key, attrs|
79
+ found[key] = [:nested, ((found[key]&.first == :nested ? found[key].last : []) + attrs).uniq]
80
+ end
81
+ walk = lambda do |node, stack|
82
+ # Rails 8's `params.expect(membership: [:plan_id])` and
83
+ # `params.dig(:membership, :starts_on)` read a nested form too.
84
+ if node.is_a?(Prism::CallNode) && node.receiver.is_a?(Prism::CallNode) && node.receiver.name == :params && node.name == :expect
85
+ Array(node.arguments&.arguments).grep(Prism::KeywordHashNode).flat_map(&:elements).each do |pair|
86
+ next unless pair.key.is_a?(Prism::SymbolNode) && pair.value.is_a?(Prism::ArrayNode)
87
+
88
+ nest.(pair.key.unescaped.to_sym, pair.value.elements.grep(Prism::SymbolNode).map { it.unescaped.to_sym })
89
+ end
90
+ elsif node.is_a?(Prism::CallNode) && node.receiver.is_a?(Prism::CallNode) && node.receiver.name == :params && node.name == :dig &&
91
+ (keys = Array(node.arguments&.arguments)).size == 2 && keys.all?(Prism::SymbolNode)
92
+ nest.(keys.first.unescaped.to_sym, [keys.last.unescaped.to_sym])
93
+ elsif node.is_a?(Prism::CallNode) && node.receiver.is_a?(Prism::CallNode) && node.receiver.name == :params &&
94
+ %i[require [] fetch].include?(node.name) && node.arguments&.arguments&.first.is_a?(Prism::SymbolNode)
95
+ key = node.arguments.arguments.first.unescaped.to_sym
96
+ at = stack.rindex { it.is_a?(Prism::CallNode) && %i[find find_by find_by!].include?(it.name) && it.receiver.is_a?(Prism::ConstantReadNode) }
97
+ pair = at && stack[at..].reverse.find { it.is_a?(Prism::AssocNode) }
98
+ model = at && stack[at].receiver.name.to_s.safe_constantize
99
+ dated = stack.last.is_a?(Prism::CallNode) && %i[to_date to_time].include?(stack.last.name)
100
+ outer = stack.last if stack.last.is_a?(Prism::CallNode) && stack.last.receiver.equal?(node)
101
+ # `params.require(:tickets).map { it.permit(:kind, ...) }` is a list of forms.
102
+ each_permit = if %i[map each filter_map flat_map].include?(outer&.name) && outer.block
103
+ found_permit = nil
104
+ todo = [outer.block]
105
+ while (n = todo.pop)
106
+ found_permit = n if n.is_a?(Prism::CallNode) && n.name == :permit && n.receiver
107
+ todo.concat(n.compact_child_nodes)
108
+ end
109
+ found_permit
110
+ end
111
+ found[key] ||= if outer&.name == :permit
112
+ [:nested, outer.arguments.arguments.grep(Prism::SymbolNode).map { it.unescaped.to_sym }]
113
+ elsif each_permit
114
+ [:nested_list, Array(each_permit.arguments&.arguments).grep(Prism::SymbolNode).map { it.unescaped.to_sym }]
115
+ elsif %i[read original_filename path].include?(outer&.name) then [:upload]
116
+ elsif model then [model, pair ? pair.key.unescaped.to_sym : :id]
117
+ elsif dated then [:date]
118
+ elsif model_for(key) then [model_for(key), :id]
119
+ else [:value]
120
+ end
121
+ end
122
+ node.compact_child_nodes.each { walk.(it, stack + [node]) }
123
+ end
124
+ tree = Prism.parse_file(file).value
125
+ defn = tree.breadth_first_search { it.is_a?(Prism::DefNode) && it.name == action.to_sym }
126
+ defn&.then { walk.(it, []) }
127
+ # The usual `member_params` helper holds the form's keys, so a method of
128
+ # the same file the action calls with no receiver is read too.
129
+ if defn
130
+ called = []
131
+ stack = [defn]
132
+ while (n = stack.pop)
133
+ called << n.name if n.is_a?(Prism::CallNode) && n.receiver.nil? && n.arguments.nil?
134
+ stack.concat(n.compact_child_nodes)
135
+ end
136
+ called.uniq.each do |name|
137
+ next if name == action.to_sym
138
+
139
+ tree.breadth_first_search { it.is_a?(Prism::DefNode) && it.name == name && it.receiver.nil? }&.then { walk.(it, []) }
140
+ end
141
+ end
142
+ # `file = params[:file]` and then `file.read`: the local is the upload.
143
+ if defn
144
+ locals = {}
145
+ seen = []
146
+ stack = [defn]
147
+ while (n = stack.pop)
148
+ seen << n
149
+ stack.concat(n.compact_child_nodes)
150
+ end
151
+ seen.grep(Prism::LocalVariableWriteNode).each do |w|
152
+ v = w.value
153
+ next unless v.is_a?(Prism::CallNode) && v.receiver.is_a?(Prism::CallNode) && v.receiver.name == :params && v.arguments&.arguments&.first.is_a?(Prism::SymbolNode)
154
+
155
+ locals[w.name] = v.arguments.arguments.first.unescaped.to_sym
156
+ end
157
+ seen.grep(Prism::CallNode).each do |c|
158
+ next unless %i[read original_filename path].include?(c.name) && c.receiver.is_a?(Prism::LocalVariableReadNode) && (key = locals[c.receiver.name])
159
+
160
+ found[key] = [:upload]
161
+ end
162
+ end
163
+ found
164
+ end
165
+
166
+ # Path segments become pool records: `/loans/:id/renew` takes a Loan. In a
167
+ # nested path the parent is read from the child (`closure.branch_id`), so
168
+ # the two always match.
169
+ def path_params(path, params, lookups: {})
170
+ resource = path.split("/").reject { it.empty? || it.start_with?(":") }
171
+ segments = path.scan(/:(\w+)/).flatten.map do |key|
172
+ key = key.to_sym
173
+ [key, key == :id ? model_for(resource.reverse.find { model_for(it) }) : model_for(key)]
174
+ end
175
+ path.gsub(/:(\w+)/) do
176
+ key = $1.to_sym
177
+ if (choices = lookups[key])
178
+ # One file finds `:id` as a patient under /patients and as an
179
+ # appointment under /appointments: the segment before the param says which.
180
+ before = path[%r{([a-z_]+)/:#{key}\b}, 1]
181
+ found = choices.find { it[0].table_name == before || pool_name(it[0]).to_s == before } || choices.first
182
+ var = found[0].model_name.singular
183
+ params[var.to_sym] = found[2] || pool_name(found[0])
184
+ next "\#{#{var}.#{found[1]}}"
185
+ end
186
+ model = segments.assoc(key)[1]
187
+ child = segments.map(&:last).compact.find do |m|
188
+ m != model && model && m.reflect_on_all_associations(:belongs_to).any? { !it.polymorphic? && it.klass == model }
189
+ end
190
+ if child
191
+ fk = child.reflect_on_all_associations(:belongs_to).find { it.klass == model }.foreign_key
192
+ "\#{#{child.model_name.singular}.#{fk}}"
193
+ elsif model
194
+ var = model.model_name.singular
195
+ params[var.to_sym] = pool_name(model)
196
+ "\#{#{var}.#{lookup_columns[[model.name, key]] || "id"}}"
197
+ elsif (source = column_source(key, models))
198
+ var = source[0].model_name.singular
199
+ params[var.to_sym] = pool_name(source[0])
200
+ "\#{#{var}.#{source[1]}}"
201
+ else
202
+ params[key] = :TODO
203
+ "\#{#{key}}"
204
+ end
205
+ end
206
+ end
207
+
208
+ # A controller that finds a path's record by another column
209
+ # (`Location.find_by!(code: params[:location_id])`) answers 404 to an id.
210
+ # Keyed by model and param, from every controller.
211
+ def lookup_columns
212
+ @lookup_columns ||= Dir[Rails.root.join("app/controllers/**/*.rb")].each_with_object({}) do |file, found|
213
+ Prism.parse_file(file).value.breadth_first_search do |c|
214
+ next false unless c.is_a?(Prism::CallNode) && %i[find_by find_by!].include?(c.name) && c.receiver.is_a?(Prism::ConstantReadNode)
215
+
216
+ c.arguments&.arguments&.grep(Prism::KeywordHashNode)&.flat_map(&:elements)&.each do |pair|
217
+ v = pair.value
218
+ next unless pair.key.is_a?(Prism::SymbolNode) && v.is_a?(Prism::CallNode) && v.name == :[] &&
219
+ v.receiver.is_a?(Prism::CallNode) && v.receiver.name == :params && v.arguments&.arguments&.first.is_a?(Prism::SymbolNode)
220
+
221
+ found[[c.receiver.name.to_s, v.arguments.arguments.first.unescaped.to_sym]] ||= pair.key.unescaped
222
+ end
223
+ false
224
+ end
225
+ end
226
+ end
227
+
228
+ # A body param a controller finds a record by
229
+ # (`Member.find_by!(email: params.expect(guest: [:host_email])[:host_email])`)
230
+ # is drawn from that record: free text names no member.
231
+ def param_lookups
232
+ @param_lookups ||= Dir[Rails.root.join("app/{controllers,api}/**/*.rb")].each_with_object({}) do |file, found|
233
+ Prism.parse_file(file).value.breadth_first_search do |c|
234
+ next false unless c.is_a?(Prism::CallNode) && %i[find_by find_by!].include?(c.name)
235
+
236
+ # A scope may stand between: `Service.online.find_by!(code: ...)`.
237
+ root = c.receiver
238
+ root = root.receiver while root.is_a?(Prism::CallNode) && root.receiver
239
+ next false unless root.is_a?(Prism::ConstantReadNode)
240
+
241
+ c.arguments&.arguments&.grep(Prism::KeywordHashNode)&.flat_map(&:elements)&.each do |pair|
242
+ next unless pair.key.is_a?(Prism::SymbolNode) && pair.value.slice.include?("params")
243
+
244
+ key = pair.value.slice.scan(/\[:(\w+)\]/).flatten.last or next
245
+ model = root.name.to_s.safe_constantize
246
+ found[key.to_sym] ||= [model, pair.key.unescaped] if model.is_a?(Class) && model < ActiveRecord::Base
247
+ end
248
+ false
249
+ end
250
+ end
251
+ end
252
+
253
+ # How an endpoint file finds a path's record: `Invoice.find(params[:id])`,
254
+ # `current_member.invoices.find_by!(number: params[:number])`. Grape names
255
+ # `/guests/:id` after the URL, not the model it finds.
256
+ def endpoint_lookups(file)
257
+ return {} unless file && File.file?(file)
258
+
259
+ text = File.read(file)
260
+ found = []
261
+ text.scan(/(?:(current_\w+)\.)?([A-Za-z_]\w*)(?:\([^)]*\))?(?:\.\w+)*?(?:\.where\([^)]*\))?\.find_by!?\((\w+):\s*params\[:(\w+)\]\)/) { |owner, word, col, key| found << [key, word, col, owner] }
262
+ text.scan(/(?:(current_\w+)\.)?([A-Za-z_]\w*)\.find\(params\[:(\w+)\]\)/) { |owner, word, key| found << [key, word, "id", owner] }
263
+ found.filter_map do |key, word, col, owner|
264
+ model = word.match?(/\A[A-Z]/) ? word.safe_constantize : model_for(word.singularize)
265
+ next unless model.is_a?(Class) && model < ActiveRecord::Base && model.column_names.include?(col)
266
+
267
+ # `current_patient.appointments.find(params[:id])` finds only the
268
+ # caller's own records: any other answers 404.
269
+ [key.to_sym, [model, col, (owned_pool(owner, word, model) if owner)]]
270
+ end.group_by(&:first).transform_values { it.map(&:last).uniq }
271
+ end
272
+
273
+ # A pool of the API caller's own records, named `<table>_of_caller`, with
274
+ # the expression that finds them.
275
+ def owned_pool(owner, assoc, model)
276
+ caller = model_for(owner.delete_prefix("current_")) or return
277
+ made = api_own_records.find { |_, line, _| line.start_with?("create(:#{factories.find { it.build_class == caller }&.name},") } or return
278
+ finder = "#{caller}.find_by(#{made[1][/(\w+: "#{made[2]}")\)\z/, 1]})"
279
+ name = :"#{pool_name(model)}_of_caller"
280
+ owned_pools[name] = "#{finder}.#{assoc}"
281
+ name
282
+ end
283
+
284
+ def owned_pools = @owned_pools ||= {}
285
+
286
+ # The table a pool draws from: `appointments_of_caller` draws appointments.
287
+ def pool_table(pool) = pool.to_s.delete_suffix("_of_caller")
288
+
289
+ # The values a model's `inclusion: { in: [...] }` accepts for a column of
290
+ # this name, when every model that validates it accepts the same list.
291
+ def inclusion_values(key)
292
+ lists = models.filter_map do |m|
293
+ v = m.validators_on(key.to_sym).grep(ActiveModel::Validations::InclusionValidator).first or next
294
+ list = v.options[:in] || v.options[:within]
295
+ list = list.to_a if list.is_a?(Range) && list.size.to_i <= 20
296
+ list if list.is_a?(Array) && !list.empty? && list.all? { it.is_a?(String) || it.is_a?(Integer) }
297
+ end.uniq
298
+ lists.first if lists.size == 1
299
+ end
300
+
301
+ # One body entry for a param: the value expression, with its spec added to
302
+ # +params+.
303
+ def value_for(key, type, params)
304
+ key = key.to_s
305
+ if !key.end_with?("_id") && (found = param_lookups[key.to_sym]) && found[1] != key
306
+ var = :"#{key}_from"
307
+ params[var] = pool_name(found[0])
308
+ "#{key}: #{var}.#{found[1]}"
309
+ elsif key.end_with?("_id") && (model = model_for(key))
310
+ var = model.model_name.singular.to_sym
311
+ var = key.delete_suffix("_id").to_sym if params.key?(var) && params[var] != pool_name(model)
312
+ params[var] = pool_name(model)
313
+ "#{key}: #{var}.id"
314
+ elsif type == "Date" || key.end_with?("_on") || %w[on date day].include?(key)
315
+ var = key.end_with?("_on") ? key : "#{key}_on"
316
+ params[var.to_sym] = :local_date
317
+ "#{key}: #{var}.iso8601"
318
+ elsif %w[Time DateTime].include?(type) || key.end_with?("_at")
319
+ var = key.end_with?("_at") ? key : "#{key}_at"
320
+ params[var.to_sym] = :local_time
321
+ "#{key}: #{var}.iso8601"
322
+ elsif type == "Integer"
323
+ params[key.to_sym] = 0..5
324
+ "#{key}:"
325
+ elsif %w[BigDecimal Float Numeric].include?(type) && COUNTS.none? { |pattern, _| key.match?(pattern) }
326
+ params[key.to_sym] = ["0", "1", "12.5", "-1"]
327
+ "#{key}:"
328
+ elsif key.end_with?("_ids") && (model = model_for(key.delete_suffix("_ids")))
329
+ # A list of ids sends one record's id: text there is refused or finds nothing.
330
+ var = model.model_name.singular.to_sym
331
+ params[var] = pool_name(model)
332
+ "#{key}: [#{var}.id]"
333
+ elsif type&.start_with?("Array") || type&.match?(/\A\[\w+\]\z/)
334
+ # `Array[String]` refuses a bare word and an empty list alike.
335
+ params[key.to_sym] = :text
336
+ "#{key}: [#{key}]"
337
+ elsif type == "Hash"
338
+ # A Hash with no declared keys refuses text; empty is the one safe draw.
339
+ "#{key}: {}"
340
+ elsif %w[Boolean Grape::API::Boolean].include?(type)
341
+ params[key.to_sym] = 0..1
342
+ "#{key}: #{key} == 1"
343
+ elsif (source = column_source(key, models))
344
+ var = :"#{key}_from"
345
+ params[var] = pool_name(source[0])
346
+ "#{key}: #{var}.#{source[1]}"
347
+ elsif (listed = inclusion_values(key))
348
+ params[key.to_sym] = listed + [listed.first.is_a?(String) ? "holdfast text" : -1]
349
+ "#{key}:"
350
+ elsif (counts = COUNTS.find { |pattern, _| key.match?(pattern) }&.last)
351
+ params[key.to_sym] = counts
352
+ "#{key}:"
353
+ else
354
+ params[key.to_sym] = :text
355
+ "#{key}:"
356
+ end
357
+ end
358
+
359
+ # A form field of a model's column is drawn by the column's type: text in
360
+ # an integer capacity or a time zone refused every draw. A string the
361
+ # model refuses as free text (a known time zone, a code format) is drawn
362
+ # from the factory's value, free text and blank.
363
+ COLUMN_TYPES = { integer: "Integer", boolean: "Boolean", date: "Date", datetime: "Time", decimal: "BigDecimal", float: "Float" }.freeze
364
+ def form_value(model, key, params)
365
+ col = model&.columns_hash&.[](key.to_s)
366
+ # A unique column drawn from records already there collides every time:
367
+ # the factory makes a new value per call.
368
+ factory = factories.find { it.build_class == model } if col
369
+ if factory && model.validators_on(key.to_sym).grep(ActiveRecord::Validations::UniquenessValidator).any? &&
370
+ (FactoryBot.attributes_for(factory.name)[key.to_sym] rescue nil).is_a?(String)
371
+ return "#{key}: FactoryBot.attributes_for(:#{factory.name})[:#{key}]"
372
+ end
373
+ foreign = model&.reflect_on_all_associations(:belongs_to)&.any? { it.foreign_key.to_s == key.to_s }
374
+ return value_for(key, nil, params) if foreign
375
+ # A percentage is drawn at and past its ends: 0..5 never asks whether
376
+ # 101 percent off is refused.
377
+ if col && %i[integer decimal float].include?(col.type) && key.to_s.include?("percent")
378
+ params[key.to_sym] = PERCENTS
379
+ return "#{key}:"
380
+ end
381
+ if col&.type == :integer && col.null
382
+ params[key.to_sym] = [nil, 0, 1, 2, 5]
383
+ return "#{key}:"
384
+ end
385
+ return value_for(key, COLUMN_TYPES[col&.type], params) unless col && %i[string text].include?(col.type) && !column_source(key.to_s, models)
386
+
387
+ fixed = factory_value(model, key.to_s) or return value_for(key, nil, params)
388
+ params[key.to_sym] = [fixed, "holdfast text", ""]
389
+ "#{key}:"
390
+ end
391
+
392
+ def factory_value(model, key)
393
+ factory = factories.find { it.build_class == model } or return nil
394
+ record = FactoryBot.build(factory.name)
395
+ value = record[key]
396
+ return nil unless value.is_a?(String) && record.valid?
397
+
398
+ record[key] = "holdfast text"
399
+ record.valid?
400
+ value if record.errors[key.to_sym].any?
401
+ rescue StandardError
402
+ nil
403
+ end
404
+
405
+ # An end time drawn apart from its start broke a duration rule at nearly
406
+ # every draw; draw it as minutes after the start instead.
407
+ DURATIONS = [30, 60, 90, 120, 180, 15, 0, -30, 240, 600].freeze
408
+ def pair_ends!(params)
409
+ params.keys.each do |key|
410
+ stem = key.to_s[/\A(.*?)(ends?|finish(?:es)?|until|to)_at\z/, 1] or next
411
+ start = params.keys.find { it.to_s.match?(/\A#{Regexp.escape(stem)}(starts?|begins?|from)_at\z/) } or next
412
+ params[key] = Sequences::After.new(from: start, minutes: DURATIONS) if params[start] == :local_time && params[key] == :local_time
413
+ end
414
+ params
415
+ end
416
+
417
+ def block_for(params, call)
418
+ params.empty? ? "{ #{call} }" : "{ |#{params.keys.map { "#{it}:" }.join(", ")}| #{call} }"
419
+ end
420
+
421
+ def route_drafts
422
+ Rails.application.routes.routes.filter_map do |route|
423
+ verb = route.verb
424
+ path = route.path.spec.to_s.sub("(.:format)", "")
425
+ next if verb.empty? || verb == "GET" || route.defaults[:controller].nil?
426
+
427
+ controller = "#{route.defaults[:controller].camelize}Controller".safe_constantize or next
428
+ action = route.defaults[:action]
429
+ params = {}
430
+ url = path_params(path, params)
431
+ segments = path.scan(/:(\w+)/).flatten.map(&:to_sym)
432
+ auth = controller_auth(controller)
433
+ body = controller_params(controller, action).filter_map do |key, (kind, attribute)|
434
+ next if segments.include?(key)
435
+ next "#{key}: ENV[#{auth[:params][key].inspect}]" if auth[:params][key]
436
+
437
+ if kind == :nested
438
+ form = model_for(key)
439
+ "#{key}: { #{attribute.map { form_value(form, it, params) }.join(", ")} }"
440
+ elsif kind == :nested_list
441
+ form = model_for(key)
442
+ "#{key}: [{ #{attribute.map { form_value(form, it, params) }.join(", ")} }]"
443
+ elsif kind == :upload
444
+ # Drawn text sent as a file, so the action runs past its upload.
445
+ params[key] = :text
446
+ %(#{key}: Rack::Test::UploadedFile.new(StringIO.new(#{key}.to_s), "text/plain", original_filename: "#{key}.txt"))
447
+ elsif kind.is_a?(Class)
448
+ var = kind.model_name.singular.to_sym
449
+ var = key.to_s.delete_suffix("_id").to_sym if params.key?(var)
450
+ params[var] = pool_name(kind)
451
+ "#{key}: #{var}.#{attribute}"
452
+ else
453
+ value_for(key, kind == :date ? "Date" : nil, params)
454
+ end
455
+ end
456
+ # The sign-in action itself sends the known credentials.
457
+ body = session_sign_in[:body] if session_sign_in && route.defaults[:controller] == session_sign_in[:controller] && action == session_sign_in[:action]
458
+ pair_ends!(params)
459
+ headers = auth[:headers].map { |h, expr| "#{h.inspect} => #{expr}" }
460
+ call = %(http.#{verb.downcase} "#{url}"#{", params: { #{body.join(", ")} }" unless body.empty?}#{", headers: { #{headers.join(", ")} }" unless headers.empty?})
461
+ call = "desk_sign_in; #{call}" if session_sign_in && (session_reader?(controller) && route.defaults[:controller] != session_sign_in[:controller])
462
+ Draft.new(name: :"#{route.defaults[:controller].tr("/", "_")}_#{action}", params:, body: block_for(params, call),
463
+ kind: :route, resource: route.defaults[:controller].tr("/", "_"), file: "#{controller.instance_method(action).source_location&.first}##{action}")
464
+ end
465
+ end
466
+
467
+ def api_drafts
468
+ return [] unless defined?(Grape::API)
469
+
470
+ Grape::API.descendants.select { it.respond_to?(:routes) }.flat_map { |api| api.routes.map { [api, it] } }.uniq { |_, r| [r.request_method, r.path] }.filter_map do |api, route|
471
+ next if route.request_method == "GET"
472
+
473
+ # An API the app mounts under a prefix (`mount DeskAPI => "/v1"`) answers
474
+ # only under it; Grape's own path leaves the prefix out.
475
+ path = grape_mounts.fetch(api, "") + route.path.sub("(.json)", "").sub(":version", route.version.to_s)
476
+ # A mounted API lists its routes once more without the root's prefix.
477
+ next if path.include?("(")
478
+
479
+ params = {}
480
+ url = path_params(path, params, lookups: endpoint_lookups(route.app.source&.source_location&.first))
481
+ # Grape lists a nested param as "lines[sku]" beside "lines"; the draft
482
+ # sends the parent as one Hash (an Array of one Hash when its type is
483
+ # Array), each child drawn under a name of its own.
484
+ top = route.params.reject { |k, _| path.include?(":#{k}") || k.to_s.include?("[") }
485
+ children = route.params.select { |k, _| k.to_s.include?("[") }.group_by { |k, _| k.to_s[/\A[^\[]+/] }
486
+ body = top.map do |key, spec|
487
+ kids = children.delete(key.to_s)
488
+ unless kids
489
+ expr = grape_value(key, spec, params)
490
+ next expr unless spec.is_a?(Hash) && spec[:required] == false
491
+
492
+ # An optional param is sent or left out: a code drawn from any
493
+ # record is refused whenever it is sent, and leaving it out is
494
+ # the call most clients make.
495
+ params[:"#{key}_sent"] = 0..1
496
+ value = expr.sub(/\A#{key}:\s*/, "")
497
+ next "**(#{key}_sent == 1 ? { #{key}: #{value.empty? ? key : value} } : {})"
498
+ end
499
+
500
+ inner = kids.filter_map do |k, s|
501
+ leaf = k.to_s[/\[([^\[\]]+)\]\z/, 1] or next
502
+ next if k.to_s.count("[") > 1
503
+ if s.is_a?(Hash) && s[:values].is_a?(Array)
504
+ next "#{leaf}: #{grape_value("#{key}_#{leaf}", s, params).sub(/\A#{key}_#{leaf}:\s*/, "").then { it.empty? ? "#{key}_#{leaf}" : it }}"
505
+ end
506
+
507
+ # A child that is a column of the parent's model is drawn as the
508
+ # desk form draws it: `kind` from the values the model accepts.
509
+ form = model_for(key.to_s)
510
+ if form&.columns_hash&.key?(leaf)
511
+ drawn = {}
512
+ expr = form_value(form, leaf, drawn).sub(/\A#{leaf}:\s*/, "")
513
+ if expr.empty?
514
+ params[:"#{key}_#{leaf}"] = drawn.delete(leaf.to_sym)
515
+ expr = "#{key}_#{leaf}"
516
+ end
517
+ params.merge!(drawn)
518
+ next "#{leaf}: #{expr}"
519
+ end
520
+ expr = value_for("#{key}_#{leaf}", s.is_a?(Hash) ? s[:type].to_s : nil, params).sub(/\A#{key}_#{leaf}:\s*/, "")
521
+ "#{leaf}: #{expr.empty? ? "#{key}_#{leaf}" : expr}"
522
+ end
523
+ hash = "{ #{inner.join(", ")} }"
524
+ "#{key}: #{spec.is_a?(Hash) && spec[:type].to_s.start_with?("Array") ? "[#{hash}]" : hash}"
525
+ end
526
+ children.each_key do |orphan|
527
+ params[orphan.to_sym] = :TODO
528
+ body << "#{orphan}:"
529
+ end
530
+ params[:caller] = pool_name(api_caller_model) if api_caller_model
531
+ pair_ends!(params)
532
+ rest = path.sub(%r{\A/api/v\d+/}, "")
533
+ name = :"api_#{route.request_method.downcase}_#{rest.gsub(/[^a-z]+/, "_").delete_suffix("_")}"
534
+ call = %(http.#{route.request_method.downcase} "#{url}"#{", params: { #{body.join(", ")} }" unless body.empty?}, headers: auth_for(#{"caller" if api_caller_model}))
535
+ Draft.new(name:, params:, body: block_for(params, call), kind: :api, resource: "api_#{rest.split("/").reject { it.start_with?(":") }.slice_after { model_for(it.singularize) }.first.join("_")}",
536
+ file: route.app.source&.source_location&.first)
537
+ end
538
+ end
539
+
540
+ # A Grape param with `values: [...]` draws each listed value and one it
541
+ # refuses; any other param is drawn by its type.
542
+ def grape_value(key, spec, params)
543
+ values = spec[:values] if spec.is_a?(Hash)
544
+ return value_for(key, spec.is_a?(Hash) ? spec[:type].to_s : nil, params) unless values.is_a?(Array) && values.all? { it.is_a?(String) || it.is_a?(Integer) }
545
+
546
+ params[key.to_sym] = values + [values.first.is_a?(String) ? "holdfast text" : -1]
547
+ "#{key}:"
548
+ end
549
+
550
+ def grape_mounts
551
+ @grape_mounts ||= Rails.application.routes.routes.each_with_object({}) do |r, out|
552
+ app = r.app.respond_to?(:app) ? r.app.app : r.app
553
+ next unless app.is_a?(Class) && app < Grape::API
554
+
555
+ out[app] = r.path.spec.to_s.sub(/\(.*\)\z/, "").delete_suffix("/")
556
+ end
557
+ end
558
+
559
+ def job_drafts
560
+ ActiveJob::Base.descendants.filter_map do |job|
561
+ next if job.name.nil? || job.name == "ApplicationJob"
562
+ # Rails' own jobs (ActionMailer::MailDeliveryJob, Active Storage's)
563
+ # take framework arguments no pool can draw.
564
+ next unless job.instance_method(:perform).source_location&.first.to_s.start_with?(Rails.root.join("app").to_s)
565
+
566
+ required = job.instance_method(:perform).parameters.select { it.first == :req }.map(&:last)
567
+ params = {}
568
+ args = required.map do |arg|
569
+ if arg.to_s.match?(/\A(on|date|day)\z|_(on|date)\z/)
570
+ params[arg] = :local_date
571
+ next arg.to_s
572
+ elsif arg.to_s.match?(/\A(at|time|now)\z|_at\z/)
573
+ params[arg] = :local_time
574
+ next arg.to_s
575
+ end
576
+ if (model = model_for(arg))
577
+ var = model.model_name.singular.to_sym
578
+ params[var] = pool_name(model)
579
+ arg.to_s.end_with?("_id") ? "#{var}.id" : var.to_s
580
+ else
581
+ value_for(arg, nil, params).sub(/\A#{arg}: ?/, "").then { it.empty? ? arg.to_s : it }
582
+ end
583
+ end
584
+ Draft.new(name: job.name.underscore.to_sym, params:, body: block_for(params, "#{job.name}.perform_now(#{args.join(", ")})"),
585
+ kind: :job, resource: nil, file: job.instance_method(:perform).source_location&.first)
586
+ end
587
+ end
588
+
589
+ def drafts = @drafts ||= route_drafts + api_drafts + job_drafts
590
+
591
+ # The fakes the specs install, as [client setter, fake class].
592
+ def fakes
593
+ @fakes ||= Rails.root.glob("spec/support/**/*.rb").flat_map do |file|
594
+ File.read(file).scan(/^\s*config\.before.*?(\w+(?:::\w+)*)\.client\s*=\s*(Fake\w+)\.new/m)
595
+ end.uniq
596
+ end
597
+
598
+ # Methods a fake has and the real client does not, named like a failure:
599
+ # each becomes a `failure`. One with a required argument needs a person
600
+ # to say which record it fails for.
601
+ def failures
602
+ @failures ||= fakes.flat_map do |service, fake_name|
603
+ fake = fake_name.constantize
604
+ real = "#{service}::Client".safe_constantize
605
+ (fake.public_instance_methods(false) - (real ? real.public_instance_methods : [])).grep(FAILURE_NAME).map do |method|
606
+ # The probe prepends a pause hook; read the fake's own signature under it.
607
+ original = fake.instance_method(method)
608
+ original = original.super_method while original.owner != fake
609
+ required = original.parameters.select { it.first == :req }.map(&:last)
610
+ name = :"#{service.underscore}_#{method}"
611
+ code = required.empty? ? "failure(:#{name}) { #{service}.client.#{method} }" :
612
+ "# TODO: failure(:#{name}) { #{service}.client.#{method}(#{required.join(", ")}) } # which #{required.join(", ")}?"
613
+ Failure.new(name:, code:, service: fake_name)
614
+ end
615
+ end
616
+ end
617
+
618
+ # How an API caller signs in: a header looked up as a record's column, or
619
+ # compared with an ENV value the setup sets.
620
+ # Each lookup is [header, model, column, literal conditions, bearer]: a
621
+ # header read straight into find_by, or first into a local (`token =
622
+ # headers["Authorization"].delete_prefix("Bearer ")`), with the record's
623
+ # other conditions (`kind: "staff"`). A lookup with conditions or a bearer
624
+ # prefix gets a record of its own in the setup, with a known token.
625
+ def api_auth
626
+ @api_auth ||= begin
627
+ text = Rails.root.glob("app/api/**/*.rb").map { File.read(it) }.join("\n")
628
+ chunks = text.split(/^\s*def /)
629
+ # A helper that reads the header (`def bearer_token = headers["Authorization"]...`)
630
+ # stands for it where a lookup calls the helper.
631
+ readers = chunks.filter_map { |c| (h = c[/headers\["([^"]+)"\]/, 1]) && [c[/\A(\w+)/, 1], [h, c.include?("Bearer")]] }.to_h
632
+ lookups = chunks.flat_map do |chunk|
633
+ locals = chunk.scan(/^\s*(\w+)\s*=\s*[^=\n]*headers\["([^"]+)"\]/).to_h
634
+ bearer = chunk.include?("Bearer")
635
+ # A scope may stand between the model and the lookup (`StaffMember.active.find_by`).
636
+ chunk.scan(/([A-Z]\w*)((?:\.\w+(?:\([^)]*\))?)*?)\.find_by!?\(([^)]*)\)/).filter_map do |model, chain, args|
637
+ where = chain[/\.where\(([^)]*)\)/, 1]
638
+ pairs = "#{where}, #{args}".scan(/(\w+):\s*([^,]+)/)
639
+ header, column = pairs.filter_map { |col, expr| (h = expr[/headers\["([^"]+)"\]/, 1] || locals[expr.strip] || readers[expr.strip[/\A(\w+)/, 1]]&.first) && [h, col] }.first
640
+ next unless header
641
+
642
+ bearer ||= readers.values.include?([header, true])
643
+
644
+ literal = pairs.select { |_, expr| expr.strip.match?(/\A("[^"]*"|\d+)\z/) }.to_h { |k, v| [k, v.strip] }
645
+ [header, model.safe_constantize, column, literal, bearer]
646
+ end
647
+ end
648
+ envs = chunks.flat_map do |chunk|
649
+ env = chunk[/ENV\.fetch\("(\w+)"/, 1] or next []
650
+ chunk.scan(/headers\["([^"]+)"\]/).flatten.map { [it, env, chunk.include?("Bearer")] }
651
+ end
652
+ plain, own = lookups.select { it[1] }.uniq.partition { it[3].empty? && !it[4] }
653
+ { lookups: plain + own, envs: envs.uniq }
654
+ end
655
+ end
656
+
657
+ def api_caller_model = api_auth[:lookups].find { it[3].empty? && !it[4] }&.dig(1)
658
+
659
+ # The record a bearer or conditioned lookup finds, made in the setup.
660
+ def api_own_records
661
+ api_auth[:lookups].reject { it[3].empty? && !it[4] }.filter_map do |header, model, column, literal, _|
662
+ factory = factories.find { it.build_class == model } or next
663
+ token = "sequences-#{header.downcase.gsub(/[^a-z]+/, "-")}"
664
+ [header, "create(:#{factory.name}, #{[*role_traits(factory), *literal.map { "#{_1}: #{_2}" }, "#{column}: #{token.inspect}"].join(", ")})", token]
665
+ end
666
+ end
667
+
668
+ # How a controller lets a caller in, read from its own source and its app
669
+ # ancestors': HTTP Basic or token auth whose secrets come from ENV or a
670
+ # literal, a param or a header compared with an ENV value. Each ENV name
671
+ # is set by every area's setup (controller_envs), so the draft sends the
672
+ # value the app expects instead of a drawn word it always refuses.
673
+ ENV_READ = /ENV(?:\.fetch\(|\[)\s*["'](\w+)["']/
674
+ def controller_auth(controller)
675
+ @controller_auth ||= {}
676
+ @controller_auth[controller] ||= begin
677
+ files = controller.ancestors.grep(Class).filter_map { Object.const_source_location(it.name)&.first if it.name }
678
+ .select { it.to_s.start_with?(Rails.root.join("app/controllers").to_s) }.uniq
679
+ text = files.map { File.read(it) }.join("\n")
680
+ secret = ->(src) { (env = src[ENV_READ, 1]) ? (controller_envs << env; "ENV[#{env.inspect}]") : src[/["'][^"']*["']/] }
681
+ out = { params: {}, headers: {} }
682
+ if (m = text.match(/http_basic_authenticate_with\s+name:\s*([^,]+),\s*password:\s*([^,\n]+)/))
683
+ out[:headers]["Authorization"] = "ActionController::HttpAuthentication::Basic.encode_credentials(#{secret.(m[1])}, #{secret.(m[2])})"
684
+ end
685
+ text.split(/^\s*def /).each do |chunk|
686
+ envs = chunk.scan(ENV_READ).flatten
687
+ next if envs.empty?
688
+
689
+ if chunk.match?(/(authenticate_or_request_with|authenticate_with)_http_basic/) && envs.size >= 2
690
+ controller_envs.concat(envs.first(2))
691
+ out[:headers]["Authorization"] = "ActionController::HttpAuthentication::Basic.encode_credentials(ENV[#{envs[0].inspect}], ENV[#{envs[1].inspect}])"
692
+ elsif chunk.match?(/(authenticate_or_request_with|authenticate_with)_http_token/)
693
+ controller_envs << envs.first
694
+ out[:headers]["Authorization"] = "ActionController::HttpAuthentication::Token.encode_credentials(ENV[#{envs.first.inspect}])"
695
+ end
696
+ compared = chunk.lines.grep(/secure_compare|==/).join
697
+ compared.scan(/params(?:\[:|\.(?:require|fetch)\(:)(\w+)/).flatten.each { |k| controller_envs << envs.first; out[:params][k.to_sym] = envs.first }
698
+ compared.scan(/request\.headers\[["']([^"']+)["']\]/).flatten.each { |h| controller_envs << envs.first; out[:headers][h] = "ENV[#{envs.first.inspect}]" }
699
+ end
700
+ out
701
+ end
702
+ end
703
+
704
+ def controller_envs = @controller_envs ||= []
705
+
706
+ # A desk page that answers "Sign in first" unless the session holds a
707
+ # staff id: the action that writes that session key (sessions#create) is
708
+ # the sign-in, its params are drafted like any route's, and a record it
709
+ # looks up by literal conditions (`kind: "staff"`) is made in the setup.
710
+ # Every route of a controller whose before_action reads the key signs in
711
+ # first, through one helper.
712
+ def session_key(text) = text[/session\[:(\w+)\]/, 1]
713
+
714
+ def session_reader?(controller)
715
+ controller._process_action_callbacks.any? do |cb|
716
+ next false unless cb.kind == :before && cb.filter.is_a?(Symbol) && (controller.method_defined?(cb.filter) || controller.private_method_defined?(cb.filter))
717
+
718
+ key = session_key(reads_through(controller, cb.filter))
719
+ key && session_sign_in && key == session_sign_in[:key]
720
+ end
721
+ rescue NameError
722
+ false
723
+ end
724
+
725
+ # A method's body and the bodies of the controller methods it calls, one
726
+ # call deep: `require_staff` reads the session through `current_staff`.
727
+ def reads_through(controller, name, depth = 1)
728
+ loc = controller.instance_method(name).source_location or return ""
729
+ body = Prism.parse_file(loc[0]).value.breadth_first_search { it.is_a?(Prism::DefNode) && it.name == name.to_sym && it.location.start_line == loc[1] }&.slice.to_s
730
+ return body if depth.zero?
731
+
732
+ called = body.scan(/\b([a-z_]\w*[?!]?)/).flatten.uniq.select { |m| m != name.to_s && (controller.method_defined?(m) || controller.private_method_defined?(m)) }
733
+ called.reject { |m| controller.instance_method(m).source_location.nil? || !controller.instance_method(m).source_location[0].start_with?(Rails.root.to_s) }
734
+ .map { reads_through(controller, it, depth - 1) }.unshift(body).join("\n")
735
+ rescue NameError
736
+ ""
737
+ end
738
+
739
+ # Desk staff that sign in with a password: the setup makes the record with
740
+ # a known one, and the sign-in sends it.
741
+ DESK_PASSWORD = "sequences-password"
742
+
743
+ def session_sign_in
744
+ return @session_sign_in if defined?(@session_sign_in)
745
+
746
+ @session_sign_in = Rails.application.routes.routes.lazy.filter_map do |route|
747
+ next unless route.verb == "POST" && route.defaults[:controller]
748
+
749
+ controller = "#{route.defaults[:controller].camelize}Controller".safe_constantize or next
750
+ action = route.defaults[:action]
751
+ loc = controller.instance_method(action).source_location rescue next
752
+ tree = Prism.parse_file(loc[0]).value.breadth_first_search { it.is_a?(Prism::DefNode) && it.name.to_s == action } or next
753
+ # The action writes the key itself, or through a helper (`sign_in(staff)`).
754
+ text = reads_through(controller, action)
755
+ key = text[/session\[:(\w+)\]\s*=[^=]/, 1] or next
756
+ # A scope may stand between the model and the lookup (`StaffMember.active.find_by`).
757
+ line = text.lines.find { it.match?(/[A-Z]\w*(?:\.\w+(?:\([^)]*\))?)*?\.find_by!?\(/) }
758
+ model = line && line[/([A-Z]\w*)(?:\.\w+(?:\([^)]*\))?)*?\.find_by/, 1].safe_constantize
759
+ literal = line ? line.scan(/(\w+):\s*("[^"]*"|\d+)/).to_h : {}
760
+ # A param a lookup column reads (`email: params[:email]`) gets a known
761
+ # value the setup gives the record too.
762
+ by_param = line ? line.scan(/(\w+):\s*params(?:\[:|\.(?:require|fetch)\(:)(\w+)/).to_h { |col, k| [k.to_sym, col] } : {}
763
+ # The secret is checked by `authenticate(params[:password])` or by a
764
+ # predicate of the record's own (`staff.pin_matches?(params[:pin])`);
765
+ # the setup gives the record that secret through its writer.
766
+ password = text[/authenticate\(params(?:\[:|\.(?:require|fetch)\(:)(\w+)/, 1]&.to_sym
767
+ secret = password ? :password : (password = text[/\.\w+\?\(params(?:\[:|\.(?:require|fetch)\(:)(\w+)/, 1]&.to_sym)
768
+ password = nil unless model&.method_defined?(:"#{secret}=")
769
+ known = by_param.to_h { |k, col| [k, col.include?("email") ? "desk@sequences.test" : "sequences-#{col.tr("_", "-")}"] }
770
+ auth = controller_auth(controller)
771
+ found = model ? model.where(literal.transform_values { it.delete('"') }) : nil
772
+ keys = controller_params(controller, action).keys | known.keys | [password].compact
773
+ body = keys.map do |k|
774
+ kind, attribute = controller_params(controller, action)[k]
775
+ if auth[:params][k] then "#{k}: ENV[#{auth[:params][k].inspect}]"
776
+ elsif known[k] then "#{k}: #{known[k].inspect}"
777
+ elsif k == password then "#{k}: #{DESK_PASSWORD.inspect}"
778
+ elsif kind.is_a?(Class) then "#{k}: #{kind.name}.where(#{literal.map { "#{_1}: #{_2}" }.join(", ")}).order(:id).first&.#{attribute}.to_s"
779
+ else "#{k}: \"\""
780
+ end
781
+ end
782
+ factory = model && factories.find { it.build_class == model && it.name.to_s == model.model_name.singular }
783
+ attrs = literal.map { "#{_1}: #{_2}" } + by_param.map { |k, col| "#{col}: #{known[k].inspect}" } + (password ? ["#{secret}: #{DESK_PASSWORD.inspect}"] : [])
784
+ setup = factory && attrs.any? ? "create(:#{factory.name}, #{[*role_traits(factory), *attrs].join(", ")})" : nil
785
+ { key:, action:, controller: route.defaults[:controller], path: route.path.spec.to_s.sub("(.:format)", ""), body:, setup:, found: found&.to_sql }
786
+ end.first
787
+ end
788
+
789
+ def factories
790
+ @factories ||= FactoryBot.factories.to_a
791
+ end
792
+
793
+ # The traits that give a signed-in record a role the app checks
794
+ # (`trait :supervisor` with `current_staff.supervisor?` in a guard), so the
795
+ # caller reaches every action instead of only the ones open to all.
796
+ def role_traits(factory)
797
+ @app_text ||= Dir[Rails.root.join("app/{controllers,api}/**/*.rb")].map { File.read(it) }.join("\n")
798
+ factory.defined_traits.map(&:name).select do |t|
799
+ factory.build_class.method_defined?(:"#{t}?") && @app_text.match?(/\.#{Regexp.escape(t)}\?/)
800
+ end.map { ":#{it}" }
801
+ end
802
+
803
+ # A factory the setup may call: every association it declares is a bare
804
+ # name, so the world can hand it records it already made. A factory that
805
+ # derives one (`copy { association :copy, status: "on_loan" }`) encodes a
806
+ # state the app should reach through its own actions.
807
+ def seedable(factory)
808
+ names = factories.map(&:name)
809
+ decls = factory.definition.declarations.to_a
810
+ assocs = decls.select { names.include?(it.name) || it.is_a?(FactoryBot::Declaration::Association) }
811
+ derived = assocs.reject { it.is_a?(FactoryBot::Declaration::Implicit) || plain_association?(it) }
812
+ return nil if derived.any? { encodes_state?(it) }
813
+
814
+ # A parent the factory makes with an identity of its own (a staff
815
+ # member whose role is "practitioner") is left to the factory.
816
+ (assocs - derived).map(&:name)
817
+ end
818
+
819
+ def encodes_state?(decl)
820
+ text = case decl
821
+ when FactoryBot::Declaration::Association then decl.instance_variable_get(:@overrides).keys.join(" ") + " " + decl.instance_variable_get(:@traits).join(" ")
822
+ when FactoryBot::Declaration::Dynamic
823
+ file, line = decl.instance_variable_get(:@block)&.source_location
824
+ file && File.file?(file) ? File.readlines(file)[line - 1].to_s : "status"
825
+ else "status"
826
+ end
827
+ text.match?(/status|state/)
828
+ end
829
+
830
+ # `branch { association :branch }` and `association :branch` name the
831
+ # record as a bare `branch` does: the world can hand it one it made. Only
832
+ # an override or a trait (`association :copy, status: "on_loan"`) encodes
833
+ # a state.
834
+ def plain_association?(decl)
835
+ case decl
836
+ when FactoryBot::Declaration::Association
837
+ decl.instance_variable_get(:@overrides).empty? && decl.instance_variable_get(:@traits).empty? &&
838
+ decl.instance_variable_get(:@factory_name) == decl.name
839
+ when FactoryBot::Declaration::Dynamic
840
+ block = decl.instance_variable_get(:@block)
841
+ file, line = block&.source_location
842
+ text = file && File.file?(file) ? File.readlines(file)[line - 1].to_s : ""
843
+ text.match?(/\b#{decl.name}\s*\{\s*association\s*\(?\s*:#{decl.name}\s*\)?\s*\}/)
844
+ else false
845
+ end
846
+ end
847
+
848
+ # The world each area starts from: two of each root record, members three,
849
+ # and each child made from records already there, every pairing once.
850
+ def setup_lines(inserted, wanted)
851
+ made = {}
852
+ lines = []
853
+ needed = Set.new
854
+ deps = ->(f) { Array(seedable(f)) | named_parents(f) }
855
+ grow = ->(f) { needed.add?(f.name) && deps.(f).each { |a| factories.find { it.name == a }&.then { grow.(it) } } }
856
+ factories.select { seedable(it) && wanted.include?(it.build_class.table_name) }.each { grow.(it) }
857
+ pending = factories.select { seedable(it) && needed.include?(it.name) }
858
+ until pending.empty?
859
+ ready = pending.select { |f| deps.(f).all? { made.key?(it) } }
860
+ break if ready.empty?
861
+
862
+ ready.each do |factory|
863
+ pending.delete(factory)
864
+ model = factory.build_class
865
+ assocs = seedable(factory)
866
+ next if assocs.any? && inserted.include?(model.table_name)
867
+
868
+ var = factory.name.to_s.pluralize
869
+ limits = capacity_args(model, factory) + distinct_times(model)
870
+ if assocs.empty? && limits.empty?
871
+ traits = factory.definition.defined_traits.map(&:name)
872
+ # Each trait in turn (a host, then a manager), so an action only
873
+ # one kind of record may take is reached.
874
+ lines << (traits.empty? ? "#{var} = create_list(:#{factory.name}, world_size(:#{var}, #{root_sizes(factory)}))" :
875
+ "#{var} = Array.new(world_size(:#{var}, #{root_sizes(factory)})) { |i| create(:#{factory.name}, *#{[[], *traits.map { [it.to_sym] }].inspect}[i % #{traits.size + 1}]) }")
876
+ else
877
+ sizes = assocs.map { made[it] }
878
+ most = sizes.inject(1) { |a, b| a * b }.clamp(2, 6)
879
+ # Strides from the sizes drawn this case, and no more children than
880
+ # parent pairings, so each child pairs its parents once.
881
+ picks = assocs.each_with_index.map do |a, i|
882
+ stride = assocs[0...i].map { "#{it.to_s.pluralize}.size" }.join(" * ").presence || "1"
883
+ "#{a}: #{a.to_s.pluralize}[i / (#{stride}) % #{a.to_s.pluralize}.size]"
884
+ end
885
+ # A parent a unique index holds to one child (one open membership
886
+ # per member) is used once: each child takes the next parent, and
887
+ # there are no more children than parents.
888
+ held = conn_unique_columns(model)
889
+ once = assocs.select { held.include?(model.reflect_on_association(it)&.foreign_key.to_s) }
890
+ # A parent that `has_one` of these takes one only if it has none
891
+ # yet: a trait (an insured patient) may have made it one already.
892
+ single = assocs.select do |a|
893
+ parent = model.reflect_on_association(a)&.klass rescue nil
894
+ parent&.reflect_on_all_associations(:has_one)&.any? { !it.through_reflection? && !it.options[:as] && (it.klass rescue nil) == model }
895
+ end
896
+ single.each do |a|
897
+ fk = model.reflect_on_association(a).foreign_key
898
+ lines << "free_#{a.to_s.pluralize} = #{a.to_s.pluralize}.reject { #{model.name}.exists?(#{fk}: it.id) }"
899
+ end
900
+ once |= single
901
+ list = ->(a) { single.include?(a) ? "free_#{a.to_s.pluralize}" : a.to_s.pluralize }
902
+ once.each { |a| picks[assocs.index(a)] = "#{a}: #{list.(a)}[i]" }
903
+ # A payment of kind "invoice" belongs to an invoice, and to that
904
+ # invoice's patient: a world where it has neither is one the app
905
+ # never makes.
906
+ named_parents(factory).each do |n|
907
+ pick = "#{n.to_s.pluralize}[i % #{n.to_s.pluralize}.size]"
908
+ parent = model.reflect_on_association(n).klass
909
+ assocs.each_with_index { |a, j| picks[j] = "#{a}: #{pick}.#{a}" if parent.reflect_on_association(a)&.macro == :belongs_to }
910
+ picks << "#{n}: #{pick}"
911
+ end
912
+ range = assocs.empty? ? root_sizes(factory) : "1..#{most}"
913
+ count = "world_size(:#{var}, #{range})"
914
+ count = "[#{count}, #{(once.any? ? once : assocs).map { "#{list.(it)}.size" }.join(once.any? ? ", " : " * ")}].min" if assocs.any?
915
+ lines << "#{var} = Array.new(#{count}) { |i| create(:#{factory.name}, #{(picks + limits).join(", ")}) }"
916
+ lines.concat(settle_lines(var, model, made.keys.map { it.to_s.pluralize }))
917
+ end
918
+ # The largest each can be: a child pairs with every parent up to it.
919
+ made[factory.name] = assocs.empty? ? root_sizes(factory).max : sizes.inject(1) { |a, b| a * b }.clamp(2, 6)
920
+ end
921
+ end
922
+ lines
923
+ end
924
+
925
+ # The optional parents a factory's own values name (kind "invoice" and
926
+ # `belongs_to :invoice, optional: true`), which the setup fills.
927
+ def named_parents(factory)
928
+ values = (FactoryBot.attributes_for(factory.name) rescue {}).values.grep(String)
929
+ names = factories.map(&:name)
930
+ factory.build_class.reflect_on_all_associations(:belongs_to)
931
+ .select { it.options[:optional] && !it.polymorphic? && values.include?(it.name.to_s) && names.include?(it.name) }.map(&:name)
932
+ end
933
+
934
+ # World sizes are drawn per case, so capacity pressure and an empty
935
+ # waitlist both happen: members 2 to 8, other roots 1 to 3.
936
+ def root_sizes(factory) = factory.name == :member ? (2..8) : (1..3)
937
+
938
+ # A capacity column (seats, capacity, a limit) drawn 1 to 3 per record.
939
+ # A capacity column some records must leave blank (seats only on a team
940
+ # plan) would make every drafted record invalid, so it is drawn only when
941
+ # the factory's record stays valid with it set.
942
+ def capacity_args(model, factory)
943
+ model.columns.select { it.type == :integer && it.name.match?(/capacity|seats|limit|\Amax_|slots|quota/) }
944
+ .select { |col| (FactoryBot.build(factory.name, col.name => 1).valid? rescue true) }
945
+ .map { "#{it.name}: world_size(:\"#{model.model_name.plural}.#{it.name}\", 1..3)" }
946
+ end
947
+
948
+ # The columns a unique index holds alone, partial ones included: a
949
+ # partial index still forbids two rows its condition selects, and the
950
+ # factory's defaults usually meet that condition.
951
+ def conn_unique_columns(model)
952
+ indexes = ActiveRecord::Base.connection.indexes(model.table_name).select(&:unique)
953
+ alone = indexes.select { Array(it.columns).size == 1 }.map { Array(it.columns).first }
954
+ # One parent with a day or a kind (`member_id, visited_on`): the factory
955
+ # gives every record the same day, so each parent is used once.
956
+ shared = indexes.filter_map { |i| ids = Array(i.columns).grep(/_id\z/); ids.first if Array(i.columns).size > 1 && ids.one? }
957
+ (alone + shared).uniq
958
+ end
959
+
960
+ # A time column a unique index or an exclusion constraint holds (one start
961
+ # per member) collides when every record takes the factory's one default,
962
+ # and a collision in the world is the setup's, not the app's. Each record
963
+ # gets its own hour instead.
964
+ def distinct_times(model)
965
+ conn = ActiveRecord::Base.connection
966
+ held = conn.indexes(model.table_name).select(&:unique).flat_map { Array(it.columns) }
967
+ held += conn.exclusion_constraints(model.table_name).flat_map { it.expression.scan(/\w+/) } if conn.respond_to?(:exclusion_constraints)
968
+ times = model.columns.select { %i[datetime timestamp].include?(it.type) && held.include?(it.name) }
969
+ # An end held with its start is left to the factory, which derives it
970
+ # from the start (`starts_at + service.duration`); the same hour for both
971
+ # makes an empty slot.
972
+ times = times.reject { it.name.match?(/\A(.*_)?(ends?|finish(es)?|until|to)_at\z/) } if times.size > 1
973
+ times.map { "#{it.name}: Time.current.beginning_of_hour + (i + 1).hours" }
974
+ end
975
+
976
+ # Tables an action of the area inserts into as its own resource (its name
977
+ # names the table, as loans_create names loans). Those the setup leaves to
978
+ # the app. A table inserted only as a side effect of another resource's
979
+ # action (a kiosk return that requests a transfer) is seeded too: that
980
+ # action needs a world of its own first, so without seeding, the actions
981
+ # that draw from the table would never run.
982
+ def made_by_own_actions(selected, stats)
983
+ selected.flat_map do |a|
984
+ stats[a.name][:writes].keys.grep(/\Ainsert /).map { it.split.last }
985
+ .select { |t| a.name.to_s.include?(t) || a.name.to_s.include?(t.singularize) }
986
+ end.uniq - copied_from(selected)
987
+ end
988
+
989
+ # A table an action draws a record or a value from (`practitioner:
990
+ # :practitioners`, `guest_email_from: :guest_passes`)
991
+ # is made in the setup even when the area inserts into it: an empty pool
992
+ # leaves the action never drawn.
993
+ def copied_from(selected) = selected.flat_map { |d| d.params.values.grep(Symbol).map { pool_table(it) } }.uniq
994
+
995
+ def pool_tables(selected) = selected.flat_map { it.params.values }.grep(Symbol).map { pool_table(it) }
996
+
997
+ # What the actions read in the probe (a member's active membership) is
998
+ # made too, or each check-in is refused for a record the area never has.
999
+ def setup_tables(selected, stats) = pool_tables(selected) | selected.flat_map { stats.dig(it.name, :reads)&.keys || [] }
1000
+
1001
+ def probe(selected)
1002
+ source = definition_source("probe", selected, {}, refusals: [], failures: [], setup: setup_lines([], models.map(&:table_name)))
1003
+ definition = Sequences::Definition.new("probe", shared: false)
1004
+ definition.instance_eval(source.lines[1..-2].join)
1005
+ Sequences.probe(definition)
1006
+ end
1007
+
1008
+ # The table a resource is about: `room_bookings` -> room_bookings,
1009
+ # `api_staff_closures` -> closures; nil for `api_kiosk` or a webhook.
1010
+ def primary(resource)
1011
+ word = resource.to_s.split("_").each_index.lazy.map { |i| resource.to_s.split("_")[i..].join("_") }.find { model_for(it.singularize) }
1012
+ word && model_for(word.singularize).table_name
1013
+ end
1014
+
1015
+ # Constants a source file names, directly or in an app class it names (one
1016
+ # hop): where the probe saw no write or no outside call, this places a job
1017
+ # or an endpoint and says which fakes it can reach.
1018
+ # A route's file is `path#action`: only that method is read.
1019
+ def static_constants(file)
1020
+ path, method = file.to_s.split("#", 2)
1021
+ return [] unless File.exist?(path.to_s)
1022
+
1023
+ @static ||= {}
1024
+ @static[file] ||= begin
1025
+ names = ->(p) { [].tap { |acc| walk_constants(Prism.parse_file(p).value, acc) } }
1026
+ tree = Prism.parse_file(path).value
1027
+ tree = tree.breadth_first_search { it.is_a?(Prism::DefNode) && it.name.to_s == method } || tree if method
1028
+ direct = [].tap { walk_constants(tree, it) }
1029
+ hop = direct.uniq.filter_map { it.safe_constantize }.select { it.is_a?(Module) && !(it.is_a?(Class) && it < ActiveRecord::Base) }
1030
+ .filter_map { Object.const_source_location(it.name)&.first }.select { it.start_with?(Rails.root.join("app").to_s) }
1031
+ (direct + hop.uniq.flat_map(&names)).uniq
1032
+ end
1033
+ end
1034
+
1035
+ def static_tables(file)
1036
+ static_constants(file).filter_map { it.safe_constantize }.select { it.is_a?(Class) && it < ActiveRecord::Base && !it.abstract_class? }
1037
+ .map(&:table_name).tally
1038
+ end
1039
+
1040
+ def walk_constants(node, acc)
1041
+ acc << node.slice if node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
1042
+ node.compact_child_nodes.each { walk_constants(it, acc) } unless node.is_a?(Prism::ConstantPathNode)
1043
+ end
1044
+
1045
+ # Areas: resources about the same table form one area (`holds` and
1046
+ # `api_holds`). A resource about no table joins the area whose table it
1047
+ # writes most (or names most, when the probe saw it write nothing). Each
1048
+ # area then gets the actions that make the records its pools take, and is
1049
+ # filled, jobs first, with the actions that write its table, up to
1050
+ # MAX_ACTIONS.
1051
+ def areas(stats)
1052
+ written = ->(d) { stats[d.name][:writes].keys.map { it.split.last }.uniq }
1053
+ inserts = ->(d, t) { stats[d.name][:writes]["insert #{t}"] }
1054
+ touches = ->(d) { written.(d).then { it.empty? || d.kind == :job ? it | static_tables(d.file).keys : it } }
1055
+ seeds = drafts.select(&:resource).group_by { primary(it.resource) || "~#{it.resource}" }
1056
+ areas = seeds.reject { |k, _| k.start_with?("~") }.map { |table, ds| [table, ds.dup] }.to_h
1057
+ seeds.select { |k, _| k.start_with?("~") }.each do |key, ds|
1058
+ counts = ds.flat_map(&written).tally
1059
+ counts = ds.map { static_tables(it.file) }.inject({}) { |a, b| a.merge(b) { |_, x, y| x + y } } if counts.empty?
1060
+ target = counts.select { |t, _| areas.key?(t) }.max_by { |t, n| [n, t] }&.first
1061
+ if target then areas[target].concat(ds)
1062
+ else areas[key.delete_prefix("~")] = ds.dup
1063
+ end
1064
+ end
1065
+ seeded = factories.select { seedable(it) }.map { it.build_class.table_name }
1066
+ # The action that made the most rows of each pool no factory seeds, so a
1067
+ # pool of loans has a loans_create to fill it.
1068
+ makers = lambda do |selected|
1069
+ needed = selected.flat_map { it.params.values }.grep(Symbol).map { pool_table(it) }.uniq - seeded
1070
+ needed.filter_map do |pool|
1071
+ next if selected.any? { inserts.(it, pool).positive? }
1072
+
1073
+ drafts.max_by { inserts.(it, pool) }.then { it if inserts.(it, pool).positive? }
1074
+ end.uniq
1075
+ end
1076
+ # An action the probe saw refused for a state ("Only a checked in
1077
+ # appointment can be completed") never runs in an area that cannot reach
1078
+ # the state. The action whose name the refusal says (check_ins) joins it.
1079
+ stem = ->(word) { word.downcase.sub(/(ed|ing|s)\z/, "") }
1080
+ enablers = lambda do |chosen|
1081
+ records = chosen.flat_map { it.params.values }.grep(Symbol).map { stem.(pool_table(it).singularize) }
1082
+ # The probe walks every action at once, so it may have reached the state
1083
+ # there: any refusal it saw counts.
1084
+ texts = chosen.flat_map { stats[it.name][:refusals] || [] }
1085
+ texts.flat_map do |text|
1086
+ said = text.downcase.scan(/[a-z]+/).map(&stem)
1087
+ drafts.reject { chosen.include?(it) }.select do |d|
1088
+ next false unless stats[d.name][:outcomes].keys.any? { !it.include?("refused") }
1089
+
1090
+ tokens = d.kind == :api ? d.name.to_s.split("_id_", 2)[1].to_s.split("_") : d.resource.to_s.split("_")
1091
+ (tokens.map(&stem) - records).reverse.take_while { said.include?(it) }.any? { it.size >= 4 }
1092
+ end
1093
+ end.uniq
1094
+ end
1095
+ placed = drafts
1096
+ result = areas.map do |table, ds|
1097
+ selected = ds + makers.(ds)
1098
+ others = drafts.reject { selected.include?(it) }.select { touches.(it).include?(table) }
1099
+ others = others.sort_by { |d| [d.kind == :job ? 0 : 1, -stats[d.name][:writes].select { |k, _| k.end_with?(" #{table}") }.values.sum, d.name.to_s] }
1100
+ others.each do |d|
1101
+ break if selected.size >= MAX_ACTIONS
1102
+
1103
+ selected += [d] + makers.(selected + [d])
1104
+ end
1105
+ selected += enablers.(selected)
1106
+ [table, selected, ([table] + selected.flat_map(&written)).uniq]
1107
+ end
1108
+ # An action no area took (a job that only reaches a table no resource is
1109
+ # about) joins the area that writes most of what it touches.
1110
+ placed.each do |d|
1111
+ next if result.any? { |_, ds, _| ds.include?(d) }
1112
+
1113
+ best = result.max_by { |_, _, own| (touches.(d) & own).size }
1114
+ best[1] << d if best && (touches.(d) & best[2]).any?
1115
+ end
1116
+ result
1117
+ end
1118
+
1119
+ # A desk route and an API endpoint that make or change the same record:
1120
+ # `holds_create` and POST /api/v1/holds, `fines_pay` and POST
1121
+ # /api/v1/fines/:id/pay. Matched by the table both are about and the verb:
1122
+ # the probe often sees one of the two only refused, so writes miss pairs.
1123
+ def door_key(d)
1124
+ if d.kind == :route
1125
+ action = d.name.to_s.delete_prefix("#{d.resource}_")
1126
+ { "create" => :create, "destroy" => :destroy }.fetch(action, action.to_sym)
1127
+ else
1128
+ verb, last = d.name.to_s[/\Aapi_(\w+?)_/, 1], d.body[%r{"/api/v\d+/[^"]*?([a-z_]+)"}, 1]
1129
+ # `POST /appointments/:id/visit` makes a visit, not an appointment.
1130
+ made = model_for(last.to_s.singularize)
1131
+ verb == "delete" ? :destroy : (made && made.table_name == primary(d.resource) ? :create : last&.to_sym)
1132
+ end
1133
+ end
1134
+
1135
+ def twins(selected, stats)
1136
+ return [] if stats.empty? # the probe itself
1137
+ selected.select { it.kind == :route }.flat_map do |desk|
1138
+ table = primary(desk.resource) or next []
1139
+ selected.select { it.kind == :api && primary(it.resource) == table && door_key(it) == door_key(desk) }
1140
+ .map { [desk.name, it.name, table] }
1141
+ end
1142
+ end
1143
+
1144
+ def definition_source(name, selected, stats, refusals:, failures:, setup: [], note: nil)
1145
+ pools = selected.flat_map { it.params.values }.grep(Symbol).reject { %i[TODO local_date local_time text].include?(it) }.uniq
1146
+ reached = ->(d) { (stats.dig(d.name, :outside) || []) | fakes.select { |service, _| static_constants(d.file).include?(service) }.map(&:last) }
1147
+ armed = failures.select { |f| f.code.start_with?("failure") && selected.any? { reached.(it).include?(f.service) } }
1148
+ out = +"Sequences.define(#{name.inspect}) do\n"
1149
+ out << " # #{note}\n" if note
1150
+ out << " setup do\n"
1151
+ fakes.each { |service, fake| out << " #{service}.client = #{fake}.new\n" }
1152
+ (api_auth[:envs].map { it[1] } | controller_envs).uniq.each { |env| out << " ENV[#{env.inspect}] = \"sequences-#{env.downcase.tr("_", "-")}\"\n" }
1153
+ signs_in = session_sign_in && selected.any? { it.body.include?("desk_sign_in") || it.body.include?(session_sign_in[:path].inspect) }
1154
+ out << " #{session_sign_in[:setup]}\n" if signs_in && session_sign_in[:setup]
1155
+ # The caller's own record heads the list of its kind when the setup
1156
+ # makes one, so the records made from that list (its membership, its
1157
+ # appointments) always include one of its own.
1158
+ own = selected.any? { it.kind == :api } ? api_own_records : []
1159
+ setup = setup.flat_map do |line|
1160
+ joined = own.select { |_, made, _| line.start_with?("#{made[/create\(:(\w+)/, 1].pluralize} = ") }
1161
+ own -= joined
1162
+ [line, *joined.map { |_, made, _| "#{made[/create\(:(\w+)/, 1].pluralize}.unshift(#{made})" }]
1163
+ end
1164
+ own.each { out << " #{it[1]}\n" }
1165
+ setup.each { out << " #{it}\n" }
1166
+ out << " end\n\n"
1167
+ pools.each { out << " pool(:#{it}) { #{owned_pools[it] || pool_table(it).classify}.order(:id) }\n" }
1168
+ out << "\n"
1169
+ unless refusals.empty?
1170
+ out << " # Errors the probe saw an action raise on purpose (classes the app defines).\n"
1171
+ out << " # Delete one that must never escape; any other error fails the run.\n"
1172
+ out << " refusals #{refusals.join(", ")}\n\n"
1173
+ end
1174
+ out << " outside_calls #{fakes.map(&:last).join(", ")}\n" unless fakes.empty?
1175
+ out << " clock start: [2026, 3, 7, 10, 0, 0], zones: [\"Pacific/Auckland\", \"America/Los_Angeles\"]\n\n"
1176
+ helper_lines = []
1177
+ if selected.any? { it.kind == :api }
1178
+ own = api_own_records
1179
+ headers = api_auth[:lookups].filter_map do |h, model, col, literal, bearer|
1180
+ next if own.any? { it[0] == h }
1181
+
1182
+ "#{h.inspect} => #{model == api_caller_model ? "caller" : "#{model}.order(:id).first"}&.#{col}.to_s"
1183
+ end
1184
+ headers += own.map { |h, _, token| "#{h.inspect} => #{(api_auth[:lookups].find { it[0] == h }&.last ? "Bearer #{token}" : token).inspect}" }
1185
+ headers += api_auth[:envs].map { |h, env, bearer| "#{h.inspect} => #{bearer ? "\"Bearer \#{ENV[#{env.inspect}]}\"" : "ENV[#{env.inspect}]"}" }
1186
+ headers.uniq! { it[/\A"[^"]+"/] }
1187
+ helper_lines << " def auth_for(caller = nil) = { #{headers.join(", ")} }"
1188
+ end
1189
+ if signs_in
1190
+ helper_lines << " # The desk pages need a staff session; #{session_sign_in[:path]} makes one."
1191
+ helper_lines << " def desk_sign_in = http.post(#{session_sign_in[:path].inspect}, params: { #{session_sign_in[:body].join(", ")} })"
1192
+ end
1193
+ out << " helpers do\n#{helper_lines.join("\n")}\n end\n\n" unless helper_lines.empty?
1194
+ shown = failures.select { |f| selected.any? { reached.(it).include?(f.service) } }
1195
+ shown.each { out << " #{it.code}\n" }
1196
+ out << "\n" unless shown.empty?
1197
+ twins(selected, stats).each { |a, b, t| out << " twins :#{a}, :#{b}, table: :#{t}\n" }
1198
+ selected.each do |d|
1199
+ spec = d.params.map { |k, v| "#{k}: #{v.is_a?(Symbol) ? ":#{v}" : v.inspect}" }
1200
+ fs = armed.select { reached.(d).include?(it.service) }.map(&:name)
1201
+ spec << "failures: %i[#{fs.join(" ")}]" unless fs.empty?
1202
+ spec << "pause_at: :batch_load" if d.kind == :job
1203
+ fresh = d.body.to_s.scan(/(\w+): FactoryBot\.attributes_for/).flatten.uniq
1204
+ spec << "fresh: %i[#{fresh.join(" ")}]" if fresh.any?
1205
+ out << " action(#{[":#{d.name}", *spec].join(", ")}) #{d.body}\n"
1206
+ end
1207
+ out << "end\n"
1208
+ end
1209
+
1210
+ def app_refusals(stats, selected)
1211
+ selected.flat_map { stats[it.name][:errors].keys }.uniq.select do |name|
1212
+ klass = name.safe_constantize
1213
+ klass.is_a?(Class) && klass < StandardError && Object.const_source_location(name)&.first.to_s.start_with?(Rails.root.join("app").to_s)
1214
+ end.sort
1215
+ end
1216
+
1217
+ def spec_file(name, source)
1218
+ <<~RUBY
1219
+ require "rails_helper"
1220
+ require ENV.fetch("SEQUENCES_LIB")
1221
+ require_relative "invariants"
1222
+
1223
+ # Drafted by propose.rb. Edit freely: the setup world, a pool (for
1224
+ # example `Loan.active`), a TODO. Invariants for every area go in invariants.rb.
1225
+ #{source.chomp}
1226
+
1227
+ RSpec.describe "#{name} sequences" do
1228
+ self.use_transactional_tests = false
1229
+
1230
+ it "holds its invariants in any order, interleaving and time zone" do
1231
+ Sequences.explore(Sequences[#{name.inspect}], test_cases: Integer(ENV.fetch("SEQ_CASES", "100")), seed: ENV["SEQ_SEED"]&.to_i)
1232
+ end
1233
+ end
1234
+ RUBY
1235
+ end
1236
+
1237
+ def statuses(model)
1238
+ return [] unless model.column_names.include?("status")
1239
+
1240
+ inclusion = model.validators_on(:status).grep(ActiveModel::Validations::InclusionValidator).first&.options&.[](:in)
1241
+ Array(inclusion || (model.const_get(:STATUSES, false) if model.const_defined?(:STATUSES, false))).map(&:to_s)
1242
+ end
1243
+
1244
+ SETTLED = /\A(paid|settled|completed?|captured|succeeded)\z/
1245
+ AMOUNT = /_cents\z|\Aamount\z/
1246
+
1247
+ # Money conserved: a record with an amount and a status, and its payments
1248
+ # (or charges) with an amount and a status. What a settled record says it
1249
+ # took must be what was captured for it, and nothing for any other status.
1250
+ MoneyRule = Data.define(:model, :amount, :settled, :assoc, :child, :paid, :captured, :kind) do
1251
+ def name = "#{model.model_name.singular}_#{amount}_matches_#{assoc.name}_captured"
1252
+ def owner = child.reflect_on_all_associations(:belongs_to).find { it.foreign_key.to_s == assoc.foreign_key.to_s }&.name
1253
+ end
1254
+
1255
+ def money_rules
1256
+ @money_rules ||= models.flat_map do |model|
1257
+ amount = model.column_names.find { it.match?(AMOUNT) } or next []
1258
+ settled = statuses(model).grep(SETTLED)
1259
+ next [] if settled.empty?
1260
+
1261
+ model.reflect_on_all_associations(:has_many).filter_map do |assoc|
1262
+ next unless assoc.name.to_s.match?(/payment|charge|capture|transaction/) && !assoc.options[:through]
1263
+
1264
+ child = assoc.klass
1265
+ paid = child.column_names.find { it.match?(AMOUNT) }
1266
+ captured = statuses(child).grep(SETTLED)
1267
+ next if paid.nil? || captured.empty?
1268
+
1269
+ # Payments of several kinds (a deposit and a fee) answer for
1270
+ # different amounts; the rule compares one kind with the amount it
1271
+ # pays, and is not drafted when nothing says which kind that is.
1272
+ kind = kind_filter(model, amount, child) or next
1273
+ MoneyRule.new(model:, amount:, settled:, assoc:, child:, paid:, captured:, kind:)
1274
+ end
1275
+ end
1276
+ end
1277
+
1278
+ def money_invariants
1279
+ money_rules.map do |r|
1280
+ # A payment of one kind (a deposit) may be taken at booking, before the
1281
+ # record reaches a settled status, and a desk booking may take none:
1282
+ # for it only "never more than the amount" holds in every status.
1283
+ rule, check =
1284
+ if r.kind.empty?
1285
+ ["a #{r.settled.join("/")} #{r.model.model_name.human.downcase} took its #{r.amount} through #{r.assoc.name} #{r.captured.join("/")}; any other status took nothing.",
1286
+ "taken == (#{r.settled.inspect}.include?(record.status) ? record.#{r.amount} : 0)"]
1287
+ else
1288
+ ["no #{r.model.model_name.human.downcase} took more than its #{r.amount} through #{r.assoc.name} #{r.captured.join("/")}.",
1289
+ "taken <= record.#{r.amount}"]
1290
+ end
1291
+ <<~RUBY.chomp
1292
+ # drafted, check it is a real rule: #{rule}
1293
+ invariant(:#{r.name}) do
1294
+ bad = #{r.model.name}.all.reject do |record|
1295
+ taken = #{r.child.name}.where(#{r.assoc.foreign_key}: record.id, status: #{r.captured.inspect}#{r.kind}).sum(:#{r.paid})
1296
+ #{check}
1297
+ end
1298
+ bad.empty? || "#{r.model.model_name.plural} \#{bad.map(&:id)}: #{r.amount} and the #{r.assoc.name} captured for them differ"
1299
+ end
1300
+ RUBY
1301
+ end
1302
+ end
1303
+
1304
+ # Factories make each record alone: an open invoice and a succeeded
1305
+ # payment for it break the money rule before any action runs. After the
1306
+ # payments are made, each record with a captured one is marked settled at
1307
+ # the amount captured, so the hunt starts from a world the rule allows.
1308
+ def settle_lines(child_var, child_model, made_vars)
1309
+ money_rules.filter_map do |r|
1310
+ next unless r.child == child_model && r.kind.empty? && r.owner
1311
+
1312
+ parent_var = r.owner.to_s.pluralize
1313
+ next unless made_vars.include?(parent_var)
1314
+
1315
+ "#{child_var}.select { #{r.captured.inspect}.include?(it.status) }.group_by(&:#{r.owner}).each { |record, taken| record.update_columns(status: #{r.settled.first.inspect}, #{r.amount}: taken.sum(&:#{r.paid})) } # keeps #{r.name}"
1316
+ end
1317
+ end
1318
+
1319
+ KIND_COLUMN = /\A(kind|purpose|category|payment_type|charge_type)\z/
1320
+
1321
+ # "" when the payments have one kind; ", kind: record.kind" when both
1322
+ # sides name the kind; ", kind: \"deposit\"" when the amount column names
1323
+ # one (deposit_cents); nil when nothing says which payments count.
1324
+ def kind_filter(model, amount, child)
1325
+ col = child.column_names.find { it.match?(KIND_COLUMN) } or return ""
1326
+ return ", #{col}: record.#{col}" if model.column_names.include?(col)
1327
+
1328
+ values = child.validators_on(col.to_sym).grep(ActiveModel::Validations::InclusionValidator).first&.options&.[](:in)
1329
+ values ||= %W[#{col.upcase}S KINDS].filter_map { child.const_get(it, false) if child.const_defined?(it, false) }.first
1330
+ stem = amount.delete_suffix("_cents")
1331
+ found = Array(values).map(&:to_s).find { stem != "amount" && (stem.include?(it) || it.include?(stem)) }
1332
+ found && ", #{col}: #{found.inspect}"
1333
+ end
1334
+ # Order kept: a waiting status (waitlisted, queued, pending) that some code
1335
+ # reads in timestamp order. Rows alike in all but when they entered it must
1336
+ # leave it in that order.
1337
+ def order_invariants
1338
+ code = Rails.root.glob("app/**/*.rb").map { File.read(it) }.join("\n")
1339
+ models.flat_map do |model|
1340
+ all = statuses(model)
1341
+ all.grep(/wait|queue|pend/).filter_map do |from|
1342
+ plural = model.model_name.plural
1343
+ ordered = code.lines.find do |line|
1344
+ line.match?(/\.order\(:?\w*_at\b|order\(created_at|order\(:created_at/) &&
1345
+ (line.match?(/scope :#{from}\b/) || line.match?(/\b#{plural}\.#{from}\b/) || line.include?("status: \"#{from}\""))
1346
+ end or next
1347
+ to = all - [from] - all.grep(/cancel|expire|withdr|delet|reject|fail|declin|refund/)
1348
+ next if to.empty?
1349
+
1350
+ call = code[/(\w+)\.#{plural}\.#{from}\b/, 1]
1351
+ group = ["#{call}_id"].find { model.column_names.include?(it) }
1352
+ name = "#{plural}_leave_#{from}_in_entry_order"
1353
+ <<~RUBY.chomp
1354
+ # drafted, check it is a real rule: read in timestamp order at
1355
+ # #{ordered.strip[0, 100]}
1356
+ # Rows alike in every column but ids and times leave #{from} in the order they entered it.
1357
+ invariant(:#{name}) do
1358
+ Sequences.order_kept(#{model.name}, from: #{from.inspect}, to: #{to.inspect}#{", group: :#{group}" if group})
1359
+ end
1360
+ RUBY
1361
+ end
1362
+ end
1363
+ end
1364
+
1365
+ # One child each: a `has_one` the database does not hold to one row (no
1366
+ # unique index on its key) is held by the code alone, and two requests at
1367
+ # once can leave two, of which the app then reads either.
1368
+ def one_child_invariants
1369
+ models.flat_map do |model|
1370
+ model.reflect_on_all_associations(:has_one).filter_map do |assoc|
1371
+ next if assoc.through_reflection? || assoc.options[:as] || (assoc.scope && assoc.scope.arity != 0)
1372
+
1373
+ child = assoc.klass rescue next
1374
+ fk = assoc.foreign_key.to_s
1375
+ next unless child.table_exists? && child.column_names.include?(fk)
1376
+ next if child.connection.indexes(child.table_name).any? { it.unique && it.columns.is_a?(Array) && it.columns.first == fk && it.columns.size == 1 && it.where.nil? }
1377
+
1378
+ rows = assoc.scope ? "#{model.name}.reflect_on_association(:#{assoc.name}).then { it.klass.instance_exec(&it.scope) }" : child.name
1379
+ <<~RUBY.chomp
1380
+ # drafted, check it is a real rule: #{model.name} has_one :#{assoc.name}, and no unique index holds it to one row.
1381
+ invariant(:#{model.model_name.singular}_has_one_#{assoc.name}) do
1382
+ bad = #{rows}.where.not(#{fk}: nil).group(:#{fk}).having("COUNT(*) > 1").count.keys
1383
+ bad.empty? || "#{model.model_name.plural} \#{bad}: more than one #{assoc.name}"
1384
+ end
1385
+ RUBY
1386
+ end
1387
+ end
1388
+ end
1389
+
1390
+ # Bounds a column name states: what is left never goes below zero, a
1391
+ # count never passes its max, and a percentage stays within 0..100.
1392
+ def bound_invariants
1393
+ models.flat_map do |model|
1394
+ cols = model.columns.select { %i[integer decimal float].include?(it.type) }.map(&:name)
1395
+ plural = model.model_name.plural
1396
+ rules = cols.grep(/(\A|_)(left|remaining)\z/).map do |col|
1397
+ ["#{plural}_#{col}_not_negative", "#{col} is never below zero", "#{model.name}.where(\"#{col} < 0\")"]
1398
+ end
1399
+ rules += cols.grep(/\Amax_(\w+)\z/).filter_map do |max|
1400
+ stem = max.delete_prefix("max_")
1401
+ count = cols.find { it == "#{stem}_count" } or next
1402
+ ["#{plural}_#{count}_within_#{max}", "#{count} never passes #{max}", "#{model.name}.where(\"#{max} IS NOT NULL AND #{count} > #{max}\")"]
1403
+ end
1404
+ rules += cols.grep(/percent/).map do |col|
1405
+ ["#{plural}_#{col}_within_0_100", "#{col} stays within 0..100", "#{model.name}.where(\"#{col} < 0 OR #{col} > 100\")"]
1406
+ end
1407
+ rules.map do |name, says, query|
1408
+ <<~RUBY.chomp
1409
+ # drafted, check it is a real rule: #{model.name}##{says}.
1410
+ invariant(:#{name}) do
1411
+ bad = #{query}.pluck(:id)
1412
+ bad.empty? || "#{plural} \#{bad} break the rule: #{says}"
1413
+ end
1414
+ RUBY
1415
+ end
1416
+ end
1417
+ end
1418
+
1419
+ def drafted_invariants = money_invariants + order_invariants + one_child_invariants + bound_invariants
1420
+
1421
+ # The drafted rules are live; the TODOs ask for the rest. A run with no
1422
+ # invariants still checks errors, 5xx, zones, offsets and doors.
1423
+ def invariants_file
1424
+ stateful = models.select { it.column_names.include?("status") }
1425
+ <<~RUBY
1426
+ # Rules that hold after every step, in every area. Each block returns true
1427
+ # (or nil) when the rule holds, or a message naming the rows that break it.
1428
+ # Keep `\#{...}` inside a plain double-quoted string; a block is Ruby, not a heredoc.
1429
+ Sequences.everywhere do
1430
+ #{drafted_invariants.join("\n\n").gsub(/^(?=.)/, " ")}
1431
+
1432
+ # invariant(:copy_on_loan_has_one_active_loan) do
1433
+ # bad = Copy.where(status: "on_loan").reject { Loan.active.where(copy_id: it.id).count == 1 }
1434
+ # bad.empty? || "copies \#{bad.map(&:id)} on loan without exactly one active loan"
1435
+ # end
1436
+ #{stateful.map { " # TODO invariant over #{it.name}.status: what must hold for each value?" }.join("\n")}
1437
+ end
1438
+ RUBY
1439
+ end
1440
+
1441
+ # An invariants.rb that exists keeps the agent's rules; a drafted rule it
1442
+ # does not name yet goes in before its last `end`.
1443
+ def add_drafted(path)
1444
+ text = File.read(path)
1445
+ missing = drafted_invariants.reject { text.include?(it[/invariant\(:(\w+)\)/, 1]) }
1446
+ return [] if missing.empty?
1447
+
1448
+ at = text.rindex(/^end\b/) or return []
1449
+ File.write(path, text[0...at] + missing.join("\n\n").gsub(/^(?=.)/, " ") + "\n" + text[at..])
1450
+ missing.map { it[/invariant\(:(\w+)\)/, 1] }
1451
+ end
1452
+
1453
+ def summarize(name, selected, stats, own)
1454
+ reached = selected.select { |d| stats[d.name][:outcomes].keys.any? { !it.include?("refused") } }
1455
+ "#{name}: #{selected.size} actions (#{reached.size} succeeded in the probe), writes #{own.first(6).join(", ")}"
1456
+ end
1457
+
1458
+ def main(argv)
1459
+ Rails.application.eager_load!
1460
+ force = argv.delete("--force")
1461
+ json_path = (i = argv.index("--probe-json")) && argv.slice!(i, 2).last
1462
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1463
+ if argv.delete("--all")
1464
+ stats = probe(drafts)
1465
+ File.write(json_path, JSON.pretty_generate(stats)) if json_path
1466
+ dir = Pathname(ENV.fetch("SEQUENCES_OUT", Rails.root.join("spec/sequences").to_s))
1467
+ dir.mkpath
1468
+ invariants = dir.join("invariants.rb")
1469
+ if invariants.exist?
1470
+ added = add_drafted(invariants)
1471
+ $stderr.puts "added drafted invariants to #{invariants}: #{added.join(", ")}" unless added.empty?
1472
+ else
1473
+ File.write(invariants, invariants_file)
1474
+ $stderr.puts "wrote #{invariants} with #{drafted_invariants.size} drafted invariants (check each is a real rule)"
1475
+ end
1476
+ areas(stats).each do |name, selected, own|
1477
+ inserted = made_by_own_actions(selected, stats)
1478
+ source = definition_source(name, selected, stats, refusals: app_refusals(stats, selected), failures:,
1479
+ setup: setup_lines(inserted, setup_tables(selected, stats)), note: "writes #{own.join(", ")}")
1480
+ path = dir.join("#{name}.rb")
1481
+ if path.exist? && !force
1482
+ $stderr.puts "kept #{path.relative_path_from(Rails.root)} (exists; --force replaces it)"
1483
+ else
1484
+ File.write(path, spec_file(name, source))
1485
+ $stderr.puts "wrote #{path.relative_path_from(Rails.root)} #{summarize(name, selected, stats, own)}"
1486
+ end
1487
+ end
1488
+ errors = stats.select { |_, st| st[:errors].any? }
1489
+ $stderr.puts "probe errors (an app class becomes a refusal; any other fails the run):" unless errors.empty?
1490
+ errors.each { |name, st| $stderr.puts " #{name}: #{st[:errors].map { |k, n| "#{k} #{n}" }.join(", ")}" }
1491
+ untouched = drafts.select { |d| stats[d.name][:runs].zero? }
1492
+ $stderr.puts "never drawn in the probe (a pool was empty): #{untouched.map(&:name).join(", ")}" unless untouched.empty?
1493
+ else
1494
+ patterns = argv.map { Regexp.new(it, Regexp::IGNORECASE) }
1495
+ abort "usage: bundle exec ruby propose.rb --all | AREA [AREA...]" if patterns.empty?
1496
+ selected = drafts.select { |d| patterns.any? { d.name.to_s.match?(it) || d.body.match?(it) } }
1497
+ stats = probe(selected)
1498
+ inserted = made_by_own_actions(selected, stats)
1499
+ name = argv.join("_").gsub(/[^a-z0-9_]+/i, "")
1500
+ puts spec_file(name, definition_source(name, selected, stats, refusals: app_refusals(stats, selected), failures:, setup: setup_lines(inserted, setup_tables(selected, stats))))
1501
+ end
1502
+ $stderr.puts "propose: #{(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round(1)} s"
1503
+ end
1504
+ end
1505
+
1506
+ Propose.main(ARGV.dup) if $PROGRAM_NAME == __FILE__