hecks 3.1.3 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +3 -3
  3. data/lib/hecks/cli/check_era.rb +3 -39
  4. data/lib/hecks/deploy/bluebook/deploy.bluebook +105 -0
  5. data/lib/hecks/deploy/bluebook/deploy.hecksagon +8 -0
  6. data/lib/hecks/deploy/oidc.json +15 -0
  7. data/lib/hecks/doors/mcp_door.rb +16 -2
  8. data/lib/hecks/doors/mcp_door_scope.rb +43 -4
  9. data/lib/hecks/doors/mcp_guide_cache.rb +75 -0
  10. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +17 -1
  11. data/lib/hecks/hecks/adapters/codebase/test_runner.rb +10 -2
  12. data/lib/hecks/hecks/adapters/cost_explorer.adapter +7 -0
  13. data/lib/hecks/hecks/adapters/cost_explorer.rb +102 -0
  14. data/lib/hecks/hecks/adapters/rust_workspace.rb +1 -1
  15. data/lib/hecks/hecks/adapters/site_toolchain.rb +21 -11
  16. data/lib/hecks/hecks/hecks.hecksagon +8 -0
  17. data/lib/hecks/language/bluebook/vocabulary.bluebook +7 -7
  18. data/lib/hecks/language/hecksagon/hecksagon.bluebook +1 -1
  19. data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +3 -3
  20. data/lib/hecks/projections/deploy/box/settings.rb +23 -2
  21. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +3 -1
  22. data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +17 -0
  23. data/lib/hecks/projections/deploy/box.rb +13 -2
  24. data/lib/hecks/projections/site/edge/behaviours.rb +5 -3
  25. data/lib/hecks/projections/site/edge/checks.rb +31 -5
  26. data/lib/hecks/projections/site/edge.rb +25 -11
  27. data/lib/hecks/projections/site/routes_ts.rb +50 -27
  28. data/lib/hecks/projections/site/site_cdn.rb +7 -4
  29. data/lib/hecks/projections/site/table/checks.rb +53 -7
  30. data/lib/hecks/projections/site/table/link_builder.rb +64 -0
  31. data/lib/hecks/projections/site/table/row_builder.rb +21 -6
  32. data/lib/hecks/projections/site/table.rb +16 -5
  33. data/lib/hecks/rust_build/kernel_input.rb +18 -6
  34. data/lib/hecks/rust_build/project_rust.rb +6 -132
  35. data/lib/hecks/site/bluebook/site.bluebook +27 -13
  36. data/lib/hecks/storehouse.rb +35 -1
  37. data/lib/hecks/tools/comment_style.rb +4 -2
  38. data/lib/hecks/tools/deploy_recipe.rb +1 -0
  39. data/lib/hecks/tools/rust_comment_style.rb +1 -1
  40. data/lib/hecks/tools/site_routes.rb +69 -28
  41. data/lib/hecks/version.rb +1 -1
  42. data/lib/hecks/vocabulary.rb +1 -1
  43. data/lib/hecks.rb +1 -0
  44. data/rust/build/src/lineage_pass.rs +1 -1
  45. data/rust/build/src/pipeline.rs +1 -1
  46. data/rust/codegen/src/attr.rs +1 -1
  47. data/rust/codegen/src/bridging.rs +1 -1
  48. data/rust/codegen/src/commands.rs +1 -1
  49. data/rust/codegen/src/constraints.rs +1 -1
  50. data/rust/codegen/src/dependency_planning.rs +1 -1
  51. data/rust/codegen/src/domain_generator.rs +2 -2
  52. data/rust/codegen/src/exemplar.rs +1 -1
  53. data/rust/codegen/src/expr_emitter.rs +1 -1
  54. data/rust/codegen/src/fielded.rs +1 -1
  55. data/rust/codegen/src/json_codec.rs +1 -1
  56. data/rust/codegen/src/main.rs +3 -4
  57. data/rust/codegen/src/mutations.rs +1 -1
  58. data/rust/codegen/src/naming.rs +1 -1
  59. data/rust/codegen/src/ports.rs +1 -1
  60. data/rust/codegen/src/queries.rs +3 -3
  61. data/rust/codegen/src/reactions.rs +1 -1
  62. data/rust/codegen/src/read_models.rs +2 -2
  63. data/rust/codegen/src/reference_specs.rs +1 -1
  64. data/rust/codegen/src/registry.rs +4 -4
  65. data/rust/codegen/src/shared.rs +1 -1
  66. data/rust/codegen/src/skip_reason.rs +1 -1
  67. data/rust/codegen/src/types.rs +4 -4
  68. data/rust/host/HECKS_RELEASE +1 -1
  69. data/rust/host/src/auth.rs +25 -1
  70. data/rust/host/src/needs.rs +94 -27
  71. data/rust/host/src/web.rs +9 -5
  72. data/rust/src/exemplar/commands.rs +1 -1
  73. data/rust/src/exemplar/constraints.rs +1 -1
  74. data/rust/src/exemplar/fielded.rs +1 -1
  75. data/rust/src/exemplar/json.rs +1 -1
  76. data/rust/src/exemplar/mod.rs +1 -1
  77. data/rust/src/exemplar/mutations.rs +1 -1
  78. data/rust/src/exemplar/queries.rs +1 -1
  79. data/rust/src/exemplar/reactions.rs +1 -1
  80. data/rust/src/exemplar/read_models.rs +1 -1
  81. data/rust/src/exemplar/registry.rs +1 -1
  82. data/rust/src/exemplar/types.rs +1 -1
  83. data/rust/src/kernel/cli.rs +2 -2
  84. data/rust/src/kernel/read_model.rs +1 -1
  85. data/rust/src/lib.rs +1 -1
  86. metadata +5 -23
  87. data/rust/project/bridging.rb +0 -257
  88. data/rust/project/commands.rb +0 -877
  89. data/rust/project/constraints.rb +0 -86
  90. data/rust/project/dependency_planning.rb +0 -178
  91. data/rust/project/domain_generator.rb +0 -983
  92. data/rust/project/exemplar.rb +0 -148
  93. data/rust/project/expr_emitter.rb +0 -72
  94. data/rust/project/fielded.rb +0 -141
  95. data/rust/project/json_codec.rb +0 -641
  96. data/rust/project/mutations.rb +0 -695
  97. data/rust/project/naming.rb +0 -223
  98. data/rust/project/ports.rb +0 -65
  99. data/rust/project/queries.rb +0 -493
  100. data/rust/project/reactions.rb +0 -317
  101. data/rust/project/read_models.rb +0 -690
  102. data/rust/project/reference_specs.rb +0 -27
  103. data/rust/project/registry.rb +0 -396
  104. data/rust/project/skip_reason.rb +0 -29
  105. data/rust/project/types.rb +0 -225
  106. data/rust/project/write_if_changed.rb +0 -7
  107. data/rust/project.rb +0 -30
  108. data/rust/project_rust_pipeline.rb +0 -391
@@ -1,695 +0,0 @@
1
- require "hecks/rust_build/append_optionals"
2
-
3
- module RustProjection
4
- module Projector
5
- module_function
6
-
7
- # True when a list attribute reads `nil` rather than `[]`: a creating command `sets` it from
8
- # an optional argument the caller omitted (`CardPayment.tags`). `Account.ledger` stays `[]`.
9
- def list_attr_creation_optional?(aggregate, attr_name, value_objects_by_name)
10
- aggregate[:commands].any? do |command|
11
- next false unless creates_owner?(aggregate, command, value_objects_by_name)
12
-
13
- command[:mutations].any? do |m|
14
- next false unless m[:op].to_s == "set" && m[:target].to_s == attr_name.to_s && m[:source][:kind] == "argument"
15
-
16
- source_attr = command[:attributes].find { |a| a[:name].to_s == m[:source][:name].to_s }
17
- source_attr && source_attr[:optional]
18
- end
19
- end
20
- end
21
-
22
- # Whether `command` builds the owner record from scratch. Needs `references: nil` plus a
23
- # supplied required owner field, by `:set` or a same-named argument. Arguments claimed by an
24
- # `append` don't count (`ValueObject.Member#position` is not the owner's). Completeness of
25
- # state is not the test: `Analyzer#complete_state?` disagrees on both sides.
26
- def creates_owner?(aggregate, command, value_objects_by_name)
27
- return false unless command[:references].nil?
28
-
29
- owner_fields = aggregate[:attributes].map { |a| a[:name].to_s }.to_set
30
- owner_fields << aggregate[:lifecycle][:field].to_s if aggregate[:lifecycle]
31
- required_fields = aggregate[:attributes].reject { |a| a[:list] || a[:optional] }.map { |a| a[:name].to_s }.to_set
32
-
33
- # Arguments claimed by an append never bare-name-match an owner field.
34
- append_claimed = Set.new
35
- command[:mutations].each do |m|
36
- next unless m[:op].to_s == "append"
37
-
38
- Array(m[:fields]&.values).each do |v|
39
- source = append_field_source(v)
40
- append_claimed << source.to_s if source.is_a?(Symbol)
41
- end
42
- end
43
-
44
- known_writes = Set.new
45
-
46
- # A same-named argument is copied across by `record_fields` with no `:set`.
47
- command[:attributes].each do |attr|
48
- name = attr[:name].to_s
49
- known_writes << name if owner_fields.include?(name) && !append_claimed.include?(name)
50
- end
51
-
52
- # A `:set` covers the source-from-another-argument shape (`sets :field, to: :other_arg`).
53
- command[:mutations].each do |m|
54
- next unless m[:op].to_s == "set"
55
-
56
- target = m[:target].to_s
57
- known_writes << target if owner_fields.include?(target)
58
- end
59
-
60
- required_fields.any? { |field| known_writes.include?(field) }
61
- end
62
-
63
- # Resolves an `append` target to its entity or value object, or nil if it is neither.
64
- def append_element(aggregate, target_type, value_objects_by_name)
65
- Hecks::RustBuild::AppendOptionals.element(aggregate, target_type, value_objects_by_name)
66
- end
67
-
68
- # Identity attribute and its single-field value object for an auto-minted entity element,
69
- # or nil for a composite or non-bare identity.
70
- def entity_identity_mint(entity, value_objects_by_name)
71
- id_path = entity[:identified_by]&.first
72
- return nil unless id_path
73
-
74
- head, *rest = id_path.split(".")
75
- return nil unless rest.size == 1
76
-
77
- attr = entity[:attributes].find { |a| a[:name].to_s == head }
78
- return nil unless attr
79
-
80
- vo = value_objects_by_name[attr[:type]]
81
- return nil unless vo && !vo[:closed_set] && vo[:attributes].size == 1 && vo[:attributes].first[:name].to_s == rest.first
82
-
83
- [attr, vo]
84
- end
85
-
86
- # Guard for a whole-list `:set` of entities: refuses duplicate identities pairwise.
87
- # Returns `[guard_text, effective_rhs]`. Every `identified_by` head is compared at once (not
88
- # just the first), so a composite identity is guarded the same as a single-field one — see
89
- # `check_entity_collision` in `entity_element.rb` (Ruby's runtime `heads.all?`).
90
- def entity_list_replace_guard(aggregate, target_attr, target_field, rhs, value_objects_by_name)
91
- entity = aggregate[:entities].find { |e| e[:name] == target_attr[:type] }
92
- return ["", rhs] unless entity && entity[:identified_by]&.any?
93
-
94
- checks = []
95
- offered_exprs = []
96
- entity[:identified_by].each do |path|
97
- id_head = path.to_s.split(".").first
98
- id_attr = entity[:attributes].find { |a| a[:name].to_s == id_head }
99
- return ["", rhs] unless id_attr
100
-
101
- id_field = rust_ident_field(id_attr[:name])
102
- checks << "prior.#{id_field} == e.#{id_field}"
103
-
104
- # Single-field unwrap, so `format!` prints the bare scalar as Ruby's refusal wording does.
105
- id_vo = value_objects_by_name[id_attr[:type]]
106
- offered_exprs <<
107
- if id_vo && !id_vo[:closed_set] && id_vo[:attributes].size == 1
108
- "e.#{id_field}.#{rust_ident_field(id_vo[:attributes].first[:name])}"
109
- else
110
- "e.#{id_field}"
111
- end
112
- end
113
-
114
- local_var = "replaced_#{target_field}"
115
- entity_lit = entity[:name].to_s.inspect
116
- aggregate_lit = aggregate[:name].to_s.inspect
117
- identity_lit = entity[:identified_by].join(", ").inspect
118
- condition = checks.join(" && ")
119
- offered_lets = offered_exprs.each_with_index.map { |expr, i| "let offered_#{i} = format!(\"{:?}\", #{expr});" }
120
- offered_refs = offered_exprs.each_index.map { |i| "offered_#{i}.as_str()" }
121
- guard =
122
- "let #{local_var} = #{rhs};\n " \
123
- "for (i, e) in #{local_var}.iter().enumerate() { if #{local_var}[..i].iter().any(|prior| #{condition}) " \
124
- "{ #{offered_lets.join(' ')} let offered = [#{offered_refs.join(', ')}]; " \
125
- "return Err(crate::kernel::Refusal::AlreadyExists(crate::kernel::refusal_wording::AlreadyExistsEntityDuplicateArgs " \
126
- "{ entity: #{entity_lit}, aggregate: #{aggregate_lit}, identity: #{identity_lit}, " \
127
- "offered: &offered }.render_args())); } }\n "
128
- [guard, local_var]
129
- end
130
-
131
- # The composite-identity twin of the single-field collision guard inlined in
132
- # `emit_mutation_line_body`'s `append` branch: builds a duplicate check across every
133
- # `identified_by` head at once (`e.batch == ... && e.sequence == ...`), matching
134
- # `EntityElement.check_entity_collision`'s `heads.all?` on the Ruby runtime side. Called only
135
- # once every head is confirmed present in the append's own `fields` map (composite identities
136
- # never mint). Kept byte-for-byte with `rust/codegen/src/mutations.rs`'s
137
- # `composite_append_collision_guard`.
138
- def composite_append_collision_guard(heads, fields, entity, command, value_objects_by_name, aggregate, target_field, identified_by)
139
- checks = []
140
- offered_exprs = []
141
- heads.each do |head|
142
- field_attr = entity[:attributes].find { |a| a[:name].to_s == head }
143
- _, source = fields.find { |field_name, _| field_name.to_s == head }
144
- rhs = append_field_rhs(source, field_attr, command, value_objects_by_name, aggregate)
145
- id_field = rust_ident_field(head)
146
- checks << "e.#{id_field} == #{rhs}"
147
-
148
- # Unwrap a single-field identity component so `offered` prints the bare scalar, as
149
- # `Rendering.describe` does; Debug-printing the value object would give
150
- # `LineSequence { value: 654 }`.
151
- vo = value_objects_by_name[field_attr[:type]]
152
- offered_exprs <<
153
- if vo && !vo[:closed_set] && vo[:attributes].size == 1
154
- "#{rhs}.#{rust_ident_field(vo[:attributes].first[:name])}"
155
- else
156
- rhs
157
- end
158
- end
159
-
160
- entity_lit = entity[:name].to_s.inspect
161
- aggregate_lit = aggregate[:name].to_s.inspect
162
- identity_lit = identified_by.join(", ").inspect
163
- condition = checks.join(" && ")
164
- offered_lets = offered_exprs.each_with_index.map { |expr, i| "let offered_#{i} = format!(\"{:?}\", #{expr});" }
165
- offered_refs = offered_exprs.each_index.map { |i| "offered_#{i}.as_str()" }
166
-
167
- "if record.#{target_field}.iter().any(|e| #{condition}) " \
168
- "{ #{offered_lets.join(' ')} let offered = [#{offered_refs.join(', ')}]; " \
169
- "return Err(crate::kernel::Refusal::AlreadyExists(crate::kernel::refusal_wording::AlreadyExistsEntityDuplicateArgs " \
170
- "{ entity: #{entity_lit}, aggregate: #{aggregate_lit}, identity: #{identity_lit}, " \
171
- "offered: &offered }.render_args())); }\n "
172
- end
173
-
174
- # Why an `append` can't be generated: an unresolvable element, an undeclared field, a source
175
- # that doesn't bridge, or an entity identity that is neither minted nor supplied.
176
- def append_field_problems(command, aggregate, value_objects_by_name)
177
- command[:mutations].select { |m| m[:op].to_s == "append" }.flat_map do |m|
178
- target_attr = aggregate[:attributes].find { |a| a[:name].to_s == m[:target].to_s }
179
- element = target_attr && append_element(aggregate, target_attr[:type], value_objects_by_name)
180
- next ["#{m[:target]}: element type #{target_attr&.dig(:type).inspect} not resolvable"] unless element
181
-
182
- problems = m[:fields].filter_map do |field_name, source|
183
- field_attr = element[:attributes].find { |a| a[:name].to_s == field_name.to_s }
184
- next "#{m[:target]}.#{field_name}: not a declared field" unless field_attr
185
-
186
- # A Symbol names an argument; anything else is the value (Hecks::Literal).
187
- parsed = append_field_source(source)
188
- next nil if parsed.is_a?(Hecks::StateRef) # checked by state_source_problems
189
- next literal_problem(m, field_name, parsed, field_attr, value_objects_by_name) unless parsed.is_a?(Symbol)
190
-
191
- arg_attr = command[:attributes].find { |a| a[:name].to_s == parsed.to_s }
192
- if arg_attr.nil?
193
- "#{m[:target]}.#{field_name}: sources undeclared argument #{parsed}"
194
- elsif !bridgeable_value_types?(arg_attr[:type], field_attr[:type], value_objects_by_name)
195
- "#{m[:target]}.#{field_name}: #{arg_attr[:type]} doesn't bridge to #{field_attr[:type]}"
196
- end
197
- end
198
-
199
- entity = aggregate[:entities].find { |e| e[:name] == target_attr[:type] }
200
- next problems unless entity
201
-
202
- present = m[:fields].keys.map(&:to_s)
203
- id_head = entity[:identified_by]&.first.to_s.split(".").first
204
- problems << "#{m[:target]}: #{entity[:name]}'s identity doesn't auto-mint" if !present.include?(id_head) && !entity_identity_mint(entity, value_objects_by_name)
205
- problems
206
- end
207
- end
208
-
209
- # Why a `remove:` can't be generated. Only entity-typed lists are supported, matched by a
210
- # single bridgeable identity field, the same shape `entity_identity_mint` requires.
211
- def remove_field_problems(command, aggregate, value_objects_by_name)
212
- command[:mutations].select { |m| m[:op].to_s == "remove" }.filter_map do |m|
213
- target_attr = aggregate[:attributes].find { |a| a[:name].to_s == m[:target].to_s }
214
- next "#{m[:target]}: not a declared list attribute" unless target_attr && target_attr[:list]
215
-
216
- entity = aggregate[:entities].find { |e| e[:name] == target_attr[:type] }
217
- next "#{m[:target]}: remove on a value-object-typed list is not generated yet" unless entity
218
-
219
- # `entity_identity_mint` inspects only the first head, so a composite identity is
220
- # excluded here.
221
- if Array(entity[:identified_by]).size != 1
222
- next "#{m[:target]}: #{entity[:name]}'s identity is composite — remove not generated yet"
223
- end
224
-
225
- id_attr, = entity_identity_mint(entity, value_objects_by_name)
226
- unless id_attr
227
- next "#{m[:target]}: #{entity[:name]}'s identity isn't a single bridgeable field — remove not generated yet"
228
- end
229
-
230
- unless m[:source].is_a?(Hash) && m[:source][:kind] == "argument"
231
- next "#{m[:target]}: remove sources a literal or record state, not an argument — not generated yet"
232
- end
233
-
234
- source_attr = command[:attributes].find { |a| a[:name].to_s == m[:source][:name].to_s }
235
- next "#{m[:target]}: remove sources undeclared argument #{m[:source][:name]}" unless source_attr
236
-
237
- unless bridgeable_value_types?(source_attr[:type], id_attr[:type], value_objects_by_name)
238
- next "#{m[:target]}: #{source_attr[:type]} doesn't bridge to #{id_attr[:type]}"
239
- end
240
-
241
- nil
242
- end
243
- end
244
-
245
- # Decodes an append field source: a Symbol names a command argument, anything else is a
246
- # literal.
247
- def append_field_source(source) = Hecks::Bluebook::Assembly::Marks.read(source)
248
-
249
- def literal_problem(mutation, field_name, literal, field_attr, value_objects_by_name)
250
- return nil if literal_set_bridgeable?(literal, field_attr[:type], value_objects_by_name)
251
-
252
- "#{mutation[:target]}.#{field_name}: literal doesn't bridge to #{field_attr[:type]}"
253
- end
254
-
255
- # Collapses the transition rows a command names into the `field`/`from_states` hash
256
- # TransitionCheck reads.
257
- def lifecycle_transition_for(command, aggregate)
258
- return nil unless aggregate[:lifecycle]
259
-
260
- rows = aggregate[:lifecycle][:transitions].select { |t| t[:command] == command[:name] }
261
- # `from:` without a transition only guards on state; `to_state: nil` means it moves
262
- # nothing.
263
- if rows.empty?
264
- froms = Array(command[:from]).compact.map(&:to_s)
265
- return nil if froms.empty?
266
-
267
- return { field: aggregate[:lifecycle][:field], to_state: nil, from_states: froms.uniq, unconstrained: false }
268
- end
269
-
270
- # A `from: nil` row is unconstrained and admits any state. It is carried as
271
- # `unconstrained`
272
- # rather than dropped: the caller also reads `to_state` to advance the lifecycle.
273
- froms = rows.map { |r| r[:from_state] }
274
- {
275
- field: aggregate[:lifecycle][:field],
276
- to_state: rows.first[:to_state],
277
- from_states: froms.compact.uniq,
278
- unconstrained: froms.any?(&:nil?)
279
- }
280
- end
281
-
282
- # Rust expression for a `:set` source, coerced to the target attribute's declared type.
283
- # With `target_list:` a list source maps element-wise instead of unwrapping a single-field
284
- # value object out of a `Vec<T>`.
285
- def mutation_set_rhs(source, target_type, command, value_objects_by_name, target_list: false)
286
- if source[:kind] == "literal"
287
- return literal_rhs_for(source[:value], target_type, value_objects_by_name)
288
- end
289
-
290
- # `state(:field)` clones the record's value; `pre` is the pre-dispatch state, so effect
291
- # order is irrelevant.
292
- return "pre.#{rust_ident_field(source[:name])}.clone()" if source[:kind] == "state"
293
-
294
- source_attr = command[:attributes].find { |a| a[:name].to_s == source[:name] }
295
- source_expr = "args.#{rust_ident_field(source[:name])}"
296
- if target_list && source_attr[:list] && list_bridge_requires_element_mapping?(source_attr[:type], target_type)
297
- return list_value_rhs(source_expr, source_attr[:type], target_type, value_objects_by_name)
298
- end
299
-
300
- value_rhs(source_expr, source_attr[:type], target_type, value_objects_by_name)
301
- end
302
-
303
- # Problems with `state(:field)` sources: only a same-type, same-list-ness copy is generated.
304
- # generator has no data for, and says so rather than guessing.
305
- def state_source_problems(command, aggregate, value_objects_by_name)
306
- command[:mutations].flat_map do |m|
307
- case m[:op].to_s
308
- when "set"
309
- next [] unless m[:source][:kind] == "state"
310
- [state_source_problem(m[:target], m[:source][:name], aggregate, aggregate[:attributes].find { |a| a[:name].to_s == m[:target].to_s })].compact
311
- when "append"
312
- target_attr = aggregate[:attributes].find { |a| a[:name].to_s == m[:target].to_s }
313
- element = target_attr && append_element(aggregate, target_attr[:type], value_objects_by_name)
314
- next [] unless element
315
- m[:fields].filter_map do |field_name, source|
316
- parsed = append_field_source(source)
317
- next unless parsed.is_a?(Hecks::StateRef)
318
- state_source_problem("#{m[:target]}.#{field_name}", parsed.name, aggregate, element[:attributes].find { |a| a[:name].to_s == field_name.to_s })
319
- end
320
- else
321
- []
322
- end
323
- end
324
- end
325
-
326
- def state_source_problem(label, state_name, aggregate, target_attr)
327
- state_attr = aggregate[:attributes].find { |a| a[:name].to_s == state_name.to_s }
328
- return "#{label}: sources state(:#{state_name}), which #{aggregate[:name]} does not declare" unless state_attr
329
- return "#{label}: no such target field" unless target_attr
330
- same = state_attr[:type] == target_attr[:type] && !!state_attr[:list] == !!target_attr[:list]
331
- "#{label}: state(:#{state_name}) is #{state_attr[:list] ? 'a list of ' : ''}#{state_attr[:type]}, the target wants #{target_attr[:list] ? 'a list of ' : ''}#{target_attr[:type]} — not generated yet" unless same
332
- end
333
-
334
- # One entry per `identified_by` component, as `{ expr:, param:, head: }`. A head that is
335
- # "declared" reads `args.<head>` (walking a dotted rest); otherwise the caller supplies it
336
- # as a
337
- # `&str` parameter, and `head:` is the raw JSON key the router reads it under.
338
- # Declared means a `:set` targets the head, or a same-named argument not claimed by an
339
- # append
340
- # supplies it (bare-name copy, `Governance::RoleAssignment.Assign`). A bare name shared with
341
- # an
342
- # appended attribute (`Aggregate.Attribute#name`) is not the owner's identity.
343
- def identity_components(aggregate, command)
344
- # Declared: a `:set` targets the head, or a same-named argument not claimed by an append.
345
- append_claimed = Set.new
346
- command[:mutations].each do |m|
347
- next unless m[:op].to_s == "append"
348
-
349
- Array(m[:fields]&.values).each do |v|
350
- source = append_field_source(v)
351
- append_claimed << source.to_s if source.is_a?(Symbol)
352
- end
353
- end
354
- declared_names = command[:attributes].map { |a| a[:name].to_s }.to_set - append_claimed
355
-
356
- aggregate[:identified_by].map do |path|
357
- head, *rest = path.split(".")
358
- set_target = command[:mutations].any? { |m| m[:op].to_s == "set" && m[:target].to_s == head }
359
- if set_target || declared_names.include?(head)
360
- if rest.any?
361
- { expr: "args.#{rust_ident_field(head)}.#{rest.map { |seg| rust_ident_field(seg) }.join('.')}.to_string()", param: nil }
362
- else
363
- { expr: "args.#{rust_ident_field(head)}.to_string()", param: nil }
364
- end
365
- else
366
- # `.to_string()` keeps this an owned `String`: a single-component identity is returned
367
- # unwrapped into a `String` field, and this param is `&str`.
368
- param = rust_ident_field(head)
369
- { expr: "#{param}.to_string()", param: "#{param}: &str", head: head }
370
- end
371
- end
372
- end
373
-
374
- def build_identity_expr(components)
375
- return components.first[:expr] if components.size == 1
376
-
377
- placeholders = components.map { "{}" }.join(":")
378
- "format!(#{placeholders.inspect}, #{components.map { |c| c[:expr] }.join(', ')})"
379
- end
380
-
381
- # Value of one `append` field: an argument runs through the `value_rhs` bridge, a literal
382
- # is built inline. A caller-omittable argument is `Option<T>` in Rust, so it bridges through
383
- # `optional_value_rhs`; `mark_append_optional_fields!` has already made the field match.
384
- def append_field_rhs(source, field_attr, command, value_objects_by_name, aggregate = nil)
385
- parsed = append_field_source(source)
386
- return state_field_rhs(parsed, field_attr, aggregate) if parsed.is_a?(Hecks::StateRef)
387
- return literal_rhs_for(parsed, field_attr[:type], value_objects_by_name) unless parsed.is_a?(Symbol)
388
-
389
- arg_attr = command[:attributes].find { |a| a[:name].to_s == parsed.to_s }
390
- arg_expr = "args.#{rust_ident_field(arg_attr[:name])}"
391
- if arg_attr[:optional]
392
- # Same representation on both sides: clone the `Option` as is. Only a cross-type
393
- # coercion needs a per-element `.map`.
394
- same_representation = arg_attr[:type] == field_attr[:type] ||
395
- (effective_scalar_type(arg_attr[:type]) && effective_scalar_type(arg_attr[:type]) == effective_scalar_type(field_attr[:type]))
396
- return "#{arg_expr}.clone()" if same_representation
397
-
398
- return optional_value_rhs(arg_expr, arg_attr[:type], field_attr[:type], value_objects_by_name)
399
- end
400
-
401
- rhs = value_rhs(arg_expr, arg_attr[:type], field_attr[:type], value_objects_by_name)
402
- # The struct field may be `Option<T>` because another command sources it optionally,
403
- # so a required source still needs `Some(...)`.
404
- field_attr[:optional] ? "Some(#{rhs})" : rhs
405
- end
406
-
407
- # Whether any effect reads the record's own state (`state(:field)` in a `set` or `append`);
408
- # only then does the closure bind `pre`.
409
- def reads_pre_state?(mutations)
410
- mutations.any? do |m|
411
- case m[:op].to_s
412
- when "set" then m[:source][:kind] == "state"
413
- when "append" then m[:fields].values.any? { |source| append_field_source(source).is_a?(Hecks::StateRef) }
414
- else false
415
- end
416
- end
417
- end
418
-
419
- def pre_state_line = " let pre = record.clone();"
420
-
421
- # An Integer effect that leaves signed 64-bit is a Fault, never a wrap or panic (C3.3).
422
- # `amount` is bound once so the wording can quote it.
423
- CHECKED_OPS = { "+" => "checked_add", "-" => "checked_sub", "*" => "checked_mul" }.freeze
424
-
425
- def checked_arithmetic(op, field_ident, symbol, amount_expr)
426
- "{ let amount = #{amount_expr}; current.#{field_ident}.#{CHECKED_OPS.fetch(symbol)}(amount)" \
427
- ".ok_or_else(|| crate::kernel::Refusal::Fault(format!(\"#{op} overflowed: {} #{symbol} {} does not fit in a " \
428
- "64-bit integer\", current.#{field_ident}, amount)))? }"
429
- end
430
-
431
- # `state(:field)` into an appended element's field. Records `Option`-wrap non-list fields
432
- # and
433
- # elements don't, so scalars unwrap and lists clone as is.
434
- def state_field_rhs(parsed, field_attr, aggregate)
435
- expr = "pre.#{rust_ident_field(parsed.name)}.clone()"
436
- state_attr = aggregate && aggregate[:attributes].find { |a| a[:name].to_s == parsed.name.to_s }
437
- return expr if state_attr && state_attr[:list]
438
- return expr if field_attr[:optional]
439
-
440
- "#{expr}.unwrap()"
441
- end
442
-
443
- # The `value_rhs` bridge run against the value a `.map` closure unwraps from an `Option<T>`.
444
- def optional_value_rhs(source_expr, source_type, target_type, value_objects_by_name)
445
- "#{source_expr}.clone().map(|v| #{value_rhs('v', source_type, target_type, value_objects_by_name)})"
446
- end
447
-
448
- # Makes an appended element's field `Option<T>` when any command sources it from an optional
449
- # argument, since Ruby stores the omitted nil without complaint. Mutates the shared
450
- # attribute
451
- # hash once, before the emitters read it, and never resets a `true`.
452
- def mark_append_optional_fields!(aggregate, value_objects_by_name)
453
- Hecks::RustBuild::AppendOptionals.mark_aggregate(aggregate, value_objects_by_name)
454
- end
455
-
456
- # Derives the mutations of a `corrects EVENT, reverses: true` command from the commands that
457
- # emit EVENT: only increment/decrement invert (ADR 0041). Left alone when nothing emits the
458
- # event
459
- # or any sibling mutation can't invert, so `command_skip_reason` still refuses it.
460
- INVERSE_MUTATION_OP = { "increment" => "decrement", "decrement" => "increment" }.freeze
461
-
462
- def derive_reverses_mutations!(aggregate)
463
- emitted_by = Hash.new { |hash, key| hash[key] = [] }
464
- aggregate[:commands].each { |command| Array(command[:emits]).each { |event_name| emitted_by[event_name.to_s] << command } }
465
-
466
- aggregate[:commands].each do |command|
467
- correction = corrects_of(command)
468
- next unless correction && corrects_reverses?(correction)
469
-
470
- sources = emitted_by[correction[:target].to_s]
471
- next if sources.empty?
472
-
473
- derived = sources.flat_map { |c| c[:mutations] }.reject { |m| m[:op].to_s == "corrects" }
474
- next if derived.empty? || derived.any? { |m| !INVERSE_MUTATION_OP.key?(m[:op].to_s) }
475
-
476
- # Never derive twice onto a command that already has mutations, so re-runs stay
477
- # idempotent.
478
- # generator re-run over already-derived IR stays idempotent.
479
- next if command[:mutations].any? { |m| m[:op].to_s != "corrects" }
480
-
481
- derived.each do |m|
482
- command[:mutations] << { op: INVERSE_MUTATION_OP.fetch(m[:op].to_s), target: m[:target], source: m[:source] }
483
- end
484
- end
485
- end
486
-
487
- # `optional:` is true for an aggregate record (non-list fields are `Option`-wrapped) and
488
- # false for an entity element.
489
- def emit_mutation_line(mutation, aggregate, command, value_objects_by_name, optional: true)
490
- target_field = rust_ident_field(mutation[:target])
491
- lifecycle_field = aggregate[:lifecycle] && aggregate[:lifecycle][:field].to_s
492
-
493
- # Callers join these lines with a newline, expecting each to carry its own 8-space indent.
494
- " #{emit_mutation_line_body(mutation, aggregate, command, value_objects_by_name, target_field, lifecycle_field, optional)}"
495
- end
496
-
497
- def emit_mutation_line_body(mutation, aggregate, command, value_objects_by_name, target_field, lifecycle_field, optional)
498
- case mutation[:op].to_s
499
- when "append"
500
- target_attr = aggregate[:attributes].find { |a| a[:name].to_s == mutation[:target].to_s }
501
- vo_type = rust_ident(target_attr[:type])
502
- entity = aggregate[:entities].find { |e| e[:name] == target_attr[:type] }
503
- element = entity || value_objects_by_name[target_attr[:type]]
504
-
505
- fields_assignment = mutation[:fields].map do |field_name, source|
506
- field_attr = element[:attributes].find { |a| a[:name].to_s == field_name.to_s }
507
- "#{rust_ident_field(field_name)}: #{append_field_rhs(source, field_attr, command, value_objects_by_name, aggregate)}"
508
- end
509
-
510
- # An entity element also needs a minted identity and its lifecycle default; no
511
- # `append:` names them.
512
- collision_guard = ""
513
- if entity
514
- present = mutation[:fields].keys.map(&:to_s)
515
- identified_by = Array(entity[:identified_by])
516
- if identified_by.size > 1
517
- # Composite identity: minting a partial key makes no sense, so every head must
518
- # already be caller-supplied (as `Venue.AddLine`'s `append: { batch: :batch,
519
- # sequence: :sequence }` does). Refuse a duplicate across the WHOLE tuple, the way
520
- # `EntityElement.check_entity_collision`'s `heads.all?` does — this branch used to be
521
- # skipped outright for any composite identity, so two elements sharing every identity
522
- # component went through unrefused. Root cause of this bug.
523
- heads = identified_by.map { |path| path.to_s.split(".").first }
524
- if heads.all? { |head| present.include?(head) }
525
- collision_guard = composite_append_collision_guard(heads, mutation[:fields], entity, command,
526
- value_objects_by_name, aggregate, target_field,
527
- identified_by)
528
- end
529
- else
530
- id_attr, id_vo = entity_identity_mint(entity, value_objects_by_name)
531
- if id_attr && !present.include?(id_attr[:name].to_s)
532
- # One past the highest identity held, never `len() + 1`, which repeats after a
533
- # shrink (C4.5).
534
- id_field = rust_ident_field(id_attr[:name])
535
- vo_field = rust_ident_field(id_vo[:attributes].first[:name])
536
- mint = "#{rust_ident(id_attr[:type])} { #{vo_field}: record.#{target_field}.iter().map(|e| e.#{id_field}.#{vo_field}).max().unwrap_or(0) + 1 }"
537
- fields_assignment << "#{rust_ident_field(id_attr[:name])}: #{mint}"
538
- present << id_attr[:name].to_s
539
- elsif id_attr
540
- # Caller-supplied single-field identity: refuse a duplicate
541
- # (`check_entity_collision`), matching rust/codegen byte for byte.
542
- id_field = rust_ident_field(id_attr[:name])
543
- _, source = mutation[:fields].find { |field_name, _| field_name.to_s == id_attr[:name].to_s }
544
- field_attr = entity[:attributes].find { |a| a[:name].to_s == id_attr[:name].to_s }
545
- id_rhs = append_field_rhs(source, field_attr, command, value_objects_by_name, aggregate)
546
- entity_lit = entity[:name].to_s.inspect
547
- aggregate_lit = aggregate[:name].to_s.inspect
548
- identity_lit = identified_by.join(", ").inspect
549
- # Single-field unwrap so `format!` prints the bare scalar; `id_vo` is non-nil here.
550
- offered_field = rust_ident_field(id_vo[:attributes].first[:name])
551
- offered_expr = "#{id_rhs}.#{offered_field}"
552
- collision_guard =
553
- "if record.#{target_field}.iter().any(|e| e.#{id_field} == #{id_rhs}) " \
554
- "{ let offered = format!(\"{:?}\", #{offered_expr}); " \
555
- "return Err(crate::kernel::Refusal::AlreadyExists(crate::kernel::refusal_wording::AlreadyExistsEntityDuplicateArgs " \
556
- "{ entity: #{entity_lit}, aggregate: #{aggregate_lit}, identity: #{identity_lit}, " \
557
- "offered: &[offered.as_str()] }.render_args())); }\n "
558
- end
559
- end
560
- if entity[:lifecycle] && !present.include?(entity[:lifecycle][:field].to_s)
561
- fields_assignment << "#{rust_ident_field(entity[:lifecycle][:field])}: #{entity[:lifecycle][:default].inspect}.to_string()"
562
- present << entity[:lifecycle][:field].to_s
563
- end
564
-
565
- # A list attribute no `append:` binding names starts empty. Ruby's fields Hash just
566
- # lacks the
567
- # key, but a Rust struct literal must assign every field.
568
- entity[:attributes].each do |attr|
569
- next unless attr[:list]
570
- next if present.include?(attr[:name].to_s)
571
-
572
- fields_assignment << "#{rust_ident_field(attr[:name])}: Vec::new()"
573
- present << attr[:name].to_s
574
- end
575
-
576
- # An optional scalar attribute is `Option<T>` in Rust, so an unmentioned one defaults
577
- # to `None`.
578
- entity[:attributes].each do |attr|
579
- next if attr[:list] || !attr[:optional]
580
- next if present.include?(attr[:name].to_s)
581
-
582
- fields_assignment << "#{rust_ident_field(attr[:name])}: None"
583
- present << attr[:name].to_s
584
- end
585
- end
586
-
587
- "#{collision_guard}#{Exemplar.render(
588
- "mutation_append",
589
- "tmpl_field" => target_field,
590
- "tmpl_fields_placeholder()" => "#{vo_type} { #{fields_assignment.join(', ')} }"
591
- )}"
592
- when "set"
593
- if mutation[:target].to_s == lifecycle_field
594
- rhs = mutation_set_rhs(mutation[:source], "String", command, value_objects_by_name)
595
- Exemplar.render("mutation_set_plain", "tmpl_field" => target_field, "tmpl_rhs_placeholder2()" => rhs)
596
- else
597
- target_attr = aggregate[:attributes].find { |a| a[:name].to_s == mutation[:target].to_s }
598
- rhs = mutation_set_rhs(mutation[:source], target_attr[:type], command, value_objects_by_name, target_list: !!target_attr[:list])
599
- source_attr = mutation[:source][:kind] == "argument" ? command[:attributes].find { |a| a[:name].to_s == mutation[:source][:name].to_s } : nil
600
-
601
- if target_attr[:list] && optional && list_attr_creation_optional?(aggregate, target_attr[:name], value_objects_by_name)
602
- # `CardPayment.Authorize`'s redundant `sets :tags`: `list_attr_creation_optional?`
603
- # already
604
- # Option-wrapped this field, so the value is assigned straight across.
605
- Exemplar.render("mutation_set_plain", "tmpl_field" => target_field, "tmpl_rhs_placeholder2()" => rhs)
606
- elsif target_attr[:list] && source_attr && source_attr[:optional]
607
- # A record's list field is a plain `Vec<T>` here; an optional source unwraps with a
608
- # `[]` fallback.
609
- Exemplar.render("mutation_set_unwrap_or_default", "tmpl_field" => target_field, "tmpl_optional_rhs_placeholder()" => rhs)
610
- elsif target_attr[:list]
611
- # Duplicate-identity guard for a whole-list replace of entities
612
- # (`Ledger.ReplaceEntries`).
613
- guard, effective_rhs = entity_list_replace_guard(aggregate, target_attr, target_field, rhs, value_objects_by_name)
614
- "#{guard}#{Exemplar.render("mutation_set_plain", "tmpl_field" => target_field, "tmpl_rhs_placeholder2()" => effective_rhs)}"
615
- else
616
- # `wrap` for a per-field `Option<T>` target or a whole-record wrap, unless the
617
- # source argument
618
- # is already `Option<T>` (wrapping again would give `Option<Option<T>>`).
619
- wrap = (optional || target_attr[:optional]) && !(source_attr && source_attr[:optional])
620
- if wrap
621
- Exemplar.render("mutation_set_wrapped", "tmpl_field" => target_field, "tmpl_rhs_placeholder2()" => rhs)
622
- else
623
- Exemplar.render("mutation_set_plain", "tmpl_field" => target_field, "tmpl_rhs_placeholder2()" => rhs)
624
- end
625
- end
626
- end
627
- when "increment", "decrement"
628
- target_attr, integer_field = arithmetic_target_field(mutation, aggregate, value_objects_by_name)
629
- vo_type = rust_ident(target_attr[:type])
630
- field_ident = rust_ident_field(integer_field)
631
- amount_expr = arithmetic_amount_expr(mutation[:source], command, value_objects_by_name, integer_field)
632
- # The sign comes from the IR's `sign` field (`Bluebook::Mutation.sign_for`), not the op
633
- # name.
634
- sign = mutation[:sign].to_s == "1" ? "+" : "-"
635
- current = optional ? "record.#{target_field}.clone().unwrap()" : "record.#{target_field}.clone()"
636
- updated = "#{vo_type} { #{field_ident}: #{checked_arithmetic(mutation[:op].to_s, field_ident, sign, amount_expr)}, ..current }"
637
- Exemplar.render(
638
- "mutation_arithmetic",
639
- "tmpl_field" => target_field,
640
- "tmpl_current_placeholder()" => current,
641
- "tmpl_updated_placeholder()" => (optional ? "Some(#{updated})" : updated)
642
- )
643
- when "multiply"
644
- # `multiply` shares increment/decrement's Integer-only target and amount pairing but has
645
- # no
646
- # `sign`; Float value objects are not reachable here.
647
- target_attr, integer_field = arithmetic_target_field(mutation, aggregate, value_objects_by_name)
648
- vo_type = rust_ident(target_attr[:type])
649
- field_ident = rust_ident_field(integer_field)
650
- amount_expr = arithmetic_amount_expr(mutation[:source], command, value_objects_by_name, integer_field)
651
- current = optional ? "record.#{target_field}.clone().unwrap()" : "record.#{target_field}.clone()"
652
- updated = "#{vo_type} { #{field_ident}: #{checked_arithmetic('multiply', field_ident, '*', amount_expr)}, ..current }"
653
- Exemplar.render(
654
- "mutation_arithmetic",
655
- "tmpl_field" => target_field,
656
- "tmpl_current_placeholder()" => current,
657
- "tmpl_updated_placeholder()" => (optional ? "Some(#{updated})" : updated)
658
- )
659
- when "clamp"
660
- # `clamp` bounds the current value into a literal `[min, max]`, so there is no amount
661
- # argument.
662
- # `i64::clamp` matches Ruby's `Integer#clamp`.
663
- target_attr, integer_field = arithmetic_target_field(mutation, aggregate, value_objects_by_name)
664
- vo_type = rust_ident(target_attr[:type])
665
- field_ident = rust_ident_field(integer_field)
666
- min, max = clamp_bounds_ints(mutation[:source])
667
- current = optional ? "record.#{target_field}.clone().unwrap()" : "record.#{target_field}.clone()"
668
- updated = "#{vo_type} { #{field_ident}: current.#{field_ident}.clamp(#{min}, #{max}), ..current }"
669
- Exemplar.render(
670
- "mutation_arithmetic",
671
- "tmpl_field" => target_field,
672
- "tmpl_current_placeholder()" => current,
673
- "tmpl_updated_placeholder()" => (optional ? "Some(#{updated})" : updated)
674
- )
675
- when "remove"
676
- # `remove:` on an entity-typed list matches by identity field (`remove_field_problems`
677
- # already
678
- # confirmed one exists); `retain` keeps every element whose identity differs.
679
- target_attr = aggregate[:attributes].find { |a| a[:name].to_s == mutation[:target].to_s }
680
- entity = aggregate[:entities].find { |e| e[:name] == target_attr[:type] }
681
- id_attr, = entity_identity_mint(entity, value_objects_by_name)
682
- id_field = rust_ident_field(id_attr[:name])
683
- source_attr = command[:attributes].find { |a| a[:name].to_s == mutation[:source][:name].to_s }
684
- match_expr = value_rhs("args.#{rust_ident_field(source_attr[:name])}", source_attr[:type],
685
- id_attr[:type], value_objects_by_name)
686
- Exemplar.render(
687
- "mutation_remove",
688
- "tmpl_field" => target_field,
689
- "tmpl_id_field" => id_field,
690
- "tmpl_remove_match_placeholder()" => match_expr
691
- )
692
- end
693
- end
694
- end
695
- end