Skip to main content

bynk_check/
stability.rs

1//! Experimental standard-library items and the project's opt-in to them
2//! (standard-library S3, #1963; track D2).
3//!
4//! A first-party item (a unit, an exported item, or a capability operation, by
5//! dotted path) is stable or experimental. Using an experimental one needs an
6//! entry covering it under `bynk.toml`'s `[unstable] allow`. One
7//! [`StabilityPolicy`] per project carries that list and the experimental items,
8//! and both the build and the editor build it the same way
9//! ([`StabilityPolicy::for_project`]), so they never disagree about a file.
10
11use 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
18/// The manifest the `[unstable]` diagnostics are attributed to.
19pub const MANIFEST: &str = "bynk.toml";
20/// The diagnostic for using an experimental item without opting in.
21pub const EXPERIMENTAL: &str = "bynk.stability.experimental";
22/// The warning for an `[unstable] allow` entry that reaches nothing experimental.
23pub const NOT_EXPERIMENTAL: &str = "bynk.project.unstable_not_experimental";
24/// The error for an `[unstable] allow` entry that names no first-party item.
25pub const UNKNOWN_ITEM: &str = "bynk.project.unstable_unknown_item";
26
27/// The stability facts one unit's checks need: the project's policy, for the
28/// paths a check computes itself (a capability operation), and the unit's
29/// imports that are blocked, by name, decided where provenance is known.
30#[derive(Debug, Clone, Default, PartialEq, Eq)]
31pub struct UnitStability {
32    pub policy: Arc<StabilityPolicy>,
33    /// Imported name → item path, for each import [`StabilityPolicy::blocks`].
34    pub blocked_imports: HashMap<String, String>,
35}
36
37/// A project's stability policy: what is experimental, and what it allows.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct StabilityPolicy {
40    /// The `[unstable] allow` entries, in manifest order.
41    pub allow: Vec<String>,
42    /// The experimental item paths; an item under one is experimental too.
43    /// [`firstparty::experimental_items`] unless a test supplies its own.
44    pub experimental: Vec<String>,
45}
46
47impl Default for StabilityPolicy {
48    /// Blocks nothing. A check input built without
49    /// [`crate::resolver::ResolvedCommons::with_stability`] (a suite case body,
50    /// `resolve_recovering`) is under neither rule, so the default must fail
51    /// open: a default that knew the experimental items but no `allow` would
52    /// refuse them while ignoring the project's opt-in. The real policy is
53    /// built only by [`StabilityPolicy::for_project`].
54    fn default() -> Self {
55        Self::permissive()
56    }
57}
58
59impl StabilityPolicy {
60    /// The policy for the project at `project_root`, reading `[unstable]` from
61    /// the overlay's `bynk.toml` or the one on disk. No project root (a
62    /// single-file or in-memory build) allows nothing.
63    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    /// A policy under which nothing is experimental: for a first-party unit's
74    /// own bodies, whose dependencies are the library's business.
75    pub fn permissive() -> Self {
76        Self {
77            allow: Vec::new(),
78            experimental: Vec::new(),
79        }
80    }
81
82    /// Whether `path` is experimental.
83    pub fn is_experimental(&self, path: &str) -> bool {
84        self.experimental
85            .iter()
86            .any(|item| firstparty::covers(item, path))
87    }
88
89    /// Whether using `path` here is refused: it is experimental and no `allow`
90    /// entry covers it.
91    pub fn blocks(&self, path: &str) -> bool {
92        self.is_experimental(path) && !firstparty::allows(&self.allow, path)
93    }
94
95    /// The error for a use of `path` at `span`, when [`Self::blocks`] refuses it.
96    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    /// The diagnostics for the `[unstable] allow` entries themselves, which
110    /// callers attribute to the project's `bynk.toml` ([`MANIFEST`]): an error
111    /// for one that names no first-party
112    /// item, and a warning for one that reaches nothing experimental (stable
113    /// now, so it can go; never an error, so stabilising an item breaks no
114    /// build).
115    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    /// The names a unit imports that are experimental and not allowed, mapped
144    /// to their item paths: `imported_from` is name → providing unit, so the
145    /// path is `unit.name`. Decided once per unit, where provenance is known,
146    /// and carried to every check built from it.
147    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    /// The project the end-to-end tests analyse: one reference of each kind to
244    /// a shipped first-party item, the function call inside a handler body
245    /// (where `imported_from` is not available, so the per-unit blocked imports
246    /// must have been carried there).
247    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    /// Every `bynk.stability.experimental` the analysis reports, as
263    /// `(item path, reported text)`.
264    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    /// End to end, through the analysis the editor runs: each kind of
287    /// reference to an experimental item is refused at its own span, and an
288    /// `allow` entry covering it lifts the refusal.
289    #[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    /// The codes a one-file project's analysis reports.
337    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    /// An extensible enum (S3, #1963): listing every variant it has today is
349    /// not enough; a wildcard is, and it is then not reported unreachable (the
350    /// future variant is still uncovered when it is reached).
351    #[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    /// A user's own enum of an extensible enum's name is not extensible: the
370    /// rule follows the first-party unit's enum, not the name.
371    #[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    /// An input built without `with_stability` (a suite case body,
382    /// `resolve_recovering`) must fail open: the default is the permissive
383    /// policy, not the shipped experimental list with an empty `allow`.
384    #[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    /// The shipped policy blocks only the experimental items: today
394    /// `bynk.cloudflare`'s `Service` (interop I5, #1972) and `R2` (P5,
395    /// #2323), and their operations with them, and the whole of `bynk.crypto`
396    /// (K4).
397    #[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}