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,493 +0,0 @@
1
- require_relative "skip_reason"
2
-
3
- # Generates the Rust named-query table from the exported IR, with skip reasons.
4
- module RustProjection
5
- module Projector
6
- module_function
7
-
8
- # Named-query codegen: a declared `query` whose where clauses compare one aggregate's own
9
- # fields becomes a `QueryDef` row, with its `order_by`/`offset`/`limit` carried alongside.
10
- #
11
- # `query_skip_reason` is the gate; a query it refuses gets no row, with the reason recorded.
12
- # Refused: cursor/consistency/freshness/inspection/use_index, order_by on a field that is not
13
- # a plain string or number, a limit/offset that is neither an integer literal nor a Symbol arg.
14
- #
15
- # Literal where values arrive through the exported IR as `value.to_s`, so an Integer and a
16
- # String literal are indistinguishable. gt/gte/lt/lte therefore never take a literal, eq/ne
17
- # take one only against a provably string-shaped field, and in/contains stringify anyway.
18
- # A Symbol value resolves from the typed wire args at dispatch, so it is always safe.
19
- COMPARATORS_NEEDING_NUMERIC_FIDELITY = %w[gt gte lt lte].freeze
20
- COMPARATORS_EXEMPT_FROM_LITERAL_TYPING = %w[in contains].freeze
21
-
22
- # The kind (`:string`, `:number`, `:other`, `:unknown`) a where/order_by `field` reduces to
23
- # on `aggregate`, mirroring `Ports::Query::InMemory#comparable`. Nested value-object
24
- # segments are walked; `:unknown` means the head is not one of the aggregate's own
25
- # attributes, which is also how a reference hop is detected.
26
- def query_field_kind(aggregate, field, value_objects_by_name)
27
- segments = field.to_s.split(".")
28
- head = segments.first
29
- rest = segments[1..] || []
30
-
31
- lifecycle_field = aggregate[:lifecycle] && aggregate[:lifecycle][:field]
32
- return rest.empty? ? :string : :unknown if lifecycle_field && lifecycle_field.to_s == head
33
-
34
- attr = aggregate[:attributes].find { |a| a[:name].to_s == head }
35
- return :unknown unless attr
36
- # A `list_of` field compares as an array, never a scalar a literal could target.
37
- return :other if attr[:list]
38
-
39
- query_type_kind(attr[:type].to_s, rest, value_objects_by_name)
40
- end
41
-
42
- def query_type_kind(type_name, segments, value_objects_by_name)
43
- return :unknown if reference_type?(type_name) && !segments.empty?
44
- return :string if reference_type?(type_name)
45
-
46
- if segments.empty?
47
- query_scalar_or_vo_kind(type_name, value_objects_by_name)
48
- else
49
- vo = value_objects_by_name[type_name]
50
- return :unknown unless vo
51
-
52
- member = vo[:attributes].find { |a| a[:name].to_s == segments.first }
53
- return :unknown if member.nil? || member[:list]
54
-
55
- query_type_kind(member[:type].to_s, segments[1..] || [], value_objects_by_name)
56
- end
57
- end
58
-
59
- def query_scalar_or_vo_kind(type_name, value_objects_by_name)
60
- case type_name
61
- when "String" then :string
62
- when "Integer", "Float" then :number
63
- when "TrueClass", "FalseClass" then :other
64
- else
65
- vo = value_objects_by_name[type_name]
66
- vo ? query_vo_collapse_kind(vo, value_objects_by_name) : :unknown
67
- end
68
- end
69
-
70
- # Mirrors `comparable` for a value object: a numeric member wins, a single member collapses
71
- # to its own kind, anything else stays a whole JSON object.
72
- def query_vo_collapse_kind(vo, value_objects_by_name)
73
- return :number if vo[:attributes].any? { |a| %w[Integer Float].include?(a[:type].to_s) }
74
- return query_type_kind(vo[:attributes].first[:type].to_s, [], value_objects_by_name) if vo[:attributes].size == 1
75
-
76
- :other
77
- end
78
-
79
- # Resolves a `/` reference-hop field (`member/sponsor/standing`) against `aggregates_by_name`.
80
- # Returns nil for a non-hop or an unresolvable one; otherwise
81
- # `{via_field:, target_aggregate:, through:, target:, inner_field:}`, where `through` holds
82
- # the steps after the first and `target` is the last aggregate the inner field is checked on.
83
- HOP_CHAIN_LIMIT = 8 # Hecks::QuerySpecification::HopPath::MAX_HOPS
84
- def query_hop_plan(aggregate, field, aggregates_by_name)
85
- segments = field.to_s.split("/")
86
- return nil if segments.size < 2 || segments.size - 1 > HOP_CHAIN_LIMIT
87
-
88
- steps = []
89
- current = aggregate
90
- segments[0..-2].each do |segment|
91
- via_attr = current[:attributes].find { |a| a[:name].to_s == segment }
92
- return nil unless via_attr && reference_type?(via_attr[:type])
93
-
94
- target_name = reference_target(via_attr[:type])
95
- target = aggregates_by_name[target_name]
96
- return nil unless target
97
-
98
- steps << { via_field: segment, target_aggregate: target_name }
99
- current = target
100
- end
101
-
102
- { via_field: steps.first[:via_field], target_aggregate: steps.first[:target_aggregate], through: steps.drop(1),
103
- target: current, inner_field: segments.last }
104
- end
105
-
106
- # One where clause's eligibility: nil when generable, otherwise a skip reason.
107
- def query_where_skip_reason(where, aggregate, value_objects_by_name)
108
- field = where[:field].to_s
109
- kind = query_field_kind(aggregate, field, value_objects_by_name)
110
- if kind == :unknown
111
- construct = field.include?("/") ? "reference_hop_where" : "where_unrecognized_field"
112
- return skip(construct, "where clause on #{field.inspect} isn't a recognized attribute of this aggregate — a hop through a " \
113
- "reference, an entity-scoped field, or simply undeclared here; cross-aggregate joins are read_model " \
114
- "territory, not this generator's job")
115
- end
116
-
117
- raw_value = where[:value].to_s
118
- return nil if raw_value.start_with?(":") # Symbol-valued — resolved from real, correctly-typed wire args at dispatch time
119
-
120
- op = where[:op].to_s
121
- unless QUERY_COMPARATOR_VARIANTS.key?(op)
122
- return skip("where_none_in_state", "where clause on #{field.inspect} uses op #{op.inspect} — Vocabulary::QueryComparator admits it, " \
123
- "and rust/src/kernel/query_comparators.rs's own QueryComparator::NoneInState variant now exists " \
124
- "and is proven correct (item #9, whole-project table-unification survey) — but no generated " \
125
- "domain has any way to hand it a cross-domain search list at the call site (named_query::run's " \
126
- "own thin `run_cross_domain([])` wrapper is what every generated QUERIES table actually calls), " \
127
- "so generating this condition today would silently answer every row 'true' rather than a real " \
128
- "anti-join — deliberately left ungenerated until a real cross-domain-search call site exists, " \
129
- "the same honest-refusal-over-silently-wrong choice this generator makes everywhere else")
130
- end
131
- if COMPARATORS_NEEDING_NUMERIC_FIDELITY.include?(op)
132
- return nil if kind == :number
133
-
134
- return skip("where_literal", "where clause on #{field.inspect} uses op #{op.inspect} against a LITERAL value whose target " \
135
- "field doesn't reduce to a plain JSON number (kind: #{kind}) — gt/gte/lt/lte only mean anything " \
136
- "against a number (query_comparators.rs's own `ordered?` gate)")
137
- end
138
- return nil if COMPARATORS_EXEMPT_FROM_LITERAL_TYPING.include?(op)
139
- return nil if kind == :string
140
- return nil if kind == :number
141
-
142
- skip("where_literal", "where clause on #{field.inspect} uses op #{op.inspect} against a LITERAL value whose target field doesn't " \
143
- "reduce to a plain JSON string (kind: #{kind}) — its true wire type can't be recovered from the exported IR")
144
- end
145
-
146
- # A whole query's eligibility: nil when `emit_query_table` can bake it in, otherwise the
147
- # first skip reason. order_by/limit content is checked last so the reason reported is the
148
- # real remaining one.
149
- def query_skip_reason(query, aggregate, value_objects_by_name, aggregates_by_name = {})
150
- extras = %i[cursor consistency freshness inspection].select { |k| query[k] }
151
- return skip(extras.first, "declares #{extras.join(', ')} — out of scope for this generator (rust/project/queries.rb's own " \
152
- "header has the full argument)") if extras.any?
153
- return skip("index_hints", "declares use_index, out of scope for the same reason the extras above are") if Array(query[:index_hints]).any?
154
-
155
- # A query with no where clause but a declared tenant still has logic: the tenant check.
156
- declared_tenant = query[:authorization] && query[:authorization][:tenant]
157
- return skip("no_wheres", "declares no where clauses at all — nothing for filter_entries to bake in") if Array(query[:wheres]).empty? && !declared_tenant
158
-
159
- # A single hop is generated via `kernel::read_model::apply_reference_hops`; its inner
160
- # clause is checked against the target aggregate. A longer chain is refused.
161
- query[:wheres].each do |where|
162
- hop = query_hop_plan(aggregate, where[:field].to_s, aggregates_by_name)
163
- if hop
164
- target_value_objects_by_name = hop[:target][:value_objects].to_h { |vo| [vo[:name], vo] }
165
- reason = query_where_skip_reason(where.merge(field: hop[:inner_field]), hop[:target], target_value_objects_by_name)
166
- return reskip(reason, "hop through #{hop[:via_field]} to #{hop[:target_aggregate]}'s own #{reason}") if reason
167
-
168
- next
169
- end
170
-
171
- reason = query_where_skip_reason(where, aggregate, value_objects_by_name)
172
- return reason if reason
173
- end
174
-
175
- auth_reason = declared_authorization_skip_reason(query[:authorization], aggregate, value_objects_by_name)
176
- return auth_reason if auth_reason
177
-
178
- order_reason = declared_order_by_skip_reason(query[:order_by], aggregate, value_objects_by_name)
179
- return order_reason if order_reason
180
-
181
- offset_reason = declared_offset_skip_reason(query[:offset])
182
- return offset_reason if offset_reason
183
-
184
- declared_limit_skip_reason(query[:limit])
185
- end
186
-
187
- # `authorize policy, tenant: :field`: no tenant is a no-op, as in `TenantScope.apply`;
188
- # a tenant field gets the same check as a where clause on it.
189
- def declared_authorization_skip_reason(authorization, aggregate, value_objects_by_name)
190
- tenant = authorization && authorization[:tenant]
191
- return nil unless tenant
192
-
193
- synthetic_where = { field: tenant, op: "eq", value: ":#{tenant}" }
194
- reason = query_where_skip_reason(synthetic_where, aggregate, value_objects_by_name)
195
- reason && skip("authorization", reason)
196
- end
197
-
198
- # Whether `order_by` names a field that reduces to a plain string or number.
199
- def declared_order_by_skip_reason(order_by, aggregate, value_objects_by_name)
200
- return nil unless order_by
201
-
202
- field = order_by[:field].to_s
203
- kind = query_field_kind(aggregate, field, value_objects_by_name)
204
- return nil if %i[string number].include?(kind)
205
-
206
- skip("order_by", "declares order_by on #{field.inspect} — this generator can only sort a field that reduces to a plain " \
207
- "JSON string or number (kind: #{kind}); a hop through a reference, an entity-scoped field, a list_of " \
208
- "field, or a multi-member non-numeric value object can't be compared generically")
209
- end
210
-
211
- # `limit` arrives through `render_value` as text, so an integer literal is told apart from a
212
- # non-numeric one here; a Symbol arg resolves from typed wire args at dispatch.
213
- def declared_limit_skip_reason(limit)
214
- return nil unless limit
215
-
216
- raw = limit[:value].to_s
217
- return nil if raw.start_with?(":") || raw.match?(/\A-?\d+\z/)
218
-
219
- skip("limit", "declares limit #{raw.inspect} — not a literal integer or a caller-bound Symbol arg, so this generator " \
220
- "can't compile a real limit count from it")
221
- end
222
-
223
- # `offset` has the same wire shape as `limit`; kept separate so the reason names "offset".
224
- def declared_offset_skip_reason(offset)
225
- return nil unless offset
226
-
227
- raw = offset[:value].to_s
228
- return nil if raw.start_with?(":") || raw.match?(/\A-?\d+\z/)
229
-
230
- skip("offset", "declares offset #{raw.inspect} — not a literal integer or a caller-bound Symbol arg, so this generator " \
231
- "can't compile a real offset count from it")
232
- end
233
-
234
- # `order_by`'s compiled form. `null_semantics` is a sibling key in the IR, passed in
235
- # separately and folded into the `OrderBy` struct.
236
- def emit_query_order_by(order_by, null_semantics = nil)
237
- descending = order_by[:direction].to_s == "desc" ? "true" : "false"
238
- "crate::kernel::query_ordering::OrderBy { field: #{order_by[:field].to_s.inspect}, descending: #{descending}, " \
239
- "nulls: #{null_semantics_variant(null_semantics)} }"
240
- end
241
-
242
- # An unrecognized `nulls` mode falls back to native ordering, as in
243
- # `QuerySpecification::Common::NullPolicy.order`; the default never reaches the wire.
244
- NULLS_MODE_VARIANTS = { "first" => "First", "last" => "Last" }.freeze
245
-
246
- def null_semantics_variant(null_semantics)
247
- mode = null_semantics && null_semantics[:mode].to_s
248
- "crate::kernel::query_ordering::NullsMode::#{NULLS_MODE_VARIANTS.fetch(mode, 'Native')}"
249
- end
250
-
251
- # `limit`'s compiled form; `declared_limit_skip_reason` has already vetted the literal.
252
- def emit_query_limit(limit)
253
- raw = limit[:value].to_s
254
- return "crate::kernel::query_ordering::Limit::Arg(#{raw.delete_prefix(':').inspect})" if raw.start_with?(":")
255
-
256
- "crate::kernel::query_ordering::Limit::Literal(#{raw.to_i})"
257
- end
258
-
259
- # `offset`'s compiled form: `Offset` aliases `Limit`, so this reuses `emit_query_limit`
260
- # and respells the type so the generated code reads naturally.
261
- def emit_query_offset(offset) = emit_query_limit(offset).sub("query_ordering::Limit::", "query_ordering::Offset::")
262
-
263
- # Where clauses as `arg:` (Symbol value) or `literal:` conditions. A string literal is
264
- # decoded with `Literal.read`; the raw wire text still carries its quote marks and would
265
- # silently match nothing.
266
- def query_conditions(query)
267
- query[:wheres].map do |where|
268
- raw_value = where[:value].to_s
269
- symbol = raw_value.start_with?(":")
270
- {
271
- field: where[:field].to_s,
272
- op: where[:op].to_s,
273
- arg: symbol ? raw_value.delete_prefix(":") : nil,
274
- literal: symbol ? nil : Hecks::Literal.read(raw_value),
275
- }
276
- end
277
- end
278
-
279
- # `Runtime::TenantScope.apply`'s synthetic `field == args[tenant]` clause, appended at
280
- # codegen time. Query-only: read models still refuse `authorize`.
281
- def query_conditions_with_authorization(query)
282
- tenant = query[:authorization] && query[:authorization][:tenant]
283
- return query_conditions(query) unless tenant
284
-
285
- query_conditions(query) << { field: tenant.to_s, op: "eq", arg: tenant.to_s, literal: nil }
286
- end
287
-
288
- # Splits a query's where clauses into local conditions (plus the tenant clause) and
289
- # single-hop `ReferenceHopCondition`s.
290
- def query_conditions_and_hops(domain_name, query, aggregate, aggregates_by_name)
291
- local, hops = Array(query[:wheres]).partition { |where| query_hop_plan(aggregate, where[:field].to_s, aggregates_by_name).nil? }
292
- [query_conditions_with_authorization(query.merge(wheres: local)),
293
- read_model_hop_conditions(domain_name, hops, aggregate, aggregates_by_name)]
294
- end
295
-
296
- # `TenantAuth`'s compiled form; nil without a declared tenant. `policy` is carried, not
297
- # enforced, as in Ruby.
298
- def emit_query_authorization(query_name, authorization)
299
- tenant = authorization && authorization[:tenant]
300
- return nil unless tenant
301
-
302
- policy = authorization[:policy]
303
- "crate::kernel::named_query::TenantAuth { query_name: #{query_name.to_s.inspect}, tenant_field: #{tenant.to_s.inspect}, policy: #{policy.to_s.inspect} }"
304
- end
305
-
306
- # Maps a where `op` to its Rust `QueryComparator` variant. `none_in_state` is omitted on
307
- # purpose: no generated call site can supply a cross-domain search list, so it would
308
- # answer every row `true` (see `query_where_skip_reason`).
309
- QUERY_COMPARATOR_VARIANTS = {
310
- "eq" => "Eq", "ne" => "Ne", "gt" => "Gt", "gte" => "Gte",
311
- "lt" => "Lt", "lte" => "Lte", "in" => "In", "contains" => "Contains",
312
- }.freeze
313
-
314
- def query_comparator_variant(op)
315
- QUERY_COMPARATOR_VARIANTS.fetch(op) do
316
- raise "unknown query comparator #{op.inspect} — query_comparator_variant doesn't cover this shape " \
317
- "(grammar-validated at declare time, so this should be unreachable for a real declared query; " \
318
- "query_where_skip_reason should have already skipped it with an honest reason)"
319
- end
320
- end
321
-
322
- def emit_query_condition_value(condition)
323
- if condition[:arg]
324
- "crate::kernel::QueryConditionValue::Arg(#{condition[:arg].inspect})"
325
- elsif condition[:literal].is_a?(Numeric)
326
- # Only a numeric-kind target reaches here with a Numeric literal, so its class picks
327
- # the variant.
328
- "crate::kernel::QueryConditionValue::NumericLiteral(#{Float(condition[:literal]).inspect})"
329
- else
330
- "crate::kernel::QueryConditionValue::Literal(#{condition[:literal].inspect})"
331
- end
332
- end
333
-
334
- def emit_query_condition(condition)
335
- comparator_expr = "crate::kernel::query_comparators::QueryComparator::#{query_comparator_variant(condition[:op])}"
336
- "crate::kernel::QueryCondition { field: #{condition[:field].inspect}, comparator: #{comparator_expr}, " \
337
- "value: #{emit_query_condition_value(condition)} },"
338
- end
339
-
340
- # `order_by`/`offset`/`limit`/`authorization` are nil unless the query declared them.
341
- def emit_query_def(query_def)
342
- conditions = query_def[:conditions].map { |c| " #{emit_query_condition(c)}" }.join("\n")
343
- reference_hop_conditions = Array(query_def[:reference_hop_conditions]).map { |h| " #{emit_reference_hop_condition(h)}" }.join("\n")
344
- order_by = query_def[:order_by] ? "Some(#{query_def[:order_by]})" : "None"
345
- offset = query_def[:offset] ? "Some(#{query_def[:offset]})" : "None"
346
- limit = query_def[:limit] ? "Some(#{query_def[:limit]})" : "None"
347
- authorization = query_def[:authorization] ? "Some(#{query_def[:authorization]})" : "None"
348
-
349
- <<~RUST.rstrip
350
- crate::kernel::QueryDef {
351
- verb: #{query_def[:verb].inspect},
352
- aggregate: #{query_def[:aggregate].inspect},
353
- conditions: &[
354
- #{conditions}
355
- ],
356
- reference_hop_conditions: &[
357
- #{reference_hop_conditions}
358
- ],
359
- order_by: #{order_by},
360
- offset: #{offset},
361
- limit: #{limit},
362
- authorization: #{authorization},
363
- },
364
- RUST
365
- end
366
-
367
- # Text of the `TMPL:query_table` row in `rust/src/exemplar/queries.rs`; `Exemplar.render`
368
- # substitutes it as an exact substring, so the spacing must match.
369
- QUERY_TABLE_ROW_PLACEHOLDER = <<~RUST.rstrip
370
- crate::kernel::QueryDef {
371
- verb: "tmpl_verb",
372
- aggregate: "tmpl_aggregate",
373
- conditions: &[
374
- crate::kernel::QueryCondition {
375
- field: "tmpl_field",
376
- comparator: crate::kernel::query_comparators::QueryComparator::Eq,
377
- value: crate::kernel::QueryConditionValue::Literal("tmpl_literal"),
378
- },
379
- ],
380
- reference_hop_conditions: &[
381
- crate::kernel::read_model::ReferenceHopCondition {
382
- via_field: "tmpl_via_field",
383
- target_aggregate: "tmpl_target_aggregate",
384
- through: &[crate::kernel::read_model::HopStep { via_field: "tmpl_via_field", target_aggregate: "tmpl_target_aggregate" }],
385
- inner_field: "tmpl_inner_field",
386
- inner_comparator: crate::kernel::query_comparators::QueryComparator::Eq,
387
- inner_value: crate::kernel::QueryConditionValue::Literal("tmpl_literal"),
388
- },
389
- ],
390
- order_by: Some(crate::kernel::query_ordering::OrderBy { field: "tmpl_order_field", descending: true, nulls: crate::kernel::query_ordering::NullsMode::Last }),
391
- offset: Some(crate::kernel::query_ordering::Offset::Literal(1)),
392
- limit: Some(crate::kernel::query_ordering::Limit::Literal(5)),
393
- authorization: Some(crate::kernel::named_query::TenantAuth { query_name: "tmpl_query_name", tenant_field: "tmpl_tenant_field", policy: "tmpl_policy" }),
394
- },
395
- RUST
396
-
397
- # The `QUERIES` table `kernel::named_query::run` walks, one row per generable query, then
398
- # the authorization-assignments constant and the entity-query table.
399
- def emit_query_table(query_defs)
400
- entity_defs, aggregate_defs = query_defs.partition { |q| q[:entity] }
401
- rows = aggregate_defs.map { |q| emit_query_def(q) }
402
- "#{Exemplar.render('query_table', QUERY_TABLE_ROW_PLACEHOLDER => rows.join("\n"))}\n" \
403
- "#{emit_authorization_assignments(query_defs)}" \
404
- "#{emit_entity_query_table(entity_defs)}"
405
- end
406
-
407
- # The chapter-local verb `provides "authorization", assignments:` names, or nil.
408
- def provided_assignments(ir)
409
- Array(ir[:provides]).find { |row| row[:capability] == "authorization" && row[:key] == "assignments" }&.dig(:verb)
410
- end
411
-
412
- # The `AUTHORIZATION_ASSIGNMENTS` constant `kernel::check_role_via` reads; a merged union
413
- # carries its chapters' `assignments` flag with their defs.
414
- def emit_authorization_assignments(query_defs)
415
- verb = query_defs.find { |q| q[:assignments] }&.dig(:verb)
416
- value = verb ? "Some(#{verb.inspect})" : "None"
417
- "/// `provides \"authorization\", assignments:` — the query `kernel::check_role_via` reads; " \
418
- "`None` when no chapter here declares one.\n" \
419
- "pub const AUTHORIZATION_ASSIGNMENTS: Option<&str> = #{value};\n"
420
- end
421
-
422
- # `ENTITY_QUERIES`: declared `Aggregate.Entity.Query` rows for `named_query::run_entity`.
423
- def emit_entity_query_table(entity_defs)
424
- rows = entity_defs.map { |q| "#{emit_entity_query_def(q)}\n" }.join
425
- "/// Declared entity queries (`Aggregate.Entity.Query`) — `kernel::named_query::run_entity`.\n" \
426
- "pub const ENTITY_QUERIES: &[crate::kernel::named_query::EntityQueryDef] = &[\n#{rows}];\n"
427
- end
428
-
429
- def emit_entity_query_def(query_def)
430
- entity = query_def[:entity]
431
- conditions = query_def[:conditions].map { |c| " #{emit_query_condition(c)}" }.join("\n")
432
- keys = entity[:identity_keys].map(&:inspect).join(", ")
433
- order_by = query_def[:order_by] ? "Some(#{query_def[:order_by]})" : "None"
434
- offset = query_def[:offset] ? "Some(#{query_def[:offset]})" : "None"
435
- limit = query_def[:limit] ? "Some(#{query_def[:limit]})" : "None"
436
- "crate::kernel::named_query::EntityQueryDef {\n verb: #{query_def[:verb].inspect},\n " \
437
- "aggregate: #{query_def[:aggregate].inspect},\n list_field: #{entity[:list_field].inspect},\n " \
438
- "parent_key: #{entity[:parent_key].inspect},\n identity_keys: &[#{keys}],\n conditions: &[\n#{conditions}\n ],\n " \
439
- "order_by: #{order_by},\n offset: #{offset},\n limit: #{limit},\n},"
440
- end
441
-
442
- # An entity query needs a list attribute holding the entity; tenant scope is not generated.
443
- # Its clauses are otherwise checked as on an aggregate query, against the entity's fields.
444
- def entity_query_skip_reason(query, entity, list_attr, value_objects_by_name)
445
- return skip("entity_query", "#{entity[:name]} is held in no list attribute on its aggregate — nothing to flatten") unless list_attr
446
- return skip("entity_query_authorization", "declares authorize — an entity query's tenant scope is not generated yet") if query[:authorization]
447
-
448
- query_skip_reason(query, entity, value_objects_by_name)
449
- end
450
-
451
- # `Hecks::Naming.snake`.
452
- def snake(text)
453
- text.to_s.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
454
- end
455
-
456
- # Builds and invariant-checks each value-object argument of a named query, as
457
- # `QueryInterpreter#normalize_args` does; the typed value is only a gate, the comparison
458
- # reads raw JSON. A closed set's `from_json` already refuses non-members, so it is not
459
- # invariant-checked. Only the aggregate's own value objects are typed.
460
- def query_arg_checks(query, mod_path, value_objects_by_name)
461
- query[:attributes].filter_map do |attr|
462
- next if attr[:list] || attr[:relationship]
463
-
464
- vo = value_objects_by_name[attr[:type]]
465
- next unless vo
466
-
467
- key = rust_field(attr[:name])
468
- build = composite_from_json_expr(attr, value_objects_by_name, "x")
469
- check = vo[:closed_set] ? "" : ".check_invariants()?"
470
- "if let Some(x) = args.get(#{key.inspect}) { #{mod_path}::#{build}#{check}; }"
471
- end
472
- end
473
-
474
- # `check_query_args`, the gate `kernel/cli.rs` runs before `named_query::run`: one arm per
475
- # query with a typed argument, `Ok(())` otherwise.
476
- def emit_query_arg_check_table(query_defs)
477
- arms = query_defs.reject { |q| q[:arg_checks].empty? }.map do |q|
478
- lines = q[:arg_checks].map { |line| " #{line}" }.join("\n")
479
- " #{q[:verb].inspect} => {\n#{lines}\n Ok(())\n }"
480
- end
481
- <<~RUST
482
- /// C3.7 for a named query's own arguments — `query_arg_checks`
483
- /// (rust/project/queries.rb) has the full story.
484
- pub fn check_query_args(verb: &str, args: &crate::kernel::Json) -> Result<(), crate::kernel::Refusal> {
485
- match verb {
486
- #{arms.join("\n")}
487
- _ => Ok(()),
488
- }
489
- }
490
- RUST
491
- end
492
- end
493
- end