hecks 3.2.0 → 3.3.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.
Files changed (187) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/bluebook/assembly/contracts.rb +2 -1
  3. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +1 -0
  4. data/lib/hecks/bluebook/dsl/command_builder.rb +2 -39
  5. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +3 -3
  6. data/lib/hecks/bluebook/dsl/need_word.rb +50 -0
  7. data/lib/hecks/bluebook/dsl/query_builder.rb +6 -1
  8. data/lib/hecks/bluebook/query.rb +8 -5
  9. data/lib/hecks/cli/check_era.rb +3 -39
  10. data/lib/hecks/cli/interview_draft.rb +86 -31
  11. data/lib/hecks/cli/interview_session.rb +77 -26
  12. data/lib/hecks/cli/interview_shape.rb +179 -0
  13. data/lib/hecks/doors/cli_runner.rb +14 -1
  14. data/lib/hecks/doors/launcher_options.rb +13 -3
  15. data/lib/hecks/doors/mcp_door.rb +16 -2
  16. data/lib/hecks/doors/mcp_door_scope.rb +45 -5
  17. data/lib/hecks/doors/mcp_guide_cache.rb +75 -0
  18. data/lib/hecks/doors.rb +1 -1
  19. data/lib/hecks/embryonaut_bluebook/manifest.rb +117 -0
  20. data/lib/hecks/embryonaut_bluebook/registry.rb +209 -0
  21. data/lib/hecks/embryonaut_bluebook/vendor.rb +1 -1
  22. data/lib/hecks/embryonaut_bluebook.rb +2 -0
  23. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +17 -1
  24. data/lib/hecks/fuzzing/qa_settings.rb +15 -34
  25. data/lib/hecks/gate/stages.yml +113 -8
  26. data/lib/hecks/hecks/adapters/codebase/regeneration.rb +10 -1
  27. data/lib/hecks/hecks/adapters/codebase/test_runner.rb +10 -2
  28. data/lib/hecks/hecks/adapters/first_admin.rb +108 -0
  29. data/lib/hecks/hecks/adapters/git.rb +59 -0
  30. data/lib/hecks/hecks/adapters/in_process_access.rb +25 -0
  31. data/lib/hecks/hecks/adapters/in_process_boot.rb +2 -0
  32. data/lib/hecks/hecks/adapters/rust_toolchain.rb +18 -3
  33. data/lib/hecks/hecks/adapters/rust_workspace.rb +1 -1
  34. data/lib/hecks/hecks/adapters/site_toolchain.rb +88 -11
  35. data/lib/hecks/hecks/codebase.behaviors +6 -0
  36. data/lib/hecks/hecks/codebase.bluebook +21 -0
  37. data/lib/hecks/hecks/custodian.behaviors +6 -0
  38. data/lib/hecks/hecks/custodian.bluebook +217 -0
  39. data/lib/hecks/hecks/hecks.hecksagon +24 -0
  40. data/lib/hecks/hecks/hecks.world +8 -3
  41. data/lib/hecks/hecks/site.world +5 -2
  42. data/lib/hecks/language/bluebook/query.bluebook +25 -0
  43. data/lib/hecks/language/bluebook/vocabulary.bluebook +220 -7
  44. data/lib/hecks/language/hecksagon/hecksagon.bluebook +1 -1
  45. data/lib/hecks/language/oidc.json +5 -0
  46. data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +3 -3
  47. data/lib/hecks/projections/deploy/box/hosting.rb +170 -0
  48. data/lib/hecks/projections/deploy/box/hosting_settings.rb +123 -0
  49. data/lib/hecks/projections/deploy/box/settings.rb +40 -6
  50. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +2 -0
  51. data/lib/hecks/projections/deploy/box/templates/deploy-service.services-roll.part +33 -0
  52. data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +79 -0
  53. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef-roll.part +77 -0
  54. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef.part +12 -0
  55. data/lib/hecks/projections/deploy/box/templates/hosting.mk.tmpl +24 -0
  56. data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +17 -0
  57. data/lib/hecks/projections/deploy/box/templates/smoke-after-deploy.sh.tmpl +271 -0
  58. data/lib/hecks/projections/deploy/box.rb +28 -4
  59. data/lib/hecks/projections/deploy/fargate.rb +3 -3
  60. data/lib/hecks/projections/deploy/lambda.rb +8 -8
  61. data/lib/hecks/projections/model/deviations.rb +1 -1
  62. data/lib/hecks/projections/site/edge/behaviours.rb +5 -3
  63. data/lib/hecks/projections/site/edge/checks.rb +31 -5
  64. data/lib/hecks/projections/site/edge.rb +25 -11
  65. data/lib/hecks/projections/site/live_edge.rb +162 -0
  66. data/lib/hecks/projections/site/routes_ts.rb +50 -27
  67. data/lib/hecks/projections/site/site_cdn.rb +7 -4
  68. data/lib/hecks/projections/site/table/checks.rb +53 -7
  69. data/lib/hecks/projections/site/table/link_builder.rb +64 -0
  70. data/lib/hecks/projections/site/table/row_builder.rb +21 -6
  71. data/lib/hecks/projections/site/table.rb +16 -5
  72. data/lib/hecks/quality_control/adapters/git_pr.rb +0 -14
  73. data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +1 -1
  74. data/lib/hecks/quality_control/cli/qa_open_pr.rb +32 -16
  75. data/lib/hecks/quality_control/cli/qa_seed_angles.rb +2 -2
  76. data/lib/hecks/quality_control/cli/qa_seed_targets.rb +1 -2
  77. data/lib/hecks/quality_control/quality_control.bluebook +76 -124
  78. data/lib/hecks/runtime/interpreting.rb +3 -22
  79. data/lib/hecks/runtime/needs.rb +48 -0
  80. data/lib/hecks/runtime/query_interpreter.rb +3 -0
  81. data/lib/hecks/runtime/registry/verification.rb +3 -3
  82. data/lib/hecks/rust_build/host.rb +147 -0
  83. data/lib/hecks/rust_build/kernel_input.rb +51 -17
  84. data/lib/hecks/rust_build/project_rust/codegen_run.rb +0 -18
  85. data/lib/hecks/rust_build/project_rust.rb +7 -140
  86. data/lib/hecks/rust_build.rb +1 -0
  87. data/lib/hecks/site/bluebook/site.bluebook +80 -14
  88. data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
  89. data/lib/hecks/sme/bluebook/sme.bluebook +128 -1
  90. data/lib/hecks/storehouse.rb +35 -1
  91. data/lib/hecks/tools/comment_style.rb +4 -2
  92. data/lib/hecks/tools/gate.rb +1 -1
  93. data/lib/hecks/tools/rust_comment_style.rb +2 -4
  94. data/lib/hecks/tools/site_routes.rb +69 -28
  95. data/lib/hecks/tools/tools_doc.rb +204 -0
  96. data/lib/hecks/tools.rb +1 -0
  97. data/lib/hecks/version.rb +1 -1
  98. data/lib/hecks/vocabulary.rb +152 -1
  99. data/lib/hecks.rb +1 -0
  100. data/qa/settings.yml +1 -2
  101. data/rust/build/src/lineage_pass.rs +31 -16
  102. data/rust/build/src/main.rs +4 -2
  103. data/rust/build/src/pipeline.rs +9 -20
  104. data/rust/codegen/src/attr.rs +1 -1
  105. data/rust/codegen/src/bridging.rs +8 -8
  106. data/rust/codegen/src/commands.rs +1 -1
  107. data/rust/codegen/src/constraints.rs +1 -1
  108. data/rust/codegen/src/dependency_planning.rs +1 -1
  109. data/rust/codegen/src/domain_generator.rs +2 -2
  110. data/rust/codegen/src/exemplar.rs +1 -1
  111. data/rust/codegen/src/expr_emitter.rs +1 -1
  112. data/rust/codegen/src/fielded.rs +1 -1
  113. data/rust/codegen/src/json.rs +214 -23
  114. data/rust/codegen/src/json_codec.rs +1 -1
  115. data/rust/codegen/src/literal.rs +6 -3
  116. data/rust/codegen/src/main.rs +23 -13
  117. data/rust/codegen/src/mutations.rs +1 -1
  118. data/rust/codegen/src/naming.rs +5 -3
  119. data/rust/codegen/src/optional_pass.rs +226 -0
  120. data/rust/codegen/src/ports.rs +1 -1
  121. data/rust/codegen/src/queries.rs +3 -3
  122. data/rust/codegen/src/reactions.rs +1 -1
  123. data/rust/codegen/src/read_models.rs +2 -2
  124. data/rust/codegen/src/reference_specs.rs +1 -1
  125. data/rust/codegen/src/registry.rs +4 -4
  126. data/rust/codegen/src/shared.rs +1 -1
  127. data/rust/{build → codegen}/src/sidecars.rs +9 -5
  128. data/rust/codegen/src/skip_reason.rs +1 -1
  129. data/rust/codegen/src/types.rs +4 -4
  130. data/rust/host/HECKS_RELEASE +1 -1
  131. data/rust/host/src/api.rs +1 -1
  132. data/rust/host/src/dispatch.rs +5 -0
  133. data/rust/host/src/needs.rs +193 -29
  134. data/rust/host/src/resend.rs +4 -4
  135. data/rust/host/src/ui_schema.rs +1 -1
  136. data/rust/parser/src/emit.rs +12 -0
  137. data/rust/parser/src/ir.rs +2 -0
  138. data/rust/parser/src/keywords.rs +2 -0
  139. data/rust/parser/src/main.rs +1 -0
  140. data/rust/parser/src/parse/command.rs +2 -44
  141. data/rust/parser/src/parse/mod.rs +1 -0
  142. data/rust/parser/src/parse/needs.rs +60 -0
  143. data/rust/parser/src/parse/query.rs +3 -0
  144. data/rust/src/exemplar/commands.rs +1 -1
  145. data/rust/src/exemplar/constraints.rs +1 -1
  146. data/rust/src/exemplar/fielded.rs +1 -1
  147. data/rust/src/exemplar/json.rs +1 -1
  148. data/rust/src/exemplar/mod.rs +1 -1
  149. data/rust/src/exemplar/mutations.rs +1 -1
  150. data/rust/src/exemplar/queries.rs +1 -1
  151. data/rust/src/exemplar/reactions.rs +1 -1
  152. data/rust/src/exemplar/read_models.rs +1 -1
  153. data/rust/src/exemplar/registry.rs +1 -1
  154. data/rust/src/exemplar/types.rs +1 -1
  155. data/rust/src/kernel/cli.rs +18 -8
  156. data/rust/src/kernel/needs.rs +104 -9
  157. data/rust/src/kernel/read_model.rs +1 -1
  158. data/rust/src/lib.rs +1 -1
  159. metadata +24 -30
  160. data/lib/hecks/rust_build/append_optionals.rb +0 -79
  161. data/lib/hecks/rust_build/rust_literal.rb +0 -33
  162. data/lib/hecks/three_zero/forms.yml +0 -127
  163. data/lib/hecks/three_zero.rb +0 -12
  164. data/rust/build/src/json.rs +0 -421
  165. data/rust/build/src/optional_pass.rs +0 -150
  166. data/rust/project/bridging.rb +0 -257
  167. data/rust/project/commands.rb +0 -877
  168. data/rust/project/constraints.rb +0 -86
  169. data/rust/project/dependency_planning.rb +0 -178
  170. data/rust/project/domain_generator.rb +0 -983
  171. data/rust/project/exemplar.rb +0 -148
  172. data/rust/project/expr_emitter.rb +0 -72
  173. data/rust/project/fielded.rb +0 -141
  174. data/rust/project/json_codec.rb +0 -641
  175. data/rust/project/mutations.rb +0 -695
  176. data/rust/project/naming.rb +0 -223
  177. data/rust/project/ports.rb +0 -65
  178. data/rust/project/queries.rb +0 -493
  179. data/rust/project/reactions.rb +0 -317
  180. data/rust/project/read_models.rb +0 -690
  181. data/rust/project/reference_specs.rb +0 -27
  182. data/rust/project/registry.rb +0 -396
  183. data/rust/project/skip_reason.rb +0 -29
  184. data/rust/project/types.rb +0 -225
  185. data/rust/project/write_if_changed.rb +0 -7
  186. data/rust/project.rb +0 -30
  187. data/rust/project_rust_pipeline.rb +0 -391
@@ -1,983 +0,0 @@
1
- require "fileutils"
2
- require "json"
3
-
4
- module RustProjection
5
- # Generates one domain's worth of Rust types, Fielded impls and commands into
6
- # `mod_dir`, plus `metadata.rs` (embedded IR JSON for runtime introspection) and
7
- # `registry.rs` (the JSON command router `kernel::cli.rs` dispatches through).
8
- module DomainGenerator
9
- module_function
10
-
11
- # One manifest row per generate/skip decision, written to manifest.json
12
- # alongside ir.json so hecks rust_coverage never has to re-derive "did this
13
- # generate?" by grepping generated Rust source.
14
- #
15
- # gap_class distinguishes a "whole_kind" gap (this construct kind has no
16
- # code path here at all, for any domain) from a "per_instance" one (the
17
- # kind generates in general, but this declared instance failed one of
18
- # this generator's own per-instance checks). construct names the family
19
- # that forced a gap; gap_class and construct must travel together, since
20
- # downstream readers (the differential fuzzer, hecks rust_coverage's
21
- # allowlist) match gaps by these fields, never by reason prose.
22
- def manifest_entry(kind:, id:, generated:, reason: nil, gap_class: nil, routed: nil, construct: nil)
23
- if (generated == false || routed == false) && gap_class.nil?
24
- raise ArgumentError, "manifest entry #{kind} #{id} records a gap with no gap_class"
25
- end
26
- if gap_class && construct.to_s.empty?
27
- raise ArgumentError, "manifest entry #{kind} #{id} declares gap_class #{gap_class.inspect} with no construct — " \
28
- "every recorded gap names the construct family that forced it"
29
- end
30
-
31
- entry = { kind: kind, id: id, generated: generated }
32
- entry[:routed] = routed unless routed.nil?
33
- entry[:gap_class] = gap_class if gap_class
34
- entry[:construct] = construct.to_s if gap_class
35
- entry[:reason] = reason.to_s if reason
36
- entry
37
- end
38
-
39
- # Entity-scoped queries have no generated code path (the query loop in
40
- # `call` walks aggregate[:queries] only). Recorded here as the
41
- # whole-kind gap they are, recursively, one entry per declared entity
42
- # query at any depth.
43
- def entity_query_entries(owner_id, entities)
44
- entities.flat_map do |entity|
45
- entity_id = "#{owner_id}.#{entity[:name]}"
46
- own = Array(entity[:queries]).map do |query|
47
- manifest_entry(kind: "query", id: "#{entity_id}.#{query[:name]}", generated: false, gap_class: "whole_kind",
48
- construct: "entity_query",
49
- reason: "an entity-scoped query has no generated code path — only an aggregate's own " \
50
- "declared queries reach the QUERIES table")
51
- end
52
- own + entity_query_entries(entity_id, Array(entity[:entities]))
53
- end
54
- end
55
-
56
- def lifecycle_extra_field(node)
57
- return [] unless node[:lifecycle]
58
-
59
- field = Projector.rust_ident_field(node[:lifecycle][:field])
60
- [[Projector.rust_field(node[:lifecycle][:field]), "crate::kernel::Json::Str(self.#{field}.clone())"]]
61
- end
62
-
63
- # For each Reference<X> command attribute, resolves X against every
64
- # aggregate this domain declares, not just the ones already written
65
- # when this command's file is reached — matching Ruby's lazy
66
- # attribute-type resolution, which already allows forward references
67
- # across aggregates in one file.
68
- def reference_checks(command, aggregates_by_name, unsupported_names)
69
- command[:attributes].filter_map do |attr|
70
- target_name = Projector.reference_target(attr[:type])
71
- next unless target_name
72
-
73
- target = aggregates_by_name[target_name]
74
- next unless target
75
- next if unsupported_names.include?(target_name)
76
-
77
- {
78
- field: attr[:name],
79
- optional: attr[:optional],
80
- target_mod: target[:name].downcase,
81
- target_name: target[:name],
82
- heads: target[:identified_by].map { |path| path.split(".").first }.join(", "),
83
- }
84
- end
85
- end
86
-
87
- # Catches a reference field redeclared on the command under a plain
88
- # value object (the attribute isn't reference?-true, so reference_checks
89
- # above finds nothing to check) by checking against the aggregate's own
90
- # Reference<X> field of the same name instead. Reuses the same
91
- # pre-dispatch, args-based check shape as reference_checks, since a bare
92
- # `sets :field` mutation (no `to:`, `append:`, etc.) copies its source
93
- # argument straight into the settled state, so the value is already in
94
- # `args` before dispatch runs (ADR 0037). Only covers a bare :set
95
- # mutation on a scalar or single-attribute value object; a `has_many`
96
- # relationship and entity-level recursion are left unchecked.
97
- def state_reference_checks(aggregate, command, aggregates_by_name, unsupported_names, value_objects_by_name)
98
- aggregate[:attributes].filter_map do |attr|
99
- target_name = Projector.reference_target(attr[:type])
100
- next unless target_name
101
-
102
- mutation = command[:mutations].find do |m|
103
- m[:op].to_s == "set" && m[:target].to_s == attr[:name].to_s && m[:source][:kind] == "argument"
104
- end
105
- next unless mutation
106
-
107
- source_attr = command[:attributes].find { |a| a[:name].to_s == mutation[:source][:name].to_s }
108
- next unless source_attr
109
- next if Projector.reference_target(source_attr[:type])
110
-
111
- # A has_many field needs one check_reference per element
112
- # (reference_check_list) rather than the scalar accessor, since
113
- # dotting `.value` into the whole Vec wouldn't compile.
114
- list_item = nil
115
- if attr[:list]
116
- list_item = list_reference_check_item(source_attr, value_objects_by_name)
117
- next unless list_item
118
-
119
- accessor = source_attr[:name]
120
- else
121
- accessor = state_reference_check_accessor(source_attr, value_objects_by_name)
122
- next unless accessor
123
- end
124
-
125
- target = aggregates_by_name[target_name]
126
- next unless target
127
- next if unsupported_names.include?(target_name)
128
-
129
- {
130
- field: accessor,
131
- optional: source_attr[:optional],
132
- list_item: list_item,
133
- target_mod: target[:name].downcase,
134
- target_name: target[:name],
135
- heads: target[:identified_by].map { |path| path.split(".").first }.join(", "),
136
- }
137
- end
138
- end
139
-
140
- # The per-element key expression reference_check_list checks: bare
141
- # `item` for a list_of(String), or `&item.<field>` for a list of
142
- # single-String-attribute value objects. nil for any other element
143
- # shape — left unchecked, same as state_reference_check_accessor's own
144
- # gaps.
145
- def list_reference_check_item(source_attr, value_objects_by_name)
146
- return nil unless source_attr[:list]
147
- return "item" if source_attr[:type].to_s == "String"
148
-
149
- vo = value_objects_by_name[source_attr[:type]]
150
- return nil unless vo && vo[:attributes].size == 1
151
- return nil unless vo[:attributes].first[:type].to_s == "String"
152
-
153
- "&item.#{Projector.rust_ident_field(vo[:attributes].first[:name])}"
154
- end
155
-
156
- # The source argument's own field-access expression, tacked onto
157
- # `args.` by emit_reference_check. A plain scalar argument needs no
158
- # accessor (nil means "use the field name as-is"). A single-attribute
159
- # value object needs `.field` appended, but only for a required
160
- # argument — check_reference's optional template assumes
161
- # Option<String>, not Option<Struct>. A multi-attribute value object
162
- # has no single field to dot into. nil for both: a deliberately narrow
163
- # gap the real corpus doesn't exercise.
164
- def state_reference_check_accessor(source_attr, value_objects_by_name)
165
- return source_attr[:name] if Projector.effective_scalar_type(source_attr[:type])
166
-
167
- vo = value_objects_by_name[source_attr[:type]]
168
- return nil unless vo
169
- return nil if source_attr[:optional]
170
- return nil unless vo[:attributes].size == 1
171
-
172
- "#{source_attr[:name]}.#{Projector.rust_ident_field(vo[:attributes].first[:name])}"
173
- end
174
-
175
- # An aggregate's tenant field is whichever field one of its own queries
176
- # names via `authorize policy, tenant: :field` — reused here to name a
177
- # field on the aggregate itself for the write side. nil when no query
178
- # declares one.
179
- def tenant_field_for(aggregate)
180
- query = Array(aggregate[:queries]).find { |q| q[:authorization] && q[:authorization][:tenant] }
181
- query && query[:authorization][:tenant]
182
- end
183
-
184
- # The write-side mirror of the query-side tenant boundary (ADR 0037):
185
- # reuses the same pre-dispatch, args-based check shape as
186
- # reference_checks/state_reference_checks above, since a bare `sets
187
- # :field` mutation copies its source argument straight into settled
188
- # state before dispatch runs. Fires only when both sides declare a
189
- # tenant field and it's reachable off this command's args in the same
190
- # narrow accessor shape state_reference_check_accessor already
191
- # establishes; every other case bails to `[]`, never raises.
192
- def tenant_boundary_checks(aggregate, command, aggregates_by_name, unsupported_names, value_objects_by_name)
193
- own_tenant_field = tenant_field_for(aggregate)
194
- return [] unless own_tenant_field
195
-
196
- own_tenant_attr = command[:attributes].find { |a| a[:name].to_s == own_tenant_field.to_s }
197
- return [] unless own_tenant_attr
198
-
199
- own_accessor = state_reference_check_accessor(own_tenant_attr, value_objects_by_name)
200
- return [] unless own_accessor
201
-
202
- command[:attributes].filter_map do |attr|
203
- target_name = Projector.reference_target(attr[:type])
204
- next unless target_name
205
-
206
- target = aggregates_by_name[target_name]
207
- next unless target
208
- next if unsupported_names.include?(target_name)
209
-
210
- target_tenant_field = tenant_field_for(target)
211
- next unless target_tenant_field
212
-
213
- target_tenant_attr = target[:attributes].find { |a| a[:name].to_s == target_tenant_field.to_s }
214
- next unless target_tenant_attr
215
-
216
- target_accessor = state_reference_check_accessor(target_tenant_attr, value_objects_by_name)
217
- next unless target_accessor
218
-
219
- {
220
- reference_field: attr[:name],
221
- target_mod: target[:name].downcase,
222
- target_name: target[:name],
223
- aggregate_name: aggregate[:name],
224
- own_tenant_field: own_tenant_field,
225
- own_accessor: own_accessor,
226
- target_tenant_field: target_tenant_field,
227
- target_accessor: target_accessor,
228
- }
229
- end
230
- end
231
-
232
- # Writes one chapter's generated Rust (per-aggregate files, registry.rs,
233
- # metadata.rs, mod.rs) into mod_dir, and returns its aggregates, queries
234
- # and read_models so a multi-chapter caller can merge them.
235
- def call(ir, source_label, mod_dir, mod_name, merged_module: true)
236
- # Validated before any side effects (mkdir_p included) so every name
237
- # collision in one domain is reported at once, rather than after some
238
- # aggregates already had files written.
239
- refusal = Projector.reserved_name_refusal(source_label, mod_name, ir[:aggregates].map { |a| a[:name] })
240
- raise refusal if refusal
241
-
242
- unsafe = Projector.unsafe_name_refusal(source_label, ir)
243
- raise unsafe if unsafe
244
-
245
- FileUtils.mkdir_p(mod_dir)
246
- domain_name = ir[:name]
247
- generated_aggregates = []
248
- registry_aggregates = []
249
- # See manifest_entry's own header. Written to manifest.json at the
250
- # end of this method, alongside the ir.json sidecar.
251
- manifest = []
252
- # Accumulated the same way registry_aggregates is, and returned so a
253
- # multi-chapter caller can union query defs across chapters too.
254
- query_defs = []
255
- aggregates_by_name = ir[:aggregates].to_h { |a| [a[:name], a] }
256
- unsupported_names = ir[:aggregates].select do |a|
257
- vo_by_name = a[:value_objects].to_h { |vo| [vo[:name], vo] }
258
- Projector.unsupported_attribute_types(a, vo_by_name).any?
259
- end.map { |a| a[:name] }
260
- # A command attribute can reference any value object the domain
261
- # declares, not just one its own aggregate also declares.
262
- # cross_aggregate_vo_imports uses this map to emit the `use` line
263
- # that makes the identifier resolve; codegen call sites still emit
264
- # the same bare type name.
265
- domain_value_object_owner = ir[:aggregates].each_with_object({}) do |a, index|
266
- a[:value_objects].each { |vo| index[vo[:name]] = a[:name] }
267
- end
268
- # Same domain-wide reach as above: bridging a cross-aggregate command
269
- # argument's type into its target field needs the actual definition,
270
- # not just which aggregate owns it. Merged into each aggregate's own
271
- # local map below, with the local map winning any name collision.
272
- domain_value_objects_by_name = ir[:aggregates].flat_map { |a| a[:value_objects] }.to_h { |vo| [vo[:name], vo] }
273
- ir[:aggregates].each do |aggregate|
274
- value_objects_by_name = domain_value_objects_by_name.merge(aggregate[:value_objects].to_h { |vo| [vo[:name], vo] })
275
- # Must run before anything below reads attr[:optional] on an
276
- # entity/value-object field, since those reads need the derived
277
- # fact, not just what the domain author wrote by hand.
278
- Projector.mark_append_optional_fields!(aggregate, value_objects_by_name)
279
- Projector.derive_reverses_mutations!(aggregate)
280
-
281
- unsupported = Projector.unsupported_attribute_types(aggregate, value_objects_by_name)
282
- if unsupported.any?
283
- aggregate_reason = "attribute type(s) #{unsupported.join(', ')} not generated yet " \
284
- "(a bare, non-list entity-typed attribute isn't resolved to a Rust type)"
285
- puts "skipping #{domain_name}::#{aggregate[:name]}: #{aggregate_reason}"
286
- manifest << manifest_entry(kind: "aggregate", id: "#{domain_name}::#{aggregate[:name]}", generated: false,
287
- gap_class: "per_instance", construct: "attribute_type", reason: aggregate_reason)
288
- # Every command/entity/port-op this aggregate owns is recorded
289
- # too, tracing back to the same root cause, rather than silently
290
- # vanishing from the manifest with the aggregate.
291
- aggregate[:commands].each do |command|
292
- manifest << manifest_entry(kind: "command", id: "#{domain_name}::#{aggregate[:name]}.#{command[:name]}",
293
- generated: false, gap_class: "per_instance",
294
- construct: "owning_aggregate", reason: "owning aggregate not generated: #{aggregate_reason}")
295
- end
296
- aggregate[:entities].each do |entity|
297
- manifest << manifest_entry(kind: "entity", id: "#{domain_name}::#{aggregate[:name]}.#{entity[:name]}",
298
- generated: false, gap_class: "per_instance",
299
- construct: "owning_aggregate", reason: "owning aggregate not generated: #{aggregate_reason}")
300
- entity[:commands].each do |command|
301
- manifest << manifest_entry(kind: "entity_command",
302
- id: "#{domain_name}::#{aggregate[:name]}.#{entity[:name]}.#{command[:name]}",
303
- generated: false, gap_class: "per_instance",
304
- construct: "owning_aggregate", reason: "owning aggregate not generated: #{aggregate_reason}")
305
- end
306
- end
307
- aggregate[:ports].each do |port|
308
- port[:operations].each do |operation|
309
- manifest << manifest_entry(kind: "port_operation",
310
- id: "#{domain_name}::#{aggregate[:name]}.#{port[:name]}.#{operation[:name]}",
311
- generated: false, gap_class: "per_instance",
312
- construct: "owning_aggregate", reason: "owning aggregate not generated: #{aggregate_reason}")
313
- end
314
- end
315
- next
316
- end
317
-
318
- manifest << manifest_entry(kind: "aggregate", id: "#{domain_name}::#{aggregate[:name]}", generated: true)
319
- generated_aggregates << aggregate
320
- can_route = Projector.extract_id_supported?(aggregate)
321
- registry_commands = []
322
- entity_commands = []
323
- nested_entity_commands = []
324
- port_operations = []
325
- record_name = Projector.rust_ident(aggregate[:name])
326
-
327
- path = File.join(mod_dir, "#{aggregate[:name].downcase}.rs")
328
- wrote = WriteIfChanged.block(path) do |f|
329
- f.puts "// GENERATED by hecks project_rust from #{source_label}'s canonical IR."
330
- f.puts "// Do not hand-edit — re-run hecks project_rust instead."
331
- f.puts "#![allow(dead_code, unused_variables)]"
332
- f.puts 'use crate::kernel::Expr;'
333
- Projector.cross_aggregate_vo_imports(aggregate, domain_value_object_owner, mod_name).each { |line| f.puts line }
334
- f.puts
335
-
336
- aggregate[:value_objects].each do |vo|
337
- f.puts Projector.emit_value_object(vo, value_objects_by_name, aggregates_by_name)
338
- f.puts
339
- if vo[:closed_set] && vo[:attributes].size == 1
340
- # A single-field closed set collapses to a Rust enum
341
- # (emit_value_object's own branch), which needs the
342
- # tag<->member codec.
343
- f.puts Projector.emit_closed_set_codec(vo)
344
- elsif vo[:closed_set]
345
- # A multi-field closed set is a data table whose String
346
- # fields are &'static str, not String — from_json can only
347
- # select one of the table's fixed rows, not construct one.
348
- f.puts Projector.emit_closed_set_table_codec(vo)
349
- else
350
- # A value object gets the same unknown-key refusal a
351
- # command's args struct gets, with no extra allowed keys —
352
- # without it, a mistyped nested VO field silently falls
353
- # through to any declared default instead of being refused.
354
- name = Projector.rust_ident(vo[:name])
355
- f.puts Projector.emit_to_json_flat(name, vo[:attributes], value_objects_by_name)
356
- f.puts
357
- f.puts Projector.emit_from_json_flat(name, vo[:attributes], value_objects_by_name, unknown_argument_allowlist: [])
358
- end
359
- f.puts
360
- end
361
-
362
- aggregate[:entities].each do |entity|
363
- entity_verb = "#{domain_name}::#{aggregate[:name]}.#{entity[:name]}"
364
- manifest << manifest_entry(kind: "entity", id: entity_verb, generated: true)
365
- f.puts Projector.emit_entity(entity, value_objects_by_name)
366
- f.puts
367
- entity_name = Projector.rust_ident(entity[:name])
368
- f.puts Projector.emit_to_json_flat(entity_name, entity[:attributes], value_objects_by_name, extra_fields: lifecycle_extra_field(entity))
369
- f.puts
370
- f.puts Projector.emit_from_json_state(entity_name, entity[:attributes], value_objects_by_name, extra_fields: lifecycle_extra_field(entity))
371
- f.puts
372
-
373
- # An entity nested inside this one (one level past what this
374
- # loop otherwise reaches). Its struct and JSON codec are needed
375
- # the moment a sibling field references it as a Vec<...>
376
- # element type. Its own commands route only via `to: {
377
- # entities: [...] }` addressing (ADR 0026); a third level is
378
- # deliberately not generalized.
379
- #
380
- # entity_can_route (used again in the nested-entities loop
381
- # below) gates whether this entity's identity shape supports
382
- # extract_id/extract_wants at all; nested_can_route is the same
383
- # check one hop deeper. Both true is what unrouted_supported
384
- # (below) gates.
385
- entity_can_route = Projector.extract_id_supported?(entity)
386
-
387
- entity[:entities].each do |nested|
388
- nested_verb = "#{entity_verb}.#{nested[:name]}"
389
- manifest << manifest_entry(kind: "entity", id: nested_verb, generated: true)
390
- f.puts Projector.emit_entity(nested, value_objects_by_name)
391
- f.puts
392
- nested_rust_name = Projector.rust_ident(nested[:name])
393
- f.puts Projector.emit_to_json_flat(nested_rust_name, nested[:attributes], value_objects_by_name, extra_fields: lifecycle_extra_field(nested))
394
- f.puts
395
- f.puts Projector.emit_from_json_state(nested_rust_name, nested[:attributes], value_objects_by_name, extra_fields: lifecycle_extra_field(nested))
396
- f.puts
397
- # identity() is what a routed dispatch needs off a
398
- # doubly-nested element, emitted unconditionally the same
399
- # way entity's own always is.
400
- f.puts Projector.emit_self_identity(nested)
401
- f.puts
402
-
403
- # extract_id/extract_wants for flat-args addressing at this
404
- # depth, gated on both hops' identity shape supporting it
405
- # (entity_can_route && nested_can_route) since a flat
406
- # dispatch must also resolve the first hop's id.
407
- nested_can_route = Projector.extract_id_supported?(nested)
408
- unrouted_supported = entity_can_route && nested_can_route
409
- if unrouted_supported
410
- f.puts Projector.emit_extract_id(nested)
411
- f.puts
412
- # The addressing sibling generated alongside extract_id: a
413
- # present-but-blank value is a valid non-matching
414
- # component here, never a refusal.
415
- f.puts Projector.emit_extract_id_lenient(nested)
416
- f.puts
417
- f.puts Projector.emit_extract_wants(nested)
418
- f.puts
419
- else
420
- reason = entity_can_route ? "identity #{nested[:identified_by].inspect} isn't a shape extract_id resolves yet (json_codec.rb)" : "entity #{entity[:name]}'s own identity isn't extract_id-supported either"
421
- puts "skipping #{nested_verb}'s flat-args fallback: #{reason} — routed (`to:`) addressing still works"
422
- end
423
-
424
- nested[:commands].each do |command|
425
- nested_command_verb = "#{nested_verb}.#{command[:name]}"
426
- reason = Projector.entity_command_skip_reason(command, nested, value_objects_by_name)
427
- if reason
428
- puts "skipping #{nested_command_verb}: #{reason}"
429
- manifest << manifest_entry(kind: "entity_command", id: nested_command_verb, generated: false,
430
- gap_class: "per_instance", construct: reason.construct, reason: reason)
431
- next
432
- end
433
-
434
- f.puts Projector.emit_nested_entity_command(command, nested, entity, aggregate, domain_name, value_objects_by_name, aggregates_by_name,
435
- process_managers: ir[:process_managers])
436
- f.puts
437
-
438
- manifest << manifest_entry(kind: "entity_command", id: nested_command_verb, generated: true, routed: true,
439
- reason: unrouted_supported ? "routed (`to: { entities: [...] }`) and flat-args (one identity head per hop) both supported (BUG#19)" : "routed (`to: { entities: [...] }`) only — no legacy/flat-argument fallback at this depth (identity shape isn't extract_id-supported at one or both hops; BUG#19's own gate)")
440
-
441
- nested_entity_commands << {
442
- verb: nested_command_verb,
443
- name: command[:name],
444
- entity_record: Projector.rust_ident(entity[:name]),
445
- nested_record: nested_rust_name,
446
- # Matches commands.rb's emit_nested_entity_command naming
447
- # exactly: dispatch_entity_#{entity.downcase}_#{nested.downcase}_#{dispatch_fn_name(cmd)}.
448
- fn: "#{entity[:name].downcase}_#{nested[:name].downcase}_#{Projector.dispatch_fn_name(Projector.rust_ident(command[:name]))}",
449
- args_struct: "#{nested_rust_name}#{Projector.rust_ident(command[:name])}NestedEntityArgs",
450
- reference_checks: reference_checks(command, aggregates_by_name, unsupported_names),
451
- reference_specs: Projector.reference_specs(domain_name, command[:attributes]),
452
- attributes: command[:attributes].map { |a| a[:name].to_s },
453
- role: command[:role],
454
- invariant_check_lines: Projector.invariant_checks_for(command, aggregates_by_name, value_objects_by_name),
455
- entity_name: entity[:name],
456
- entity_identity_reading: entity[:identified_by].join(", "),
457
- nested_name: nested[:name],
458
- nested_identity_reading: nested[:identified_by].join(", "),
459
- # Whether the router gets a flat-args fallback branch for
460
- # this command, or stays routed-only; both hops must
461
- # support extract_id/extract_wants (see this loop's
462
- # header above).
463
- unrouted_supported: unrouted_supported,
464
- # registry.rb builds kernel::ArgumentGates from this
465
- # command's generated gate functions (emit_argument_gates)
466
- # and the kernel calls them in vocabulary order, ahead of
467
- # identity resolution.
468
- }
469
- end
470
- end
471
-
472
- # Computed once above (the nested-entities loop needs it too).
473
- entity_router_reason = "identity #{entity[:identified_by].inspect} isn't a shape extract_id resolves yet (json_codec.rb)"
474
- if entity_can_route
475
- f.puts Projector.emit_extract_id(entity)
476
- f.puts
477
- # commands.rb's delegate_prelude/emit_entity_command and
478
- # registry.rb's entity_arms route-less branch both address
479
- # this entity by extract_id_lenient, not extract_id.
480
- f.puts Projector.emit_extract_id_lenient(entity)
481
- f.puts
482
- f.puts Projector.emit_extract_wants(entity)
483
- f.puts
484
- f.puts Projector.emit_self_identity(entity)
485
- f.puts
486
- else
487
- puts "skipping #{domain_name}::#{aggregate[:name]}.#{entity[:name]}'s JSON router entries: #{entity_router_reason}"
488
- end
489
-
490
- entity[:commands].each do |command|
491
- entity_command_verb = "#{domain_name}::#{aggregate[:name]}.#{entity[:name]}.#{command[:name]}"
492
- reason = Projector.entity_command_skip_reason(command, entity, value_objects_by_name)
493
- if reason
494
- puts "skipping #{entity_command_verb}: #{reason}"
495
- manifest << manifest_entry(kind: "entity_command", id: entity_command_verb, generated: false,
496
- gap_class: "per_instance", construct: reason.construct, reason: reason)
497
- next
498
- end
499
-
500
- f.puts Projector.emit_entity_command(command, entity, aggregate, domain_name, value_objects_by_name, aggregates_by_name,
501
- process_managers: ir[:process_managers])
502
- f.puts
503
-
504
- # This command's Rust function was already emitted
505
- # unconditionally above; whether anything can dispatch to it
506
- # depends on the entity's identity shape, not this command's
507
- # own. `generated: true, routed: false` distinguishes a
508
- # real-but-unreachable function from one that was never
509
- # generated at all.
510
- unless entity_can_route
511
- manifest << manifest_entry(kind: "entity_command", id: entity_command_verb, generated: true,
512
- routed: false, gap_class: "per_instance",
513
- construct: "router_identity", reason: "generated as a real Rust function, but not JSON-dispatchable —#{entity_router_reason}")
514
- next
515
- end
516
-
517
- manifest << manifest_entry(kind: "entity_command", id: entity_command_verb, generated: true, routed: true)
518
-
519
- entity_commands << {
520
- verb: entity_command_verb,
521
- name: command[:name],
522
- entity_record: entity_name,
523
- # Matches commands.rb's emit_entity_command naming exactly:
524
- # dispatch_entity_#{entity[:name].downcase}_#{dispatch_fn_name(cmd)}.
525
- fn: "#{entity[:name].downcase}_#{Projector.dispatch_fn_name(Projector.rust_ident(command[:name]))}",
526
- # EntityArgs, not Args — an aggregate command named after the
527
- # entity command it delegates to would otherwise share its
528
- # args struct's name.
529
- args_struct: "#{entity_name}#{Projector.rust_ident(command[:name])}EntityArgs",
530
- reference_checks: reference_checks(command, aggregates_by_name, unsupported_names),
531
- reference_specs: Projector.reference_specs(domain_name, command[:attributes]),
532
- # Declared attribute names; see the identical field on
533
- # registry_commands, above.
534
- attributes: command[:attributes].map { |a| a[:name].to_s },
535
- role: command[:role],
536
- # The same VO invariant/admits/pattern checks
537
- # emit_entity_command already bakes into dispatch_entity_*,
538
- # computed here too so registry.rb's router can run them
539
- # before refuse_role_mismatch/resolve_references, matching
540
- # Ruby's own dispatch order.
541
- invariant_check_lines: Projector.invariant_checks_for(command, aggregates_by_name, value_objects_by_name),
542
- # entity_name/entity_identity_reading come from the entity's
543
- # own declared name, threaded through registry.rb's dispatch
544
- # call the same way the parent aggregate's already do.
545
- entity_name: entity[:name],
546
- entity_identity_reading: entity[:identified_by].join(", "),
547
- # One generated function per declared argument-gate step,
548
- # called in vocabulary order by
549
- # kernel::decode_aggregate_arguments.
550
- }
551
- end
552
- end
553
-
554
- # aggregate[:attributes] plus a String-typed, always-optional
555
- # pseudo-attribute per `projects` field: the record's
556
- # struct/Fielded/JSON shape needs to carry a seeded projection
557
- # like any other attribute. A command's own Args struct never
558
- # sees this merge.
559
- record_attributes = aggregate[:attributes] + Projector.projected_field_pseudo_attributes(aggregate)
560
- record_for_struct = aggregate.merge(attributes: record_attributes)
561
-
562
- f.puts Projector.emit_record(record_for_struct, value_objects_by_name)
563
- f.puts
564
- f.puts Projector.emit_to_json_flat(record_name, record_attributes, value_objects_by_name, optional: true, extra_fields: lifecycle_extra_field(aggregate) + Projector.corrects_extra_fields(aggregate), aggregate: aggregate)
565
- f.puts
566
- f.puts Projector.emit_from_json_state(record_name, record_attributes, value_objects_by_name, optional: true, extra_fields: lifecycle_extra_field(aggregate) + Projector.corrects_extra_fields(aggregate), aggregate: aggregate)
567
- f.puts
568
- # dispatch/dispatch_entity are generic over the record type and
569
- # need a trait-bound to_json, since the inherent method above
570
- # can't be called on a bare generic T. Emitted only for
571
- # aggregate records, never value objects/entities/Args structs.
572
- f.puts <<~RUST
573
- impl crate::kernel::ToJson for #{record_name} {
574
- fn to_json(&self) -> crate::kernel::Json {
575
- #{record_name}::to_json(self)
576
- }
577
- }
578
- RUST
579
- f.puts
580
- f.puts Projector.emit_set_projected_field(aggregate)
581
- f.puts
582
- f.puts Projector.emit_projected_field_table(aggregate)
583
- f.puts
584
- acting_router_reason = "identity #{aggregate[:identified_by].inspect} isn't a shape extract_id resolves yet (json_codec.rb)"
585
- if can_route
586
- f.puts Projector.emit_extract_id(aggregate)
587
- f.puts
588
- else
589
- puts "skipping #{domain_name}::#{aggregate[:name]}'s JSON router acting-command entries: #{acting_router_reason}"
590
- end
591
-
592
- f.puts Projector.emit_invariants_fn(aggregate)
593
- f.puts
594
-
595
- aggregate[:commands].each do |command|
596
- command_verb = "#{domain_name}::#{aggregate[:name]}.#{command[:name]}"
597
- reason = Projector.command_skip_reason(command, aggregate, value_objects_by_name)
598
- if reason
599
- puts "skipping #{command_verb}: #{reason}"
600
- manifest << manifest_entry(kind: "command", id: command_verb, generated: false, gap_class: "per_instance", construct: reason.construct, reason: reason)
601
- next
602
- end
603
-
604
- f.puts Projector.emit_command(command, aggregate, domain_name, value_objects_by_name, aggregates_by_name)
605
- f.puts
606
- args_struct = "#{Projector.rust_ident(command[:name])}Args"
607
- # An event's payload is args.to_json() directly, matching what
608
- # a policy or process-manager reaction needs to forward real
609
- # data into a re-triggered command's from_json.
610
- #
611
- # Not sparse: true here — that would diverge this generator's
612
- # output from the separate hecks-codegen reimplementation that
613
- # spec/codegen_parity_spec.rb holds byte-identical to this one,
614
- # across nearly every domain with an optional command attribute.
615
- f.puts Projector.emit_to_json_flat(args_struct, command[:attributes], value_objects_by_name, sparse: true)
616
- f.puts
617
- allowlist = Projector.command_argument_allowlist(aggregate, command, ir[:process_managers])
618
- f.puts Projector.emit_from_json_flat(args_struct, command[:attributes], value_objects_by_name, unknown_argument_allowlist: allowlist, command_name: command[:name].to_s, absent_argument_check: true, interleave_checks: true, aggregates_by_name: aggregates_by_name)
619
- f.puts
620
- # One generated function per declared argument-gate step,
621
- # called in vocabulary order by
622
- # kernel::decode_aggregate_arguments.
623
- f.puts Projector.emit_argument_gates(args_struct, command[:name].to_s, command[:attributes], allowlist)
624
- f.puts
625
-
626
- # A creating command's identity comes from its own typed args
627
- # (build_identity_expr, already emitted by emit_command), so
628
- # it's routable regardless of extract_id — including when
629
- # identity needs an extra `head:` parameter read off the raw
630
- # JSON rather than the typed Args struct. An acting command's
631
- # id comes from extract_id instead, so it's routable only
632
- # when that is.
633
- creates = Projector.creates_owner?(aggregate, command, value_objects_by_name)
634
- # identity_components only applies to a creating command: an
635
- # acting command reaches an existing record via
636
- # extract_id/id_line, not by minting one, so it never needs
637
- # these extra params.
638
- identity_extra_params = creates ? Projector.identity_components(aggregate, command).filter_map { |c| c[:head] } : []
639
-
640
- unless creates || can_route
641
- puts "skipping #{command_verb}'s JSON router entry: #{acting_router_reason}"
642
- manifest << manifest_entry(kind: "command", id: command_verb, generated: true, routed: false,
643
- gap_class: "per_instance",
644
- construct: "router_identity", reason: "generated as a real Rust function, but not JSON-dispatchable —#{acting_router_reason}")
645
- next
646
- end
647
-
648
- manifest << manifest_entry(kind: "command", id: command_verb, generated: true, routed: true)
649
- registry_commands << {
650
- verb: command_verb,
651
- name: command[:name],
652
- fn: Projector.dispatch_fn_name(Projector.rust_ident(command[:name])),
653
- args_struct: args_struct,
654
- creates: creates,
655
- identity_extra_params: identity_extra_params,
656
- # Adds only entries reference_checks above doesn't already
657
- # cover — a reference field redeclared under a plain value
658
- # object, checked against the aggregate's own field of the
659
- # same name (ADR 0037).
660
- reference_checks: reference_checks(command, aggregates_by_name, unsupported_names) +
661
- state_reference_checks(aggregate, command, aggregates_by_name, unsupported_names, value_objects_by_name),
662
- # tenant_boundary_checks' own header has the full argument.
663
- # Empty for every command outside tenant_ledger today — no
664
- # other aggregate in the corpus declares a tenant-scoping
665
- # query.
666
- tenant_boundary_checks: tenant_boundary_checks(aggregate, command, aggregates_by_name, unsupported_names, value_objects_by_name),
667
- reference_specs: Projector.reference_specs(domain_name, command[:attributes]),
668
- # Read by reactions.rb's emit_command_attributes_table and
669
- # ReactionInvocation.command_facts on the Ruby side.
670
- attributes: command[:attributes].map { |a| a[:name].to_s },
671
- role: command[:role],
672
- # See the identical field on entity_commands, above, for the
673
- # full reasoning.
674
- invariant_check_lines: Projector.invariant_checks_for(command, aggregates_by_name, value_objects_by_name),
675
- # registry.rb builds kernel::ArgumentGates from this
676
- # command's generated gate functions (emit_argument_gates)
677
- # and the kernel calls them in vocabulary order, ahead of
678
- # identity resolution.
679
- }
680
- end
681
-
682
- # The primary/driving half (ports.rb's own header): no Hydrate,
683
- # no repo, so can_route never gates these — the operation's own
684
- # reference attribute already names the record.
685
- aggregate[:ports].each do |port|
686
- port[:operations].each do |operation|
687
- operation_verb = "#{domain_name}::#{aggregate[:name]}.#{port[:name]}.#{operation[:name]}"
688
- reason = Projector.port_operation_skip_reason(operation, aggregate[:name], value_objects_by_name)
689
- if reason
690
- puts "skipping #{operation_verb}: #{reason}"
691
- manifest << manifest_entry(kind: "port_operation", id: operation_verb, generated: false,
692
- gap_class: "per_instance", construct: reason.construct, reason: reason)
693
- next
694
- end
695
-
696
- f.puts Projector.emit_port_operation(operation, port[:name], aggregate[:name], domain_name, value_objects_by_name, aggregates_by_name)
697
- f.puts
698
-
699
- manifest << manifest_entry(kind: "port_operation", id: operation_verb, generated: true, routed: true)
700
- operation_args_struct = "#{Projector.rust_ident(port[:name])}#{Projector.rust_ident(operation[:name])}Args"
701
- # If this operation still declares a Reference-typed
702
- # attribute pointing back at its own aggregate,
703
- # emit_port_operation excludes it from the generated args
704
- # struct (routing supplies the receiver now), so filter it
705
- # out of reference checks too.
706
- legacy_receiver_field = operation[:attributes]
707
- .find { |attr| Projector.reference_target(attr[:type]) == aggregate[:name] }
708
- &.dig(:name)
709
- # Mirrors Dispatcher#port_invocation's additive branch: no
710
- # Reference-typed attribute exists for a `to:`-declared
711
- # operation, so the receiver comes from a plain fact
712
- # attribute named for the owning aggregate's own
713
- # identified_by field instead. Kept as a separate field
714
- # since split_aggregate_receiver must not strip it from the
715
- # payload — it's a real declared fact, not routing-only
716
- # state. Uses `.split(".").first` to take just the head of
717
- # identified_by, since a value-object-typed identity
718
- # resolves to a dotted internal path rather than the flat
719
- # name the operation's own attribute is actually named.
720
- to_receiver_field = operation[:to] == aggregate[:name] ? aggregate[:identified_by]&.first&.split(".")&.first : nil
721
- operation_reference_checks = reference_checks(operation, aggregates_by_name, unsupported_names)
722
- .reject { |check| check[:target_name] == aggregate[:name] }
723
- port_operations << {
724
- verb: operation_verb,
725
- name: operation[:name],
726
- fn: "#{port[:name].downcase}_#{Projector.dispatch_fn_name(Projector.rust_ident(operation[:name]))}",
727
- args_struct: operation_args_struct,
728
- reference_checks: operation_reference_checks,
729
- legacy_receiver_field: legacy_receiver_field,
730
- to_receiver_field: to_receiver_field,
731
- }
732
- end
733
- end
734
- end
735
- puts(wrote ? "wrote #{path}" : "#{path} unchanged")
736
-
737
- registry_aggregates << {
738
- name: aggregate[:name],
739
- mod: aggregate[:name].downcase,
740
- record: record_name,
741
- commands: registry_commands,
742
- entity_commands: entity_commands,
743
- nested_entity_commands: nested_entity_commands,
744
- ports: port_operations,
745
- # Carried through verbatim; emit_identity_head_table only
746
- # resolves the single-component case — a composite identity is
747
- # a documented gap there, not silently assumed to work.
748
- identified_by: aggregate[:identified_by],
749
- # Name + identity paths only; a saga-dispatched entity command
750
- # needs the entity's own identity, not just its parent
751
- # aggregate's.
752
- entities: aggregate[:entities].map { |e| { name: e[:name], identified_by: e[:identified_by] } },
753
- # Which top-level generated module this aggregate's .rs file
754
- # lives under, so a per-chapter registry.rs can qualify
755
- # cross-file paths as crate::generated::#{chapter_mod}::...
756
- # instead of a bare super::...
757
- chapter_mod: mod_name,
758
- # This aggregate's own declared reference_to/belongs_to
759
- # attributes, for the domain-wide REFERENCE_TABLE and an acting
760
- # command's owner_deref fetch — computed once here rather than
761
- # re-derived per command.
762
- reference_specs: Projector.reference_specs(domain_name, aggregate[:attributes]),
763
- # The bluebook's declared name (e.g. "Governance", not the
764
- # lowercase module), so a merged multi-chapter registry labels
765
- # each record by its own chapter correctly.
766
- domain_name: domain_name,
767
- }
768
- end
769
-
770
- # A declared query now generates for real for the subset
771
- # query_skip_reason admits (field-comparator conditions, order_by,
772
- # limit, offset, a nulls override); anything else is a
773
- # per-instance gap, not a whole-kind one, since the construct kind
774
- # has a real code path.
775
- query_aggregates_by_name = ir[:aggregates].to_h { |a| [a[:name], a] }
776
- assignments_verb = Projector.provided_assignments(ir)
777
- ir[:aggregates].each do |aggregate|
778
- value_objects_by_name = aggregate[:value_objects].to_h { |vo| [vo[:name], vo] }
779
-
780
- aggregate[:queries].each do |query|
781
- query_verb = "#{domain_name}::#{aggregate[:name]}.#{query[:name]}"
782
- reason = Projector.query_skip_reason(query, aggregate, value_objects_by_name, query_aggregates_by_name)
783
- if reason
784
- puts "skipping query #{query_verb}: #{reason}"
785
- manifest << manifest_entry(kind: "query", id: query_verb, generated: false, gap_class: "per_instance", construct: reason.construct, reason: reason)
786
- next
787
- end
788
-
789
- manifest << manifest_entry(kind: "query", id: query_verb, generated: true)
790
- conditions, reference_hop_conditions = Projector.query_conditions_and_hops(domain_name, query, aggregate, query_aggregates_by_name)
791
- query_defs << {
792
- verb: query_verb,
793
- aggregate: "#{domain_name}::#{aggregate[:name]}",
794
- arg_checks: Projector.query_arg_checks(query, "crate::generated::#{mod_name}::#{aggregate[:name].downcase}",
795
- value_objects_by_name),
796
- conditions: conditions,
797
- reference_hop_conditions: reference_hop_conditions,
798
- order_by: query[:order_by] ? Projector.emit_query_order_by(query[:order_by], query[:null_semantics]) : nil,
799
- offset: query[:offset] ? Projector.emit_query_offset(query[:offset]) : nil,
800
- limit: query[:limit] ? Projector.emit_query_limit(query[:limit]) : nil,
801
- authorization: Projector.emit_query_authorization(query[:name], query[:authorization]),
802
- assignments: assignments_verb == "#{aggregate[:name]}.#{query[:name]}",
803
- }
804
- end
805
-
806
- # Generated when the aggregate holds the entity in a list
807
- # (kernel::named_query::run_entity). A query on an entity nested
808
- # inside another entity stays a recorded gap.
809
- aggregate_id = "#{domain_name}::#{aggregate[:name]}"
810
- aggregate[:entities].each do |entity|
811
- list_attr = aggregate[:attributes].find { |a| a[:list] && a[:type].to_s == entity[:name].to_s }
812
- Array(entity[:queries]).each do |query|
813
- query_verb = "#{aggregate_id}.#{entity[:name]}.#{query[:name]}"
814
- reason = Projector.entity_query_skip_reason(query, entity, list_attr, value_objects_by_name)
815
- if reason
816
- puts "skipping query #{query_verb}: #{reason}"
817
- manifest << manifest_entry(kind: "query", id: query_verb, generated: false, gap_class: "per_instance", construct: reason.construct, reason: reason)
818
- next
819
- end
820
-
821
- manifest << manifest_entry(kind: "query", id: query_verb, generated: true)
822
- query_defs << {
823
- verb: query_verb,
824
- aggregate: aggregate_id,
825
- entity: { list_field: list_attr[:name].to_s, parent_key: Projector.snake(aggregate[:name]),
826
- identity_keys: Array(entity[:identified_by]).map { |path| path.to_s.split(".").first } },
827
- arg_checks: [],
828
- conditions: Projector.query_conditions(query),
829
- order_by: query[:order_by] ? Projector.emit_query_order_by(query[:order_by], query[:null_semantics]) : nil,
830
- offset: query[:offset] ? Projector.emit_query_offset(query[:offset]) : nil,
831
- limit: query[:limit] ? Projector.emit_query_limit(query[:limit]) : nil,
832
- authorization: nil,
833
- }
834
- end
835
- manifest.concat(entity_query_entries("#{aggregate_id}.#{entity[:name]}", Array(entity[:entities])))
836
- end
837
- end
838
-
839
- # A declared report block now generates for real for the subset
840
- # read_model_skip_reason admits (a root aggregate fetched by
841
- # reference id, plus reference-matched sibling heads — no
842
- # where/order_by/limit). Anything else is a per-instance gap, not a
843
- # whole-kind one, matching the query codegen above.
844
- read_model_defs = []
845
- ir[:read_models].each do |read_model|
846
- read_model_id = "#{domain_name}::#{read_model[:name]}"
847
- reason = Projector.read_model_skip_reason(read_model, aggregates_by_name, unsupported_names)
848
- if reason
849
- puts "skipping read_model #{read_model_id}: #{reason}"
850
- manifest << manifest_entry(kind: "read_model", id: read_model_id, generated: false, gap_class: "per_instance", construct: reason.construct, reason: reason)
851
- next
852
- end
853
-
854
- manifest << manifest_entry(kind: "read_model", id: read_model_id, generated: true)
855
- read_model_defs << Projector.read_model_def(domain_name, read_model, aggregates_by_name)
856
- end
857
-
858
- # A same-domain policy generates into POLICIES and dispatches
859
- # locally; a cross-domain policy generates into the separate
860
- # CROSS_DOMAIN_POLICIES table instead, delivered by rust/host's
861
- # lambda_client.rs rather than a local dispatch_by_name call. This
862
- # manifest entry can't attest that a live cross-domain reaction's
863
- # target actually exists — that's an operational fact about a real
864
- # deploy, not something codegen can check.
865
- ir[:policies].each do |policy|
866
- manifest << manifest_entry(kind: "policy", id: "#{domain_name}::#{policy[:name]}", generated: true, routed: true)
867
- end
868
-
869
- # Unlike emit_policy_table above, no process manager has a
870
- # per-instance skip condition: every declared dispatch target is
871
- # already fully domain-qualified on the wire.
872
- ir[:process_managers].each do |pm|
873
- manifest << manifest_entry(kind: "process_manager", id: "#{domain_name}::#{pm[:name]}", generated: true, routed: true)
874
- end
875
-
876
- # Which adapter an aggregate is bound to is a deployment fact, not
877
- # something re-derived from the bluebook's shape. Always generated
878
- # and routed: rust/host's journal read/write path is generic over
879
- # storage_name, and reachable outside the WASM kernel/cli.rs match
880
- # arm entirely.
881
- ir.fetch(:lineage, {}).fetch(:capable_aggregates, []).each do |aggregate|
882
- manifest << manifest_entry(
883
- kind: "lineage_aggregate", id: "#{domain_name}::#{aggregate[:name]}", generated: true, routed: true,
884
- reason: "read via rust/host's journal::read_lineage_head_all/_by_id, written via journal::" \
885
- "append_lineage_mutation — both generic over storage_name (\"#{aggregate[:storage_name]}\"), " \
886
- "dispatched OUTSIDE the WASM kernel/InMemoryRepository path entirely, matching Ruby's own " \
887
- "CommandInterpreter routing for a Postgres-bound aggregate (rust/project.rb's own header)"
888
- )
889
- end
890
-
891
- metadata_path = File.join(mod_dir, "metadata.rs")
892
- wrote = WriteIfChanged.block(metadata_path) do |f|
893
- f.puts "// GENERATED by hecks project_rust — #{source_label}'s own canonical IR,"
894
- f.puts "// embedded for runtime self-description. Not read by any dispatch"
895
- f.puts "// function in this module — introspection only."
896
- f.puts "pub const IR_JSON: &str = #{Projector.rust_string_literal(JSON.pretty_generate(ir))};"
897
- end
898
- puts(wrote ? "wrote #{metadata_path}" : "#{metadata_path} unchanged")
899
-
900
- # rust/host carries no path dependency on this crate — its .wasm
901
- # module is an opaque, untrusted artifact loaded via wasmtime, so
902
- # metadata.rs's IR_JSON is unreachable from rust/host at compile
903
- # time. hecks build_wasm copies this sidecar into rust/dist/ so a
904
- # Rust-native web layer can read it at runtime via HECKS_IR_PATH.
905
- ir_json_path = File.join(mod_dir, "ir.json")
906
- wrote = WriteIfChanged.call(ir_json_path, JSON.pretty_generate(ir))
907
- puts(wrote ? "wrote #{ir_json_path}" : "#{ir_json_path} unchanged")
908
-
909
- # Sits alongside ir.json for the same reason: one is this call's
910
- # account of what it read, the other of what it did with what it
911
- # read. hecks rust_coverage reads both and diffs against an
912
- # allowlist; nothing in this generator reads manifest.json back.
913
- manifest_path = File.join(mod_dir, "manifest.json")
914
- wrote = WriteIfChanged.call(manifest_path, JSON.pretty_generate(manifest))
915
- puts(wrote ? "wrote #{manifest_path}" : "#{manifest_path} unchanged")
916
-
917
- registry_path = File.join(mod_dir, "registry.rs")
918
- wrote_registry = WriteIfChanged.block(registry_path) do |f|
919
- f.puts Projector.emit_registry(registry_aggregates)
920
- f.puts
921
- f.puts Projector.emit_reference_lookup(registry_aggregates)
922
- f.puts
923
- f.puts Projector.emit_policy_table(domain_name, ir[:policies], ir[:aggregates])
924
- f.puts
925
- f.puts Projector.emit_cross_domain_policy_table(domain_name, ir[:policies])
926
- f.puts
927
- f.puts Projector.emit_process_manager_table(ir[:process_managers])
928
- f.puts
929
- f.puts Projector.emit_reference_key_table([[domain_name, generated_aggregates.map { |a| a[:name] }]])
930
- f.puts
931
- f.puts Projector.emit_creates_table(registry_aggregates)
932
- f.puts
933
- f.puts Projector.emit_identity_head_table(registry_aggregates)
934
- f.puts
935
- f.puts Projector.emit_entity_identity_head_table(registry_aggregates)
936
- f.puts
937
- f.puts Projector.emit_command_attributes_table(registry_aggregates)
938
- f.puts
939
- f.puts Projector.emit_query_table(query_defs)
940
- f.puts
941
- f.puts Projector.emit_query_arg_check_table(query_defs)
942
- f.puts
943
- read_model_defs.each do |rmd|
944
- next unless rmd[:group_by_fn_body]
945
-
946
- f.puts rmd[:group_by_fn_body]
947
- f.puts
948
- end
949
- f.puts Projector.emit_read_model_table(read_model_defs)
950
- end
951
- puts(wrote_registry ? "wrote #{registry_path}" : "#{registry_path} unchanged")
952
-
953
- mod_path = File.join(mod_dir, "mod.rs")
954
- # This generator never writes `pub mod merged;` itself (`hecks project_rust`
955
- # appends it separately, only once a chapter gets its own
956
- # merged.rs). Re-including an already-present trailer keeps
957
- # WriteIfChanged's before/after comparison apples-to-apples —
958
- # without it, comparing base-only content against base-plus-merged
959
- # content on disk would look changed every run. Requires
960
- # merged_module too, not just presence on disk: a framework chapter
961
- # never regenerates merged.rs, and an orphaned trailer here would
962
- # leave a dangling `pub mod merged;` in the chapter's mod.rs once
963
- # pruning removes the file.
964
- merged_trailer = merged_module && File.exist?(mod_path) && File.read(mod_path).include?("pub mod merged;")
965
- wrote_mod = WriteIfChanged.block(mod_path) do |f|
966
- f.puts "// GENERATED by hecks project_rust — re-run it to refresh this list."
967
- f.puts "pub mod metadata;"
968
- f.puts "pub mod registry;"
969
- generated_aggregates.each { |a| f.puts "pub mod #{a[:name].downcase};" }
970
- f.puts "pub mod merged;" if merged_trailer
971
- end
972
- puts(wrote_mod ? "wrote #{mod_path}" : "#{mod_path} unchanged")
973
-
974
- # Returned so hecks project_rust can concatenate :aggregates across
975
- # every chapter a domain attaches into one merged
976
- # Store/dispatch_by_name, and :queries the same way. Each aggregate
977
- # already carries chapter_mod (set above); a query_def needs no
978
- # chapter tag since its verb/aggregate are already fully
979
- # domain-qualified.
980
- { aggregates: registry_aggregates, queries: query_defs, read_models: read_model_defs }
981
- end
982
- end
983
- end