hecks 3.0.3 → 3.0.4
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.
- checksums.yaml +4 -4
- data/exe/hecks +6 -2
- data/lib/hecks/bluebook/assembly/contracts.rb +3 -1
- data/lib/hecks/bluebook/assembly/marks.rb +3 -0
- data/lib/hecks/bluebook/command.rb +7 -5
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +1 -0
- data/lib/hecks/bluebook/dsl/command_builder.rb +42 -0
- data/lib/hecks/bluebook/expression/canonical_form.rb +12 -0
- data/lib/hecks/bluebook/expression/projection.json +21 -0
- data/lib/hecks/bluebook/meta_validator/shapes.rb +3 -0
- data/lib/hecks/cli/project_cli.rb +6 -2
- data/lib/hecks/deploy/bluebook/deploy.bluebook +34 -0
- data/lib/hecks/doors/cli_runner.rb +57 -12
- data/lib/hecks/doors/launcher_options.rb +23 -0
- data/lib/hecks/fuzzing/properties.rb +1 -1
- data/lib/hecks/fuzzing/sequence_generator/adversary.rb +11 -3
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +9 -7
- data/lib/hecks/gate/stages.yml +51 -0
- data/lib/hecks/grammar/expression.bluebook +6 -1
- data/lib/hecks/grammar/expression_operators.json +135 -0
- data/lib/hecks/hecks/adapters/codebase/gate.rb +37 -0
- data/lib/hecks/hecks/adapters/codebase/regeneration.rb +24 -4
- data/lib/hecks/hecks/adapters/codebase/source_tree.rb +3 -1
- data/lib/hecks/hecks/codebase.behaviors +12 -0
- data/lib/hecks/hecks/codebase.bluebook +194 -0
- data/lib/hecks/hecks/custodian.bluebook +2 -0
- data/lib/hecks/hecks/hecks.hecksagon +14 -0
- data/lib/hecks/hecks/hecks.world +3 -2
- data/lib/hecks/language/bluebook/command.bluebook +26 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +29 -1
- data/lib/hecks/language/oidc.json +5 -0
- data/lib/hecks/ports/clock.rb +3 -1
- data/lib/hecks/projections/deploy/lambda.rb +3 -4
- data/lib/hecks/quality_control/quality_control.bluebook +6 -0
- data/lib/hecks/runtime/command_interpreter.rb +6 -4
- data/lib/hecks/runtime/entity_interpreter.rb +5 -2
- data/lib/hecks/runtime/interpreting.rb +29 -0
- data/lib/hecks/runtime/invocation.rb +5 -1
- data/lib/hecks/runtime/loader.rb +16 -13
- data/lib/hecks/runtime.rb +11 -0
- data/lib/hecks/three_zero/forms.yml +1 -1
- data/lib/hecks/tools/ci_gate_decision.rb +109 -0
- data/lib/hecks/tools/ci_gates.rb +124 -0
- data/lib/hecks/tools/gate.rb +117 -0
- data/lib/hecks/tools.rb +3 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +6 -1
- data/lib/hecks.rb +12 -2
- data/rust/codegen/src/naming.rs +13 -2
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/dispatch.rs +207 -1
- data/rust/host/src/main.rs +1 -0
- data/rust/host/src/needs.rs +243 -0
- data/rust/host/src/resend.rs +52 -10
- data/rust/host/src/server.rs +7 -3
- data/rust/parser/src/canonical.rs +137 -2
- data/rust/parser/src/emit.rs +25 -19
- data/rust/parser/src/ir.rs +2 -0
- data/rust/parser/src/keywords.rs +2 -0
- data/rust/parser/src/main.rs +1 -0
- data/rust/parser/src/parse/command.rs +44 -0
- data/rust/project/naming.rb +4 -2
- data/rust/src/kernel/cli.rs +3 -0
- data/rust/src/kernel/mod.rs +1 -0
- data/rust/src/kernel/needs.rs +194 -0
- data/rust/src/kernel/orchestrate.rs +84 -0
- data/rust/src/kernel/repository.rs +43 -2
- metadata +9 -2
data/rust/host/src/resend.rs
CHANGED
|
@@ -43,7 +43,7 @@ impl Delivery {
|
|
|
43
43
|
|
|
44
44
|
/// How email leaves the host: really, through Resend, or logged and dropped.
|
|
45
45
|
pub enum Mailer {
|
|
46
|
-
Live { http: reqwest::Client, api_key: String, from: String, base_url: String },
|
|
46
|
+
Live { http: reqwest::Client, api_key: String, from: String, reply_to: Option<String>, base_url: String },
|
|
47
47
|
Mock,
|
|
48
48
|
}
|
|
49
49
|
|
|
@@ -53,6 +53,7 @@ impl Mailer {
|
|
|
53
53
|
pub fn from_env() -> Result<Option<Mailer>, String> {
|
|
54
54
|
let var = |name: &str| std::env::var(name).unwrap_or_default();
|
|
55
55
|
Self::configured(&var("RESEND_API_KEY"), &var("RESEND_FROM"), &var("RESEND_MOCK"))
|
|
56
|
+
.map(|mailer| mailer.map(|mailer| mailer.replying_to(&var("RESEND_REPLY_TO"))))
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
fn configured(api_key: &str, from: &str, mock: &str) -> Result<Option<Mailer>, String> {
|
|
@@ -69,7 +70,19 @@ impl Mailer {
|
|
|
69
70
|
/// A mailer that posts to `base_url` (Resend's API, or a stand-in in tests).
|
|
70
71
|
pub fn live(api_key: &str, from: &str, base_url: &str) -> Mailer {
|
|
71
72
|
let http = reqwest::Client::builder().timeout(TIMEOUT).build().expect("a reqwest client with a timeout always builds");
|
|
72
|
-
Mailer::Live { http, api_key: api_key.to_string(), from: from.to_string(), base_url: base_url.to_string() }
|
|
73
|
+
Mailer::Live { http, api_key: api_key.to_string(), from: from.to_string(), reply_to: None, base_url: base_url.to_string() }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/// Sets where replies go (`Reply-To`), when the address people reply to is not the one the mail
|
|
77
|
+
/// is sent from. Blank leaves replies going to the From address. A mock mailer has no replies.
|
|
78
|
+
pub fn replying_to(self, reply_to: &str) -> Mailer {
|
|
79
|
+
match self {
|
|
80
|
+
Mailer::Live { http, api_key, from, base_url, .. } => {
|
|
81
|
+
let reply_to = (!reply_to.trim().is_empty()).then(|| reply_to.trim().to_string());
|
|
82
|
+
Mailer::Live { http, api_key, from, reply_to, base_url }
|
|
83
|
+
}
|
|
84
|
+
mock => mock,
|
|
85
|
+
}
|
|
73
86
|
}
|
|
74
87
|
|
|
75
88
|
#[cfg(test)]
|
|
@@ -80,7 +93,7 @@ impl Mailer {
|
|
|
80
93
|
pub async fn deliver(&self, email: &Email<'_>) -> Delivery {
|
|
81
94
|
match self {
|
|
82
95
|
Mailer::Mock => mock_deliver(email),
|
|
83
|
-
Mailer::Live { http, api_key, from, base_url } => live_deliver(http, api_key, from, base_url, email).await,
|
|
96
|
+
Mailer::Live { http, api_key, from, reply_to, base_url } => live_deliver(http, api_key, from, reply_to.as_deref(), base_url, email).await,
|
|
84
97
|
}
|
|
85
98
|
}
|
|
86
99
|
}
|
|
@@ -93,8 +106,11 @@ fn mock_deliver(email: &Email<'_>) -> Delivery {
|
|
|
93
106
|
Delivery::sent(format!("re_{}", uuid::Uuid::new_v4().simple()))
|
|
94
107
|
}
|
|
95
108
|
|
|
96
|
-
fn payload(from: &str, email: &Email<'_>) -> Value {
|
|
109
|
+
fn payload(from: &str, reply_to: Option<&str>, email: &Email<'_>) -> Value {
|
|
97
110
|
let mut payload = json!({ "from": from, "to": [email.to], "subject": email.subject });
|
|
111
|
+
if let Some(reply_to) = reply_to {
|
|
112
|
+
payload["reply_to"] = json!(reply_to);
|
|
113
|
+
}
|
|
98
114
|
let content_key = if email.body.trim_start().starts_with('<') { "html" } else { "text" };
|
|
99
115
|
payload[content_key] = json!(email.body);
|
|
100
116
|
if let Some(url) = email.unsubscribe_url {
|
|
@@ -113,8 +129,8 @@ fn retry_delay(response: &reqwest::Response) -> Duration {
|
|
|
113
129
|
Duration::from_secs(secs.min(MAX_RETRY_SECS))
|
|
114
130
|
}
|
|
115
131
|
|
|
116
|
-
async fn live_deliver(http: &reqwest::Client, api_key: &str, from: &str, base_url: &str, email: &Email<'_>) -> Delivery {
|
|
117
|
-
let body = payload(from, email);
|
|
132
|
+
async fn live_deliver(http: &reqwest::Client, api_key: &str, from: &str, reply_to: Option<&str>, base_url: &str, email: &Email<'_>) -> Delivery {
|
|
133
|
+
let body = payload(from, reply_to, email);
|
|
118
134
|
for attempt in 1..=MAX_ATTEMPTS {
|
|
119
135
|
let response = match http.post(format!("{base_url}/emails")).bearer_auth(api_key).json(&body).send().await {
|
|
120
136
|
Ok(r) => r,
|
|
@@ -225,19 +241,45 @@ mod tests {
|
|
|
225
241
|
|
|
226
242
|
#[test]
|
|
227
243
|
fn an_html_body_goes_as_html_and_a_plain_one_as_text() {
|
|
228
|
-
assert!(payload("f", &email("a@b.com", " <p>hi</p>")).get("html").is_some());
|
|
229
|
-
let plain = payload("f", &email("a@b.com", "just a link"));
|
|
244
|
+
assert!(payload("f", None, &email("a@b.com", " <p>hi</p>")).get("html").is_some());
|
|
245
|
+
let plain = payload("f", None, &email("a@b.com", "just a link"));
|
|
230
246
|
assert!(plain.get("text").is_some() && plain.get("html").is_none());
|
|
231
247
|
}
|
|
232
248
|
|
|
233
249
|
#[test]
|
|
234
250
|
fn the_unsubscribe_url_rides_as_a_list_unsubscribe_header() {
|
|
235
|
-
let with = payload("f", &email("a@b.com", "x"));
|
|
251
|
+
let with = payload("f", None, &email("a@b.com", "x"));
|
|
236
252
|
assert_eq!(with["headers"]["List-Unsubscribe"], "<https://example.com/newsletter-unsubscribed.html?email=a%40b.com>");
|
|
237
|
-
let without = payload("f", &Email { to: "a@b.com", subject: "s", body: "x", unsubscribe_url: None });
|
|
253
|
+
let without = payload("f", None, &Email { to: "a@b.com", subject: "s", body: "x", unsubscribe_url: None });
|
|
238
254
|
assert!(without.get("headers").is_none());
|
|
239
255
|
}
|
|
240
256
|
|
|
257
|
+
#[test]
|
|
258
|
+
fn a_reply_to_address_rides_in_the_payload_and_is_absent_without_one() {
|
|
259
|
+
let with = payload("f", Some("lifeadelics@gmail.com"), &email("a@b.com", "x"));
|
|
260
|
+
assert_eq!(with["reply_to"], "lifeadelics@gmail.com");
|
|
261
|
+
assert!(payload("f", None, &email("a@b.com", "x")).get("reply_to").is_none());
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
#[test]
|
|
265
|
+
fn replying_to_trims_the_address_and_a_blank_one_sets_none() {
|
|
266
|
+
let set = Mailer::live("re_x", "n@mail.example.com", "http://x").replying_to(" me@example.com ");
|
|
267
|
+
assert!(matches!(&set, Mailer::Live { reply_to: Some(r), .. } if r == "me@example.com"));
|
|
268
|
+
let blank = Mailer::live("re_x", "n@mail.example.com", "http://x").replying_to(" ");
|
|
269
|
+
assert!(matches!(&blank, Mailer::Live { reply_to: None, .. }));
|
|
270
|
+
assert!(Mailer::Mock.replying_to("me@example.com").is_mock(), "a mock mailer has no replies");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
#[tokio::test]
|
|
274
|
+
async fn a_live_send_carries_the_reply_to_address_to_resend() {
|
|
275
|
+
let fake = FakeResend::start(vec![("200 OK", "", r#"{"id":"em_9"}"#)]);
|
|
276
|
+
let mailer = Mailer::live("re_x", "News <n@mail.example.com>", &fake.base).replying_to("lifeadelics@gmail.com");
|
|
277
|
+
|
|
278
|
+
mailer.deliver(&email("a@b.com", "x")).await;
|
|
279
|
+
|
|
280
|
+
assert!(fake.requests()[0].contains(r#""reply_to":"lifeadelics@gmail.com""#), "{}", fake.requests()[0]);
|
|
281
|
+
}
|
|
282
|
+
|
|
241
283
|
#[test]
|
|
242
284
|
fn the_mock_logs_instead_of_sending_and_bounces_the_test_address() {
|
|
243
285
|
let sent = mock_deliver(&email("a@b.com", "x"));
|
data/rust/host/src/server.rs
CHANGED
|
@@ -53,25 +53,29 @@ pub async fn dispatch_body(
|
|
|
53
53
|
.ok_or("event missing \"verb\"")?
|
|
54
54
|
.to_string();
|
|
55
55
|
let role = body.get("role").and_then(|v| v.as_str()).map(|s| s.to_string());
|
|
56
|
+
// Who the caller is, for Governance. Honored only where the internal protocol is: from this
|
|
57
|
+
// host's own peers (`trusts_internal_dispatch`), never from a public caller.
|
|
58
|
+
let actor_id = body.get("actor_id").and_then(|v| v.as_str()).map(|s| s.to_string());
|
|
59
|
+
let caller = dispatch::Caller { role: role.as_deref(), actor_id: actor_id.as_deref() };
|
|
56
60
|
|
|
57
61
|
let outcome = if let Some(to) = body.get("to").cloned() {
|
|
58
62
|
if body.get("args").is_some() {
|
|
59
63
|
return Err("cannot combine to/with with legacy args".into());
|
|
60
64
|
}
|
|
61
65
|
let facts = body.get("with").cloned().unwrap_or_else(|| serde_json::json!({}));
|
|
62
|
-
dispatch::
|
|
66
|
+
dispatch::handle_routed_as(client, wasm_path, &verb, to, facts, caller, config, invoker)
|
|
63
67
|
.await
|
|
64
68
|
.map_err(|e| format!("{e:#}"))?
|
|
65
69
|
} else if let Some(facts) = body.get("with").cloned() {
|
|
66
70
|
if body.get("args").is_some() {
|
|
67
71
|
return Err("cannot combine \"with\" with legacy args".into());
|
|
68
72
|
}
|
|
69
|
-
dispatch::
|
|
73
|
+
dispatch::handle_facts_as(client, wasm_path, &verb, facts, caller, config, invoker)
|
|
70
74
|
.await
|
|
71
75
|
.map_err(|e| format!("{e:#}"))?
|
|
72
76
|
} else {
|
|
73
77
|
let args = body.get("args").cloned().unwrap_or_else(|| serde_json::json!({}));
|
|
74
|
-
dispatch::
|
|
78
|
+
dispatch::handle_as(client, wasm_path, &verb, args, caller, config, invoker)
|
|
75
79
|
.await
|
|
76
80
|
.map_err(|e| format!("{e:#}"))?
|
|
77
81
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
//! Source-body slicing and whitespace collapse, mirroring Ruby's `CanonicalForm`.
|
|
2
|
-
//! Hand-mirrored from projection.json: a
|
|
2
|
+
//! Hand-mirrored from projection.json: a rule there (collapse_whitespace, replace, scale_call) needs a matching rule here.
|
|
3
3
|
|
|
4
4
|
/// Slices the raw source between two byte offsets, before any normalisation.
|
|
5
5
|
pub fn slice(source: &str, start: usize, end: usize) -> &str {
|
|
@@ -11,7 +11,87 @@ pub fn slice(source: &str, start: usize, end: usize) -> &str {
|
|
|
11
11
|
pub fn apply(source: &str) -> String {
|
|
12
12
|
let collapsed = map_outside_strings(source, collapse_whitespace);
|
|
13
13
|
let replaced = map_outside_strings(&collapsed, |run| replace_word_boundary(run, ".length", ".size"));
|
|
14
|
-
|
|
14
|
+
let scaled = SCALE_CALLS.iter().fold(replaced, |text, (token, seconds)| {
|
|
15
|
+
map_outside_strings(&text, |run| scale_call(run, token, seconds))
|
|
16
|
+
});
|
|
17
|
+
scaled.trim().to_string()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/// The `scale_call` rows of projection.json, in position order: (call name, seconds per unit).
|
|
21
|
+
const SCALE_CALLS: [(&str, &str); 3] = [("days", "86400"), ("hours", "3600"), ("minutes", "60")];
|
|
22
|
+
|
|
23
|
+
/// Every normalisation row of projection.json as (strategy, source_token, replacement,
|
|
24
|
+
/// boundary, position), in position order; emitted into the IR's expression table.
|
|
25
|
+
pub const TABLE: [(&str, &str, &str, &str, &str); 5] = [
|
|
26
|
+
("collapse_whitespace", "", "", "none", "1"),
|
|
27
|
+
("replace", ".length", ".size", "word", "2"),
|
|
28
|
+
("scale_call", "days", "86400", "word", "3"),
|
|
29
|
+
("scale_call", "hours", "3600", "word", "4"),
|
|
30
|
+
("scale_call", "minutes", "60", "word", "5"),
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/// Folds `token(<whole number>)` into the number times `seconds`, as Ruby's `scale_call` does.
|
|
34
|
+
/// The name must not follow a word character, digit, underscore or dot; spaces inside the
|
|
35
|
+
/// parentheses are allowed; any other argument leaves the call as written.
|
|
36
|
+
fn scale_call(text: &str, token: &str, seconds: &str) -> String {
|
|
37
|
+
let mut out = String::with_capacity(text.len());
|
|
38
|
+
let mut i = 0;
|
|
39
|
+
while i < text.len() {
|
|
40
|
+
if text[i..].starts_with(token) {
|
|
41
|
+
let preceded = text[..i]
|
|
42
|
+
.chars()
|
|
43
|
+
.next_back()
|
|
44
|
+
.map_or(false, |c| c.is_alphanumeric() || c == '_' || c == '.');
|
|
45
|
+
if !preceded {
|
|
46
|
+
if let Some((digits, len)) = whole_number_call(&text[i + token.len()..]) {
|
|
47
|
+
out.push_str(&multiply(digits, seconds));
|
|
48
|
+
i += token.len() + len;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
let ch = text[i..].chars().next().unwrap();
|
|
54
|
+
out.push(ch);
|
|
55
|
+
i += ch.len_utf8();
|
|
56
|
+
}
|
|
57
|
+
out
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/// Matches `(` spaces digits spaces `)` at the start of `rest`; returns the digits and the
|
|
61
|
+
/// matched length.
|
|
62
|
+
fn whole_number_call(rest: &str) -> Option<(&str, usize)> {
|
|
63
|
+
let inner = rest.strip_prefix('(')?;
|
|
64
|
+
let digits_at = inner.len() - inner.trim_start_matches(char::is_whitespace).len();
|
|
65
|
+
let body = &inner[digits_at..];
|
|
66
|
+
let digit_len = body.bytes().take_while(|b| b.is_ascii_digit()).count();
|
|
67
|
+
if digit_len == 0 {
|
|
68
|
+
return None;
|
|
69
|
+
}
|
|
70
|
+
let tail = &body[digit_len..];
|
|
71
|
+
let close = tail.len() - tail.trim_start_matches(char::is_whitespace).len();
|
|
72
|
+
tail[close..].strip_prefix(')')?;
|
|
73
|
+
Some((&body[..digit_len], 1 + digits_at + digit_len + close + 1))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/// Decimal `digits` times the decimal `factor`, as text with no leading zeros (any length).
|
|
77
|
+
fn multiply(digits: &str, factor: &str) -> String {
|
|
78
|
+
let factor: u32 = factor.parse().expect("scale factor is a whole number");
|
|
79
|
+
let mut carry = 0u32;
|
|
80
|
+
let mut out: Vec<u8> = Vec::with_capacity(digits.len() + 6);
|
|
81
|
+
for d in digits.bytes().rev() {
|
|
82
|
+
let v = (d - b'0') as u32 * factor + carry;
|
|
83
|
+
out.push(b'0' + (v % 10) as u8);
|
|
84
|
+
carry = v / 10;
|
|
85
|
+
}
|
|
86
|
+
while carry > 0 {
|
|
87
|
+
out.push(b'0' + (carry % 10) as u8);
|
|
88
|
+
carry /= 10;
|
|
89
|
+
}
|
|
90
|
+
while out.len() > 1 && out.last() == Some(&b'0') {
|
|
91
|
+
out.pop();
|
|
92
|
+
}
|
|
93
|
+
out.reverse();
|
|
94
|
+
String::from_utf8(out).unwrap()
|
|
15
95
|
}
|
|
16
96
|
|
|
17
97
|
/// Applies `transform` to the unquoted runs of `text` and copies quoted runs verbatim;
|
|
@@ -104,6 +184,61 @@ mod tests {
|
|
|
104
184
|
assert_eq!(apply("lengthy"), "lengthy");
|
|
105
185
|
}
|
|
106
186
|
|
|
187
|
+
#[test]
|
|
188
|
+
fn folds_duration_calls_into_seconds() {
|
|
189
|
+
assert_eq!(apply("days(730)"), "63072000");
|
|
190
|
+
assert_eq!(apply("days( 1 ) + hours(2) + minutes(15)"), "86400 + 7200 + 900");
|
|
191
|
+
assert_eq!(
|
|
192
|
+
apply("x.days(3) workdays(3) days(n) days(1.5)"),
|
|
193
|
+
"x.days(3) workdays(3) days(n) days(1.5)"
|
|
194
|
+
);
|
|
195
|
+
assert_eq!(apply("'days(2)' == days(0)"), "'days(2)' == 0");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/// Splits JSON text into its string literals, decoding escapes.
|
|
199
|
+
fn json_strings(text: &str) -> Vec<String> {
|
|
200
|
+
let mut out = Vec::new();
|
|
201
|
+
let mut chars = text.chars();
|
|
202
|
+
while let Some(c) = chars.next() {
|
|
203
|
+
if c != '"' {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
let mut s = String::new();
|
|
207
|
+
while let Some(c) = chars.next() {
|
|
208
|
+
match c {
|
|
209
|
+
'"' => break,
|
|
210
|
+
'\\' => match chars.next().unwrap() {
|
|
211
|
+
'n' => s.push('\n'),
|
|
212
|
+
't' => s.push('\t'),
|
|
213
|
+
'r' => s.push('\r'),
|
|
214
|
+
'u' => {
|
|
215
|
+
let hex: String = chars.by_ref().take(4).collect();
|
|
216
|
+
s.push(char::from_u32(u32::from_str_radix(&hex, 16).unwrap()).unwrap());
|
|
217
|
+
}
|
|
218
|
+
other => s.push(other),
|
|
219
|
+
},
|
|
220
|
+
c => s.push(c),
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
out.push(s);
|
|
224
|
+
}
|
|
225
|
+
out
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
#[test]
|
|
229
|
+
fn matches_the_shared_canonical_form_cases() {
|
|
230
|
+
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../spec/fixtures/canonical_form_cases.json");
|
|
231
|
+
let text = std::fs::read_to_string(path).expect("shared cases file");
|
|
232
|
+
let strings = json_strings(&text);
|
|
233
|
+
let start = strings.iter().position(|s| s == "cases").expect("cases key") + 1;
|
|
234
|
+
let rows: Vec<&[String]> = strings[start..].chunks(4).collect();
|
|
235
|
+
assert!(!rows.is_empty());
|
|
236
|
+
for row in rows {
|
|
237
|
+
assert_eq!((row[0].as_str(), row[2].as_str()), ("source", "canonical"));
|
|
238
|
+
assert_eq!(apply(&row[1]), row[3], "source {:?}", row[1]);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
107
242
|
#[test]
|
|
108
243
|
fn slices_the_exact_source_span() {
|
|
109
244
|
let source = "given(\"ok\") { balance >= amount }";
|
data/rust/parser/src/emit.rs
CHANGED
|
@@ -43,25 +43,20 @@ pub fn write(value: &JsonValue) -> String {
|
|
|
43
43
|
/// The pinned `Expression::CanonicalForm.table` rules from projection.json, in Ruby's field
|
|
44
44
|
/// order; `position` is a string.
|
|
45
45
|
fn canonical_form_table() -> JsonValue {
|
|
46
|
-
JsonValue::Array(
|
|
47
|
-
|
|
48
|
-
(
|
|
49
|
-
|
|
50
|
-
JsonValue::
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
("replacement".to_string(), JsonValue::str(".size")),
|
|
61
|
-
("boundary".to_string(), JsonValue::str("word")),
|
|
62
|
-
("position".to_string(), JsonValue::str("2")),
|
|
63
|
-
]),
|
|
64
|
-
])
|
|
46
|
+
JsonValue::Array(
|
|
47
|
+
crate::canonical::TABLE
|
|
48
|
+
.iter()
|
|
49
|
+
.map(|(strategy, token, replacement, boundary, position)| {
|
|
50
|
+
JsonValue::Object(vec![
|
|
51
|
+
("strategy".to_string(), JsonValue::str(*strategy)),
|
|
52
|
+
("source_token".to_string(), JsonValue::str(*token)),
|
|
53
|
+
("replacement".to_string(), JsonValue::str(*replacement)),
|
|
54
|
+
("boundary".to_string(), JsonValue::str(*boundary)),
|
|
55
|
+
("position".to_string(), JsonValue::str(*position)),
|
|
56
|
+
])
|
|
57
|
+
})
|
|
58
|
+
.collect(),
|
|
59
|
+
)
|
|
65
60
|
}
|
|
66
61
|
|
|
67
62
|
/// `IR::Bluebook#to_h`; field order is pinned there.
|
|
@@ -304,6 +299,17 @@ fn command_json(c: &ir::Command) -> JsonValue {
|
|
|
304
299
|
"ensures".to_string(),
|
|
305
300
|
JsonValue::Array(c.ensures.iter().map(given_json).collect()),
|
|
306
301
|
),
|
|
302
|
+
(
|
|
303
|
+
"needs".to_string(),
|
|
304
|
+
JsonValue::Array(
|
|
305
|
+
c.needs
|
|
306
|
+
.iter()
|
|
307
|
+
.map(|fact| {
|
|
308
|
+
JsonValue::Object(vec![("fact".to_string(), JsonValue::str(fact.clone()))])
|
|
309
|
+
})
|
|
310
|
+
.collect(),
|
|
311
|
+
),
|
|
312
|
+
),
|
|
307
313
|
(
|
|
308
314
|
"mutations".to_string(),
|
|
309
315
|
JsonValue::Array(c.mutations.iter().map(mutation_json).collect()),
|
data/rust/parser/src/ir.rs
CHANGED
|
@@ -98,6 +98,8 @@ pub struct Command {
|
|
|
98
98
|
pub attributes: Vec<Attribute>,
|
|
99
99
|
pub givens: Vec<Given>,
|
|
100
100
|
pub ensures: Vec<Ensures>,
|
|
101
|
+
// Outside facts the runtime supplies before any given runs (`needs :now`); only `now` today.
|
|
102
|
+
pub needs: Vec<String>,
|
|
101
103
|
pub mutations: Vec<Mutation>,
|
|
102
104
|
pub emits: Vec<String>,
|
|
103
105
|
// The lifecycle state this command is admissible from; see `CommandFrom`.
|
data/rust/parser/src/keywords.rs
CHANGED
|
@@ -107,6 +107,7 @@ pub static KEYWORDS: &[KeywordRow] = &[
|
|
|
107
107
|
KeywordRow { word: "state", context: "Command", body: "none", inner: "", opens: "", fills: "mutations", status: "admitted", was: "", resolves_via: "", disambiguator: "" },
|
|
108
108
|
KeywordRow { word: "attribute", context: "Command", body: "none", inner: "", opens: "", fills: "attributes", status: "admitted", was: "", resolves_via: "", disambiguator: "" },
|
|
109
109
|
KeywordRow { word: "ensures", context: "Command", body: "source", inner: "", opens: "", fills: "ensures", status: "admitted", was: "", resolves_via: "", disambiguator: "" },
|
|
110
|
+
KeywordRow { word: "needs", context: "Command", body: "none", inner: "", opens: "", fills: "needs", status: "admitted", was: "", resolves_via: "", disambiguator: "" },
|
|
110
111
|
KeywordRow { word: "corrects", context: "Command", body: "none", inner: "", opens: "", fills: "mutations", status: "admitted", was: "", resolves_via: "", disambiguator: "" },
|
|
111
112
|
KeywordRow { word: "description", context: "Entity", body: "none", inner: "", opens: "", fills: "description", status: "admitted", was: "", resolves_via: "", disambiguator: "" },
|
|
112
113
|
KeywordRow { word: "identified_by", context: "Entity", body: "keywords", inner: "ValueObject", opens: "ValueObject", fills: "identified_by", status: "admitted", was: "", resolves_via: "", disambiguator: "" },
|
|
@@ -308,6 +309,7 @@ pub static ARGUMENTS: &[ArgumentRow] = &[
|
|
|
308
309
|
ArgumentRow { keyword: "reference_to", context: "Command", at: "", named: "optional", kind: "flag", required: "false", fills: "optional", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
|
|
309
310
|
ArgumentRow { keyword: "given", context: "Command", at: "1", named: "", kind: "text", required: "true", fills: "description", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
|
|
310
311
|
ArgumentRow { keyword: "ensures", context: "Command", at: "1", named: "", kind: "text", required: "true", fills: "description", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
|
|
312
|
+
ArgumentRow { keyword: "needs", context: "Command", at: "1", named: "", kind: "symbol", required: "true", fills: "fact", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
|
|
311
313
|
ArgumentRow { keyword: "sets", context: "Command", at: "1", named: "", kind: "symbol", required: "true", fills: "target", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
|
|
312
314
|
ArgumentRow { keyword: "sets", context: "Command", at: "", named: "to", kind: "literal", required: "false", fills: "source", selects: "op=set", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
|
|
313
315
|
ArgumentRow { keyword: "sets", context: "Command", at: "", named: "append", kind: "literal", required: "false", fills: "source", selects: "op=append", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
|
data/rust/parser/src/main.rs
CHANGED
|
@@ -164,6 +164,7 @@ pub fn parse_body(
|
|
|
164
164
|
owner_entities,
|
|
165
165
|
);
|
|
166
166
|
refuse_duplicate_targets(file, *pos, &command)?;
|
|
167
|
+
refuse_undeclared_needs(file, *pos, &command)?;
|
|
167
168
|
return Ok((command, pending));
|
|
168
169
|
};
|
|
169
170
|
let line = gated.line.number;
|
|
@@ -205,6 +206,27 @@ pub fn parse_body(
|
|
|
205
206
|
canonical: canonical::apply(&raw),
|
|
206
207
|
});
|
|
207
208
|
}
|
|
209
|
+
"needs" => {
|
|
210
|
+
let fact = super::positional_symbol(file, line, "needs", &gated.args, 1)?;
|
|
211
|
+
if !NEEDABLE_FACTS.contains(&fact.as_str()) {
|
|
212
|
+
return Err(Diagnostic::new(
|
|
213
|
+
file,
|
|
214
|
+
line,
|
|
215
|
+
format!(
|
|
216
|
+
"{} needs :{fact}, which the runtime cannot supply — it supplies :now",
|
|
217
|
+
command.name
|
|
218
|
+
),
|
|
219
|
+
));
|
|
220
|
+
}
|
|
221
|
+
if command.needs.contains(&fact) {
|
|
222
|
+
return Err(Diagnostic::new(
|
|
223
|
+
file,
|
|
224
|
+
line,
|
|
225
|
+
format!("{} declares needs :{fact} twice", command.name),
|
|
226
|
+
));
|
|
227
|
+
}
|
|
228
|
+
command.needs.push(fact);
|
|
229
|
+
}
|
|
208
230
|
"provenance" => {
|
|
209
231
|
let raw = super::named_raw(&gated.args, "from")
|
|
210
232
|
.ok_or_else(|| Diagnostic::new(file, line, "'provenance' requires a from:"))?;
|
|
@@ -297,6 +319,28 @@ fn state_ref(raw: &str) -> Option<String> {
|
|
|
297
319
|
Some(inner.to_string())
|
|
298
320
|
}
|
|
299
321
|
|
|
322
|
+
/// The outside facts a command may declare it needs; mirrors `CommandBuilder::NEEDABLE_FACTS`.
|
|
323
|
+
const NEEDABLE_FACTS: &[&str] = &["now"];
|
|
324
|
+
|
|
325
|
+
/// A need names the attribute the runtime fills, so the command must declare it.
|
|
326
|
+
fn refuse_undeclared_needs(file: &str, line: usize, command: &ir::Command) -> ParseResult<()> {
|
|
327
|
+
match command
|
|
328
|
+
.needs
|
|
329
|
+
.iter()
|
|
330
|
+
.find(|fact| !command.attributes.iter().any(|a| &a.name == *fact))
|
|
331
|
+
{
|
|
332
|
+
Some(fact) => Err(Diagnostic::new(
|
|
333
|
+
file,
|
|
334
|
+
line,
|
|
335
|
+
format!(
|
|
336
|
+
"{} needs :{fact} but declares no attribute :{fact} for the runtime to fill — add `attribute :{fact}, <type>`",
|
|
337
|
+
command.name
|
|
338
|
+
),
|
|
339
|
+
)),
|
|
340
|
+
None => Ok(()),
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
300
344
|
/// Refuses a field written twice: a command's effects are one update set (C4.2).
|
|
301
345
|
/// `delegates_to`/`corrects` name no field and are not counted.
|
|
302
346
|
fn refuse_duplicate_targets(file: &str, line: usize, command: &ir::Command) -> ParseResult<()> {
|
data/rust/project/naming.rb
CHANGED
|
@@ -100,12 +100,14 @@ module RustProjection
|
|
|
100
100
|
|
|
101
101
|
# Every declared `name` in the IR, in document order. The `fields` maps under
|
|
102
102
|
# `mutations` are skipped: their keys are attribute names and their values
|
|
103
|
-
# are source text, not declarations.
|
|
103
|
+
# are source text, not declarations. So is `translations`: an era edge names
|
|
104
|
+
# data paths (`attendee.first_name`, a backfill into a nested value object)
|
|
105
|
+
# that the host applies to stored rows and never writes into Rust.
|
|
104
106
|
def declared_names(node, out = [])
|
|
105
107
|
case node
|
|
106
108
|
when Hash
|
|
107
109
|
node.each do |key, value|
|
|
108
|
-
if key.to_s
|
|
110
|
+
if %w[fields translations].include?(key.to_s)
|
|
109
111
|
next
|
|
110
112
|
elsif key.to_s == "name" && value.is_a?(String)
|
|
111
113
|
out << value
|
data/rust/src/kernel/cli.rs
CHANGED
|
@@ -16,6 +16,9 @@ pub fn run(input: &str) -> String {
|
|
|
16
16
|
Err(e) => return error_output(&format!("invalid JSON on stdin: {e}")),
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
+
// Optional `"needs"`: which commands need which outside facts (ADR 0081), passed by the host.
|
|
20
|
+
super::needs::install(parsed.get("needs"));
|
|
21
|
+
|
|
19
22
|
let steps = match parsed.get("steps").and_then(Json::as_array) {
|
|
20
23
|
Some(s) => s,
|
|
21
24
|
None => return error_output("expected a top-level {\"steps\": [...]} object"),
|