upkeep-rails 0.1.9

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 (65) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +311 -0
  4. data/docs/how-it-works.md +269 -0
  5. data/lib/generators/upkeep/install/install_generator.rb +127 -0
  6. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
  7. data/lib/generators/upkeep/install/templates/subscription.js +99 -0
  8. data/lib/generators/upkeep/install/templates/upkeep.rb +63 -0
  9. data/lib/upkeep/active_record_query.rb +392 -0
  10. data/lib/upkeep/capture/request.rb +150 -0
  11. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  12. data/lib/upkeep/dag.rb +370 -0
  13. data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
  14. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  15. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  16. data/lib/upkeep/delivery/transport.rb +194 -0
  17. data/lib/upkeep/delivery/turbo_streams.rb +302 -0
  18. data/lib/upkeep/delivery.rb +7 -0
  19. data/lib/upkeep/dependencies.rb +550 -0
  20. data/lib/upkeep/herb/developer_report.rb +135 -0
  21. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  22. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  23. data/lib/upkeep/herb/source_instrumenter.rb +149 -0
  24. data/lib/upkeep/herb/template_manifest.rb +518 -0
  25. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  26. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  27. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  28. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  29. data/lib/upkeep/invalidation/planner.rb +360 -0
  30. data/lib/upkeep/invalidation.rb +7 -0
  31. data/lib/upkeep/rails/action_view_capture.rb +920 -0
  32. data/lib/upkeep/rails/activation_token.rb +55 -0
  33. data/lib/upkeep/rails/cable/channel.rb +143 -0
  34. data/lib/upkeep/rails/cable/subscriber_identity.rb +341 -0
  35. data/lib/upkeep/rails/cable.rb +4 -0
  36. data/lib/upkeep/rails/client_subscription.rb +45 -0
  37. data/lib/upkeep/rails/configuration.rb +245 -0
  38. data/lib/upkeep/rails/controller_runtime.rb +154 -0
  39. data/lib/upkeep/rails/delivery_job.rb +29 -0
  40. data/lib/upkeep/rails/install.rb +28 -0
  41. data/lib/upkeep/rails/railtie.rb +50 -0
  42. data/lib/upkeep/rails/replay.rb +197 -0
  43. data/lib/upkeep/rails/testing.rb +258 -0
  44. data/lib/upkeep/rails.rb +370 -0
  45. data/lib/upkeep/replay.rb +439 -0
  46. data/lib/upkeep/runtime.rb +1202 -0
  47. data/lib/upkeep/shared_streams.rb +72 -0
  48. data/lib/upkeep/subscriptions/active_record_store.rb +387 -0
  49. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +407 -0
  50. data/lib/upkeep/subscriptions/active_registry.rb +87 -0
  51. data/lib/upkeep/subscriptions/async_durable_writer.rb +131 -0
  52. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  53. data/lib/upkeep/subscriptions/layered_reverse_index.rb +129 -0
  54. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +223 -0
  55. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  56. data/lib/upkeep/subscriptions/reverse_index.rb +301 -0
  57. data/lib/upkeep/subscriptions/shape.rb +116 -0
  58. data/lib/upkeep/subscriptions/store.rb +375 -0
  59. data/lib/upkeep/subscriptions.rb +7 -0
  60. data/lib/upkeep/targeting.rb +135 -0
  61. data/lib/upkeep/version.rb +5 -0
  62. data/lib/upkeep-rails.rb +3 -0
  63. data/lib/upkeep.rb +14 -0
  64. data/upkeep-rails.gemspec +54 -0
  65. metadata +308 -0
@@ -0,0 +1,550 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module Upkeep
7
+ module Dependencies
8
+ class Base
9
+ attr_reader :source, :key, :metadata
10
+
11
+ def initialize(source:, key:, metadata: {})
12
+ @source = source
13
+ @key = key
14
+ @metadata = metadata
15
+ end
16
+
17
+ def cache_key
18
+ [source, key]
19
+ end
20
+
21
+ def matches_change?(_change)
22
+ false
23
+ end
24
+
25
+ def identity?
26
+ false
27
+ end
28
+
29
+ def identity_key
30
+ nil
31
+ end
32
+
33
+ def visibility
34
+ :public
35
+ end
36
+
37
+ def precision
38
+ :unknown
39
+ end
40
+
41
+ def narrow_frame_safe?
42
+ false
43
+ end
44
+
45
+ def to_h
46
+ {
47
+ source: source,
48
+ key: key,
49
+ visibility: visibility,
50
+ precision: precision,
51
+ metadata: metadata
52
+ }
53
+ end
54
+ end
55
+
56
+ class ActiveRecordAttribute < Base
57
+ def initialize(table:, id:, attribute:, model: nil)
58
+ super(
59
+ source: :active_record_attribute,
60
+ key: { table: table, id: id, attribute: attribute },
61
+ metadata: { model: model }.compact
62
+ )
63
+ end
64
+
65
+ def matches_change?(change)
66
+ key.fetch(:table) == change.fetch(:table) &&
67
+ (key.fetch(:id).nil? || !change[:id] || key.fetch(:id) == change[:id]) &&
68
+ change.fetch(:changed_attributes, []).include?(key.fetch(:attribute))
69
+ end
70
+
71
+ def precision
72
+ :record_attribute
73
+ end
74
+
75
+ def narrow_frame_safe?
76
+ true
77
+ end
78
+ end
79
+
80
+ class ActiveRecordCollection < Base
81
+ UNKNOWN = Object.new
82
+
83
+ def initialize(
84
+ primary_table:,
85
+ table_columns:,
86
+ coverage:,
87
+ sql:,
88
+ predicates: [],
89
+ source: :active_record_collection,
90
+ precision: :collection_predicate
91
+ )
92
+ table_columns = normalize_table_columns(table_columns)
93
+ coverage = coverage.to_sym
94
+ unless coverage == :columns
95
+ raise ArgumentError,
96
+ "unsupported Active Record predicate coverage: #{coverage}; collection dependencies require proven column coverage"
97
+ end
98
+
99
+ @precision = precision.to_sym
100
+ super(
101
+ source: source.to_sym,
102
+ key: {
103
+ table: primary_table,
104
+ predicate_digest: Digest::SHA256.hexdigest(sql)[0, 16]
105
+ },
106
+ metadata: {
107
+ primary_table: primary_table,
108
+ table_columns: table_columns,
109
+ coverage: coverage.to_s,
110
+ sql: sql,
111
+ predicates: normalize_predicates(predicates)
112
+ }
113
+ )
114
+ end
115
+
116
+ def matches_change?(change)
117
+ return false unless table_columns.key?(change.fetch(:table))
118
+
119
+ predicate_match = predicate_match(change)
120
+ return predicate_match unless predicate_match == UNKNOWN
121
+
122
+ return true if create_change?(change)
123
+ return true if delete_change?(change)
124
+
125
+ table_columns.fetch(change.fetch(:table)).intersect?(change.fetch(:changed_attributes, []))
126
+ end
127
+
128
+ def precision
129
+ @precision
130
+ end
131
+
132
+ def collection_lookup_tables
133
+ table_columns.keys.sort
134
+ end
135
+
136
+ def collection_lookup_columns
137
+ table_columns.flat_map do |table, columns|
138
+ columns.map { |column| [table, column] }
139
+ end.sort
140
+ end
141
+
142
+ private
143
+
144
+ def predicate_match(change)
145
+ predicates = predicates_for_table(change.fetch(:table))
146
+ return UNKNOWN if predicates.empty?
147
+
148
+ old_match = values_match_predicates(change.fetch(:old_values, {}), predicates)
149
+ new_match = values_match_predicates(change.fetch(:new_values, {}), predicates)
150
+ return true if old_match == true || new_match == true
151
+
152
+ if old_match == false || new_match == false
153
+ return false if predicate_columns(predicates).intersect?(change.fetch(:changed_attributes, [])) ||
154
+ create_change?(change) ||
155
+ delete_change?(change)
156
+ end
157
+
158
+ UNKNOWN
159
+ end
160
+
161
+ def create_change?(change)
162
+ change.fetch(:type).to_s.include?("create")
163
+ end
164
+
165
+ def delete_change?(change)
166
+ type = change.fetch(:type).to_s
167
+ type.include?("delete") || type.include?("destroy")
168
+ end
169
+
170
+ def values_match_predicates(values, predicates)
171
+ values = stringify_keys(values)
172
+ group_matches = predicate_groups(predicates).map do |_group, group_predicates|
173
+ values_match_predicate_group(values, group_predicates)
174
+ end
175
+
176
+ return true if group_matches.include?(true)
177
+ return false if group_matches.all?(false)
178
+
179
+ UNKNOWN
180
+ end
181
+
182
+ def values_match_predicate_group(values, predicates)
183
+ return UNKNOWN unless predicates.all? { |predicate| values.key?(predicate.fetch(:column)) }
184
+
185
+ matches = predicates.map do |predicate|
186
+ predicate_matches_value?(predicate, values.fetch(predicate.fetch(:column)))
187
+ end
188
+ return UNKNOWN if matches.include?(UNKNOWN)
189
+
190
+ matches.all?
191
+ end
192
+
193
+ def predicate_matches_value?(predicate, value)
194
+ values = predicate.fetch(:values)
195
+
196
+ case predicate.fetch(:operator).to_s
197
+ when "eq", "in"
198
+ values.include?(value)
199
+ when "not_eq", "not_in"
200
+ !value.nil? && !values.include?(value)
201
+ else
202
+ UNKNOWN
203
+ end
204
+ end
205
+
206
+ def predicate_groups(predicates)
207
+ predicates.group_by { |predicate| predicate.fetch(:group, "__all__").to_s }
208
+ end
209
+
210
+ def predicates_for_table(table)
211
+ predicates.select { |predicate| predicate.fetch(:table) == table.to_s }
212
+ end
213
+
214
+ def predicate_columns(predicates)
215
+ predicates.map { |predicate| predicate.fetch(:column) }.uniq
216
+ end
217
+
218
+ def coverage
219
+ metadata.fetch(:coverage).to_sym.tap do |value|
220
+ raise ArgumentError, "unsupported Active Record collection coverage: #{value}" unless value == :columns
221
+ end
222
+ end
223
+
224
+ def table_columns
225
+ metadata.fetch(:table_columns)
226
+ end
227
+
228
+ def predicates
229
+ metadata.fetch(:predicates)
230
+ end
231
+
232
+ def normalize_table_columns(value)
233
+ Dependencies.symbolize_keys(value).to_h do |table, columns|
234
+ [table.to_s, Array(columns).map(&:to_s).uniq.sort]
235
+ end
236
+ end
237
+
238
+ def normalize_predicates(value)
239
+ Array(value).filter_map do |predicate|
240
+ predicate = Dependencies.symbolize_keys(predicate)
241
+ values = Array(predicate[:values])
242
+ next if predicate[:table].nil? || predicate[:column].nil? || values.empty?
243
+
244
+ normalized = {
245
+ table: predicate.fetch(:table).to_s,
246
+ column: predicate.fetch(:column).to_s,
247
+ operator: predicate.fetch(:operator, "eq").to_s,
248
+ values: values.uniq
249
+ }
250
+ normalized[:group] = predicate.fetch(:group).to_s if predicate.key?(:group)
251
+ normalized
252
+ end
253
+ end
254
+
255
+ def stringify_keys(values)
256
+ values.to_h.transform_keys(&:to_s)
257
+ end
258
+ end
259
+
260
+ class ActiveRecordQuery < ActiveRecordCollection
261
+ def initialize(primary_table:, table_columns:, coverage:, sql:, predicates: [])
262
+ super(
263
+ primary_table: primary_table,
264
+ table_columns: table_columns,
265
+ coverage: coverage,
266
+ sql: sql,
267
+ predicates: predicates,
268
+ source: :active_record_query,
269
+ precision: :query_predicate
270
+ )
271
+ end
272
+ end
273
+
274
+ class Identity < Base
275
+ def initialize(source:, key:, value:, metadata: {}, partitioning: nil, absent_by_name: nil)
276
+ metadata = metadata.dup
277
+ metadata[:partitioning_identity] = partitioning unless partitioning.nil?
278
+ if absent_by_name&.any?
279
+ metadata[:identity_absent_by_name] = absent_by_name.to_h.transform_keys(&:to_s)
280
+ end
281
+
282
+ super(
283
+ source: source,
284
+ key: { key: key, value: value },
285
+ metadata: metadata
286
+ )
287
+ end
288
+
289
+ def identity?
290
+ true
291
+ end
292
+
293
+ def identity_key
294
+ { source: source, key: key.fetch(:key), value: key.fetch(:value) }
295
+ end
296
+
297
+ def nil_identity?
298
+ return true if key.fetch(:value).nil?
299
+
300
+ value_class = metadata[:value_class] || metadata["value_class"]
301
+ value_class.to_s == "NilClass"
302
+ end
303
+
304
+ def visibility
305
+ :identity_bound
306
+ end
307
+
308
+ def precision
309
+ :identity
310
+ end
311
+ end
312
+
313
+ class WardenUser < Identity
314
+ def initialize(scope:, user:, partitioning: nil, absent_by_name: nil)
315
+ super(
316
+ source: :warden_user,
317
+ key: scope.to_s,
318
+ value: Dependencies.model_identity(user),
319
+ metadata: Dependencies.model_metadata(user).merge(scope: scope.to_s),
320
+ partitioning: partitioning,
321
+ absent_by_name: absent_by_name
322
+ )
323
+ end
324
+ end
325
+
326
+ class CurrentAttribute < Identity
327
+ def initialize(owner:, name:, value:, partitioning: nil, absent_by_name: nil)
328
+ super(
329
+ source: :current_attribute,
330
+ key: "#{owner}.#{name}",
331
+ value: Dependencies.canonical_identity(value),
332
+ metadata: { owner: owner.to_s, name: name.to_s },
333
+ partitioning: partitioning,
334
+ absent_by_name: absent_by_name
335
+ )
336
+ end
337
+ end
338
+
339
+ class SessionValue < Identity
340
+ def initialize(key:, value:, partitioning: nil, absent_by_name: nil)
341
+ super(
342
+ source: :session,
343
+ key: key.to_s,
344
+ value: Dependencies.private_fingerprint(value),
345
+ metadata: { key: key.to_s, value_class: value.class.name },
346
+ partitioning: partitioning,
347
+ absent_by_name: absent_by_name
348
+ )
349
+ end
350
+ end
351
+
352
+ class CookieValue < Identity
353
+ def initialize(key:, value:, partitioning: nil, absent_by_name: nil)
354
+ super(
355
+ source: :cookie,
356
+ key: key.to_s,
357
+ value: Dependencies.private_fingerprint(value),
358
+ metadata: { key: key.to_s, value_class: value.class.name },
359
+ partitioning: partitioning,
360
+ absent_by_name: absent_by_name
361
+ )
362
+ end
363
+ end
364
+
365
+ class RequestValue < Identity
366
+ def initialize(key:, value:)
367
+ super(
368
+ source: :request,
369
+ key: key.to_s,
370
+ value: Dependencies.private_fingerprint(value),
371
+ metadata: { key: key.to_s, value_class: value.class.name }
372
+ )
373
+ end
374
+ end
375
+
376
+ class Unknown < Base
377
+ def initialize(source:, metadata: {})
378
+ super(
379
+ source: source,
380
+ key: Digest::SHA256.hexdigest(metadata.inspect)[0, 16],
381
+ metadata: metadata
382
+ )
383
+ end
384
+
385
+ def visibility
386
+ :private
387
+ end
388
+ end
389
+
390
+ class Restored < Base
391
+ def initialize(source:, key:, metadata:, visibility:, precision:)
392
+ super(source: source, key: key, metadata: metadata)
393
+ @visibility = visibility.to_sym
394
+ @precision = precision.to_sym
395
+ end
396
+
397
+ def identity?
398
+ visibility == :identity_bound
399
+ end
400
+
401
+ def identity_key
402
+ return unless identity?
403
+
404
+ { source: source, key: key.fetch(:key), value: key.fetch(:value) }
405
+ end
406
+
407
+ attr_reader :visibility, :precision
408
+ end
409
+
410
+ module_function
411
+
412
+ def from_h(snapshot)
413
+ snapshot = symbolize_keys(snapshot)
414
+ source = snapshot.fetch(:source)
415
+ key = symbolize_keys(snapshot.fetch(:key))
416
+ metadata = symbolize_keys(snapshot.fetch(:metadata))
417
+
418
+ case source.to_sym
419
+ when :active_record_attribute
420
+ ActiveRecordAttribute.new(
421
+ table: key.fetch(:table),
422
+ id: key.fetch(:id),
423
+ attribute: key.fetch(:attribute),
424
+ model: metadata[:model]
425
+ )
426
+ when :active_record_collection, :active_record_query
427
+ dependency_class = source.to_sym == :active_record_query ? ActiveRecordQuery : ActiveRecordCollection
428
+ dependency_class.new(
429
+ primary_table: metadata.fetch(:primary_table),
430
+ table_columns: metadata.fetch(:table_columns),
431
+ coverage: metadata.fetch(:coverage),
432
+ sql: metadata.fetch(:sql),
433
+ predicates: metadata.fetch(:predicates)
434
+ )
435
+ else
436
+ if snapshot.fetch(:visibility).to_sym == :identity_bound
437
+ Identity.new(
438
+ source: source,
439
+ key: key.fetch(:key),
440
+ value: key.fetch(:value),
441
+ metadata: metadata
442
+ )
443
+ else
444
+ Restored.new(
445
+ source: source,
446
+ key: key,
447
+ metadata: metadata,
448
+ visibility: snapshot.fetch(:visibility),
449
+ precision: snapshot.fetch(:precision)
450
+ )
451
+ end
452
+ end
453
+ end
454
+
455
+ def model_identity(value)
456
+ return nil unless value
457
+
458
+ if value.respond_to?(:id) && value.class.respond_to?(:name)
459
+ { model: value.class.name, id: value.id }
460
+ end
461
+ end
462
+
463
+ def model_metadata(value)
464
+ return {} unless value
465
+
466
+ {
467
+ model: value.class.name,
468
+ table: value.class.respond_to?(:table_name) ? value.class.table_name : nil,
469
+ id: value.respond_to?(:id) ? value.id : nil
470
+ }.compact
471
+ end
472
+
473
+ def canonical_identity(value)
474
+ case value
475
+ when nil, true, false, Numeric, String, Symbol
476
+ value
477
+ else
478
+ model_identity(value) || private_fingerprint(value)
479
+ end
480
+ end
481
+
482
+ def partitioning_identity?(dependency)
483
+ return false unless dependency.identity?
484
+
485
+ flag = metadata_value(dependency, :partitioning_identity)
486
+ return flag if [true, false].include?(flag)
487
+
488
+ !nil_identity?(dependency)
489
+ end
490
+
491
+ def identity_absent_for?(dependency, name)
492
+ absent_by_name = metadata_value(dependency, :identity_absent_by_name) || {}
493
+ absent_by_name = absent_by_name.transform_keys(&:to_s) if absent_by_name.respond_to?(:transform_keys)
494
+ return absent_by_name.fetch(name.to_s) if absent_by_name.key?(name.to_s)
495
+
496
+ !partitioning_identity?(dependency)
497
+ end
498
+
499
+ def nil_identity?(dependency)
500
+ dependency.respond_to?(:nil_identity?) && dependency.nil_identity?
501
+ end
502
+
503
+ def metadata_value(dependency, key)
504
+ return unless dependency.respond_to?(:metadata)
505
+
506
+ if dependency.metadata.key?(key)
507
+ dependency.metadata.fetch(key)
508
+ elsif dependency.metadata.key?(key.to_s)
509
+ dependency.metadata.fetch(key.to_s)
510
+ end
511
+ end
512
+
513
+ def private_fingerprint(value)
514
+ Digest::SHA256.hexdigest(JSON.generate(private_fingerprint_payload(value)))[0, 16]
515
+ end
516
+
517
+ def private_fingerprint_payload(value)
518
+ case value
519
+ when nil, true, false, Numeric, String
520
+ [value.class.name, value]
521
+ when Symbol
522
+ ["Symbol", value.to_s]
523
+ when Array
524
+ ["Array", value.map { |item| private_fingerprint_payload(item) }]
525
+ when Hash
526
+ entries = value.keys.sort_by { |key| JSON.generate(private_fingerprint_payload(key)) }.map do |key|
527
+ [private_fingerprint_payload(key), private_fingerprint_payload(value.fetch(key))]
528
+ end
529
+ ["Hash", entries]
530
+ else
531
+ identity = model_identity(value)
532
+ identity ? ["Model", identity] : ["Object", value.class.name, value.inspect]
533
+ end
534
+ end
535
+
536
+ def symbolize_keys(value)
537
+ case value
538
+ when Hash
539
+ value.each_with_object({}) do |(key, nested_value), result|
540
+ normalized_key = key.respond_to?(:to_sym) ? key.to_sym : key
541
+ result[normalized_key] = symbolize_keys(nested_value)
542
+ end
543
+ when Array
544
+ value.map { |nested_value| symbolize_keys(nested_value) }
545
+ else
546
+ value
547
+ end
548
+ end
549
+ end
550
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "template_manifest"
4
+
5
+ module Upkeep
6
+ module HerbSupport
7
+ class DeveloperReport
8
+ FALLBACK_ACTIONS = {
9
+ "helper_hidden_collection" => "Move collection rendering out of helper-only HTML and into an explicit render site.",
10
+ "manifest_runtime_mismatch" => "Inspect manifest provenance mismatches before trusting narrow updates.",
11
+ "multi_root_partial" => "Wrap the partial in one stable root element so it can carry a fragment marker.",
12
+ "no_herb_render_site" => "Extract updateable repeated markup into a partial render so Herb can plan a render site.",
13
+ "page_dependency_without_narrower_frame" => "Add a fragment or render-site boundary around the data-dependent region.",
14
+ "parse_failure" => "Fix the Herb parse error before using source-derived update addresses.",
15
+ "parse_recovered" => "Herb recovered with non-strict parsing, but Upkeep kept narrow source-derived addresses disabled. Fix the strict warnings to enable narrow updates.",
16
+ "preloaded_plain_data" => "Keep record identity available to the view or attach summaries to their source records."
17
+ }.freeze
18
+
19
+ def initialize(manifests:, proof_report: nil)
20
+ @manifests = manifests
21
+ @proof_report = proof_report
22
+ end
23
+
24
+ def to_h
25
+ actions = template_actions + fallback_actions
26
+
27
+ {
28
+ summary: TemplateManifest.summary(manifests).merge(
29
+ page_fallback_reasons: page_fallback_reasons,
30
+ actionable_items: actions.size,
31
+ gate_passed: actions.all? { |action| action.fetch(:message) }
32
+ ),
33
+ templates: manifests.map { |manifest| template_report(manifest) },
34
+ actions: actions
35
+ }
36
+ end
37
+
38
+ private
39
+
40
+ attr_reader :manifests, :proof_report
41
+
42
+ def template_report(manifest)
43
+ {
44
+ path: manifest.path,
45
+ kind: manifest.partial? ? "partial" : "page",
46
+ parse_ok: manifest.parse.fetch(:ok),
47
+ parse_recovered: manifest.recovered?,
48
+ strict_parse_errors: strict_parse_errors(manifest),
49
+ parse_warnings: parse_warnings(manifest),
50
+ render_sites: manifest.render_nodes.size,
51
+ recovered_render_sites: manifest.recovery_render_nodes.size,
52
+ fragment_root_tags: manifest.frontend_tag_plan.count { |tag| tag.fetch(:kind) == "fragment_root" },
53
+ helper_lowered_elements: manifest.helper_lowered_elements.size,
54
+ blockers: template_blockers(manifest)
55
+ }
56
+ end
57
+
58
+ def template_actions
59
+ manifests.flat_map do |manifest|
60
+ template_blockers(manifest).map do |blocker|
61
+ action = {
62
+ source: "template",
63
+ path: manifest.path,
64
+ reason: blocker,
65
+ message: template_action_message(blocker)
66
+ }
67
+ action[:recovered_render_sites] = manifest.recovery_render_nodes.size if blocker == "parse_recovered"
68
+ action
69
+ end
70
+ end
71
+ end
72
+
73
+ def template_blockers(manifest)
74
+ blockers = []
75
+ blockers << (manifest.recovered? ? "parse_recovered" : "parse_failure") unless manifest.parse.fetch(:ok)
76
+ blockers << "partial_without_single_root" if manifest.partial? && manifest.parse.fetch(:ok) && !manifest.root_shape.fetch(:single_root, false)
77
+ blockers << "page_without_render_site" if !manifest.partial? && manifest.parse.fetch(:ok) && manifest.render_nodes.empty?
78
+ blockers
79
+ end
80
+
81
+ def template_action_message(blocker)
82
+ case blocker
83
+ when "parse_failure"
84
+ FALLBACK_ACTIONS.fetch("parse_failure")
85
+ when "parse_recovered"
86
+ FALLBACK_ACTIONS.fetch("parse_recovered")
87
+ when "partial_without_single_root"
88
+ FALLBACK_ACTIONS.fetch("multi_root_partial")
89
+ when "page_without_render_site"
90
+ FALLBACK_ACTIONS.fetch("no_herb_render_site")
91
+ end
92
+ end
93
+
94
+ def fallback_actions
95
+ proof_cases.flat_map do |test_case|
96
+ test_case.fetch(:selected_targets).filter_map do |target|
97
+ next unless target.fetch(:kind) == "page"
98
+
99
+ fallback_reason = target[:fallback_reason]
100
+ next unless fallback_reason
101
+
102
+ {
103
+ source: "proof",
104
+ case: test_case.fetch(:name),
105
+ target: target.fetch(:id),
106
+ reason: fallback_reason,
107
+ message: FALLBACK_ACTIONS.fetch(fallback_reason, "Add a narrower template boundary or keep the page fallback.")
108
+ }
109
+ end
110
+ end
111
+ end
112
+
113
+ def proof_cases
114
+ Array(proof_report&.fetch(:cases, []))
115
+ end
116
+
117
+ def page_fallback_reasons
118
+ proof_report&.fetch(:summary, {})&.fetch(:page_fallback_reasons, {}) || {}
119
+ end
120
+
121
+ def strict_parse_errors(manifest)
122
+ Array(manifest.parse[:errors])
123
+ end
124
+
125
+ def parse_warnings(manifest)
126
+ warnings = Array(manifest.parse[:warnings])
127
+ return warnings unless manifest.recovered?
128
+
129
+ warnings + strict_parse_errors(manifest).map do |error|
130
+ error.merge(severity: "warning", recovery: "non_strict")
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end