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
@@ -110,21 +110,34 @@ pub fn table(domain_ir: &Value) -> Value {
110
110
  Value::Object(verbs)
111
111
  }
112
112
 
113
+ fn list<'a>(value: &'a Value, key: &str) -> &'a [Value] {
114
+ value.get(key).and_then(Value::as_array).map(Vec::as_slice).unwrap_or(&[])
115
+ }
116
+
117
+ fn named(value: &Value, name: &str) -> bool {
118
+ value.get("name").and_then(Value::as_str) == Some(name)
119
+ }
120
+
121
+ /// The command a verb names: `Domain::Aggregate.Command` on the aggregate itself, or
122
+ /// `Domain::Aggregate.Entity[.Entity].Command` on an entity nested inside it.
123
+ fn command_for<'a>(domain_ir: &'a Value, verb: &str) -> Option<&'a Value> {
124
+ let mut segments = verb.split('.');
125
+ let aggregate = segments.next()?.rsplit("::").next()?;
126
+ let mut path: Vec<&str> = segments.collect();
127
+ let command_name = path.pop()?;
128
+ let mut node = list(domain_ir, "aggregates").iter().find(|a| named(a, aggregate))?;
129
+ for entity in path {
130
+ node = list(node, "entities").iter().find(|e| named(e, entity))?;
131
+ }
132
+ list(node, "commands").iter().find(|c| named(c, command_name))
133
+ }
134
+
113
135
  /// The command's attributes that declare a default (`attribute :runs, Count, default: 30`), each
114
136
  /// with the declared value. An attribute whose default is null declares none.
115
137
  fn declared_defaults<'a>(domain_ir: &'a Value, verb: &str) -> Vec<(&'a str, &'a Value)> {
116
- let Some((qualified_aggregate, command_name)) = verb.rsplit_once('.') else { return Vec::new() };
117
- let aggregate = qualified_aggregate.rsplit("::").next().unwrap_or(qualified_aggregate);
118
- let named = |value: &Value, name: &str| value.get("name").and_then(Value::as_str) == Some(name);
119
- let list = |value: &'a Value, key: &str| -> Vec<&'a Value> {
120
- value.get(key).and_then(Value::as_array).map(|a| a.iter().collect()).unwrap_or_default()
121
- };
122
- list(domain_ir, "aggregates")
123
- .into_iter()
124
- .filter(|a| named(a, aggregate))
125
- .flat_map(|a| list(a, "commands"))
126
- .filter(|c| named(c, command_name))
127
- .flat_map(|c| list(c, "attributes"))
138
+ let Some(command) = command_for(domain_ir, verb) else { return Vec::new() };
139
+ list(command, "attributes")
140
+ .iter()
128
141
  .filter_map(|a| {
129
142
  let default = a.get("default").filter(|d| !d.is_null())?;
130
143
  Some((a.get("name").and_then(Value::as_str)?, default))
@@ -133,29 +146,35 @@ fn declared_defaults<'a>(domain_ir: &'a Value, verb: &str) -> Vec<(&'a str, &'a
133
146
  }
134
147
 
135
148
  /// Every command's declared defaults as the kernel's input `"defaults"` reads them:
136
- /// `{ verb: { attribute: value } }`. The kernel fills a reaction's command from this the same way
137
- /// the host fills the outermost one.
149
+ /// `{ verb: { attribute: value } }`, entity commands included. The kernel fills a reaction's
150
+ /// command from this the same way the host fills the outermost one.
138
151
  pub fn defaults_table(domain_ir: &Value) -> Value {
139
152
  let domain = domain_ir.get("name").and_then(Value::as_str).unwrap_or_default();
140
153
  let mut verbs = serde_json::Map::new();
141
- let aggregates = domain_ir.get("aggregates").and_then(Value::as_array).into_iter().flatten();
142
- for aggregate in aggregates {
154
+ for aggregate in list(domain_ir, "aggregates") {
143
155
  let aggregate_name = aggregate.get("name").and_then(Value::as_str).unwrap_or_default();
144
- for command in aggregate.get("commands").and_then(Value::as_array).into_iter().flatten() {
145
- let command_name = command.get("name").and_then(Value::as_str).unwrap_or_default();
146
- let verb = format!("{domain}::{aggregate_name}.{command_name}");
147
- let defaults: serde_json::Map<String, Value> = declared_defaults(domain_ir, &verb)
148
- .into_iter()
149
- .map(|(name, value)| (name.to_string(), value.clone()))
150
- .collect();
151
- if !defaults.is_empty() {
152
- verbs.insert(verb, Value::Object(defaults));
153
- }
154
- }
156
+ collect_defaults(domain_ir, aggregate, &format!("{domain}::{aggregate_name}"), &mut verbs);
155
157
  }
156
158
  Value::Object(verbs)
157
159
  }
158
160
 
161
+ /// Adds the defaults of `node`'s commands, then of each entity nested in it, under `prefix`.
162
+ fn collect_defaults(domain_ir: &Value, node: &Value, prefix: &str, verbs: &mut serde_json::Map<String, Value>) {
163
+ for command in list(node, "commands") {
164
+ let command_name = command.get("name").and_then(Value::as_str).unwrap_or_default();
165
+ let verb = format!("{prefix}.{command_name}");
166
+ let defaults: serde_json::Map<String, Value> =
167
+ declared_defaults(domain_ir, &verb).into_iter().map(|(name, value)| (name.to_string(), value.clone())).collect();
168
+ if !defaults.is_empty() {
169
+ verbs.insert(verb, Value::Object(defaults));
170
+ }
171
+ }
172
+ for entity in list(node, "entities") {
173
+ let entity_name = entity.get("name").and_then(Value::as_str).unwrap_or_default();
174
+ collect_defaults(domain_ir, entity, &format!("{prefix}.{entity_name}"), verbs);
175
+ }
176
+ }
177
+
159
178
  /// The facts of a command invocation: the `with` object when the call carries one, else the
160
179
  /// flat object itself (the kernel's `CommandInvocation` reads both shapes).
161
180
  fn facts_mut(args: &mut Value) -> Option<&mut serde_json::Map<String, Value>> {
@@ -167,9 +186,11 @@ fn facts_mut(args: &mut Value) -> Option<&mut serde_json::Map<String, Value>> {
167
186
 
168
187
  fn answer(fact: &str, attribute_type: Option<&str>, clock: &dyn Clock) -> Option<Value> {
169
188
  match fact {
170
- "now" => {
189
+ // `now` is epoch seconds; `today` the day those fall in, whole days since the epoch (UTC).
190
+ "now" | "today" => {
171
191
  let secs = clock.now_secs();
172
- Some(if attribute_type == Some("Integer") { json!(secs) } else { json!({ "value": secs }) })
192
+ let answer = if fact == "today" { secs.div_euclid(86_400) } else { secs };
193
+ Some(if attribute_type == Some("Integer") { json!(answer) } else { json!({ "value": answer }) })
173
194
  }
174
195
  _ => None,
175
196
  }
@@ -193,6 +214,49 @@ pub fn fill_needs(domain_ir: &Value, verb: &str, args: &mut Value, clock: &dyn C
193
214
  }
194
215
  }
195
216
 
217
+ /// The facts the query `question` (`Domain::Aggregate.Query`) needs, each with the type of the
218
+ /// argument of the same name. An entity's query is not looked up here.
219
+ fn declared_query_needs<'a>(domain_ir: &'a Value, question: &str) -> Vec<(&'a str, Option<&'a str>)> {
220
+ let Some((qualified_aggregate, query_name)) = question.rsplit_once('.') else { return Vec::new() };
221
+ let aggregate = qualified_aggregate.rsplit("::").next().unwrap_or(qualified_aggregate);
222
+ let Some(query) = list(domain_ir, "aggregates")
223
+ .iter()
224
+ .find(|a| named(a, aggregate))
225
+ .and_then(|a| list(a, "queries").iter().find(|q| named(q, query_name)))
226
+ else {
227
+ return Vec::new();
228
+ };
229
+ let attributes = list(query, "attributes");
230
+ list(query, "needs")
231
+ .iter()
232
+ .filter_map(|n| n.get("fact").and_then(Value::as_str))
233
+ .map(|fact| {
234
+ let attribute_type =
235
+ attributes.iter().find(|a| named(a, fact)).and_then(|a| a.get("type")).and_then(Value::as_str);
236
+ (fact, attribute_type)
237
+ })
238
+ .collect()
239
+ }
240
+
241
+ /// Fills each fact the query `question` needs that `args` leaves out, before the kernel checks the
242
+ /// arguments. A key the caller supplied (even a null) is kept; a query that needs nothing, or whose
243
+ /// arguments are not an object, is untouched.
244
+ pub fn fill_query_needs(domain_ir: &Value, question: &str, args: &mut Value, clock: &dyn Clock) {
245
+ let needs = declared_query_needs(domain_ir, question);
246
+ if needs.is_empty() {
247
+ return;
248
+ }
249
+ let Some(facts) = args.as_object_mut() else { return };
250
+ for (fact, attribute_type) in needs {
251
+ if facts.contains_key(fact) {
252
+ continue;
253
+ }
254
+ if let Some(value) = answer(fact, attribute_type, clock) {
255
+ facts.insert(fact.to_string(), value);
256
+ }
257
+ }
258
+ }
259
+
196
260
  /// Gives each argument `verb` declares a default for, and `args` leaves out, that default, after
197
261
  /// `fill_needs` and before any gate reads the arguments. A key the caller supplied (even a null)
198
262
  /// is kept, as is a fact `fill_needs` already answered.
@@ -351,4 +415,104 @@ mod tests {
351
415
  assert_eq!(defaults_table(&defaulted_ir()), json!({"Qc::Check.Start": {"runs": 30}}));
352
416
  assert_eq!(defaults_table(&integer_ir()), json!({}));
353
417
  }
418
+
419
+ // A board entity with a defaulted command, and a card nested inside it with another.
420
+ fn nested_ir() -> Value {
421
+ json!({"name": "Np", "aggregates": [{"name": "Workspace", "commands": [], "entities": [
422
+ {"name": "Board", "commands": [
423
+ {"name": "Retitle", "attributes": [
424
+ {"name": "label", "type": "BoardLabel", "default": {"value": "untitled"}}], "needs": []}
425
+ ], "entities": [
426
+ {"name": "Card", "commands": [
427
+ {"name": "Remark", "attributes": [
428
+ {"name": "note", "type": "CardNote", "default": {"text": "none"}}], "needs": []}
429
+ ]}
430
+ ]}
431
+ ]}]})
432
+ }
433
+
434
+ #[test]
435
+ fn an_entity_command_is_filled_from_its_own_declared_default() {
436
+ let mut args = json!({"reference": {"value": "W1"}, "number": {"value": 1}});
437
+ fill_defaults(&nested_ir(), "Np::Workspace.Board.Retitle", &mut args);
438
+ assert_eq!(args["label"], json!({"value": "untitled"}));
439
+ }
440
+
441
+ #[test]
442
+ fn a_command_on_an_entity_nested_in_an_entity_is_filled_too() {
443
+ let mut args = json!({"with": {"reference": {"value": "W1"}}});
444
+ fill_defaults(&nested_ir(), "Np::Workspace.Board.Card.Remark", &mut args);
445
+ assert_eq!(args["with"]["note"], json!({"text": "none"}));
446
+ }
447
+
448
+ #[test]
449
+ fn an_entity_path_that_names_nothing_is_left_untouched() {
450
+ let mut args = json!({"number": 1});
451
+ fill_defaults(&nested_ir(), "Np::Workspace.Shelf.Retitle", &mut args);
452
+ fill_defaults(&nested_ir(), "Np::Workspace.Board.Remark", &mut args);
453
+ assert_eq!(args, json!({"number": 1}));
454
+ }
455
+
456
+ // An aggregate with a query that needs `now` and one that needs `today`.
457
+ fn query_ir() -> Value {
458
+ json!({"name": "Lease", "aggregates": [{"name": "Slot", "commands": [], "queries": [
459
+ {"name": "Expired", "attributes": [{"name": "now", "type": "LeaseInstant"}],
460
+ "needs": [{"fact": "now"}]},
461
+ {"name": "IssuedToday", "attributes": [{"name": "today", "type": "Integer"}],
462
+ "needs": [{"fact": "today"}]},
463
+ {"name": "Plain", "attributes": [{"name": "now", "type": "LeaseInstant"}]}
464
+ ]}]})
465
+ }
466
+
467
+ #[test]
468
+ fn a_needed_query_fact_the_caller_left_out_is_filled_from_the_clock() {
469
+ let mut args = json!({});
470
+ fill_query_needs(&query_ir(), "Lease::Slot.Expired", &mut args, &FixedClock(1_700_000_000));
471
+ assert_eq!(args["now"], json!({"value": 1_700_000_000}));
472
+ }
473
+
474
+ #[test]
475
+ fn a_query_value_the_caller_supplied_is_kept_even_when_null() {
476
+ let mut args = json!({"now": null});
477
+ fill_query_needs(&query_ir(), "Lease::Slot.Expired", &mut args, &FixedClock(5));
478
+ assert!(args["now"].is_null());
479
+ let mut named = json!({"now": {"value": 9}});
480
+ fill_query_needs(&query_ir(), "Lease::Slot.Expired", &mut named, &FixedClock(5));
481
+ assert_eq!(named["now"], json!({"value": 9}));
482
+ }
483
+
484
+ #[test]
485
+ fn a_query_that_needs_nothing_or_names_nothing_is_untouched() {
486
+ let mut args = json!({});
487
+ fill_query_needs(&query_ir(), "Lease::Slot.Plain", &mut args, &FixedClock(5));
488
+ fill_query_needs(&query_ir(), "Lease::Slot.Missing", &mut args, &FixedClock(5));
489
+ fill_query_needs(&query_ir(), "Lease::Shelf.Expired", &mut args, &FixedClock(5));
490
+ assert_eq!(args, json!({}));
491
+ }
492
+
493
+ #[test]
494
+ fn today_is_whole_days_since_the_epoch_shaped_for_its_argument() {
495
+ let mut args = json!({});
496
+ fill_query_needs(&query_ir(), "Lease::Slot.IssuedToday", &mut args, &FixedClock((19_000 * 86_400) + 86_399));
497
+ assert_eq!(args["today"], json!(19_000));
498
+
499
+ let mut command = json!({"with": {}});
500
+ let ir = json!({"name": "D", "aggregates": [{"name": "A", "commands": [
501
+ {"name": "Open", "attributes": [{"name": "day", "type": "Day"}], "needs": [{"fact": "day"}]},
502
+ {"name": "Take", "attributes": [{"name": "today", "type": "Day"}], "needs": [{"fact": "today"}]}
503
+ ]}]});
504
+ fill_needs(&ir, "D::A.Take", &mut command, &FixedClock(19_001 * 86_400));
505
+ assert_eq!(command["with"]["today"], json!({"value": 19_001}));
506
+ }
507
+
508
+ #[test]
509
+ fn the_kernel_table_lists_entity_commands_under_their_full_path() {
510
+ assert_eq!(
511
+ defaults_table(&nested_ir()),
512
+ json!({
513
+ "Np::Workspace.Board.Retitle": {"label": {"value": "untitled"}},
514
+ "Np::Workspace.Board.Card.Remark": {"note": {"text": "none"}}
515
+ })
516
+ );
517
+ }
354
518
  }
@@ -256,8 +256,8 @@ mod tests {
256
256
 
257
257
  #[test]
258
258
  fn a_reply_to_address_rides_in_the_payload_and_is_absent_without_one() {
259
- let with = payload("f", Some("lifeadelics@gmail.com"), &email("a@b.com", "x"));
260
- assert_eq!(with["reply_to"], "lifeadelics@gmail.com");
259
+ let with = payload("f", Some("replies@example.com"), &email("a@b.com", "x"));
260
+ assert_eq!(with["reply_to"], "replies@example.com");
261
261
  assert!(payload("f", None, &email("a@b.com", "x")).get("reply_to").is_none());
262
262
  }
263
263
 
@@ -273,11 +273,11 @@ mod tests {
273
273
  #[tokio::test]
274
274
  async fn a_live_send_carries_the_reply_to_address_to_resend() {
275
275
  let fake = FakeResend::start(vec![("200 OK", "", r#"{"id":"em_9"}"#)]);
276
- let mailer = Mailer::live("re_x", "News <n@mail.example.com>", &fake.base).replying_to("lifeadelics@gmail.com");
276
+ let mailer = Mailer::live("re_x", "News <n@mail.example.com>", &fake.base).replying_to("replies@example.com");
277
277
 
278
278
  mailer.deliver(&email("a@b.com", "x")).await;
279
279
 
280
- assert!(fake.requests()[0].contains(r#""reply_to":"lifeadelics@gmail.com""#), "{}", fake.requests()[0]);
280
+ assert!(fake.requests()[0].contains(r#""reply_to":"replies@example.com""#), "{}", fake.requests()[0]);
281
281
  }
282
282
 
283
283
  #[test]
@@ -860,7 +860,7 @@ fn humanize(key: &str) -> String {
860
860
  mod tests {
861
861
  use super::*;
862
862
 
863
- // Embryonaut-shaped, because that's the domain the Ruby engine
863
+ // Shaped like a real member-gated app, the kind the Ruby engine
864
864
  // actually serves. Each test below pins one rule of ui_schema.rb.
865
865
 
866
866
  fn domain() -> Value {
@@ -463,6 +463,18 @@ fn query_json(q: &ir::Query) -> JsonValue {
463
463
  if let Some(returns) = &q.returns {
464
464
  pairs.push(("returns".to_string(), JsonValue::str(returns.clone())));
465
465
  }
466
+ // `needs` follows `returns`, only when declared, so a query that needs nothing keeps its shape.
467
+ if !q.needs.is_empty() {
468
+ pairs.push((
469
+ "needs".to_string(),
470
+ JsonValue::Array(
471
+ q.needs
472
+ .iter()
473
+ .map(|fact| JsonValue::Object(vec![("fact".to_string(), JsonValue::str(fact.clone()))]))
474
+ .collect(),
475
+ ),
476
+ ));
477
+ }
466
478
  JsonValue::Object(pairs)
467
479
  }
468
480
 
@@ -133,6 +133,8 @@ pub struct Query {
133
133
  pub options: QueryOptions,
134
134
  /// `returns Name` or `returns list_of(Name)`, spelled as `Query#returns` holds it.
135
135
  pub returns: Option<String>,
136
+ /// Outside facts the runtime supplies before the filter reads the arguments (`needs :now`).
137
+ pub needs: Vec<String>,
136
138
  }
137
139
 
138
140
  /// `AuthorizationSpec#to_h`: both fields are bare `.to_s`, never Literal-rendered.
@@ -161,6 +161,7 @@ pub static KEYWORDS: &[KeywordRow] = &[
161
161
  KeywordRow { word: "authorize", context: "Query", body: "none", inner: "", opens: "", fills: "options", status: "admitted", was: "", resolves_via: "", disambiguator: "" },
162
162
  KeywordRow { word: "inspect_query", context: "Query", body: "none", inner: "", opens: "", fills: "options", status: "admitted", was: "", resolves_via: "", disambiguator: "" },
163
163
  KeywordRow { word: "returns", context: "Query", body: "none", inner: "", opens: "", fills: "returns", status: "admitted", was: "", resolves_via: "", disambiguator: "" },
164
+ KeywordRow { word: "needs", context: "Query", body: "none", inner: "", opens: "", fills: "needs", status: "admitted", was: "", resolves_via: "", disambiguator: "" },
164
165
  KeywordRow { word: "attribute", context: "ValueObject", body: "none", inner: "", opens: "", fills: "attributes", status: "admitted", was: "", resolves_via: "", disambiguator: "" },
165
166
  KeywordRow { word: "one_of", context: "ValueObject", body: "rows", inner: "OneOf", opens: "", fills: "rows", status: "admitted", was: "", resolves_via: "", disambiguator: "" },
166
167
  KeywordRow { word: "invariant", context: "ValueObject", body: "source", inner: "", opens: "", fills: "invariants", status: "admitted", was: "", resolves_via: "sibling_scan", disambiguator: "" },
@@ -437,6 +438,7 @@ pub static ARGUMENTS: &[ArgumentRow] = &[
437
438
  ArgumentRow { keyword: "authorize", context: "Query", at: "", named: "tenant", kind: "symbol", required: "false", fills: "tenant", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
438
439
  ArgumentRow { keyword: "inspect_query", context: "Query", at: "1", named: "", kind: "symbol", required: "false", fills: "mode", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
439
440
  ArgumentRow { keyword: "returns", context: "Query", at: "1", named: "", kind: "constant", required: "true", fills: "type", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
441
+ ArgumentRow { keyword: "needs", context: "Query", at: "1", named: "", kind: "symbol", required: "true", fills: "fact", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
440
442
  ArgumentRow { keyword: "attribute", context: "ValueObject", at: "1", named: "", kind: "symbol", required: "true", fills: "name", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
441
443
  ArgumentRow { keyword: "attribute", context: "ValueObject", at: "2", named: "", kind: "constant", required: "true", fills: "type", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
442
444
  ArgumentRow { keyword: "attribute", context: "ValueObject", at: "", named: "default", kind: "literal", required: "false", fills: "default", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
@@ -205,6 +205,7 @@ const COVERED_PAIRS: &[(&str, &str)] = &[
205
205
  ("limit", "Query"),
206
206
  ("authorize", "Query"),
207
207
  ("returns", "Query"),
208
+ ("needs", "Query"),
208
209
  ("on", "Policy"),
209
210
  ("trigger", "Policy"),
210
211
  ("across", "Policy"),
@@ -164,7 +164,7 @@ pub fn parse_body(
164
164
  owner_entities,
165
165
  );
166
166
  refuse_duplicate_targets(file, *pos, &command)?;
167
- refuse_undeclared_needs(file, *pos, &command)?;
167
+ super::needs::refuse_undeclared(file, *pos, &command.name, &command.needs, &command.attributes)?;
168
168
  return Ok((command, pending));
169
169
  };
170
170
  let line = gated.line.number;
@@ -206,27 +206,7 @@ pub fn parse_body(
206
206
  canonical: canonical::apply(&raw),
207
207
  });
208
208
  }
209
- "needs" => {
210
- let fact = super::positional_symbol(file, line, "needs", &gated.args, 1)?;
211
- if !NEEDABLE_FACTS.contains(&fact.as_str()) {
212
- return Err(Diagnostic::new(
213
- file,
214
- line,
215
- format!(
216
- "{} needs :{fact}, which the runtime cannot supply — it supplies :now",
217
- command.name
218
- ),
219
- ));
220
- }
221
- if command.needs.contains(&fact) {
222
- return Err(Diagnostic::new(
223
- file,
224
- line,
225
- format!("{} declares needs :{fact} twice", command.name),
226
- ));
227
- }
228
- command.needs.push(fact);
229
- }
209
+ "needs" => super::needs::declare(file, line, &command.name, &gated.args, &mut command.needs)?,
230
210
  "provenance" => {
231
211
  let raw = super::named_raw(&gated.args, "from")
232
212
  .ok_or_else(|| Diagnostic::new(file, line, "'provenance' requires a from:"))?;
@@ -319,28 +299,6 @@ fn state_ref(raw: &str) -> Option<String> {
319
299
  Some(inner.to_string())
320
300
  }
321
301
 
322
- /// The outside facts a command may declare it needs; mirrors `CommandBuilder::NEEDABLE_FACTS`.
323
- const NEEDABLE_FACTS: &[&str] = &["now"];
324
-
325
- /// A need names the attribute the runtime fills, so the command must declare it.
326
- fn refuse_undeclared_needs(file: &str, line: usize, command: &ir::Command) -> ParseResult<()> {
327
- match command
328
- .needs
329
- .iter()
330
- .find(|fact| !command.attributes.iter().any(|a| &a.name == *fact))
331
- {
332
- Some(fact) => Err(Diagnostic::new(
333
- file,
334
- line,
335
- format!(
336
- "{} needs :{fact} but declares no attribute :{fact} for the runtime to fill — add `attribute :{fact}, <type>`",
337
- command.name
338
- ),
339
- )),
340
- None => Ok(()),
341
- }
342
- }
343
-
344
302
  /// Refuses a field written twice: a command's effects are one update set (C4.2).
345
303
  /// `delegates_to`/`corrects` name no field and are not counted.
346
304
  fn refuse_duplicate_targets(file: &str, line: usize, command: &ir::Command) -> ParseResult<()> {
@@ -9,6 +9,7 @@ pub mod entity;
9
9
  pub mod file;
10
10
  pub mod hecksagon;
11
11
  pub mod lifecycle;
12
+ pub mod needs;
12
13
  pub mod policy;
13
14
  pub mod process_manager;
14
15
  pub mod query;
@@ -0,0 +1,60 @@
1
+ //! The `needs` word, shared by the two declarations that may use it: a command and a query
2
+ //! (ADR 0081). Mirrors `Hecks::Bluebook::DSL::NeedWord`.
3
+
4
+ use crate::diag::{Diagnostic, ParseResult};
5
+ use crate::ir;
6
+ use crate::parse::ArgumentGateResult;
7
+
8
+ /// The outside facts a declaration may need, each the name of the attribute the runtime fills;
9
+ /// mirrors `NeedWord::NEEDABLE_FACTS`. `now` is the clock port's reading in epoch seconds and
10
+ /// `today` the day it falls in, whole days since the epoch in UTC.
11
+ const NEEDABLE_FACTS: &[&str] = &["now", "today"];
12
+
13
+ /// Reads one `needs :fact` line into `needs`.
14
+ ///
15
+ /// Refuses a fact the runtime cannot supply and a fact declared twice, as the Ruby builder does.
16
+ pub fn declare(
17
+ file: &str,
18
+ line: usize,
19
+ owner: &str,
20
+ args: &ArgumentGateResult,
21
+ needs: &mut Vec<String>,
22
+ ) -> ParseResult<()> {
23
+ let fact = super::positional_symbol(file, line, "needs", args, 1)?;
24
+ if !NEEDABLE_FACTS.contains(&fact.as_str()) {
25
+ let supplied: Vec<String> = NEEDABLE_FACTS.iter().map(|known| format!(":{known}")).collect();
26
+ return Err(Diagnostic::new(
27
+ file,
28
+ line,
29
+ format!(
30
+ "{owner} needs :{fact}, which the runtime cannot supply — it supplies {}",
31
+ supplied.join(", ")
32
+ ),
33
+ ));
34
+ }
35
+ if needs.contains(&fact) {
36
+ return Err(Diagnostic::new(file, line, format!("{owner} declares needs :{fact} twice")));
37
+ }
38
+ needs.push(fact);
39
+ Ok(())
40
+ }
41
+
42
+ /// A need names the attribute the runtime fills, so the declaration must have one.
43
+ pub fn refuse_undeclared(
44
+ file: &str,
45
+ line: usize,
46
+ owner: &str,
47
+ needs: &[String],
48
+ attributes: &[ir::Attribute],
49
+ ) -> ParseResult<()> {
50
+ match needs.iter().find(|fact| !attributes.iter().any(|a| &a.name == *fact)) {
51
+ Some(fact) => Err(Diagnostic::new(
52
+ file,
53
+ line,
54
+ format!(
55
+ "{owner} needs :{fact} but declares no attribute :{fact} for the runtime to fill — add `attribute :{fact}, <type>`"
56
+ ),
57
+ )),
58
+ None => Ok(()),
59
+ }
60
+ }
@@ -25,6 +25,8 @@ pub fn parse_body(
25
25
 
26
26
  loop {
27
27
  let Some(gated) = super::next_line(file, lines, pos, "Query")? else {
28
+ let end = lines.get(pos.saturating_sub(1)).map_or(0, |l| l.number);
29
+ super::needs::refuse_undeclared(file, end, name, &query.needs, &query.attributes)?;
28
30
  return Ok(query);
29
31
  };
30
32
  let line = gated.line.number;
@@ -83,6 +85,7 @@ pub fn parse_body(
83
85
  type_name
84
86
  });
85
87
  }
88
+ "needs" => super::needs::declare(file, line, name, &gated.args, &mut query.needs)?,
86
89
  word if OPTION_WORDS.contains(&word) => {
87
90
  query_options::apply(file, line, word, &gated.args, &mut query.options)?
88
91
  }
@@ -1,4 +1,4 @@
1
- //! Exemplar shapes for rust/project/commands.rb (see mod.rs).
1
+ //! Exemplar shapes for rust/codegen/src/commands.rs (see mod.rs).
2
2
  //!
3
3
  //! `dispatch_fn` proves the outer `pub fn .. -> DispatchResult { .. dispatch(..) .. }` wrapper.
4
4
  #![allow(dead_code, unused_variables)]
@@ -1,4 +1,4 @@
1
- //! Exemplar shapes for rust/project/constraints.rb (see mod.rs).
1
+ //! Exemplar shapes for rust/codegen/src/constraints.rs (see mod.rs).
2
2
  //!
3
3
  //! Both shapes are single-line refusal checks spliced into a `Result`-returning body.
4
4
  #![allow(dead_code, unused_variables)]
@@ -1,4 +1,4 @@
1
- //! Exemplar shapes for rust/project/fielded.rb (see mod.rs).
1
+ //! Exemplar shapes for rust/codegen/src/fielded.rs (see mod.rs).
2
2
  //!
3
3
  //! Each `Fielded::field` arm shape is a standalone leaf so both outer skeletons can reuse it.
4
4
  #![allow(dead_code, unused_variables)]
@@ -1,4 +1,4 @@
1
- // Exemplar shapes for rust/project/json_codec.rb; see mod.rs.
1
+ // Exemplar shapes for rust/codegen/src/json_codec.rs; see mod.rs.
2
2
  // Kept out of the fenced region so its prose is not baked into every generated codec.
3
3
  #![allow(dead_code, unused_variables)]
4
4
 
@@ -1,4 +1,4 @@
1
- // Compile-checked Rust for every shape rust/project/*.rb emits; exemplar.rb slices the fenced
1
+ // Compile-checked Rust for every shape rust/codegen/src/*.rs emits; exemplar.rs slices the fenced
2
2
  // `// TMPL:<id> BEGIN` / `END` regions and substitutes real names for the `tmpl_` vocabulary.
3
3
  //
4
4
  // `tmpl_*_host` functions only give fenced fragments real types and are never called, hence
@@ -1,4 +1,4 @@
1
- // Exemplar shapes for rust/project/mutations.rb; see mod.rs.
1
+ // Exemplar shapes for rust/codegen/src/mutations.rs; see mod.rs.
2
2
  // Hosts take `record: &mut Tmpl...`, not `&mut self`, as the generated mutation closures do.
3
3
  #![allow(dead_code, unused_variables)]
4
4
 
@@ -1,4 +1,4 @@
1
- // Exemplar shape for rust/project/registry.rb's `emit_query_table`; see mod.rs.
1
+ // Exemplar shape for rust/codegen/src/registry.rs's `emit_query_table`; see mod.rs.
2
2
  #![allow(dead_code, unused_variables)]
3
3
 
4
4
  // A `const` cannot call a function, so the row is a real literal substituted wholesale.
@@ -1,4 +1,4 @@
1
- // Exemplar shapes that rust/project/reactions.rb slices into generated tables;
1
+ // Exemplar shapes that rust/codegen/src/reactions.rs slices into generated tables;
2
2
  // see mod.rs for how they are used.
3
3
  #![allow(dead_code, unused_variables)]
4
4
 
@@ -1,4 +1,4 @@
1
- // Exemplar shapes for rust/project/read_models.rb's `emit_read_model_table`
1
+ // Exemplar shapes for rust/codegen/src/read_models.rs's `emit_read_model_table`
2
2
  // — see mod.rs's own header. `ReadModelDef`/`ReadModelHead`/`FilteredHead`/
3
3
  // `ReferenceField` are real kernel types (`rust/src/kernel/read_model.rs`).
4
4
  #![allow(dead_code, unused_variables)]
@@ -1,4 +1,4 @@
1
- // Exemplar shapes for rust/project/registry.rb; see mod.rs.
1
+ // Exemplar shapes for rust/codegen/src/registry.rs; see mod.rs.
2
2
  // `InMemoryRepository<T>` only needs `T: Clone`, so an `i64` record type suffices.
3
3
  #![allow(dead_code, unused_variables)]
4
4
 
@@ -1,4 +1,4 @@
1
- // Exemplar shapes for rust/project/types.rb; see mod.rs.
1
+ // Exemplar shapes for rust/codegen/src/types.rs; see mod.rs.
2
2
  // `closed_set_enum` is a one-field closed set (one variant per member); a multi-field closed
3
3
  // set is a fixed data table instead.
4
4
  #![allow(dead_code, unused_variables)]