1use crate::firstparty;
12use bynk_syntax::CompileError;
13use bynk_syntax::span::Span;
14use std::collections::HashMap;
15use std::path::{Path, PathBuf};
16use std::sync::Arc;
17
18pub const MANIFEST: &str = "bynk.toml";
20pub const EXPERIMENTAL: &str = "bynk.stability.experimental";
22pub const NOT_EXPERIMENTAL: &str = "bynk.project.unstable_not_experimental";
24pub const UNKNOWN_ITEM: &str = "bynk.project.unstable_unknown_item";
26
27#[derive(Debug, Clone, Default, PartialEq, Eq)]
31pub struct UnitStability {
32 pub policy: Arc<StabilityPolicy>,
33 pub blocked_imports: HashMap<String, String>,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct StabilityPolicy {
40 pub allow: Vec<String>,
42 pub experimental: Vec<String>,
45}
46
47impl Default for StabilityPolicy {
48 fn default() -> Self {
55 Self::permissive()
56 }
57}
58
59impl StabilityPolicy {
60 pub fn for_project(project_root: Option<&Path>, overlay: &HashMap<PathBuf, String>) -> Self {
64 let allow = project_root
65 .map(|root| bynk_project::read_unstable_config(root, overlay).allow)
66 .unwrap_or_default();
67 Self {
68 allow,
69 experimental: firstparty::experimental_items(),
70 }
71 }
72
73 pub fn permissive() -> Self {
76 Self {
77 allow: Vec::new(),
78 experimental: Vec::new(),
79 }
80 }
81
82 pub fn is_experimental(&self, path: &str) -> bool {
84 self.experimental
85 .iter()
86 .any(|item| firstparty::covers(item, path))
87 }
88
89 pub fn blocks(&self, path: &str) -> bool {
92 self.is_experimental(path) && !firstparty::allows(&self.allow, path)
93 }
94
95 pub fn check(&self, path: &str, span: Span) -> Option<CompileError> {
97 self.blocks(path).then(|| {
98 CompileError::new(
99 EXPERIMENTAL,
100 span,
101 format!("`{path}` is experimental: it may change or disappear in any release"),
102 )
103 .with_note(format!(
104 "to use it anyway, add \"{path}\" to `[unstable] allow` in `bynk.toml`"
105 ))
106 })
107 }
108
109 pub fn manifest_diagnostics(&self) -> Vec<CompileError> {
116 let mut out = Vec::new();
117 for entry in &self.allow {
118 if !firstparty::names_first_party_item(entry) {
119 out.push(CompileError::new(
120 UNKNOWN_ITEM,
121 Span::default(),
122 format!(
123 "`bynk.toml`'s `[unstable] allow` lists `{entry}`, which names no standard-library item"
124 ),
125 )
126 .with_note(
127 "an entry is a unit (`bynk.cloudflare`), an item it exports (`bynk.cloudflare.Kv`) or an operation (`bynk.cloudflare.Kv.list`)",
128 ));
129 } else if !firstparty::allow_entry_is_live(&self.experimental, entry) {
130 out.push(CompileError::new(
131 NOT_EXPERIMENTAL,
132 Span::default(),
133 format!(
134 "`bynk.toml`'s `[unstable] allow` lists `{entry}`, but nothing it covers is experimental"
135 ),
136 )
137 .with_note("it is stable now; remove it from `[unstable] allow`"));
138 }
139 }
140 out
141 }
142
143 pub fn blocked_imports(
148 &self,
149 imported_from: &HashMap<String, String>,
150 ) -> HashMap<String, String> {
151 imported_from
152 .iter()
153 .filter(|(_, unit)| firstparty::unit(unit).is_some())
154 .map(|(name, unit)| (name.clone(), format!("{unit}.{name}")))
155 .filter(|(_, path)| self.blocks(path))
156 .collect()
157 }
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 fn policy(allow: &[&str], experimental: &[&str]) -> StabilityPolicy {
165 StabilityPolicy {
166 allow: allow.iter().map(|s| s.to_string()).collect(),
167 experimental: experimental.iter().map(|s| s.to_string()).collect(),
168 }
169 }
170
171 #[test]
172 fn an_experimental_item_is_blocked_until_allowed() {
173 let p = policy(&[], &["bynk.cloudflare.Kv.putTtl"]);
174 assert!(p.blocks("bynk.cloudflare.Kv.putTtl"));
175 assert!(!p.blocks("bynk.cloudflare.Kv.put"));
176 assert!(
177 !p.blocks("bynk.cloudflare.Kv"),
178 "its capability stays stable"
179 );
180 let err = p
181 .check("bynk.cloudflare.Kv.putTtl", Span::default())
182 .unwrap();
183 assert_eq!(err.category, "bynk.stability.experimental");
184 assert!(err.notes[0].contains("\"bynk.cloudflare.Kv.putTtl\""));
185
186 for allow in [
187 "bynk.cloudflare.Kv.putTtl",
188 "bynk.cloudflare.Kv",
189 "bynk.cloudflare",
190 ] {
191 assert!(
192 !policy(&[allow], &["bynk.cloudflare.Kv.putTtl"])
193 .blocks("bynk.cloudflare.Kv.putTtl"),
194 "{allow}"
195 );
196 }
197 }
198
199 #[test]
200 fn manifest_entries_are_checked_for_existence_and_staleness() {
201 let p = policy(
202 &[
203 "bynk.cloudflare.Kv.putTtl",
204 "bynk.cloudflare",
205 "bynk.list",
206 "bynk.cloudflare.Kvv",
207 "user.thing",
208 ],
209 &["bynk.cloudflare.Kv.putTtl"],
210 );
211 let found: Vec<(String, String)> = p
212 .manifest_diagnostics()
213 .into_iter()
214 .map(|e| (e.category.to_string(), e.message.clone()))
215 .collect();
216 let codes: Vec<&str> = found.iter().map(|(c, _)| c.as_str()).collect();
217 assert_eq!(codes, [NOT_EXPERIMENTAL, UNKNOWN_ITEM, UNKNOWN_ITEM]);
218 assert!(found[0].1.contains("`bynk.list`"), "stale: {found:?}");
219 assert!(found[1].1.contains("`bynk.cloudflare.Kvv`"));
220 assert!(found[2].1.contains("`user.thing`"));
221 }
222
223 #[test]
224 fn blocked_imports_are_first_party_experimental_and_unallowed() {
225 let imported = HashMap::from([
226 ("join".to_string(), "bynk.string".to_string()),
227 ("reverse".to_string(), "bynk.list".to_string()),
228 ("Money".to_string(), "app.money".to_string()),
229 ]);
230 let p = policy(&[], &["bynk.string.join", "app.money"]);
231 assert_eq!(
232 p.blocked_imports(&imported),
233 HashMap::from([("join".to_string(), "bynk.string.join".to_string())]),
234 "a user unit is never experimental, whatever the list says"
235 );
236 assert!(
237 policy(&["bynk.string"], &["bynk.string.join"])
238 .blocked_imports(&imported)
239 .is_empty()
240 );
241 }
242
243 const PROJECT: &[(&str, &str)] = &[
248 (
249 "app/probe.test.bynk",
250 "suite app.probe\n\ncase \"a case calls an operation\" {\n\tlet _ <- Kv.putTtl(\"k\", \"v\", 120)\n\texpect Kv.putTtl called once\n}\n",
251 ),
252 (
253 "app/qual.bynk",
254 "context app.qual\n\nconsumes bynk\n\nservice ping {\n\ton call() -> Effect[Int] given bynk.Clock {\n\t\tlet _ <- bynk.Clock.now()\n\t\tEffect.pure(1)\n\t}\n}\n",
255 ),
256 (
257 "app/probe.bynk",
258 "context app.probe\n\nuses bynk.string\nuses bynk.locale.types\nconsumes bynk { Random }\nconsumes bynk.cloudflare { Kv }\n\nservice probe {\n\ton call(xs: List[String]) -> Effect[String] given Kv {\n\t\tlet _ <- Kv.putTtl(\"k\", \"v\", 120)\n\t\tlet joiner: (List[String], String) -> String = join\n\t\tEffect.pure(join(xs, \",\"))\n\t}\n}\n",
259 ),
260 ];
261
262 fn experimental_uses(policy: StabilityPolicy) -> Vec<(String, String)> {
265 let analysed = crate::testkit::analyse_with(PROJECT, Some(policy));
266 let mut out: Vec<(String, String)> = analysed
267 .analysis
268 .errors
269 .iter()
270 .filter(|a| a.error.category == "bynk.stability.experimental")
271 .map(|a| {
272 let path = a.error.message.split('`').nth(1).unwrap_or("").to_string();
273 let (start, end) = (a.error.span.start, a.error.span.end);
274 let file = a.source_path.as_deref().expect("a use is in a file");
275 let (_, text) = PROJECT
276 .iter()
277 .find(|(rel, _)| file.ends_with(rel))
278 .expect("a project file");
279 (path, text[start..end].to_string())
280 })
281 .collect();
282 out.sort();
283 out
284 }
285
286 #[test]
290 fn every_reference_point_refuses_an_experimental_item() {
291 let experimental = &[
292 "bynk.Clock.now",
293 "bynk.Random",
294 "bynk.locale.types",
295 "bynk.string.join",
296 "bynk.cloudflare.Kv.putTtl",
297 ];
298 assert_eq!(
299 experimental_uses(policy(&[], experimental)),
300 [
301 ("bynk.Clock.now".to_string(), "now".to_string()),
302 ("bynk.Random".to_string(), "Random".to_string()),
303 (
304 "bynk.cloudflare.Kv.putTtl".to_string(),
305 "putTtl".to_string()
306 ),
307 (
308 "bynk.locale.types".to_string(),
309 "bynk.locale.types".to_string()
310 ),
311 ("bynk.string.join".to_string(), "join".to_string()),
312 ("bynk.string.join".to_string(), "join".to_string()),
313 ],
314 "a consumes selection, a uses, a capability operation (flattened and qualified), and a function called and passed as a value in a handler; never in a suite case"
315 );
316 assert!(
317 experimental_uses(policy(
318 &[
319 "bynk.Clock",
320 "bynk.Random",
321 "bynk.locale",
322 "bynk.string",
323 "bynk.cloudflare.Kv"
324 ],
325 experimental
326 ))
327 .is_empty(),
328 "allow entries cover their path and everything under it"
329 );
330 assert!(
331 experimental_uses(StabilityPolicy::default()).is_empty(),
332 "the default fails open"
333 );
334 }
335
336 fn codes(source: &str) -> Vec<&'static str> {
338 crate::testkit::analyse(&[("app/probe.bynk", source)])
339 .analysis
340 .errors
341 .iter()
342 .map(|a| a.error.category)
343 .collect()
344 }
345
346 const FETCH_MATCH: &str = "context app.probe\n\nconsumes bynk { Fetch }\n\nservice probe {\n\ton call(url: String) -> Effect[String] given Fetch {\n\t\tlet res <- Fetch.send(Request { method: Get, url: url, contentType: None, authorization: None, headers: Map.empty(), body: None })\n\t\tmatch res {\n\t\t\tOk(r) => Effect.pure(\"ok\")\n\t\t\tErr(Network) => Effect.pure(\"network\")\n\t\t\tErr(Timeout) => Effect.pure(\"timeout\")\n\t\t\tErr(InvalidHeader) => Effect.pure(\"header\")\nWILDCARD\t\t}\n\t}\n}\n";
347
348 #[test]
352 fn an_extensible_enum_needs_a_wildcard_which_is_then_reachable() {
353 assert_eq!(
354 codes(&FETCH_MATCH.replace("WILDCARD", "")),
355 ["bynk.types.extensible_match_needs_wildcard"]
356 );
357 assert_eq!(
358 codes(&FETCH_MATCH.replace("WILDCARD", "\t\t\tErr(_) => Effect.pure(\"later\")\n")),
359 Vec::<&str>::new(),
360 "a wildcard satisfies it, and is not `bynk.types.unreachable_arm`"
361 );
362 assert_eq!(
363 codes(&FETCH_MATCH.replace("WILDCARD", "\t\t\tother => Effect.pure(\"later\")\n")),
364 Vec::<&str>::new(),
365 "so does a binding"
366 );
367 }
368
369 #[test]
372 fn a_users_own_enum_of_the_same_name_is_not_extensible() {
373 assert_eq!(
374 codes(
375 "context app.probe\n\ntype FetchError = enum { Network, Timeout, InvalidHeader }\n\nfn label(e: FetchError) -> String {\n\tmatch e {\n\t\tNetwork => \"n\"\n\t\tTimeout => \"t\"\n\t\tInvalidHeader => \"h\"\n\t}\n}\n\nservice probe {\n\ton call() -> Effect[String] {\n\t\tEffect.pure(label(Network))\n\t}\n}\n"
376 ),
377 Vec::<&str>::new()
378 );
379 }
380
381 #[test]
385 fn the_default_policy_fails_open() {
386 assert_eq!(StabilityPolicy::default(), StabilityPolicy::permissive());
387 assert_eq!(
388 UnitStability::default().policy.experimental,
389 Vec::<String>::new()
390 );
391 }
392
393 #[test]
398 fn the_shipped_policy_blocks_only_the_experimental_items() {
399 let p = StabilityPolicy::for_project(None, &HashMap::new());
400 assert_eq!(
401 p.experimental,
402 [
403 "bynk.cloudflare.Service",
404 "bynk.cloudflare.R2",
405 "bynk.cloudflare.RateLimit",
406 "bynk.cloudflare.EdgeCache",
407 "bynk.crypto"
408 ]
409 );
410 assert!(!p.blocks("bynk.cloudflare.Kv.list"));
411 assert!(p.blocks("bynk.cloudflare.Service"));
412 assert!(p.blocks("bynk.cloudflare.Service.send"));
413 assert!(p.blocks("bynk.cloudflare.R2"));
414 assert!(p.blocks("bynk.cloudflare.R2.putWith"));
415 assert!(!p.blocks("bynk.cloudflare.R2Put"));
416 assert!(p.blocks("bynk.cloudflare.RateLimit.allow"));
417 assert!(p.blocks("bynk.cloudflare.EdgeCache.purge"));
418 assert!(p.manifest_diagnostics().is_empty());
419 }
420}