Skip to main content

bynk_check/
requirements.rs

1//! v0.99 (ADR — agent capability provenance): the capability-requirement ledger.
2//!
3//! A requirement is *where a capability is needed and why*. The checker already
4//! decides, at every capability-consuming site, whether the enclosing handler's
5//! `given` covers it (and errors when it does not). This sink records **every**
6//! such decision — covered or not — so the editor surfaces can answer two
7//! questions the bare diagnostic cannot:
8//!
9//! - *"What `given` does this handler still need?"* — the uncovered requirements
10//!   drive the materializable ghost `given` inlay hint (DECISION E).
11//! - *"Why does this handler declare `given Clock`?"* — the covered requirements
12//!   explain, on hover, what a declared capability is *for*.
13//!
14//! The sink mirrors [`HintSink`](crate::hints::HintSink): a `&mut` parameter
15//! threaded through the checker entry points, NOT part of the `Ok(TypedCommons)`
16//! payload — so requirements persist through a transient type error at every
17//! site the checker still reaches. Spans are bare byte offsets into the file the
18//! sink was attributed to via [`RequirementSink::enter_file`].
19//!
20//! **Decisive property (DECISION C):** the human *reason* is a total function of
21//! the [`RequirementSource`] — a small closed enum — with **no per-capability
22//! text**. Adding a new capability needs zero new reason text; a fragment is
23//! authored only when a new capability-*consuming feature* is added (a store
24//! kind, a builtin) — a closed, compiler-internal set.
25
26use bynk_syntax::span::Span;
27use std::collections::HashMap;
28use std::path::{Path, PathBuf};
29
30/// Why a capability requirement arises. The reason renders from this alone.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum RequirementSource {
33    /// The body calls `Cap.op(...)` directly — the call site *is* the
34    /// explanation. Correct for **any** capability, including user-defined ones
35    /// (`Payments.authorise` → *"calls `Payments.authorise`"*) with no bespoke
36    /// text.
37    DirectCall { op: String },
38    /// A storage op consumes a capability. `(kind, op)` keys a reason fragment
39    /// owned by the storage feature — the only code that knows *why* a store
40    /// needs a capability (e.g. `Cache` eviction reads the clock).
41    StoreOp { kind: StoreKind, op: String },
42    /// A language builtin draws on a capability (e.g. `Uuid` → `Random`). The
43    /// fragment is owned by the builtin's surface.
44    Builtin { feature: String },
45}
46
47/// The storage kinds that consume a capability. A closed set: a new kind is a
48/// deliberate language addition, and adding one is the only time a new store
49/// reason fragment is authored.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum StoreKind {
52    Cache,
53    Log,
54}
55
56impl StoreKind {
57    pub fn as_str(self) -> &'static str {
58        match self {
59            StoreKind::Cache => "Cache",
60            StoreKind::Log => "Log",
61        }
62    }
63}
64
65impl RequirementSource {
66    /// The human reason for a requirement, derived purely from the source — no
67    /// per-capability text. `capability` names the required capability so a
68    /// `DirectCall` can render `calls \`Cap.op\``.
69    pub fn reason(&self, capability: &str) -> String {
70        match self {
71            RequirementSource::DirectCall { op } => format!("calls `{capability}.{op}`"),
72            RequirementSource::StoreOp { kind, op } => store_reason(*kind, op).to_string(),
73            RequirementSource::Builtin { feature } => {
74                format!("the `{feature}` builtin draws on `{capability}`")
75            }
76        }
77    }
78}
79
80/// The storage feature's `(kind, op) -> reason` table — the one place that knows
81/// *why* a store consumes a capability. Total over the clock-consuming store ops
82/// (`requirements::tests::store_reason_table_is_total` pins this); a `(kind, op)`
83/// outside the table falls back to a generic phrasing rather than panicking, so
84/// a new store op can never crash a render before its fragment is authored.
85pub fn store_reason(kind: StoreKind, op: &str) -> &'static str {
86    match (kind, op) {
87        // Every `Cache` op but `remove` applies TTL expiry, which reads the clock.
88        (StoreKind::Cache, "put" | "get" | "update" | "upsert" | "contains" | "size") => {
89            "a `Cache` operation applies TTL expiry, which reads the clock"
90        }
91        // `Log.append` stamps the current time.
92        (StoreKind::Log, "append") => "`Log.append` stamps the current time, which reads the clock",
93        _ => "a storage operation consumes this capability",
94    }
95}
96
97/// One capability requirement: which capability, at what site, why, and whether
98/// the enclosing handler already covers it.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct Requirement {
101    /// The required capability's simple name (the deps key) — e.g. `Clock`.
102    pub capability: String,
103    /// The consuming site (the op or direct-call span).
104    pub site: Span,
105    pub source: RequirementSource,
106    /// `true` when the enclosing handler's `given` already lists the capability.
107    /// An uncovered requirement drives the ghost `given` inlay hint; a covered
108    /// one explains, on hover, what a declared capability is for.
109    pub covered: bool,
110    /// For an **uncovered** requirement, where the ghost `given` would render
111    /// (the handler's return-type span) — and the edit that materializes the
112    /// clause. `None` for covered requirements and where no anchor applies
113    /// (e.g. a provider body, which has no return-type-anchored `given`).
114    pub materialize: Option<Materialize>,
115}
116
117/// The data the ghost `given` inlay hint needs to render and one-click apply.
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct Materialize {
120    /// Where the ghost clause renders — the end of the handler's return type.
121    pub anchor: Span,
122    /// The text edit that writes the real `given Cap` (or `, Cap`) clause.
123    pub edit_span: Span,
124    pub edit_text: String,
125}
126
127/// Project-relative source path → that file's requirements, span-ordered.
128pub type FileRequirements = HashMap<PathBuf, Vec<Requirement>>;
129
130/// Records capability requirements per file. A fresh sink records nothing until
131/// [`enter_file`](Self::enter_file) attributes it.
132#[derive(Debug, Default)]
133pub struct RequirementSink {
134    files: FileRequirements,
135    file: Option<PathBuf>,
136    /// Set for synthetic (toolchain-injected) and test/integration files:
137    /// requirements are discarded — they never surface in an editor.
138    muted: bool,
139}
140
141impl RequirementSink {
142    pub fn new() -> Self {
143        Self::default()
144    }
145
146    /// Enter a per-file recording context.
147    pub fn enter_file(&mut self, file: &Path, muted: bool) {
148        self.file = Some(file.to_path_buf());
149        self.muted = muted;
150    }
151
152    /// Record a requirement. Dropped when muted or before any `enter_file`.
153    pub fn record(&mut self, req: Requirement) {
154        if self.muted {
155            return;
156        }
157        let Some(file) = &self.file else {
158            return;
159        };
160        self.files.entry(file.clone()).or_default().push(req);
161    }
162
163    /// Drain the recorded requirements, each file's entries ordered by span.
164    pub fn take_files(&mut self) -> FileRequirements {
165        let mut files = std::mem::take(&mut self.files);
166        for reqs in files.values_mut() {
167            reqs.sort_by_key(|r| (r.site.start, r.site.end));
168        }
169        files
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    /// DECISION C: the store-reason table is total over the clock-consuming
178    /// store ops — every op that records a `StoreOp` requirement has a concrete
179    /// (non-fallback) fragment, so the reason is never the generic placeholder
180    /// for a real requirement.
181    #[test]
182    fn store_reason_table_is_total() {
183        let cache_ops = ["put", "get", "update", "upsert", "contains", "size"];
184        for op in cache_ops {
185            let r = store_reason(StoreKind::Cache, op);
186            assert_ne!(
187                r, "a storage operation consumes this capability",
188                "Cache.{op} has no concrete reason fragment"
189            );
190        }
191        assert_ne!(
192            store_reason(StoreKind::Log, "append"),
193            "a storage operation consumes this capability",
194            "Log.append has no concrete reason fragment"
195        );
196    }
197
198    /// DECISION C: a user-defined capability's `DirectCall` reason renders with
199    /// no bespoke entry — the call site is the whole explanation.
200    #[test]
201    fn direct_call_reason_needs_no_bespoke_entry() {
202        let src = RequirementSource::DirectCall {
203            op: "authorise".to_string(),
204        };
205        assert_eq!(src.reason("Payments"), "calls `Payments.authorise`");
206    }
207}