hecks 3.0.4 → 3.1.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 (159) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +25 -7
  3. data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
  4. data/lib/hecks/adapters/driven/lambda.rb +1 -1
  5. data/lib/hecks/adapters/driven/tenant_provisioner.rb +2 -2
  6. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +59 -32
  7. data/lib/hecks/bluebook/hexagon.rb +45 -25
  8. data/lib/hecks/bluebook/model_check.rb +7 -7
  9. data/lib/hecks/chapters.rb +31 -3
  10. data/lib/hecks/cli/check_era.rb +1 -1
  11. data/lib/hecks/cli/console.rb +24 -8
  12. data/lib/hecks/cli/deploy_template_diff.rb +1 -1
  13. data/lib/hecks/cli/doc_coverage.rb +1 -1
  14. data/lib/hecks/cli/domain_stub.rb +128 -0
  15. data/lib/hecks/cli/interview_draft.rb +246 -0
  16. data/lib/hecks/cli/model_check.rb +1 -1
  17. data/lib/hecks/cli/project_cli.rb +69 -18
  18. data/lib/hecks/cli/reference.rb +2 -1
  19. data/lib/hecks/cli/refresh_rspec_runtime_baseline.rb +1 -1
  20. data/lib/hecks/cli/seed_semantics_corpus.rb +38 -9
  21. data/lib/hecks/cli/spec_example.rb +1 -1
  22. data/lib/hecks/cli/stress_concurrency_specs.rb +14 -5
  23. data/lib/hecks/corpus.rb +11 -8
  24. data/lib/hecks/deploy/bluebook/deploy.bluebook +85 -12
  25. data/lib/hecks/deploy/oidc.json +5 -0
  26. data/lib/hecks/doc/reference.rb +4 -3
  27. data/lib/hecks/doors/cli_door.rb +13 -13
  28. data/lib/hecks/doors/cli_runner.rb +52 -32
  29. data/lib/hecks/doors/launcher_options.rb +23 -5
  30. data/lib/hecks/doors/mcp_door.rb +40 -18
  31. data/lib/hecks/doors/mcp_door_scope.rb +244 -24
  32. data/lib/hecks/doors.rb +1 -1
  33. data/lib/hecks/embryonaut_bluebook.rb +2 -2
  34. data/lib/hecks/forms/banking_presentation.rb +1 -1
  35. data/lib/hecks/framework/bluebook/compliance.bluebook +1 -1
  36. data/lib/hecks/framework/bluebook/console_settings.bluebook +1 -1
  37. data/lib/hecks/framework/bluebook/framework.hecksagon +4 -4
  38. data/lib/hecks/framework.rb +1 -1
  39. data/lib/hecks/fuzzing/combination_miner.rb +1 -1
  40. data/lib/hecks/fuzzing/concurrent_dispatch.rb +1 -1
  41. data/lib/hecks/fuzzing/differential.rb +4 -2
  42. data/lib/hecks/fuzzing/era_boundary.rb +1 -1
  43. data/lib/hecks/fuzzing/form_census.rb +2 -2
  44. data/lib/hecks/fuzzing/generated_domain_check.rb +1 -1
  45. data/lib/hecks/fuzzing/isolated_boot.rb +10 -3
  46. data/lib/hecks/fuzzing/persistence_parity.rb +2 -2
  47. data/lib/hecks/fuzzing/target_capabilities.rb +3 -3
  48. data/lib/hecks/gate/stages.yml +8 -4
  49. data/lib/hecks/grammar/grammar.hecksagon +2 -2
  50. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +4 -4
  51. data/lib/hecks/hecks/adapters/host_http.rb +4 -3
  52. data/lib/hecks/hecks/adapters/local_files.rb +59 -0
  53. data/lib/hecks/hecks/adapters/site_toolchain.adapter +7 -0
  54. data/lib/hecks/hecks/adapters/site_toolchain.rb +54 -0
  55. data/lib/hecks/hecks/codebase.bluebook +4 -3
  56. data/lib/hecks/hecks/context_map.hecksagon +2 -2
  57. data/lib/hecks/hecks/custodian.bluebook +45 -3
  58. data/lib/hecks/hecks/hecks.hecksagon +10 -4
  59. data/lib/hecks/hecks/hecks.world +7 -4
  60. data/lib/hecks/hecks/site.behaviors +17 -0
  61. data/lib/hecks/hecks/site.world +10 -0
  62. data/lib/hecks/language/bluebook/bluebook.hecksagon +1 -1
  63. data/lib/hecks/language/hecksagon/hecksagon.bluebook +24 -47
  64. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +4 -4
  65. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +2 -1
  66. data/lib/hecks/projections/deploy/box/settings.rb +248 -0
  67. data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +267 -0
  68. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +70 -0
  69. data/lib/hecks/projections/deploy/box/templates/fetch-secrets.sh +21 -0
  70. data/lib/hecks/projections/deploy/box/templates/rds.yaml.tmpl +165 -0
  71. data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +43 -0
  72. data/lib/hecks/projections/deploy/box.rb +387 -0
  73. data/lib/hecks/projections/deploy/fargate/cdn.rb +13 -1
  74. data/lib/hecks/projections/deploy/fargate.rb +1 -1
  75. data/lib/hecks/projections/deploy/lambda.rb +11 -10
  76. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +1 -1
  77. data/lib/hecks/projections/diagrams.rb +2 -2
  78. data/lib/hecks/projections/site/edge/behaviours.rb +107 -0
  79. data/lib/hecks/projections/site/edge/checks.rb +137 -0
  80. data/lib/hecks/projections/site/edge/pattern.rb +50 -0
  81. data/lib/hecks/projections/site/edge/rules.rb +128 -0
  82. data/lib/hecks/projections/site/edge.rb +136 -0
  83. data/lib/hecks/projections/site/regions.rb +44 -0
  84. data/lib/hecks/projections/site/routes_ts.rb +284 -0
  85. data/lib/hecks/projections/site/site_cdn.rb +78 -0
  86. data/lib/hecks/projections/site/table/checks.rb +124 -0
  87. data/lib/hecks/projections/site/table/row_builder.rb +155 -0
  88. data/lib/hecks/projections/site/table.rb +98 -0
  89. data/lib/hecks/projections.rb +2 -0
  90. data/lib/hecks/projector/cli_projector.rb +172 -95
  91. data/lib/hecks/quality_control/adapters/agent.adapter +1 -1
  92. data/lib/hecks/quality_control/adapters/agent.rb +2 -2
  93. data/lib/hecks/quality_control/cli/qa_concurrency_racer.rb +3 -3
  94. data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +8 -8
  95. data/lib/hecks/quality_control/cli/qa_domain_novelty.rb +3 -3
  96. data/lib/hecks/quality_control/cli/qa_generated_domains.rb +4 -4
  97. data/lib/hecks/quality_control/cli/qa_log_bug.rb +2 -2
  98. data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +6 -5
  99. data/lib/hecks/quality_control/cli/qa_postgres_migrate.rb +2 -2
  100. data/lib/hecks/quality_control/cli/qa_postgres_role.rb +5 -5
  101. data/lib/hecks/quality_control/cli/qa_pr_check.rb +3 -3
  102. data/lib/hecks/quality_control/cli/qa_sweep/all_mode.rb +3 -3
  103. data/lib/hecks/quality_control/cli/qa_sweep/checks.rb +2 -2
  104. data/lib/hecks/quality_control/cli/qa_sweep.rb +10 -9
  105. data/lib/hecks/quality_control/cli/qa_tick.rb +3 -3
  106. data/lib/hecks/quality_control/quality_control.bluebook +18 -18
  107. data/lib/hecks/quality_control/quality_control.hecksagon +1 -1
  108. data/lib/hecks/runtime/command_interpreter.rb +2 -0
  109. data/lib/hecks/runtime/interpreting.rb +21 -0
  110. data/lib/hecks/runtime/invocation.rb +5 -3
  111. data/lib/hecks/runtime/registry/verification.rb +15 -29
  112. data/lib/hecks/runtime/registry.rb +14 -20
  113. data/lib/hecks/rust_build/append_optionals.rb +79 -0
  114. data/lib/hecks/rust_build/conformance_fuzz.rb +2 -1
  115. data/lib/hecks/rust_build/domain_name.rb +26 -0
  116. data/lib/hecks/rust_build/kernel_input.rb +58 -0
  117. data/lib/hecks/rust_build/project_rust/codegen_run.rb +143 -0
  118. data/lib/hecks/rust_build/project_rust/root_mod.rb +1 -1
  119. data/lib/hecks/rust_build/project_rust.rb +57 -24
  120. data/lib/hecks/rust_build/rust_literal.rb +33 -0
  121. data/lib/hecks/rust_build/write_if_changed.rb +86 -0
  122. data/lib/hecks/site/bluebook/site.bluebook +205 -0
  123. data/lib/hecks/site/bluebook/site.hecksagon +5 -0
  124. data/lib/hecks/site/bluebook/site.ports.hecksagon +15 -0
  125. data/lib/hecks/sme/bluebook/sme.bluebook +336 -0
  126. data/lib/hecks/sme/bluebook/sme.hecksagon +5 -0
  127. data/lib/hecks/storehouse.rb +37 -7
  128. data/lib/hecks/tenancy/bluebook/tenancy.bluebook +1 -1
  129. data/lib/hecks/three_zero/forms.yml +88 -88
  130. data/lib/hecks/tools/ci_gates.rb +1 -1
  131. data/lib/hecks/tools/deploy_recipe.rb +14 -6
  132. data/lib/hecks/tools/oidc_manifests.rb +2 -1
  133. data/lib/hecks/tools/sequence_script.rb +2 -2
  134. data/lib/hecks/tools/site_routes.rb +152 -0
  135. data/lib/hecks/tools/tenant_provisioning.rb +1 -1
  136. data/lib/hecks/tools.rb +1 -0
  137. data/lib/hecks/version.rb +1 -1
  138. data/rust/build/src/cargo_sync.rs +1 -1
  139. data/rust/build/src/pipeline.rs +23 -16
  140. data/rust/build/src/resolve.rs +39 -6
  141. data/rust/host/HECKS_RELEASE +1 -1
  142. data/rust/host/src/api.rs +1 -1
  143. data/rust/host/src/dispatch.rs +87 -7
  144. data/rust/host/src/needs.rs +111 -0
  145. data/rust/host/src/web/newsletter/template.rs +69 -0
  146. data/rust/host/src/web/newsletter/tests.rs +43 -0
  147. data/rust/host/src/web/newsletter.rs +6 -1
  148. data/rust/host/src/web/newsletter_send.rs +1 -1
  149. data/rust/parser/src/keywords.rs +7 -6
  150. data/rust/parser/src/main.rs +4 -3
  151. data/rust/parser/src/parse/chapter.rs +1 -1
  152. data/rust/parser/src/parse/hecksagon.rs +25 -10
  153. data/rust/project/mutations.rb +4 -30
  154. data/rust/project/naming.rb +8 -28
  155. data/rust/project/write_if_changed.rb +4 -80
  156. data/rust/project_rust_pipeline.rb +16 -12
  157. data/rust/src/kernel/cli.rs +2 -0
  158. data/rust/src/kernel/needs.rs +74 -1
  159. metadata +39 -2
@@ -77,12 +77,45 @@ pub fn grammar_files(root: &Path) -> Result<Vec<PathBuf>, String> {
77
77
  bluebook_files(&dir)
78
78
  }
79
79
 
80
- pub fn resolve_uses_framework(parser_bin: &Path, chapter_name: &str, hecksagon_path: &Path) -> Result<Vec<String>, String> {
81
- resolve_json(parser_bin, chapter_name, hecksagon_path, "uses_framework")
80
+ // The names `attaches "Name"` gave, for a chapter the gem carries.
81
+ pub fn resolve_gem_chapters(parser_bin: &Path, chapter_name: &str, hecksagon_path: &Path) -> Result<Vec<String>, String> {
82
+ resolve_json(parser_bin, chapter_name, hecksagon_path, "gem_chapters")
82
83
  }
83
84
 
84
- pub fn resolve_uses_embryonaut_bluebook(parser_bin: &Path, chapter_name: &str, hecksagon_path: &Path) -> Result<Vec<String>, String> {
85
- resolve_json(parser_bin, chapter_name, hecksagon_path, "uses_embryonaut_bluebook")
85
+ // The names `attaches "name", from: :vendor` gave, for a vendored package.
86
+ pub fn resolve_vendored_packages(parser_bin: &Path, chapter_name: &str, hecksagon_path: &Path) -> Result<Vec<String>, String> {
87
+ resolve_json(parser_bin, chapter_name, hecksagon_path, "vendored_packages")
88
+ }
89
+
90
+ // Where the gem keeps the chapters other than framework members, relative to `lib/hecks`; the
91
+ // same places `Hecks::Chapters::GLOBS` lists.
92
+ const OTHER_GEM_CHAPTER_DIRS: &[&str] = &["language", "grammar", "tenancy/bluebook", "deploy/bluebook", "quality_control"];
93
+
94
+ // The names of the chapters the gem carries beside the framework members, found by reading each
95
+ // file's header. Only the Ruby runtime loads them, so the build uses this list to tell a real
96
+ // gem chapter from a misspelt name.
97
+ pub fn other_gem_chapter_names(root: &Path) -> Result<Vec<String>, String> {
98
+ fn walk(directory: &Path, names: &mut Vec<String>) -> Result<(), String> {
99
+ let Ok(entries) = std::fs::read_dir(directory) else { return Ok(()) };
100
+ for entry in entries {
101
+ let path = entry.map_err(|e| format!("reading {}: {e}", directory.display()))?.path();
102
+ if path.is_dir() {
103
+ walk(&path, names)?;
104
+ } else if path.extension().and_then(|e| e.to_str()) == Some("bluebook") {
105
+ if let Ok(name) = header_chapter_name(&path) {
106
+ names.push(name);
107
+ }
108
+ }
109
+ }
110
+ Ok(())
111
+ }
112
+ let mut names = Vec::new();
113
+ for directory in OTHER_GEM_CHAPTER_DIRS {
114
+ walk(&root.join("lib/hecks").join(directory), &mut names)?;
115
+ }
116
+ names.sort();
117
+ names.dedup();
118
+ Ok(names)
86
119
  }
87
120
 
88
121
  fn resolve_json(parser_bin: &Path, chapter_name: &str, hecksagon_path: &Path, key: &str) -> Result<Vec<String>, String> {
@@ -105,13 +138,13 @@ pub fn valid_package_name(name: &str) -> bool {
105
138
  pub fn vendored_bluebook_files(domain: &Path, pkg_name: &str) -> Result<Vec<PathBuf>, String> {
106
139
  if !valid_package_name(pkg_name) {
107
140
  return Err(format!(
108
- "uses_embryonaut_bluebook {pkg_name:?} is not a package name — it must match [a-z][a-z0-9_]* (the name `hecks vendor` accepts)"
141
+ "attaches {pkg_name:?}, from: :vendor is not a package name — it must match [a-z][a-z0-9_]* (the name `hecks vendor` accepts)"
109
142
  ));
110
143
  }
111
144
  let dir = domain.join("vendor").join("embryonaut_bluebooks").join(pkg_name).join("bluebook");
112
145
  if !dir.is_dir() {
113
146
  return Err(format!(
114
- "uses_embryonaut_bluebook {pkg_name:?} names no vendored bluebook at {} — run hecks vendor {pkg_name}",
147
+ "attaches {pkg_name:?}, from: :vendor names no vendored bluebook at {} — run hecks vendor {pkg_name}",
115
148
  dir.display()
116
149
  ));
117
150
  }
@@ -1 +1 @@
1
- 3.0.4
1
+ 3.1.0
data/rust/host/src/api.rs CHANGED
@@ -780,7 +780,7 @@ async fn presentation_save(
780
780
  const PRESENTATION_WRITE_REFUSAL: &str =
781
781
  "this host's kernel carries no ConsoleSettings chapter, so it has no StateStyle/Collection/Overview \
782
782
  commands to dispatch, and writing the rows behind its back would skip the invariants those commands \
783
- enforce. Attach it with `uses_framework \"ConsoleSettings\"` in this domain's own .hecksagon and \
783
+ enforce. Attach it with `attaches \"ConsoleSettings\"` in this domain's own .hecksagon and \
784
784
  rebuild the kernel, or save from the Ruby console engine instead.";
785
785
 
786
786
  // ---- response envelopes ---------------------------------------------
@@ -31,7 +31,9 @@ pub const ANONYMOUS_ROLE: &str = "Anonymous";
31
31
  pub enum Enforcement {
32
32
  /// A caller with no role is unchecked, as before.
33
33
  Off,
34
- /// An unidentified or unassigned caller is let through and logged as `would_refuse_role`.
34
+ /// An unidentified or unassigned caller is let through and logged as `would_refuse_role`; a
35
+ /// caller that states a role is checked exactly as under `Enforce`, so shadow never accepts
36
+ /// what `Off` would not.
35
37
  Shadow,
36
38
  /// An unidentified or unassigned caller is refused.
37
39
  Enforce,
@@ -62,6 +64,15 @@ impl Enforcement {
62
64
  }
63
65
  }
64
66
 
67
+ /// Where a dispatch comes from. A host-internal dispatch is the host acting on its own behalf (a
68
+ /// signup, a provisioning step, a presentation write); it names no caller and is never subject to
69
+ /// `HECKS_ROLE_ENFORCEMENT`, which guards what callers outside the host may do.
70
+ #[derive(Clone, Copy, Debug, PartialEq)]
71
+ enum Origin {
72
+ External,
73
+ HostInternal,
74
+ }
75
+
65
76
  /// Whether the kernel refused the command for the caller's role.
66
77
  fn refused_for_role(result: &serde_json::Value) -> bool {
67
78
  result.get("refusals").and_then(|r| r.as_array()).is_some_and(|all| {
@@ -189,6 +200,10 @@ pub async fn handle_facts_as(
189
200
  // `role` is optional, matching `Adapters::Lambda::Client#dispatch`'s own
190
201
  // `role: nil` default: a step with no role looks the same on the wire
191
202
  // whether or not a caller is bound.
203
+ //
204
+ // This entry point and `handle_facts`/`handle_routed` are the host's own: a call with no role is a
205
+ // host-internal dispatch and is unchecked in every enforcement mode. A call that names a role is
206
+ // still checked against it. Callers outside the host go through the `_as` entry points.
192
207
  pub async fn handle(
193
208
  client: &Mutex<Client>,
194
209
  wasm_path: &Path,
@@ -198,7 +213,8 @@ pub async fn handle(
198
213
  config: &LineageConfig,
199
214
  invoker: &dyn LambdaInvoker,
200
215
  ) -> anyhow::Result<Outcome> {
201
- handle_as(client, wasm_path, verb, args, Caller { role, actor_id: None }, config, invoker).await
216
+ let origin = if role.is_none() { Origin::HostInternal } else { Origin::External };
217
+ dispatch_in(Enforcement::from_env(), origin, client, wasm_path, verb, args, Caller { role, actor_id: None }, config, invoker).await
202
218
  }
203
219
 
204
220
  /// Dispatches for a caller, applying `HECKS_ROLE_ENFORCEMENT`: unidentified callers are refused (or,
@@ -212,12 +228,13 @@ pub async fn handle_as(
212
228
  config: &LineageConfig,
213
229
  invoker: &dyn LambdaInvoker,
214
230
  ) -> anyhow::Result<Outcome> {
215
- handle_with(Enforcement::from_env(), client, wasm_path, verb, args, caller, config, invoker).await
231
+ dispatch_in(Enforcement::from_env(), Origin::External, client, wasm_path, verb, args, caller, config, invoker).await
216
232
  }
217
233
 
218
234
  #[allow(clippy::too_many_arguments)]
219
- async fn handle_with(
235
+ async fn dispatch_in(
220
236
  mode: Enforcement,
237
+ origin: Origin,
221
238
  client: &Mutex<Client>,
222
239
  wasm_path: &Path,
223
240
  verb: &str,
@@ -226,9 +243,11 @@ async fn handle_with(
226
243
  config: &LineageConfig,
227
244
  invoker: &dyn LambdaInvoker,
228
245
  ) -> anyhow::Result<Outcome> {
229
- let checked = mode.effective(caller);
246
+ let checked = if origin == Origin::HostInternal { caller } else { mode.effective(caller) };
230
247
  let outcome = run(client, wasm_path, verb, args.clone(), checked, config, invoker).await?;
231
- if mode == Enforcement::Shadow && !outcome.accepted && refused_for_role(&outcome.result) {
248
+ // Only a caller that stated no role (unidentified, or an actor Governance holds no grant for)
249
+ // is let through; a stated role that does not match stays refused, as it is under `Off`.
250
+ if mode == Enforcement::Shadow && caller.role.is_none() && !outcome.accepted && refused_for_role(&outcome.result) {
232
251
  crate::log::error("would_refuse_role", serde_json::json!({
233
252
  "verb": verb, "role": checked.role, "actor_id": checked.actor_id,
234
253
  "refusals": outcome.result.get("refusals"),
@@ -287,6 +306,8 @@ async fn run(
287
306
  let mut args = args;
288
307
  if let Some(domain_ir) = crate::ir::ir() {
289
308
  crate::needs::fill_needs(domain_ir, verb, &mut args, &crate::needs::ProcessClock);
309
+ // An argument with a declared default is filled the same way, so the journal holds it too.
310
+ crate::needs::fill_defaults(domain_ir, verb, &mut args);
290
311
  }
291
312
  let mut step = serde_json::json!({ "verb": verb, "args": args.clone() });
292
313
  if let Some(role) = role {
@@ -330,6 +351,10 @@ async fn run(
330
351
  if table.as_object().is_some_and(|t| !t.is_empty()) {
331
352
  input["needs"] = table;
332
353
  }
354
+ let defaults = crate::needs::defaults_table(domain_ir);
355
+ if defaults.as_object().is_some_and(|t| !t.is_empty()) {
356
+ input["defaults"] = defaults;
357
+ }
333
358
  }
334
359
  let input = input.to_string();
335
360
  // `wasm_runner::run` is sync, and wasmtime-wasi's sync bridge spins up
@@ -1353,7 +1378,7 @@ pub(crate) mod tests {
1353
1378
  let client = &client;
1354
1379
  let config = &config;
1355
1380
  async move {
1356
- handle_with(mode, client, &wasm_path(), "Banking::Customer.Register", register(id), Caller::default(), config, &lambda_client::NeverInvoker)
1381
+ dispatch_in(mode, Origin::External, client, &wasm_path(), "Banking::Customer.Register", register(id), Caller::default(), config, &lambda_client::NeverInvoker)
1357
1382
  .await
1358
1383
  .unwrap()
1359
1384
  }
@@ -1375,6 +1400,61 @@ pub(crate) mod tests {
1375
1400
  assert_eq!(steps.len(), 2, "the enforced refusal was not persisted; off and shadow were");
1376
1401
  }
1377
1402
 
1403
+ // Shadow may only let through what Off would: a caller that stated a wrong role is refused.
1404
+ #[tokio::test]
1405
+ async fn shadow_refuses_a_stated_wrong_role_and_still_lets_an_unidentified_caller_through() {
1406
+ let client = scratch_db("rust_host_dispatch_test_shadow_stated").await;
1407
+ provision_lineage(&*client.lock().await, "Banking", 1, &["Customer"]).await;
1408
+ let config = test_config("Banking", 1);
1409
+ let go = |mode: Enforcement, origin: Origin, caller: Caller<'static>, id: &'static str| {
1410
+ let client = &client;
1411
+ let config = &config;
1412
+ async move {
1413
+ dispatch_in(mode, origin, client, &wasm_path(), "Banking::Customer.Register", register(id), caller, config, &lambda_client::NeverInvoker)
1414
+ .await
1415
+ .unwrap()
1416
+ }
1417
+ };
1418
+ let wrong = Caller { role: Some("Auditor"), actor_id: None };
1419
+
1420
+ let shadow_wrong = go(Enforcement::Shadow, Origin::External, wrong, "CUST-0031").await;
1421
+ assert!(!shadow_wrong.accepted, "shadow must not accept a stated wrong role: {:?}", shadow_wrong.result);
1422
+ assert!(refused_for_role(&shadow_wrong.result), "{:?}", shadow_wrong.result);
1423
+ let off_wrong = go(Enforcement::Off, Origin::External, wrong, "CUST-0032").await;
1424
+ assert!(!off_wrong.accepted, "off refuses it too: {:?}", off_wrong.result);
1425
+
1426
+ let nobody = go(Enforcement::Shadow, Origin::External, Caller::default(), "CUST-0033").await;
1427
+ assert!(nobody.accepted, "shadow lets an unidentified caller through: {:?}", nobody.result);
1428
+
1429
+ let steps = journal::load_steps(&*client.lock().await).await.unwrap();
1430
+ assert_eq!(steps.len(), 1, "only the unidentified caller's registration was persisted");
1431
+ }
1432
+
1433
+ // A host-internal dispatch names no caller and is unchecked in every mode, while an external
1434
+ // unidentified caller is refused under enforce.
1435
+ #[tokio::test]
1436
+ async fn a_host_internal_dispatch_is_unchecked_under_enforce_but_an_external_one_is_refused() {
1437
+ let client = scratch_db("rust_host_dispatch_test_internal").await;
1438
+ provision_lineage(&*client.lock().await, "Banking", 1, &["Customer"]).await;
1439
+ let config = test_config("Banking", 1);
1440
+ let go = |mode: Enforcement, origin: Origin, id: &'static str| {
1441
+ let client = &client;
1442
+ let config = &config;
1443
+ async move {
1444
+ dispatch_in(mode, origin, client, &wasm_path(), "Banking::Customer.Register", register(id), Caller::default(), config, &lambda_client::NeverInvoker)
1445
+ .await
1446
+ .unwrap()
1447
+ }
1448
+ };
1449
+
1450
+ let external = go(Enforcement::Enforce, Origin::External, "CUST-0041").await;
1451
+ assert!(!external.accepted, "{:?}", external.result);
1452
+ for (mode, id) in [(Enforcement::Enforce, "CUST-0042"), (Enforcement::Shadow, "CUST-0043"), (Enforcement::Off, "CUST-0044")] {
1453
+ let internal = go(mode, Origin::HostInternal, id).await;
1454
+ assert!(internal.accepted, "{mode:?}: {:?}", internal.result);
1455
+ }
1456
+ }
1457
+
1378
1458
  #[tokio::test]
1379
1459
  async fn a_mid_transaction_failure_rolls_back_any_saga_state_already_written() {
1380
1460
  // Deliberately doesn't provision "Transfer": its mutation fails
@@ -110,6 +110,52 @@ pub fn table(domain_ir: &Value) -> Value {
110
110
  Value::Object(verbs)
111
111
  }
112
112
 
113
+ /// The command's attributes that declare a default (`attribute :runs, Count, default: 30`), each
114
+ /// with the declared value. An attribute whose default is null declares none.
115
+ 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"))
128
+ .filter_map(|a| {
129
+ let default = a.get("default").filter(|d| !d.is_null())?;
130
+ Some((a.get("name").and_then(Value::as_str)?, default))
131
+ })
132
+ .collect()
133
+ }
134
+
135
+ /// 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.
138
+ pub fn defaults_table(domain_ir: &Value) -> Value {
139
+ let domain = domain_ir.get("name").and_then(Value::as_str).unwrap_or_default();
140
+ 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 {
143
+ 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
+ }
155
+ }
156
+ Value::Object(verbs)
157
+ }
158
+
113
159
  /// The facts of a command invocation: the `with` object when the call carries one, else the
114
160
  /// flat object itself (the kernel's `CommandInvocation` reads both shapes).
115
161
  fn facts_mut(args: &mut Value) -> Option<&mut serde_json::Map<String, Value>> {
@@ -147,6 +193,22 @@ pub fn fill_needs(domain_ir: &Value, verb: &str, args: &mut Value, clock: &dyn C
147
193
  }
148
194
  }
149
195
 
196
+ /// Gives each argument `verb` declares a default for, and `args` leaves out, that default, after
197
+ /// `fill_needs` and before any gate reads the arguments. A key the caller supplied (even a null)
198
+ /// is kept, as is a fact `fill_needs` already answered.
199
+ pub fn fill_defaults(domain_ir: &Value, verb: &str, args: &mut Value) {
200
+ let defaults = declared_defaults(domain_ir, verb);
201
+ if defaults.is_empty() {
202
+ return;
203
+ }
204
+ let Some(facts) = facts_mut(args) else { return };
205
+ for (name, value) in defaults {
206
+ if !facts.contains_key(name) {
207
+ facts.insert(name.to_string(), value.clone());
208
+ }
209
+ }
210
+ }
211
+
150
212
  #[cfg(test)]
151
213
  mod tests {
152
214
  use super::*;
@@ -240,4 +302,53 @@ mod tests {
240
302
  clear_fixed_clock();
241
303
  assert!(ProcessClock.now_secs() > 1_700_000_000);
242
304
  }
305
+
306
+ // A command with one defaulted attribute, one plain, and one whose default is null.
307
+ fn defaulted_ir() -> Value {
308
+ json!({"name": "Qc", "aggregates": [{"name": "Check", "commands": [
309
+ {"name": "Start", "attributes": [
310
+ {"name": "ref", "type": "Ref", "default": null},
311
+ {"name": "runs", "type": "Count", "default": 30},
312
+ {"name": "note", "type": "String", "default": null}
313
+ ], "needs": []}
314
+ ]}]})
315
+ }
316
+
317
+ #[test]
318
+ fn an_omitted_argument_is_filled_with_its_declared_default() {
319
+ let mut args = json!({"with": {"ref": {"value": "a"}}});
320
+ fill_defaults(&defaulted_ir(), "Qc::Check.Start", &mut args);
321
+ assert_eq!(args["with"]["runs"], 30);
322
+ assert!(args["with"].get("note").is_none(), "a null default declares none");
323
+ }
324
+
325
+ #[test]
326
+ fn a_flat_invocation_gets_its_defaults_at_its_top_level() {
327
+ let mut args = json!({"ref": {"value": "a"}});
328
+ fill_defaults(&defaulted_ir(), "Qc::Check.Start", &mut args);
329
+ assert_eq!(args["runs"], 30);
330
+ }
331
+
332
+ #[test]
333
+ fn a_supplied_argument_is_kept_even_when_null() {
334
+ let mut args = json!({"runs": null});
335
+ fill_defaults(&defaulted_ir(), "Qc::Check.Start", &mut args);
336
+ assert!(args["runs"].is_null());
337
+ let mut args = json!({"runs": {"value": 7}});
338
+ fill_defaults(&defaulted_ir(), "Qc::Check.Start", &mut args);
339
+ assert_eq!(args["runs"], json!({"value": 7}));
340
+ }
341
+
342
+ #[test]
343
+ fn a_command_that_declares_no_default_is_untouched() {
344
+ let mut args = json!({"with": {}});
345
+ fill_defaults(&integer_ir(), "Qc::Check.Run", &mut args);
346
+ assert_eq!(args, json!({"with": {}}));
347
+ }
348
+
349
+ #[test]
350
+ fn the_kernel_table_lists_each_command_that_declares_a_default() {
351
+ assert_eq!(defaults_table(&defaulted_ir()), json!({"Qc::Check.Start": {"runs": 30}}));
352
+ assert_eq!(defaults_table(&integer_ir()), json!({}));
353
+ }
243
354
  }
@@ -0,0 +1,69 @@
1
+ // The optional branded confirmation email: an HTML template an operator
2
+ // hosts, fetched at send time. Hecks ships no brand; with no template (or
3
+ // any trouble with one) the caller keeps the plain-text body.
4
+ use super::super::newsletter_send::personalize;
5
+ use std::time::Duration;
6
+
7
+ pub(super) const CONFIRM_TOKEN: &str = "{{CONFIRM_URL}}";
8
+ const FETCH_TIMEOUT: Duration = Duration::from_secs(5);
9
+ pub(super) const MAX_TEMPLATE_BYTES: usize = 256 * 1024;
10
+
11
+ // Escapes a URL for an HTML attribute or text position.
12
+ fn escape_html(value: &str) -> String {
13
+ value.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;").replace('\'', "&#39;")
14
+ }
15
+
16
+ // Fills the placeholders with HTML-escaped URLs. `Err` names why the
17
+ // template is unusable: without {{CONFIRM_URL}} the email would carry no
18
+ // link. {{UNSUBSCRIBE_URL}} is optional. The result starts at its first
19
+ // non-blank character so the mailer sends it as HTML.
20
+ pub(super) fn fill_template(template: &str, confirm_url: &str, unsubscribe_url: &str) -> Result<String, &'static str> {
21
+ if !template.contains(CONFIRM_TOKEN) {
22
+ return Err("the template has no {{CONFIRM_URL}} placeholder");
23
+ }
24
+ let filled = template.replace(CONFIRM_TOKEN, &escape_html(confirm_url));
25
+ Ok(personalize(&filled, &escape_html(unsubscribe_url)).trim_start().to_string())
26
+ }
27
+
28
+ // Reads a response body, stopping as soon as it passes the cap.
29
+ async fn read_capped(mut response: reqwest::Response) -> Result<String, String> {
30
+ let mut bytes: Vec<u8> = Vec::new();
31
+ while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? {
32
+ bytes.extend_from_slice(&chunk);
33
+ if bytes.len() > MAX_TEMPLATE_BYTES {
34
+ return Err(format!("the template is larger than {MAX_TEMPLATE_BYTES} bytes"));
35
+ }
36
+ }
37
+ String::from_utf8(bytes).map_err(|_| "the template is not UTF-8".to_string())
38
+ }
39
+
40
+ // GETs the template (5 second timeout, size capped); a non-2xx answer or
41
+ // any transport trouble is an `Err` with the reason.
42
+ async fn fetch_template(url: &str) -> Result<String, String> {
43
+ let http = reqwest::Client::builder().timeout(FETCH_TIMEOUT).build().map_err(|e| e.to_string())?;
44
+ let response = http.get(url).send().await.map_err(|e| e.to_string())?;
45
+ if !response.status().is_success() {
46
+ return Err(format!("the template answered {}", response.status()));
47
+ }
48
+ read_capped(response).await
49
+ }
50
+
51
+ // The HTML body when NEWSLETTER_CONFIRMATION_TEMPLATE_URL names a usable
52
+ // template; None (after logging why) when the caller should send plain text.
53
+ pub(super) async fn branded_body(confirm_url: &str, unsubscribe_url: &str) -> Option<String> {
54
+ let url = std::env::var("NEWSLETTER_CONFIRMATION_TEMPLATE_URL").ok().filter(|u| !u.trim().is_empty())?;
55
+ let template = match fetch_template(url.trim()).await {
56
+ Ok(t) => t,
57
+ Err(e) => {
58
+ eprintln!("newsletter: the confirmation template could not be fetched ({e}), so the plain-text confirmation was sent");
59
+ return None;
60
+ }
61
+ };
62
+ match fill_template(&template, confirm_url, unsubscribe_url) {
63
+ Ok(body) => Some(body),
64
+ Err(e) => {
65
+ eprintln!("newsletter: the confirmation template was rejected ({e}), so the plain-text confirmation was sent");
66
+ None
67
+ }
68
+ }
69
+ }
@@ -102,3 +102,46 @@ fn a_confirm_token_is_not_an_unsubscribe_token_and_the_reverse() {
102
102
  assert!(!unsubscribe_token_matches("s3cret-value", &confirm, "a@example.com"));
103
103
  assert!(!confirm_token_matches("s3cret-value", &unsubscribe, "a@example.com"));
104
104
  }
105
+
106
+ use template::fill_template;
107
+
108
+ const CONFIRM: &str = "https://example.com/newsletter-confirmed.html?email=a%40b.c&token=t";
109
+ const UNSUBSCRIBE: &str = "https://example.com/newsletter-unsubscribed.html?email=a%40b.c&token=u";
110
+
111
+ #[test]
112
+ fn a_template_gets_the_confirm_link_in_place_of_its_placeholder() {
113
+ let body = fill_template("<p><a href=\"{{CONFIRM_URL}}\">Confirm</a></p>", CONFIRM, UNSUBSCRIBE).unwrap();
114
+ assert_eq!(body, "<p><a href=\"https://example.com/newsletter-confirmed.html?email=a%40b.c&amp;token=t\">Confirm</a></p>");
115
+ }
116
+
117
+ #[test]
118
+ fn a_template_without_the_confirm_placeholder_is_rejected() {
119
+ assert!(fill_template("<p>{{UNSUBSCRIBE_URL}}</p>", CONFIRM, UNSUBSCRIBE).is_err());
120
+ assert!(fill_template("", CONFIRM, UNSUBSCRIBE).is_err());
121
+ }
122
+
123
+ #[test]
124
+ fn the_unsubscribe_placeholder_is_optional_and_replaced_wherever_present() {
125
+ assert!(fill_template("<a href=\"{{CONFIRM_URL}}\">x</a>", CONFIRM, UNSUBSCRIBE).is_ok());
126
+ let body = fill_template("<a href=\"{{CONFIRM_URL}}\">x</a>{{UNSUBSCRIBE_URL}}|{{UNSUBSCRIBE_URL}}", CONFIRM, UNSUBSCRIBE).unwrap();
127
+ assert!(!body.contains("{{"));
128
+ assert_eq!(body.matches("newsletter-unsubscribed.html").count(), 2);
129
+ }
130
+
131
+ #[test]
132
+ fn ampersands_and_quotes_in_a_url_are_escaped_inside_the_html() {
133
+ let body = fill_template("<a href=\"{{CONFIRM_URL}}\">x</a>", "https://e.com/?a=1&b=\"2\"<>'", UNSUBSCRIBE).unwrap();
134
+ assert_eq!(body, "<a href=\"https://e.com/?a=1&amp;b=&quot;2&quot;&lt;&gt;&#39;\">x</a>");
135
+ }
136
+
137
+ #[test]
138
+ fn a_filled_template_starts_at_its_first_tag_so_the_mailer_sends_html() {
139
+ let body = fill_template("\n <p>{{CONFIRM_URL}}</p>", CONFIRM, UNSUBSCRIBE).unwrap();
140
+ assert!(body.starts_with('<'));
141
+ }
142
+
143
+ #[test]
144
+ fn without_a_template_the_plain_text_confirmation_is_unchanged() {
145
+ let body = confirmation_body(CONFIRM);
146
+ assert_eq!(body, format!("Please confirm your newsletter subscription by opening this link:\n\n{CONFIRM}\n\nIf you didn't ask for this, you can ignore this email and nothing will be sent to you.\n"));
147
+ }
@@ -14,6 +14,7 @@ use tokio::sync::Mutex;
14
14
  use tokio_postgres::Client;
15
15
 
16
16
  mod cooldown;
17
+ mod template;
17
18
  #[cfg(test)]
18
19
  mod tests;
19
20
 
@@ -194,8 +195,12 @@ pub(super) async fn send_confirmation(email: &str) {
194
195
  let site = site_url();
195
196
  let link = confirm_url(&site, email, &confirm_token(&secret, email));
196
197
  let unsubscribe = unsubscribe_url(&site, email, &unsubscribe_token(&secret, email));
198
+ let body = match template::branded_body(&link, &unsubscribe).await {
199
+ Some(html) => html,
200
+ None => confirmation_body(&link),
201
+ };
197
202
  let delivery = mailer
198
- .deliver(&Email { to: email, subject: "Confirm your newsletter subscription", body: &confirmation_body(&link), unsubscribe_url: Some(&unsubscribe) })
203
+ .deliver(&Email { to: email, subject: "Confirm your newsletter subscription", body: &body, unsubscribe_url: Some(&unsubscribe) })
199
204
  .await;
200
205
  if !delivery.ok {
201
206
  eprintln!("newsletter: the confirmation email was not delivered: {}", delivery.reason.unwrap_or_default());
@@ -132,7 +132,7 @@ pub(super) fn unsubscribe_url(site_url: &str, email: &str, token: &str) -> Strin
132
132
  url.to_string()
133
133
  }
134
134
 
135
- fn personalize(body: &str, unsubscribe_url: &str) -> String {
135
+ pub(super) fn personalize(body: &str, unsubscribe_url: &str) -> String {
136
136
  body.replace(UNSUBSCRIBE_TOKEN, unsubscribe_url)
137
137
  }
138
138
 
@@ -182,9 +182,9 @@ pub static KEYWORDS: &[KeywordRow] = &[
182
182
  KeywordRow { word: "answers_query", context: "DomainPort", body: "none", inner: "", opens: "", fills: "answered_queries", status: "admitted", was: "", resolves_via: "", disambiguator: "" },
183
183
  KeywordRow { word: "hecksagon", context: "File", body: "keywords", inner: "Hecksagon", opens: "Hecksagon", fills: "", status: "admitted", was: "", resolves_via: "", disambiguator: "" },
184
184
  KeywordRow { word: "subscribe", context: "Hecksagon", body: "none", inner: "", opens: "", fills: "subscriptions", status: "admitted", was: "", resolves_via: "", disambiguator: "" },
185
- KeywordRow { word: "uses_framework", context: "Hecksagon", body: "none", inner: "", opens: "", fills: "framework_members", status: "admitted", was: "", resolves_via: "", disambiguator: "" },
186
- KeywordRow { word: "uses_embryonaut_bluebook", context: "Hecksagon", body: "none", inner: "", opens: "", fills: "vendored_bluebooks", status: "admitted", was: "", resolves_via: "", disambiguator: "" },
187
- KeywordRow { word: "attaches", context: "Hecksagon", body: "none", inner: "", opens: "", fills: "attached_chapters", status: "admitted", was: "", resolves_via: "", disambiguator: "" },
185
+ KeywordRow { word: "attaches", context: "Hecksagon", body: "none", inner: "", opens: "", fills: "attachments", status: "admitted", was: "", resolves_via: "", disambiguator: "" },
186
+ KeywordRow { word: "uses_framework", context: "Hecksagon", body: "none", inner: "", opens: "", fills: "attachments", status: "deprecated", was: "", resolves_via: "", disambiguator: "" },
187
+ KeywordRow { word: "uses_embryonaut_bluebook", context: "Hecksagon", body: "none", inner: "", opens: "", fills: "attachments", status: "deprecated", was: "", resolves_via: "", disambiguator: "" },
188
188
  KeywordRow { word: "port", context: "Hecksagon", body: "keywords", inner: "DomainPort", opens: "DomainPort", fills: "", status: "admitted", was: "", resolves_via: "", disambiguator: "" },
189
189
  KeywordRow { word: "translates", context: "Hecksagon", body: "keywords", inner: "Policy", opens: "Policy", fills: "", status: "admitted", was: "", resolves_via: "", disambiguator: "" },
190
190
  KeywordRow { word: "bounded", context: "Hecksagon", body: "none", inner: "", opens: "", fills: "", status: "admitted", was: "", resolves_via: "", disambiguator: "" },
@@ -466,9 +466,10 @@ pub static ARGUMENTS: &[ArgumentRow] = &[
466
466
  ArgumentRow { keyword: "asks", context: "DomainPort", at: "", named: "to", kind: "constant", required: "false", fills: "to", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
467
467
  ArgumentRow { keyword: "hecksagon", context: "File", at: "1", named: "", kind: "text", required: "true", fills: "domain", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
468
468
  ArgumentRow { keyword: "subscribe", context: "Hecksagon", at: "1", named: "", kind: "text", required: "true", fills: "subscriptions", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
469
- ArgumentRow { keyword: "uses_framework", context: "Hecksagon", at: "1", named: "", kind: "text", required: "true", fills: "framework_members", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
470
- ArgumentRow { keyword: "uses_embryonaut_bluebook", context: "Hecksagon", at: "1", named: "", kind: "text", required: "true", fills: "vendored_bluebooks", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
471
- ArgumentRow { keyword: "attaches", context: "Hecksagon", at: "1", named: "", kind: "text", required: "true", fills: "attached_chapters", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
469
+ ArgumentRow { keyword: "attaches", context: "Hecksagon", at: "1", named: "", kind: "text", required: "true", fills: "attachments", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
470
+ ArgumentRow { keyword: "attaches", context: "Hecksagon", at: "", named: "from", kind: "symbol", required: "false", fills: "source", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
471
+ ArgumentRow { keyword: "uses_framework", context: "Hecksagon", at: "1", named: "", kind: "text", required: "true", fills: "attachments", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
472
+ ArgumentRow { keyword: "uses_embryonaut_bluebook", context: "Hecksagon", at: "1", named: "", kind: "text", required: "true", fills: "attachments", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
472
473
  ArgumentRow { keyword: "port", context: "Hecksagon", at: "1", named: "", kind: "text", required: "true", fills: "name", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
473
474
  ArgumentRow { keyword: "translates", context: "Hecksagon", at: "1", named: "", kind: "text", required: "true", fills: "name", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
474
475
  ArgumentRow { keyword: "reference_to", context: "PortOperation", at: "1", named: "", kind: "constant", required: "true", fills: "type", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
@@ -91,7 +91,8 @@ fn run_chapter(args: &[String]) -> Result<(), RunError> {
91
91
  }
92
92
 
93
93
  /// `hecks-parse resolve --chapter <Name> <file.hecksagon>`: prints that chapter's
94
- /// `uses_framework` and `uses_embryonaut_bluebook` names as JSON, in file order.
94
+ /// `attaches` names as JSON, in file order: `gem_chapters` for a chapter the gem carries and
95
+ /// `vendored_packages` for `from: :vendor`.
95
96
  ///
96
97
  /// `--chapter` is required because one file can hold several `Hecks.hecksagon` blocks.
97
98
  fn run_resolve(args: &[String]) -> Result<(), RunError> {
@@ -120,7 +121,7 @@ fn run_resolve(args: &[String]) -> Result<(), RunError> {
120
121
  let value = emit::JsonValue::Object(vec![
121
122
  ("domain".to_string(), emit::JsonValue::String(chapter_name)),
122
123
  (
123
- "uses_framework".to_string(),
124
+ "gem_chapters".to_string(),
124
125
  emit::JsonValue::Array(
125
126
  framework_names
126
127
  .into_iter()
@@ -129,7 +130,7 @@ fn run_resolve(args: &[String]) -> Result<(), RunError> {
129
130
  ),
130
131
  ),
131
132
  (
132
- "uses_embryonaut_bluebook".to_string(),
133
+ "vendored_packages".to_string(),
133
134
  emit::JsonValue::Array(
134
135
  vendored_names
135
136
  .into_iter()
@@ -306,7 +306,7 @@ pub fn parse_chapter(chapter_name: &str, files: &[(String, String)]) -> ParseRes
306
306
  Ok(bluebook)
307
307
  }
308
308
 
309
- /// Lists the chapters `<Name>`'s block pulls in via `uses_framework`/`uses_embryonaut_bluebook`.
309
+ /// Lists the chapters `<Name>`'s block pulls in via `attaches` (or its deprecated spellings).
310
310
  /// Every block is still gated; the IR accumulator is a throwaway.
311
311
  pub fn resolve_hecksagon_dependencies(
312
312
  chapter_name: &str,