hecks 3.13.0 → 3.14.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/examples/pizzas/bluebook/pizzas.bluebook +4 -4
  3. data/lib/hecks/bluebook/dsl/attribute_collector.rb +15 -5
  4. data/lib/hecks/bluebook/named_pattern.rb +33 -0
  5. data/lib/hecks/bluebook.rb +1 -0
  6. data/lib/hecks/cli/domain_stub.rb +1 -1
  7. data/lib/hecks/cli/interview_aggregate.rb +4 -4
  8. data/lib/hecks/deploy/bluebook/deploy.bluebook +34 -34
  9. data/lib/hecks/framework/bluebook/change.bluebook +2 -2
  10. data/lib/hecks/fuzzing/domain_generator/block_rendering.rb +1 -1
  11. data/lib/hecks/hecks/codebase.bluebook +37 -37
  12. data/lib/hecks/hecks/custodian.bluebook +14 -14
  13. data/lib/hecks/hecks/hecks.bluebook +2 -2
  14. data/lib/hecks/hecks/rehearsal.bluebook +3 -3
  15. data/lib/hecks/hecks/tooling.bluebook +22 -22
  16. data/lib/hecks/language/bluebook/aggregate.bluebook +1 -0
  17. data/lib/hecks/language/bluebook/command.bluebook +1 -0
  18. data/lib/hecks/language/bluebook/entity.bluebook +1 -0
  19. data/lib/hecks/language/bluebook/query.bluebook +1 -0
  20. data/lib/hecks/language/bluebook/shape.bluebook +8 -7
  21. data/lib/hecks/language/hecksagon/port_operation.bluebook +1 -0
  22. data/lib/hecks/quality_control/quality_control.bluebook +51 -51
  23. data/lib/hecks/site/bluebook/site.bluebook +5 -5
  24. data/lib/hecks/sme/bluebook/sme.bluebook +12 -12
  25. data/lib/hecks/tickets/bluebook/tickets.bluebook +3 -3
  26. data/lib/hecks/version.rb +1 -1
  27. data/prebuilt/{2b59737fbdbd5f44edda0fddce2611f45d76087b3a7c782d1f1a17ea58c77948.marshal → 1a6f596be124228be93eba1d86dbc9d2933190fa389b284331241cb9f27115a0.marshal} +0 -0
  28. data/prebuilt/{58208399bada10b0db99928c48e38789288fc58f7b71a72b21bfa386680e32ed.marshal → a883a7e568667aa5ac904c82daff356bdf9e996252a36af6def4a4a43efb967e.marshal} +0 -0
  29. data/prebuilt/verdicts-872993b1aa26d73fe48f28251374b8585fed145d02aab42be0f00de386b6ff80.json +1 -0
  30. data/rust/host/HECKS_RELEASE +1 -1
  31. data/rust/host/src/payments/roles.rs +159 -0
  32. data/rust/host/src/payments/tests.rs +25 -0
  33. data/rust/host/src/payments.rs +20 -11
  34. data/rust/parser/src/build/mod.rs +1 -0
  35. data/rust/parser/src/build/named_pattern.rs +23 -0
  36. data/rust/parser/src/keywords.rs +6 -0
  37. data/rust/parser/src/parse/mod.rs +22 -1
  38. metadata +7 -4
  39. data/prebuilt/verdicts-48318ca172edd21443b1ad02801c697fff100b746e828f09db987647641a5df2.json +0 -1
@@ -1 +1 @@
1
- 3.13.0
1
+ 3.14.0
@@ -0,0 +1,159 @@
1
+ //! Which signed-in roles may manage a site's payments: a per-site setting, read once at boot.
2
+ //!
3
+ //! `HECKS_PAYMENTS_MANAGER_ROLES` is a comma-separated list of exact role names. Unset means
4
+ //! `Owner`, which is what the host did before the setting existed. A set value must be a
5
+ //! plausible list or the boot is refused; a refusal names the variable and the kind of mistake,
6
+ //! never the value. A caller manages payments when their role is in the list, compared exactly:
7
+ //! no case folding, no hierarchy, no wildcard.
8
+
9
+ use std::sync::OnceLock;
10
+
11
+ /// The variable that names the roles allowed to manage payments.
12
+ pub const VARIABLE: &str = "HECKS_PAYMENTS_MANAGER_ROLES";
13
+ /// The role that manages payments when the variable is unset.
14
+ pub const DEFAULT_ROLE: &str = "Owner";
15
+ /// The most roles one list may name.
16
+ pub const MAX_ENTRIES: usize = 8;
17
+ /// The longest role name, after trimming.
18
+ pub const MAX_ENTRY_LENGTH: usize = 32;
19
+
20
+ /// The roles that manage payments when nothing is configured.
21
+ pub fn default_roles() -> Vec<String> {
22
+ vec![DEFAULT_ROLE.to_string()]
23
+ }
24
+
25
+ /// Parses the variable's value; `None` (unset) gives the default list.
26
+ pub fn parse(raw: Option<&str>) -> Result<Vec<String>, String> {
27
+ let Some(raw) = raw else { return Ok(default_roles()) };
28
+ let refuse = |why: &str| Err(format!("{VARIABLE} is invalid: {why}"));
29
+ let entries: Vec<&str> = raw.split(',').map(str::trim).collect();
30
+ if entries.len() > MAX_ENTRIES {
31
+ return refuse(&format!("it names more than {MAX_ENTRIES} roles"));
32
+ }
33
+ for (index, entry) in entries.iter().enumerate() {
34
+ let position = index + 1;
35
+ if entry.is_empty() {
36
+ return refuse(&format!("entry {position} is empty; name at least one role and no blank entries"));
37
+ }
38
+ if entry.len() > MAX_ENTRY_LENGTH {
39
+ return refuse(&format!("entry {position} is longer than {MAX_ENTRY_LENGTH} characters"));
40
+ }
41
+ let starts_well = entry.starts_with(|c: char| c.is_ascii_alphanumeric());
42
+ if !starts_well || !entry.chars().all(|c| c.is_ascii_alphanumeric() || c == ' ' || c == '-') {
43
+ return refuse(&format!("entry {position} is not a role name: letters, digits, spaces and hyphens only, starting with a letter or digit"));
44
+ }
45
+ }
46
+ Ok(entries.into_iter().map(String::from).collect())
47
+ }
48
+
49
+ /// Reads the setting into `cell` the first time and never again, whatever the environment
50
+ /// does afterwards. `read` stands for the environment lookup.
51
+ pub fn read_once(cell: &OnceLock<Result<Vec<String>, String>>, read: impl FnOnce() -> Option<String>) -> &Result<Vec<String>, String> {
52
+ cell.get_or_init(|| parse(read().as_deref()))
53
+ }
54
+
55
+ /// This process's setting, read from the environment on first use (the boot check is the first).
56
+ pub fn configured() -> &'static Result<Vec<String>, String> {
57
+ static CELL: OnceLock<Result<Vec<String>, String>> = OnceLock::new();
58
+ read_once(&CELL, || std::env::var(VARIABLE).ok())
59
+ }
60
+
61
+ /// Refuses the boot when this process's setting is invalid.
62
+ pub fn check_boot() -> Result<(), String> {
63
+ configured().as_ref().map(|_| ()).map_err(Clone::clone)
64
+ }
65
+
66
+ /// The roles for a request. An invalid setting never gets here (the boot was refused); should it
67
+ /// ever, only the default role manages, so a mistake narrows access instead of widening it.
68
+ pub fn manager_roles() -> Vec<String> {
69
+ configured().clone().unwrap_or_else(|_| default_roles())
70
+ }
71
+
72
+ /// Whether `role` is one of `roles`, by exact string match.
73
+ pub fn manages(roles: &[String], role: &str) -> bool {
74
+ roles.iter().any(|allowed| allowed == role)
75
+ }
76
+
77
+ #[cfg(test)]
78
+ mod tests {
79
+ use super::*;
80
+
81
+ fn roles(raw: Option<&str>) -> Vec<String> {
82
+ parse(raw).unwrap()
83
+ }
84
+
85
+ #[test]
86
+ fn unset_means_owner_only() {
87
+ let list = roles(None);
88
+ assert_eq!(list, ["Owner"]);
89
+ assert!(manages(&list, "Owner"));
90
+ assert!(!manages(&list, "Admin"));
91
+ assert!(!manages(&list, "Member"));
92
+ }
93
+
94
+ #[test]
95
+ fn a_configured_list_admits_exactly_its_roles() {
96
+ let list = roles(Some("Owner,Admin"));
97
+ assert!(manages(&list, "Owner") && manages(&list, "Admin"));
98
+ assert!(!manages(&list, "Editor"));
99
+ assert!(!manages(&list, ""));
100
+ }
101
+
102
+ #[test]
103
+ fn whitespace_is_trimmed_and_case_is_exact() {
104
+ let list = roles(Some(" Owner , Account Admin "));
105
+ assert_eq!(list, ["Owner", "Account Admin"]);
106
+ let lower = roles(Some("admin"));
107
+ assert!(manages(&lower, "admin"));
108
+ assert!(!manages(&lower, "Admin"), "no case folding");
109
+ assert!(!manages(&roles(Some("Admin")), "admin"));
110
+ }
111
+
112
+ #[test]
113
+ fn no_wildcard_and_no_hierarchy() {
114
+ assert!(parse(Some("*")).is_err());
115
+ assert!(!manages(&roles(Some("Admin")), "Owner"), "naming Admin does not admit Owner");
116
+ }
117
+
118
+ #[test]
119
+ fn an_invalid_setting_is_refused_naming_the_variable() {
120
+ let too_many = ["Role"; MAX_ENTRIES + 1].join(",");
121
+ let too_long = "A".repeat(MAX_ENTRY_LENGTH + 1);
122
+ for bad in ["", " ", ",", "Owner,", ",Owner", "Owner,,Admin", "Own/er", "Owner\nAdmin", "Own\u{7}er", "Admin;Owner", "-Admin", "Ad_min", "Régisseur", too_many.as_str(), too_long.as_str()] {
123
+ let message = parse(Some(bad)).expect_err(bad);
124
+ assert!(message.starts_with(VARIABLE), "{message}");
125
+ }
126
+ assert!(parse(Some(&"A".repeat(MAX_ENTRY_LENGTH))).is_ok());
127
+ assert!(parse(Some(&["Role"; MAX_ENTRIES].join(","))).is_ok());
128
+ }
129
+
130
+ #[test]
131
+ fn a_refusal_never_echoes_the_value() {
132
+ let message = parse(Some("sk_live_SECRET/value")).unwrap_err();
133
+ assert!(!message.contains("sk_live"), "{message}");
134
+ }
135
+
136
+ #[test]
137
+ fn the_setting_is_read_once() {
138
+ let cell = OnceLock::new();
139
+ let reads = std::cell::Cell::new(0);
140
+ let first = read_once(&cell, || {
141
+ reads.set(reads.get() + 1);
142
+ Some("Owner,Admin".to_string())
143
+ });
144
+ assert_eq!(first.as_ref().unwrap(), &["Owner", "Admin"]);
145
+ let second = read_once(&cell, || {
146
+ reads.set(reads.get() + 1);
147
+ Some("Editor".to_string())
148
+ });
149
+ assert_eq!(second.as_ref().unwrap(), &["Owner", "Admin"]);
150
+ assert_eq!(reads.get(), 1);
151
+ }
152
+
153
+ #[test]
154
+ fn an_invalid_value_stays_refused_once_read() {
155
+ let cell = OnceLock::new();
156
+ assert!(read_once(&cell, || Some("a/b".to_string())).is_err());
157
+ assert!(read_once(&cell, || None).is_err());
158
+ }
159
+ }
@@ -197,6 +197,7 @@ async fn tenant(name: &str) -> Tenant {
197
197
  direct_live_publishable_key: String::new(),
198
198
  webhook_secret: Some(WEBHOOK_SECRET.to_string()),
199
199
  operators: vec![OPERATOR.to_string()],
200
+ manager_roles: roles::default_roles(),
200
201
  };
201
202
  Tenant {
202
203
  client,
@@ -412,6 +413,30 @@ async fn only_an_owner_may_connect_or_disconnect_and_only_the_operator_may_enabl
412
413
  assert_eq!(t.status().await, "enabled");
413
414
  }
414
415
 
416
+ #[tokio::test]
417
+ async fn a_site_that_lists_admin_as_a_payments_manager_lets_an_admin_manage() {
418
+ let mut t = tenant("hecks_pay_test_manager_roles").await;
419
+ t.platform.manager_roles = vec!["Owner".to_string(), "Admin".to_string()];
420
+
421
+ let (status, body) = t.call("GET", "/payments/connection", json!({}), Some(ADMIN)).await;
422
+ assert_eq!((status, body["can_manage"].clone()), (200, json!(true)), "{body}");
423
+ t.connect().await;
424
+ assert_eq!(t.status().await, "connected");
425
+ let (status, _) = t.call("POST", "/payments/connection/disconnect", json!({}), Some(ADMIN)).await;
426
+ assert_eq!(status, 200);
427
+ assert_eq!(t.status().await, "disconnected");
428
+
429
+ // Enabling stays with the operator, and the Owner still manages.
430
+ let (status, body) = t.call("GET", "/payments/connection", json!({}), Some(OWNER)).await;
431
+ assert_eq!((status, body["can_manage"].clone()), (200, json!(true)), "{body}");
432
+ // Someone signed out, or not listed, is refused with today's message.
433
+ let (status, _) = t.call("GET", "/payments/connection", json!({}), None).await;
434
+ assert_eq!(status, 401);
435
+ t.platform.manager_roles = vec!["Editor".to_string()];
436
+ let (status, body) = t.call("POST", "/payments/connection/direct", json!({"mode": "test"}), Some(OWNER)).await;
437
+ assert_eq!((status, body["error"].as_str()), (403, Some("only an Owner can manage payments")));
438
+ }
439
+
415
440
  #[tokio::test]
416
441
  async fn an_admin_granted_owner_passes_the_payments_gate_and_keeps_the_admin_gate() {
417
442
  let t = tenant("hecks_pay_test_owner_bootstrap").await;
@@ -15,6 +15,8 @@ use tokio::sync::Mutex;
15
15
  use tokio_postgres::Client;
16
16
 
17
17
  mod keystore;
18
+ /// Which member roles may manage payments (`HECKS_PAYMENTS_MANAGER_ROLES`).
19
+ pub mod roles;
18
20
  use keystore::{KeyStore, StoredDocument, StoredKeys};
19
21
 
20
22
  const CONNECTION_SLUG: &str = "payments";
@@ -63,11 +65,16 @@ pub struct PlatformConfig {
63
65
  pub webhook_secret: Option<String>,
64
66
  /// Lower-cased emails allowed to enable or disable real payments.
65
67
  pub operators: Vec<String>,
68
+ /// The exact role names that may manage payments (`HECKS_PAYMENTS_MANAGER_ROLES`,
69
+ /// default `Owner`), read once at boot; see `roles`.
70
+ pub manager_roles: Vec<String>,
66
71
  }
67
72
 
68
- /// Refuses the boot on AWS when checkout is enabled but `PAYMENTS_ACCOUNT_SECRET_ID`
69
- /// names no secret; off AWS, or with checkout off, the host boots as usual.
73
+ /// Refuses the boot when `HECKS_PAYMENTS_MANAGER_ROLES` is invalid, and on AWS when
74
+ /// checkout is enabled but `PAYMENTS_ACCOUNT_SECRET_ID` names no secret; off AWS, or with
75
+ /// checkout off, the host boots as usual.
70
76
  pub fn check_boot(checkout_enabled: bool) -> Result<(), String> {
77
+ roles::check_boot()?;
71
78
  keystore::check_boot(checkout_enabled)
72
79
  }
73
80
 
@@ -102,6 +109,7 @@ impl PlatformConfig {
102
109
  .map(|email| email.trim().to_lowercase())
103
110
  .filter(|email| !email.is_empty())
104
111
  .collect(),
112
+ manager_roles: roles::manager_roles(),
105
113
  }
106
114
  }
107
115
 
@@ -276,7 +284,7 @@ fn refusal_message(refusal: &Value) -> String {
276
284
  }
277
285
 
278
286
  struct Caller {
279
- owner: bool,
287
+ manager: bool,
280
288
  operator: bool,
281
289
  }
282
290
 
@@ -288,14 +296,14 @@ async fn caller(domain_ir: &Value, cookies: &HashMap<String, String>, secret: &s
288
296
  return Err(not_logged_in());
289
297
  };
290
298
  match auth::active_role(client, domain_ir, &email).await {
291
- Ok(Some(role)) => Ok(Caller { owner: role == "Owner", operator: platform.is_operator(&email) }),
299
+ Ok(Some(role)) => Ok(Caller { manager: roles::manages(&platform.manager_roles, &role), operator: platform.is_operator(&email) }),
292
300
  Ok(None) => Err(not_logged_in()),
293
301
  Err(e) => Err(json_error(500, &format!("members lookup failed: {e}"))),
294
302
  }
295
303
  }
296
304
 
297
- fn require_owner(caller: &Caller) -> Result<(), Value> {
298
- if caller.owner { Ok(()) } else { Err(json_error(403, "only an Owner can manage payments")) }
305
+ fn require_manager(caller: &Caller) -> Result<(), Value> {
306
+ if caller.manager { Ok(()) } else { Err(json_error(403, "only an Owner can manage payments")) }
299
307
  }
300
308
 
301
309
  fn require_operator(caller: &Caller) -> Result<(), Value> {
@@ -322,7 +330,7 @@ fn connection_json(connection: Option<&Connection>, platform: &PlatformConfig, c
322
330
  "direct_modes": platform.direct_modes(),
323
331
  "direct": connection.is_some_and(Connection::is_direct),
324
332
  "can_save_keys": platform.can_save_keys(),
325
- "can_manage": caller.owner,
333
+ "can_manage": caller.manager,
326
334
  "can_enable": caller.operator,
327
335
  })
328
336
  }
@@ -375,9 +383,9 @@ pub async fn route(
375
383
  })
376
384
  }
377
385
 
378
- // GET: readable by an Owner or the operator, nobody else.
386
+ // GET: readable by a payments manager or the operator, nobody else.
379
387
  async fn show(caller: &Caller, platform: &PlatformConfig, client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig) -> Value {
380
- if !caller.owner && !caller.operator {
388
+ if !caller.manager && !caller.operator {
381
389
  return json_error(403, "not permitted");
382
390
  }
383
391
  connection_response(caller, platform, client, wasm_path, config).await
@@ -429,7 +437,7 @@ async fn record_connection(
429
437
  // name), and the key itself is never stored or logged.
430
438
  #[allow(clippy::too_many_arguments)]
431
439
  async fn direct_route(caller: &Caller, raw_body: &str, platform: &PlatformConfig, client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig, invoker: &dyn LambdaInvoker) -> Value {
432
- if let Err(response) = require_owner(caller) {
440
+ if let Err(response) = require_manager(caller) {
433
441
  return response;
434
442
  }
435
443
  let body = match parse_body(raw_body) {
@@ -462,7 +470,7 @@ async fn direct_route(caller: &Caller, raw_body: &str, platform: &PlatformConfig
462
470
  // guests have paid through this account. Disconnecting the business's own account
463
471
  // also removes its webhook and deletes the saved keys.
464
472
  async fn disconnect_route(caller: &Caller, platform: &PlatformConfig, client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig, invoker: &dyn LambdaInvoker) -> Value {
465
- if let Err(response) = require_owner(caller) {
473
+ if let Err(response) = require_manager(caller) {
466
474
  return response;
467
475
  }
468
476
  let existing = match current_connection(client, wasm_path, config).await {
@@ -762,6 +770,7 @@ pub(crate) fn test_platform() -> PlatformConfig {
762
770
  direct_live_publishable_key: String::new(),
763
771
  webhook_secret: None,
764
772
  operators: Vec::new(),
773
+ manager_roles: roles::default_roles(),
765
774
  }
766
775
  }
767
776
 
@@ -4,6 +4,7 @@
4
4
  pub mod closed_sets;
5
5
  pub mod identity;
6
6
  pub mod naming;
7
+ pub mod named_pattern;
7
8
  pub mod pattern_subset;
8
9
  pub mod query_derive;
9
10
  pub mod query_inference;
@@ -0,0 +1,23 @@
1
+ //! The patterns a bluebook names instead of spelling: `matches: :snake_name` stands for exactly
2
+ //! its regex. Mirrors `Hecks::Bluebook::NamedPattern`; spec/named_pattern_spec.rb holds the two
3
+ //! tables to each other.
4
+
5
+ /// Name => the regex source it stands for.
6
+ const TABLE: &[(&str, &str)] = &[
7
+ ("present", r"[^ \t\n\r]"),
8
+ ("not_empty", r"[^\t\n\r]"),
9
+ ("no_whitespace", r"^[^ \t\n\r]+$"),
10
+ ("snake_name", r"^[a-z][a-z0-9_]*$"),
11
+ ("git_sha", r"^[0-9a-fA-F]{7,40}$"),
12
+ ("digits", r"^[0-9]+$"),
13
+ ];
14
+
15
+ /// The regex source `name` stands for, or `None` when the language gives no such name.
16
+ pub fn source(name: &str) -> Option<&'static str> {
17
+ TABLE.iter().find(|(given, _)| *given == name).map(|(_, source)| *source)
18
+ }
19
+
20
+ /// Every name the language gives, in table order.
21
+ pub fn names() -> Vec<&'static str> {
22
+ TABLE.iter().map(|(name, _)| *name).collect()
23
+ }
@@ -256,6 +256,7 @@ pub static ARGUMENTS: &[ArgumentRow] = &[
256
256
  ArgumentRow { keyword: "attribute", context: "Aggregate", at: "", named: "default", kind: "literal", required: "false", fills: "default", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
257
257
  ArgumentRow { keyword: "attribute", context: "Aggregate", at: "", named: "optional", kind: "flag", required: "false", fills: "optional", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
258
258
  ArgumentRow { keyword: "attribute", context: "Aggregate", at: "", named: "pattern", kind: "text", required: "false", fills: "pattern", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
259
+ ArgumentRow { keyword: "attribute", context: "Aggregate", at: "", named: "matches", kind: "symbol", required: "false", fills: "matches", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
259
260
  ArgumentRow { keyword: "attribute", context: "Aggregate", at: "", named: "admits", kind: "text", required: "false", fills: "admits", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
260
261
  ArgumentRow { keyword: "attribute", context: "Aggregate", at: "", named: "one_of", kind: "list", required: "false", fills: "one_of", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
261
262
  ArgumentRow { keyword: "invariant", context: "Aggregate", at: "1", named: "", kind: "text", required: "true", fills: "description", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
@@ -356,6 +357,7 @@ pub static ARGUMENTS: &[ArgumentRow] = &[
356
357
  ArgumentRow { keyword: "attribute", context: "Command", at: "", named: "default", kind: "literal", required: "false", fills: "default", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
357
358
  ArgumentRow { keyword: "attribute", context: "Command", at: "", named: "optional", kind: "flag", required: "false", fills: "optional", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
358
359
  ArgumentRow { keyword: "attribute", context: "Command", at: "", named: "pattern", kind: "text", required: "false", fills: "pattern", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
360
+ ArgumentRow { keyword: "attribute", context: "Command", at: "", named: "matches", kind: "symbol", required: "false", fills: "matches", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
359
361
  ArgumentRow { keyword: "attribute", context: "Command", at: "", named: "admits", kind: "text", required: "false", fills: "admits", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
360
362
  ArgumentRow { keyword: "attribute", context: "Command", at: "", named: "one_of", kind: "list", required: "false", fills: "one_of", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
361
363
  ArgumentRow { keyword: "corrects", context: "Command", at: "1", named: "", kind: "text", required: "true", fills: "target", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
@@ -380,6 +382,7 @@ pub static ARGUMENTS: &[ArgumentRow] = &[
380
382
  ArgumentRow { keyword: "attribute", context: "Entity", at: "", named: "default", kind: "literal", required: "false", fills: "default", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
381
383
  ArgumentRow { keyword: "attribute", context: "Entity", at: "", named: "optional", kind: "flag", required: "false", fills: "optional", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
382
384
  ArgumentRow { keyword: "attribute", context: "Entity", at: "", named: "pattern", kind: "text", required: "false", fills: "pattern", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
385
+ ArgumentRow { keyword: "attribute", context: "Entity", at: "", named: "matches", kind: "symbol", required: "false", fills: "matches", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
383
386
  ArgumentRow { keyword: "attribute", context: "Entity", at: "", named: "admits", kind: "text", required: "false", fills: "admits", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
384
387
  ArgumentRow { keyword: "attribute", context: "Entity", at: "", named: "one_of", kind: "list", required: "false", fills: "one_of", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
385
388
  ArgumentRow { keyword: "reference_to", context: "Entity", at: "1", named: "", kind: "constant", required: "true", fills: "type", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
@@ -446,6 +449,7 @@ pub static ARGUMENTS: &[ArgumentRow] = &[
446
449
  ArgumentRow { keyword: "attribute", context: "Query", at: "", named: "default", kind: "literal", required: "false", fills: "default", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
447
450
  ArgumentRow { keyword: "attribute", context: "Query", at: "", named: "optional", kind: "flag", required: "false", fills: "optional", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
448
451
  ArgumentRow { keyword: "attribute", context: "Query", at: "", named: "pattern", kind: "text", required: "false", fills: "pattern", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
452
+ ArgumentRow { keyword: "attribute", context: "Query", at: "", named: "matches", kind: "symbol", required: "false", fills: "matches", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
449
453
  ArgumentRow { keyword: "attribute", context: "Query", at: "", named: "admits", kind: "text", required: "false", fills: "admits", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
450
454
  ArgumentRow { keyword: "attribute", context: "Query", at: "", named: "one_of", kind: "list", required: "false", fills: "one_of", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
451
455
  ArgumentRow { keyword: "reference_to", context: "Query", at: "1", named: "", kind: "constant", required: "true", fills: "type", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
@@ -464,6 +468,7 @@ pub static ARGUMENTS: &[ArgumentRow] = &[
464
468
  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: "" },
465
469
  ArgumentRow { keyword: "attribute", context: "ValueObject", at: "", named: "optional", kind: "flag", required: "false", fills: "optional", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
466
470
  ArgumentRow { keyword: "attribute", context: "ValueObject", at: "", named: "pattern", kind: "text", required: "false", fills: "pattern", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
471
+ ArgumentRow { keyword: "attribute", context: "ValueObject", at: "", named: "matches", kind: "symbol", required: "false", fills: "matches", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
467
472
  ArgumentRow { keyword: "attribute", context: "ValueObject", at: "", named: "admits", kind: "text", required: "false", fills: "admits", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
468
473
  ArgumentRow { keyword: "attribute", context: "ValueObject", at: "", named: "one_of", kind: "list", required: "false", fills: "one_of", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
469
474
  ArgumentRow { keyword: "invariant", context: "ValueObject", at: "1", named: "", kind: "text", required: "true", fills: "description", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
@@ -500,6 +505,7 @@ pub static ARGUMENTS: &[ArgumentRow] = &[
500
505
  ArgumentRow { keyword: "attribute", context: "PortOperation", at: "", named: "default", kind: "literal", required: "false", fills: "default", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
501
506
  ArgumentRow { keyword: "attribute", context: "PortOperation", at: "", named: "optional", kind: "flag", required: "false", fills: "optional", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
502
507
  ArgumentRow { keyword: "attribute", context: "PortOperation", at: "", named: "pattern", kind: "text", required: "false", fills: "pattern", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
508
+ ArgumentRow { keyword: "attribute", context: "PortOperation", at: "", named: "matches", kind: "symbol", required: "false", fills: "matches", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
503
509
  ArgumentRow { keyword: "attribute", context: "PortOperation", at: "", named: "admits", kind: "text", required: "false", fills: "admits", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
504
510
  ArgumentRow { keyword: "attribute", context: "PortOperation", at: "", named: "one_of", kind: "list", required: "false", fills: "one_of", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
505
511
  ArgumentRow { keyword: "emits", context: "PortOperation", at: "1", named: "", kind: "text", required: "true", fills: "emits", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
@@ -1134,7 +1134,28 @@ pub(crate) fn build_attribute(
1134
1134
  let (type_name, list, closed_set) = resolve_type_expression(file, line, word, &name, text)?;
1135
1135
  let default = named_raw(args, "default").map(ruby_value::read);
1136
1136
  let optional = named_flag(args, "optional");
1137
- let pattern = named_text(args, "pattern");
1137
+ // `matches: :name` is the pattern the language gives that name.
1138
+ let pattern = match named_symbol(args, "matches") {
1139
+ Some(given) => {
1140
+ if named_text(args, "pattern").is_some() {
1141
+ return Err(Diagnostic::new(file, line, "matches: and pattern: both state the pattern".to_string()));
1142
+ }
1143
+ match crate::build::named_pattern::source(&given) {
1144
+ Some(source) => Some(source.to_string()),
1145
+ None => {
1146
+ return Err(Diagnostic::new(
1147
+ file,
1148
+ line,
1149
+ format!(
1150
+ "matches: {given:?} is not a named pattern — the names are {}",
1151
+ crate::build::named_pattern::names().join(", ")
1152
+ ),
1153
+ ))
1154
+ }
1155
+ }
1156
+ }
1157
+ None => named_text(args, "pattern"),
1158
+ };
1138
1159
  let admits = named_text(args, "admits");
1139
1160
  // Fail closed at declaration time on a pattern outside the shared subset, as Ruby does.
1140
1161
  if let Some(pat) = &pattern {
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hecks
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.13.0
4
+ version: 3.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chris Young
@@ -322,6 +322,7 @@ files:
322
322
  - lib/hecks/bluebook/model_check/reference_checks.rb
323
323
  - lib/hecks/bluebook/model_check/saga_checks.rb
324
324
  - lib/hecks/bluebook/model_check/verbs.rb
325
+ - lib/hecks/bluebook/named_pattern.rb
325
326
  - lib/hecks/bluebook/pattern_subset.rb
326
327
  - lib/hecks/bluebook/pattern_subset/refusals.rb
327
328
  - lib/hecks/bluebook/pattern_subset/scan.rb
@@ -1636,9 +1637,9 @@ files:
1636
1637
  - lib/rubocop/cop/hecks/symbol_string_key_mix.rb
1637
1638
  - lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb
1638
1639
  - lib/rubocop/cop/hecks/untyped_raise.rb
1639
- - prebuilt/2b59737fbdbd5f44edda0fddce2611f45d76087b3a7c782d1f1a17ea58c77948.marshal
1640
- - prebuilt/58208399bada10b0db99928c48e38789288fc58f7b71a72b21bfa386680e32ed.marshal
1641
- - prebuilt/verdicts-48318ca172edd21443b1ad02801c697fff100b746e828f09db987647641a5df2.json
1640
+ - prebuilt/1a6f596be124228be93eba1d86dbc9d2933190fa389b284331241cb9f27115a0.marshal
1641
+ - prebuilt/a883a7e568667aa5ac904c82daff356bdf9e996252a36af6def4a4a43efb967e.marshal
1642
+ - prebuilt/verdicts-872993b1aa26d73fe48f28251374b8585fed145d02aab42be0f00de386b6ff80.json
1642
1643
  - qa/settings.yml
1643
1644
  - rust/.cargo/config.toml
1644
1645
  - rust/.cargo/mutants.toml
@@ -1734,6 +1735,7 @@ files:
1734
1735
  - rust/host/src/needs.rs
1735
1736
  - rust/host/src/payments.rs
1736
1737
  - rust/host/src/payments/keystore.rs
1738
+ - rust/host/src/payments/roles.rs
1737
1739
  - rust/host/src/payments/tests.rs
1738
1740
  - rust/host/src/platform_signin/attack_tests.rs
1739
1741
  - rust/host/src/platform_signin/config.rs
@@ -1804,6 +1806,7 @@ files:
1804
1806
  - rust/parser/src/build/closed_sets.rs
1805
1807
  - rust/parser/src/build/identity.rs
1806
1808
  - rust/parser/src/build/mod.rs
1809
+ - rust/parser/src/build/named_pattern.rs
1807
1810
  - rust/parser/src/build/naming.rs
1808
1811
  - rust/parser/src/build/pattern_subset.rs
1809
1812
  - rust/parser/src/build/query_derive.rs