hecks 3.0.5 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +24 -12
  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 +32 -3
  10. data/lib/hecks/cli/check_era.rb +1 -1
  11. data/lib/hecks/cli/deploy_template_diff.rb +1 -1
  12. data/lib/hecks/cli/doc_coverage.rb +1 -1
  13. data/lib/hecks/cli/domain_stub.rb +128 -0
  14. data/lib/hecks/cli/domain_writer.rb +39 -0
  15. data/lib/hecks/cli/interview_agent.rb +59 -0
  16. data/lib/hecks/cli/interview_draft.rb +246 -0
  17. data/lib/hecks/cli/interview_run.rb +87 -0
  18. data/lib/hecks/cli/interview_session.rb +299 -0
  19. data/lib/hecks/cli/model_check.rb +1 -1
  20. data/lib/hecks/cli/project_cli.rb +51 -29
  21. data/lib/hecks/cli/reference.rb +2 -1
  22. data/lib/hecks/cli/refresh_rspec_runtime_baseline.rb +1 -1
  23. data/lib/hecks/cli/seed_semantics_corpus.rb +3 -3
  24. data/lib/hecks/cli/spec_example.rb +1 -1
  25. data/lib/hecks/cli/stress_concurrency_specs.rb +14 -5
  26. data/lib/hecks/corpus.rb +12 -8
  27. data/lib/hecks/deploy/bluebook/deploy.bluebook +85 -12
  28. data/lib/hecks/deploy/oidc.json +5 -0
  29. data/lib/hecks/doc/reference.rb +4 -3
  30. data/lib/hecks/doors/cli_door.rb +13 -13
  31. data/lib/hecks/doors/cli_runner.rb +53 -33
  32. data/lib/hecks/doors/launcher_options.rb +5 -5
  33. data/lib/hecks/doors/mcp_door.rb +40 -18
  34. data/lib/hecks/doors/mcp_door_scope.rb +244 -24
  35. data/lib/hecks/doors/usage_cache.rb +123 -0
  36. data/lib/hecks/doors.rb +1 -1
  37. data/lib/hecks/embryonaut_bluebook.rb +2 -2
  38. data/lib/hecks/forms/banking_presentation.rb +1 -1
  39. data/lib/hecks/framework/bluebook/compliance.bluebook +1 -1
  40. data/lib/hecks/framework/bluebook/console_settings.bluebook +1 -1
  41. data/lib/hecks/framework/bluebook/framework.hecksagon +4 -4
  42. data/lib/hecks/framework.rb +1 -1
  43. data/lib/hecks/fuzzing/combination_miner.rb +1 -1
  44. data/lib/hecks/fuzzing/concurrent_dispatch.rb +1 -1
  45. data/lib/hecks/fuzzing/differential.rb +4 -2
  46. data/lib/hecks/fuzzing/era_boundary.rb +1 -1
  47. data/lib/hecks/fuzzing/form_census.rb +2 -2
  48. data/lib/hecks/fuzzing/generated_domain_check.rb +1 -1
  49. data/lib/hecks/fuzzing/isolated_boot.rb +10 -3
  50. data/lib/hecks/fuzzing/persistence_parity.rb +2 -2
  51. data/lib/hecks/fuzzing/target_capabilities.rb +3 -3
  52. data/lib/hecks/gate/stages.yml +5 -5
  53. data/lib/hecks/grammar/grammar.hecksagon +2 -2
  54. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +4 -4
  55. data/lib/hecks/hecks/adapters/finding_github.adapter +7 -0
  56. data/lib/hecks/hecks/adapters/finding_github.rb +114 -0
  57. data/lib/hecks/hecks/adapters/host_http.rb +4 -3
  58. data/lib/hecks/hecks/adapters/local_files.rb +59 -0
  59. data/lib/hecks/hecks/adapters/site_toolchain.adapter +7 -0
  60. data/lib/hecks/hecks/adapters/site_toolchain.rb +54 -0
  61. data/lib/hecks/hecks/adapters/terminal.rb +15 -0
  62. data/lib/hecks/hecks/codebase.bluebook +4 -3
  63. data/lib/hecks/hecks/context_map.hecksagon +2 -2
  64. data/lib/hecks/hecks/custodian.bluebook +81 -3
  65. data/lib/hecks/hecks/hecks.bluebook +18 -0
  66. data/lib/hecks/hecks/hecks.hecksagon +16 -4
  67. data/lib/hecks/hecks/hecks.world +7 -6
  68. data/lib/hecks/hecks/site.behaviors +17 -0
  69. data/lib/hecks/hecks/site.world +10 -0
  70. data/lib/hecks/language/bluebook/bluebook.hecksagon +1 -1
  71. data/lib/hecks/language/hecksagon/hecksagon.bluebook +24 -47
  72. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +4 -4
  73. data/lib/hecks/projections/deploy/box/settings.rb +324 -0
  74. data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +268 -0
  75. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +68 -0
  76. data/lib/hecks/projections/deploy/box/templates/fetch-secrets.sh +21 -0
  77. data/lib/hecks/projections/deploy/box/templates/rds.yaml.tmpl +181 -0
  78. data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +49 -0
  79. data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +43 -0
  80. data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +91 -0
  81. data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +58 -0
  82. data/lib/hecks/projections/deploy/box.rb +553 -0
  83. data/lib/hecks/projections/deploy/fargate/cdn.rb +13 -1
  84. data/lib/hecks/projections/deploy/fargate.rb +1 -1
  85. data/lib/hecks/projections/deploy/lambda.rb +11 -10
  86. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +1 -1
  87. data/lib/hecks/projections/diagrams.rb +2 -2
  88. data/lib/hecks/projections/site/edge/behaviours.rb +107 -0
  89. data/lib/hecks/projections/site/edge/checks.rb +137 -0
  90. data/lib/hecks/projections/site/edge/pattern.rb +50 -0
  91. data/lib/hecks/projections/site/edge/rules.rb +128 -0
  92. data/lib/hecks/projections/site/edge.rb +136 -0
  93. data/lib/hecks/projections/site/regions.rb +44 -0
  94. data/lib/hecks/projections/site/routes_ts.rb +284 -0
  95. data/lib/hecks/projections/site/site_cdn.rb +78 -0
  96. data/lib/hecks/projections/site/table/checks.rb +124 -0
  97. data/lib/hecks/projections/site/table/row_builder.rb +155 -0
  98. data/lib/hecks/projections/site/table.rb +98 -0
  99. data/lib/hecks/projections.rb +2 -0
  100. data/lib/hecks/projector/cli_projector.rb +153 -111
  101. data/lib/hecks/quality_control/adapters/agent.adapter +1 -1
  102. data/lib/hecks/quality_control/adapters/agent.rb +2 -2
  103. data/lib/hecks/quality_control/cli/qa_concurrency_racer.rb +3 -3
  104. data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +8 -8
  105. data/lib/hecks/quality_control/cli/qa_domain_novelty.rb +3 -3
  106. data/lib/hecks/quality_control/cli/qa_generated_domains.rb +4 -4
  107. data/lib/hecks/quality_control/cli/qa_log_bug.rb +2 -2
  108. data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +6 -5
  109. data/lib/hecks/quality_control/cli/qa_postgres_migrate.rb +2 -2
  110. data/lib/hecks/quality_control/cli/qa_postgres_role.rb +5 -5
  111. data/lib/hecks/quality_control/cli/qa_pr_check.rb +3 -3
  112. data/lib/hecks/quality_control/cli/qa_sweep/all_mode.rb +3 -3
  113. data/lib/hecks/quality_control/cli/qa_sweep/checks.rb +2 -2
  114. data/lib/hecks/quality_control/cli/qa_sweep.rb +10 -9
  115. data/lib/hecks/quality_control/cli/qa_tick.rb +3 -3
  116. data/lib/hecks/quality_control/quality_control.bluebook +18 -18
  117. data/lib/hecks/quality_control/quality_control.hecksagon +1 -1
  118. data/lib/hecks/runtime/loader.rb +31 -8
  119. data/lib/hecks/runtime/registry/verification.rb +15 -29
  120. data/lib/hecks/runtime/registry.rb +14 -20
  121. data/lib/hecks/rust_build/append_optionals.rb +79 -0
  122. data/lib/hecks/rust_build/conformance_fuzz.rb +2 -1
  123. data/lib/hecks/rust_build/domain_name.rb +26 -0
  124. data/lib/hecks/rust_build/kernel_input.rb +58 -0
  125. data/lib/hecks/rust_build/project_rust/codegen_run.rb +143 -0
  126. data/lib/hecks/rust_build/project_rust/root_mod.rb +1 -1
  127. data/lib/hecks/rust_build/project_rust.rb +57 -24
  128. data/lib/hecks/rust_build/rust_literal.rb +33 -0
  129. data/lib/hecks/rust_build/write_if_changed.rb +86 -0
  130. data/lib/hecks/site/bluebook/site.bluebook +205 -0
  131. data/lib/hecks/site/bluebook/site.hecksagon +5 -0
  132. data/lib/hecks/site/bluebook/site.ports.hecksagon +15 -0
  133. data/lib/hecks/sme/bluebook/sme.bluebook +336 -0
  134. data/lib/hecks/sme/bluebook/sme.hecksagon +5 -0
  135. data/lib/hecks/storehouse.rb +37 -7
  136. data/lib/hecks/tenancy/bluebook/tenancy.bluebook +1 -1
  137. data/lib/hecks/three_zero/forms.yml +88 -88
  138. data/lib/hecks/tickets/bluebook/tickets.bluebook +331 -0
  139. data/lib/hecks/tickets/bluebook/tickets.hecksagon +11 -0
  140. data/lib/hecks/tickets/bluebook/tickets.ports.hecksagon +26 -0
  141. data/lib/hecks/tools/ci_gates.rb +1 -1
  142. data/lib/hecks/tools/deploy_recipe.rb +14 -6
  143. data/lib/hecks/tools/oidc_manifests.rb +2 -1
  144. data/lib/hecks/tools/sequence_script.rb +2 -2
  145. data/lib/hecks/tools/site_routes.rb +152 -0
  146. data/lib/hecks/tools/tenant_provisioning.rb +1 -1
  147. data/lib/hecks/tools.rb +1 -0
  148. data/lib/hecks/version.rb +1 -1
  149. data/lib/hecks.rb +1 -0
  150. data/rust/build/src/cargo_sync.rs +1 -1
  151. data/rust/build/src/pipeline.rs +23 -16
  152. data/rust/build/src/resolve.rs +39 -6
  153. data/rust/codegen/src/json_codec.rs +3 -0
  154. data/rust/codegen/src/naming.rs +4 -0
  155. data/rust/codegen/src/types.rs +45 -0
  156. data/rust/host/HECKS_RELEASE +1 -1
  157. data/rust/host/src/api.rs +1 -1
  158. data/rust/host/src/dispatch.rs +87 -7
  159. data/rust/host/src/needs.rs +111 -0
  160. data/rust/parser/src/keywords.rs +7 -6
  161. data/rust/parser/src/main.rs +4 -3
  162. data/rust/parser/src/parse/chapter.rs +1 -1
  163. data/rust/parser/src/parse/hecksagon.rs +25 -10
  164. data/rust/project/mutations.rb +4 -30
  165. data/rust/project/naming.rb +8 -28
  166. data/rust/project/write_if_changed.rb +4 -80
  167. data/rust/project_rust_pipeline.rb +16 -12
  168. data/rust/src/kernel/cli.rs +2 -0
  169. data/rust/src/kernel/needs.rs +74 -1
  170. metadata +51 -2
@@ -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
  }
@@ -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,
@@ -1,4 +1,4 @@
1
- //! The `Hecksagon` construct: a `.hecksagon` file's `port` blocks and `uses_framework` words.
1
+ //! The `Hecksagon` construct: a `.hecksagon` file's `port` blocks and `attaches` words.
2
2
  //! Adapter binds (`persisted_by`, `projected_by`, `subscribe`) are shape-matched and dropped.
3
3
 
4
4
  use super::domain_port;
@@ -14,8 +14,10 @@ pub fn not_implemented(file: &str, line: usize, word: &str) -> Diagnostic {
14
14
  /// Applies a `.hecksagon` body onto an already-built `ir::Bluebook`, attaching
15
15
  /// aggregate-scoped ports to the aggregates the sibling `.bluebook` registered.
16
16
  ///
17
- /// `uses_framework_names` and `vendored_bluebook_names` collect each argument in file order
18
- /// for `hecks-parse resolve`; `ir.json` itself carries neither.
17
+ /// `gem_chapter_names` collects each `attaches "Name"` (and the deprecated `uses_framework`) and
18
+ /// `vendored_bluebook_names` each `attaches "name", from: :vendor` (and the deprecated
19
+ /// `uses_embryonaut_bluebook`), in file order, for `hecks-parse resolve`; `ir.json` itself
20
+ /// carries neither.
19
21
  ///
20
22
  /// `require_matching_aggregate` is `false` for resolve, which never sees a `.bluebook`, so an
21
23
  /// aggregate-scoped `port` skips the attach step instead of failing the lookup.
@@ -24,7 +26,7 @@ pub fn apply(
24
26
  lines: &[SourceLine],
25
27
  pos: &mut usize,
26
28
  bluebook: &mut ir::Bluebook,
27
- uses_framework_names: &mut Vec<String>,
29
+ gem_chapter_names: &mut Vec<String>,
28
30
  vendored_bluebook_names: &mut Vec<String>,
29
31
  require_matching_aggregate: bool,
30
32
  ) -> ParseResult<()> {
@@ -96,9 +98,9 @@ pub fn apply(
96
98
  let built = policy::parse_body(file, lines, pos, &name)?;
97
99
  bluebook.policies.push(built);
98
100
  }
99
- // Gated, then collected; `ir.json` carries no `uses_framework` key.
101
+ // Deprecated spelling of `attaches "Name"`; gated, then collected.
100
102
  "uses_framework" => {
101
- uses_framework_names.push(super::positional_text(
103
+ gem_chapter_names.push(super::positional_text(
102
104
  file,
103
105
  gated.line.number,
104
106
  "uses_framework",
@@ -106,7 +108,7 @@ pub fn apply(
106
108
  1,
107
109
  )?);
108
110
  }
109
- // Collected like `uses_framework`; a binding fact, absent from `ir.json`.
111
+ // Deprecated spelling of `attaches "name", from: :vendor`; collected the same way.
110
112
  "uses_embryonaut_bluebook" => {
111
113
  vendored_bluebook_names.push(super::positional_text(
112
114
  file,
@@ -117,10 +119,23 @@ pub fn apply(
117
119
  )?);
118
120
  }
119
121
  "subscribe" => {}
120
- // A chapter the gem carries, attached by name (ADR 0080); only the Ruby runtime
121
- // loads it, so its one argument is checked and then dropped like `subscribe`.
122
+ // One word for a chapter the gem carries and for a vendored package; a binding
123
+ // fact collected for `resolve`, absent from `ir.json`. Only `from: :vendor`
124
+ // is admitted as a source, and anything else is refused.
122
125
  "attaches" => {
123
- super::positional_text(file, gated.line.number, "attaches", &gated.args, 1)?;
126
+ let name =
127
+ super::positional_text(file, gated.line.number, "attaches", &gated.args, 1)?;
128
+ match super::named_symbol(&gated.args, "from").as_deref() {
129
+ None => gem_chapter_names.push(name),
130
+ Some("vendor") => vendored_bluebook_names.push(name),
131
+ Some(other) => {
132
+ return Err(Diagnostic::new(
133
+ file,
134
+ gated.line.number,
135
+ format!("'attaches' from: takes only :vendor, got :{other}"),
136
+ ))
137
+ }
138
+ }
124
139
  }
125
140
  // Consumer-owned bounded-context mark; a wiring fact, dropped like `subscribe`.
126
141
  "bounded" => {}
@@ -1,3 +1,5 @@
1
+ require "hecks/rust_build/append_optionals"
2
+
1
3
  module RustProjection
2
4
  module Projector
3
5
  module_function
@@ -60,13 +62,7 @@ module RustProjection
60
62
 
61
63
  # Resolves an `append` target to its entity or value object, or nil if it is neither.
62
64
  def append_element(aggregate, target_type, value_objects_by_name)
63
- # LOCAL FIRST — an aggregate's own nested entity is scoped to that
64
- # Local entities win over the domain-wide value objects: Syntax's local `Argument` entity
65
- # shares a name with Command's `Argument` value object.
66
- local = aggregate[:entities].find { |e| e[:name] == target_type }
67
- return local if local
68
-
69
- value_objects_by_name[target_type]
65
+ Hecks::RustBuild::AppendOptionals.element(aggregate, target_type, value_objects_by_name)
70
66
  end
71
67
 
72
68
  # Identity attribute and its single-field value object for an auto-minted entity element,
@@ -454,29 +450,7 @@ module RustProjection
454
450
  # attribute
455
451
  # hash once, before the emitters read it, and never resets a `true`.
456
452
  def mark_append_optional_fields!(aggregate, value_objects_by_name)
457
- aggregate[:attributes].each do |target_attr|
458
- element = append_element(aggregate, target_attr[:type], value_objects_by_name)
459
- next unless element
460
-
461
- aggregate[:commands].each do |command|
462
- command[:mutations].each do |m|
463
- next unless m[:op].to_s == "append" && m[:target].to_s == target_attr[:name].to_s
464
-
465
- m[:fields].each do |field_name, source|
466
- # A Symbol is an argument name (optional ones are what this pass wants); else a
467
- # literal.
468
- parsed = append_field_source(source)
469
- next unless parsed.is_a?(Symbol)
470
-
471
- source_attr = command[:attributes].find { |a| a[:name].to_s == parsed.to_s }
472
- next unless source_attr && source_attr[:optional]
473
-
474
- field_attr = element[:attributes].find { |a| a[:name].to_s == field_name.to_s }
475
- field_attr[:optional] = true if field_attr
476
- end
477
- end
478
- end
479
- end
453
+ Hecks::RustBuild::AppendOptionals.mark_aggregate(aggregate, value_objects_by_name)
480
454
  end
481
455
 
482
456
  # Derives the mutations of a `corrects EVENT, reverses: true` command from the commands that
@@ -1,5 +1,7 @@
1
1
  require "hecks/vocabulary"
2
2
  require "hecks/bluebook/model_check"
3
+ require "hecks/rust_build/domain_name"
4
+ require "hecks/rust_build/rust_literal"
3
5
 
4
6
  module RustProjection
5
7
  # Domain-to-Rust codegen support: type mapping, reserved-name checks, and
@@ -59,15 +61,10 @@ module RustProjection
59
61
  # feature set.
60
62
  #
61
63
  # Declared once as the `CargoReservedName` vocabulary.
62
- CARGO_RESERVED_DOMAIN_NAMES = Hecks::Vocabulary.fetch("CargoReservedName")
63
-
64
- # Checks identifier shape locally; delegates the reserved-word half to
65
- # `ModelCheck.rust_reserved_name_findings`, the one shared check.
66
- def valid_domain_mod_name?(name)
67
- str = name.to_s
68
- str.match?(/\A[a-z_][a-z0-9_]*\z/) &&
69
- Hecks::Bluebook::ModelCheck.rust_reserved_name_findings(domain_name: str).empty?
70
- end
64
+ CARGO_RESERVED_DOMAIN_NAMES = Hecks::RustBuild::DomainName::CARGO_RESERVED
65
+
66
+ # Moved to `Hecks::RustBuild::DomainName`, which the codegen-backed `hecks project_rust` uses.
67
+ def valid_domain_mod_name?(name) = Hecks::RustBuild::DomainName.valid?(name)
71
68
 
72
69
  # Identifier-shape check only (a PascalCase name always passes); whether
73
70
  # the downcased name is a Rust keyword is `reserved_name_refusal`'s job,
@@ -220,24 +217,7 @@ module RustProjection
220
217
  end
221
218
  end
222
219
 
223
- # Ruby's String#inspect escapes for Ruby's own read-back (e.g. a
224
- # brace-less `\uXXXX`), which isn't valid Rust syntax. This escapes
225
- # only what Rust's string-literal grammar needs: backslash,
226
- # double-quote, and control characters.
227
- def rust_string_literal(str)
228
- escaped = str.to_s.each_char.map do |ch|
229
- case ch
230
- when "\\" then "\\\\"
231
- when "\"" then "\\\""
232
- when "\n" then "\\n"
233
- when "\r" then "\\r"
234
- when "\t" then "\\t"
235
- else
236
- cp = ch.ord
237
- cp < 0x20 || cp == 0x7F ? format("\\u{%x}", cp) : ch
238
- end
239
- end.join
240
- "\"#{escaped}\""
241
- end
220
+ # Moved to `Hecks::RustBuild::RustLiteral`, which the codegen-backed `hecks project_rust` uses.
221
+ def rust_string_literal(str) = Hecks::RustBuild::RustLiteral.string(str)
242
222
  end
243
223
  end
@@ -1,83 +1,7 @@
1
- require "fileutils"
2
- require "set"
1
+ require "hecks/rust_build/write_if_changed"
3
2
 
4
- # Skips writes whose content is unchanged so file mtimes stay stable.
5
- # A rewrite alone forces Cargo to rebuild downstream crates.
6
3
  module RustProjection
7
- module WriteIfChanged
8
- module_function
9
-
10
- # Records every path written or confirmed unchanged inside the block, then deletes
11
- # files left in `dir` that were never touched (orphans of removed aggregates).
12
- # Every write is recorded against every open ancestor directory.
13
- # `push_directory`/`pop_and_prune` are the same operation split for non-adjacent writes.
14
- def track_directory(dir)
15
- push_directory(dir)
16
- yield
17
- ensure
18
- pop_and_prune(dir)
19
- end
20
-
21
- def push_directory(dir)
22
- FileUtils.mkdir_p(dir)
23
- stack.push([File.expand_path(dir), Set.new])
24
- end
25
-
26
- # Matches by directory, not LIFO: nested directories can close out of order.
27
- def pop_and_prune(dir)
28
- abs_dir = File.expand_path(dir)
29
- idx = stack.rindex { |tracked_dir, _| tracked_dir == abs_dir }
30
- return unless idx
31
-
32
- _dir, touched = stack.delete_at(idx)
33
- existing = Dir.glob(File.join(abs_dir, "*"))
34
- (existing - touched.to_a).each do |orphan|
35
- FileUtils.rm_rf(orphan)
36
- puts "pruned #{orphan} (no longer generated)"
37
- end
38
- end
39
-
40
- def stack
41
- Thread.current[:hecks_write_if_changed_stack] ||= []
42
- end
43
- private_class_method :stack
44
-
45
- def record_touch(path)
46
- abs = File.expand_path(path)
47
- stack.each do |dir, touched|
48
- touched << abs if abs.start_with?("#{dir}/")
49
- end
50
- end
51
- private_class_method :record_touch
52
-
53
- # Writes `content` to `path` unless it already holds identical bytes.
54
- # Returns whether it wrote.
55
- def call(path, content)
56
- record_touch(path)
57
- # `.b` on both sides: String#== is encoding-aware, so UTF-8 vs ASCII-8BIT
58
- # content can compare unequal even when the bytes are identical.
59
- return false if File.exist?(path) && File.binread(path) == content.b
60
-
61
- File.write(path, content)
62
- true
63
- end
64
-
65
- # Like `call`, for callers that build content by writing to an IO. Yields a sibling
66
- # temp file, then renames it into place only if the bytes differ. Returns whether it wrote.
67
- def block(path)
68
- record_touch(path)
69
- tmp = "#{path}.tmp-#{Process.pid}-#{rand(1_000_000)}"
70
- begin
71
- File.open(tmp, "w") { |f| yield f }
72
- if File.exist?(path) && FileUtils.compare_file(tmp, path)
73
- false
74
- else
75
- FileUtils.mv(tmp, path)
76
- true
77
- end
78
- ensure
79
- File.delete(tmp) if File.exist?(tmp)
80
- end
81
- end
82
- end
4
+ # The write-if-unchanged helper moved to `Hecks::RustBuild::WriteIfChanged`, which the
5
+ # codegen-backed `hecks project_rust` also uses; this name stays until this generator is deleted.
6
+ WriteIfChanged = Hecks::RustBuild::WriteIfChanged
83
7
  end