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}