upkeep-rails 0.1.6

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.

Potentially problematic release.


This version of upkeep-rails might be problematic. Click here for more details.

Files changed (77) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +614 -0
  4. data/docs/architecture/ambient-inputs-roadmap.md +308 -0
  5. data/docs/architecture/herb-roadmap.md +324 -0
  6. data/docs/architecture/identity-and-sharing.md +306 -0
  7. data/docs/architecture/query-dependencies.md +230 -0
  8. data/docs/architecture/subscription-store-contract.md +66 -0
  9. data/docs/cost-model-roadmap.md +704 -0
  10. data/docs/guides/getting-started.md +462 -0
  11. data/docs/handoff-2026-05-15.md +230 -0
  12. data/docs/production_roadmap.md +372 -0
  13. data/docs/shared-warm-scale-roadmap.md +214 -0
  14. data/docs/single-subscriber-cold-roadmap.md +192 -0
  15. data/docs/stress-test-findings.md +310 -0
  16. data/docs/testing.md +143 -0
  17. data/lib/generators/upkeep/install/install_generator.rb +127 -0
  18. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
  19. data/lib/generators/upkeep/install/templates/subscription.js +99 -0
  20. data/lib/generators/upkeep/install/templates/upkeep.rb +63 -0
  21. data/lib/upkeep/active_record_query.rb +294 -0
  22. data/lib/upkeep/capture/request.rb +150 -0
  23. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  24. data/lib/upkeep/dag.rb +370 -0
  25. data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
  26. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  27. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  28. data/lib/upkeep/delivery/transport.rb +194 -0
  29. data/lib/upkeep/delivery/turbo_streams.rb +302 -0
  30. data/lib/upkeep/delivery.rb +7 -0
  31. data/lib/upkeep/dependencies.rb +518 -0
  32. data/lib/upkeep/herb/developer_report.rb +135 -0
  33. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  34. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  35. data/lib/upkeep/herb/source_instrumenter.rb +149 -0
  36. data/lib/upkeep/herb/template_manifest.rb +514 -0
  37. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  38. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  39. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  40. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  41. data/lib/upkeep/invalidation/planner.rb +360 -0
  42. data/lib/upkeep/invalidation.rb +7 -0
  43. data/lib/upkeep/rails/action_view_capture.rb +821 -0
  44. data/lib/upkeep/rails/activation_token.rb +55 -0
  45. data/lib/upkeep/rails/cable/channel.rb +143 -0
  46. data/lib/upkeep/rails/cable/subscriber_identity.rb +341 -0
  47. data/lib/upkeep/rails/cable.rb +4 -0
  48. data/lib/upkeep/rails/client_subscription.rb +45 -0
  49. data/lib/upkeep/rails/configuration.rb +245 -0
  50. data/lib/upkeep/rails/controller_runtime.rb +137 -0
  51. data/lib/upkeep/rails/delivery_job.rb +29 -0
  52. data/lib/upkeep/rails/install.rb +28 -0
  53. data/lib/upkeep/rails/railtie.rb +50 -0
  54. data/lib/upkeep/rails/replay.rb +176 -0
  55. data/lib/upkeep/rails/testing.rb +97 -0
  56. data/lib/upkeep/rails.rb +349 -0
  57. data/lib/upkeep/replay.rb +408 -0
  58. data/lib/upkeep/runtime.rb +1100 -0
  59. data/lib/upkeep/shared_streams.rb +72 -0
  60. data/lib/upkeep/subscriptions/active_record_store.rb +383 -0
  61. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +407 -0
  62. data/lib/upkeep/subscriptions/active_registry.rb +87 -0
  63. data/lib/upkeep/subscriptions/async_durable_writer.rb +131 -0
  64. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  65. data/lib/upkeep/subscriptions/layered_reverse_index.rb +129 -0
  66. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +223 -0
  67. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  68. data/lib/upkeep/subscriptions/reverse_index.rb +298 -0
  69. data/lib/upkeep/subscriptions/shape.rb +116 -0
  70. data/lib/upkeep/subscriptions/store.rb +171 -0
  71. data/lib/upkeep/subscriptions.rb +7 -0
  72. data/lib/upkeep/targeting.rb +135 -0
  73. data/lib/upkeep/version.rb +5 -0
  74. data/lib/upkeep-rails.rb +3 -0
  75. data/lib/upkeep.rb +14 -0
  76. data/upkeep-rails.gemspec +54 -0
  77. metadata +320 -0
@@ -0,0 +1,518 @@
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
+ (!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
+ return UNKNOWN unless predicates.all? { |predicate| values.key?(predicate.fetch(:column)) }
173
+
174
+ predicates.all? do |predicate|
175
+ value = values.fetch(predicate.fetch(:column))
176
+ predicate.fetch(:values).include?(value)
177
+ end
178
+ end
179
+
180
+ def predicates_for_table(table)
181
+ predicates.select { |predicate| predicate.fetch(:table) == table.to_s }
182
+ end
183
+
184
+ def predicate_columns(predicates)
185
+ predicates.map { |predicate| predicate.fetch(:column) }.uniq
186
+ end
187
+
188
+ def coverage
189
+ metadata.fetch(:coverage).to_sym.tap do |value|
190
+ raise ArgumentError, "unsupported Active Record collection coverage: #{value}" unless value == :columns
191
+ end
192
+ end
193
+
194
+ def table_columns
195
+ metadata.fetch(:table_columns)
196
+ end
197
+
198
+ def predicates
199
+ metadata.fetch(:predicates)
200
+ end
201
+
202
+ def normalize_table_columns(value)
203
+ Dependencies.symbolize_keys(value).to_h do |table, columns|
204
+ [table.to_s, Array(columns).map(&:to_s).uniq.sort]
205
+ end
206
+ end
207
+
208
+ def normalize_predicates(value)
209
+ Array(value).filter_map do |predicate|
210
+ predicate = Dependencies.symbolize_keys(predicate)
211
+ values = Array(predicate[:values]).compact
212
+ next if predicate[:table].nil? || predicate[:column].nil? || values.empty?
213
+
214
+ {
215
+ table: predicate.fetch(:table).to_s,
216
+ column: predicate.fetch(:column).to_s,
217
+ operator: predicate.fetch(:operator, "eq").to_s,
218
+ values: values.uniq
219
+ }
220
+ end
221
+ end
222
+
223
+ def stringify_keys(values)
224
+ values.to_h.transform_keys(&:to_s)
225
+ end
226
+ end
227
+
228
+ class ActiveRecordQuery < ActiveRecordCollection
229
+ def initialize(primary_table:, table_columns:, coverage:, sql:, predicates: [])
230
+ super(
231
+ primary_table: primary_table,
232
+ table_columns: table_columns,
233
+ coverage: coverage,
234
+ sql: sql,
235
+ predicates: predicates,
236
+ source: :active_record_query,
237
+ precision: :query_predicate
238
+ )
239
+ end
240
+ end
241
+
242
+ class Identity < Base
243
+ def initialize(source:, key:, value:, metadata: {}, partitioning: nil, absent_by_name: nil)
244
+ metadata = metadata.dup
245
+ metadata[:partitioning_identity] = partitioning unless partitioning.nil?
246
+ if absent_by_name&.any?
247
+ metadata[:identity_absent_by_name] = absent_by_name.to_h.transform_keys(&:to_s)
248
+ end
249
+
250
+ super(
251
+ source: source,
252
+ key: { key: key, value: value },
253
+ metadata: metadata
254
+ )
255
+ end
256
+
257
+ def identity?
258
+ true
259
+ end
260
+
261
+ def identity_key
262
+ { source: source, key: key.fetch(:key), value: key.fetch(:value) }
263
+ end
264
+
265
+ def nil_identity?
266
+ return true if key.fetch(:value).nil?
267
+
268
+ value_class = metadata[:value_class] || metadata["value_class"]
269
+ value_class.to_s == "NilClass"
270
+ end
271
+
272
+ def visibility
273
+ :identity_bound
274
+ end
275
+
276
+ def precision
277
+ :identity
278
+ end
279
+ end
280
+
281
+ class WardenUser < Identity
282
+ def initialize(scope:, user:, partitioning: nil, absent_by_name: nil)
283
+ super(
284
+ source: :warden_user,
285
+ key: scope.to_s,
286
+ value: Dependencies.model_identity(user),
287
+ metadata: Dependencies.model_metadata(user).merge(scope: scope.to_s),
288
+ partitioning: partitioning,
289
+ absent_by_name: absent_by_name
290
+ )
291
+ end
292
+ end
293
+
294
+ class CurrentAttribute < Identity
295
+ def initialize(owner:, name:, value:, partitioning: nil, absent_by_name: nil)
296
+ super(
297
+ source: :current_attribute,
298
+ key: "#{owner}.#{name}",
299
+ value: Dependencies.canonical_identity(value),
300
+ metadata: { owner: owner.to_s, name: name.to_s },
301
+ partitioning: partitioning,
302
+ absent_by_name: absent_by_name
303
+ )
304
+ end
305
+ end
306
+
307
+ class SessionValue < Identity
308
+ def initialize(key:, value:, partitioning: nil, absent_by_name: nil)
309
+ super(
310
+ source: :session,
311
+ key: key.to_s,
312
+ value: Dependencies.private_fingerprint(value),
313
+ metadata: { key: key.to_s, value_class: value.class.name },
314
+ partitioning: partitioning,
315
+ absent_by_name: absent_by_name
316
+ )
317
+ end
318
+ end
319
+
320
+ class CookieValue < Identity
321
+ def initialize(key:, value:, partitioning: nil, absent_by_name: nil)
322
+ super(
323
+ source: :cookie,
324
+ key: key.to_s,
325
+ value: Dependencies.private_fingerprint(value),
326
+ metadata: { key: key.to_s, value_class: value.class.name },
327
+ partitioning: partitioning,
328
+ absent_by_name: absent_by_name
329
+ )
330
+ end
331
+ end
332
+
333
+ class RequestValue < Identity
334
+ def initialize(key:, value:)
335
+ super(
336
+ source: :request,
337
+ key: key.to_s,
338
+ value: Dependencies.private_fingerprint(value),
339
+ metadata: { key: key.to_s, value_class: value.class.name }
340
+ )
341
+ end
342
+ end
343
+
344
+ class Unknown < Base
345
+ def initialize(source:, metadata: {})
346
+ super(
347
+ source: source,
348
+ key: Digest::SHA256.hexdigest(metadata.inspect)[0, 16],
349
+ metadata: metadata
350
+ )
351
+ end
352
+
353
+ def visibility
354
+ :private
355
+ end
356
+ end
357
+
358
+ class Restored < Base
359
+ def initialize(source:, key:, metadata:, visibility:, precision:)
360
+ super(source: source, key: key, metadata: metadata)
361
+ @visibility = visibility.to_sym
362
+ @precision = precision.to_sym
363
+ end
364
+
365
+ def identity?
366
+ visibility == :identity_bound
367
+ end
368
+
369
+ def identity_key
370
+ return unless identity?
371
+
372
+ { source: source, key: key.fetch(:key), value: key.fetch(:value) }
373
+ end
374
+
375
+ attr_reader :visibility, :precision
376
+ end
377
+
378
+ module_function
379
+
380
+ def from_h(snapshot)
381
+ snapshot = symbolize_keys(snapshot)
382
+ source = snapshot.fetch(:source)
383
+ key = symbolize_keys(snapshot.fetch(:key))
384
+ metadata = symbolize_keys(snapshot.fetch(:metadata))
385
+
386
+ case source.to_sym
387
+ when :active_record_attribute
388
+ ActiveRecordAttribute.new(
389
+ table: key.fetch(:table),
390
+ id: key.fetch(:id),
391
+ attribute: key.fetch(:attribute),
392
+ model: metadata[:model]
393
+ )
394
+ when :active_record_collection, :active_record_query
395
+ dependency_class = source.to_sym == :active_record_query ? ActiveRecordQuery : ActiveRecordCollection
396
+ dependency_class.new(
397
+ primary_table: metadata.fetch(:primary_table),
398
+ table_columns: metadata.fetch(:table_columns),
399
+ coverage: metadata.fetch(:coverage),
400
+ sql: metadata.fetch(:sql),
401
+ predicates: metadata.fetch(:predicates)
402
+ )
403
+ else
404
+ if snapshot.fetch(:visibility).to_sym == :identity_bound
405
+ Identity.new(
406
+ source: source,
407
+ key: key.fetch(:key),
408
+ value: key.fetch(:value),
409
+ metadata: metadata
410
+ )
411
+ else
412
+ Restored.new(
413
+ source: source,
414
+ key: key,
415
+ metadata: metadata,
416
+ visibility: snapshot.fetch(:visibility),
417
+ precision: snapshot.fetch(:precision)
418
+ )
419
+ end
420
+ end
421
+ end
422
+
423
+ def model_identity(value)
424
+ return nil unless value
425
+
426
+ if value.respond_to?(:id) && value.class.respond_to?(:name)
427
+ { model: value.class.name, id: value.id }
428
+ end
429
+ end
430
+
431
+ def model_metadata(value)
432
+ return {} unless value
433
+
434
+ {
435
+ model: value.class.name,
436
+ table: value.class.respond_to?(:table_name) ? value.class.table_name : nil,
437
+ id: value.respond_to?(:id) ? value.id : nil
438
+ }.compact
439
+ end
440
+
441
+ def canonical_identity(value)
442
+ case value
443
+ when nil, true, false, Numeric, String, Symbol
444
+ value
445
+ else
446
+ model_identity(value) || private_fingerprint(value)
447
+ end
448
+ end
449
+
450
+ def partitioning_identity?(dependency)
451
+ return false unless dependency.identity?
452
+
453
+ flag = metadata_value(dependency, :partitioning_identity)
454
+ return flag if [true, false].include?(flag)
455
+
456
+ !nil_identity?(dependency)
457
+ end
458
+
459
+ def identity_absent_for?(dependency, name)
460
+ absent_by_name = metadata_value(dependency, :identity_absent_by_name) || {}
461
+ absent_by_name = absent_by_name.transform_keys(&:to_s) if absent_by_name.respond_to?(:transform_keys)
462
+ return absent_by_name.fetch(name.to_s) if absent_by_name.key?(name.to_s)
463
+
464
+ !partitioning_identity?(dependency)
465
+ end
466
+
467
+ def nil_identity?(dependency)
468
+ dependency.respond_to?(:nil_identity?) && dependency.nil_identity?
469
+ end
470
+
471
+ def metadata_value(dependency, key)
472
+ return unless dependency.respond_to?(:metadata)
473
+
474
+ if dependency.metadata.key?(key)
475
+ dependency.metadata.fetch(key)
476
+ elsif dependency.metadata.key?(key.to_s)
477
+ dependency.metadata.fetch(key.to_s)
478
+ end
479
+ end
480
+
481
+ def private_fingerprint(value)
482
+ Digest::SHA256.hexdigest(JSON.generate(private_fingerprint_payload(value)))[0, 16]
483
+ end
484
+
485
+ def private_fingerprint_payload(value)
486
+ case value
487
+ when nil, true, false, Numeric, String
488
+ [value.class.name, value]
489
+ when Symbol
490
+ ["Symbol", value.to_s]
491
+ when Array
492
+ ["Array", value.map { |item| private_fingerprint_payload(item) }]
493
+ when Hash
494
+ entries = value.keys.sort_by { |key| JSON.generate(private_fingerprint_payload(key)) }.map do |key|
495
+ [private_fingerprint_payload(key), private_fingerprint_payload(value.fetch(key))]
496
+ end
497
+ ["Hash", entries]
498
+ else
499
+ identity = model_identity(value)
500
+ identity ? ["Model", identity] : ["Object", value.class.name, value.inspect]
501
+ end
502
+ end
503
+
504
+ def symbolize_keys(value)
505
+ case value
506
+ when Hash
507
+ value.each_with_object({}) do |(key, nested_value), result|
508
+ normalized_key = key.respond_to?(:to_sym) ? key.to_sym : key
509
+ result[normalized_key] = symbolize_keys(nested_value)
510
+ end
511
+ when Array
512
+ value.map { |nested_value| symbolize_keys(nested_value) }
513
+ else
514
+ value
515
+ end
516
+ end
517
+ end
518
+ 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