Skip to main content

lean_ctx/core/session/
finding_sources.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Findings recorded together with the source they come from.
3//!
4//! `Finding` keeps its shape; the binding of a finding lives beside the
5//! session store (`archive::bindings`). A finding is bound only to a source
6//! that exists at the write and that the admitted reader returns then; every
7//! other finding is recorded exactly as before and is originless.
8
9use crate::core::archive::authority::ArchiveAuthority;
10use crate::core::archive::bindings::{self, Item};
11use crate::core::context_admission::stored::SourceRef;
12
13use super::SessionState;
14
15impl SessionState {
16    /// [`Self::add_finding`] for a finding an agent records for `file`: the
17    /// finding is bound to that file as the admitted reader returns it now.
18    /// No file, a file that cannot be bound (missing, outside the project,
19    /// not permitted, restricted) or a session without a project root: the
20    /// finding is recorded unbound.
21    pub fn add_finding_for_file(&mut self, file: Option<&str>, line: Option<u32>, summary: &str) {
22        self.add_finding(file, line, summary);
23        self.bind_last_finding(|project| bindings::sources_at(project, &[file?], "ctx_session"));
24    }
25
26    /// [`Self::add_finding`] for a finding extracted from the output of the
27    /// current tool call. `call_sources` is the call's captured source set
28    /// and is passed only when the tool declares every source: the finding
29    /// is then bound to exactly those files. `None`, or an incomplete set:
30    /// recorded unbound.
31    pub fn add_finding_from_call(
32        &mut self,
33        file: Option<&str>,
34        summary: &str,
35        call_sources: Option<&ArchiveAuthority>,
36    ) {
37        self.add_finding(file, None, summary);
38        self.bind_last_finding(|_| bindings::sources_of_call(call_sources?));
39    }
40
41    /// Bind the finding just recorded, with its text as the session keeps it.
42    fn bind_last_finding(&self, sources: impl FnOnce(&str) -> Option<Vec<SourceRef>>) {
43        let (Some(project), Some(finding)) = (self.project_root.as_deref(), self.findings.last())
44        else {
45            return;
46        };
47        let Some(sources) = sources(project) else {
48            return;
49        };
50        bindings::bind(
51            &Item::SessionFinding {
52                session: &self.id,
53                file: finding.file.as_deref(),
54            },
55            &finding.summary,
56            project,
57            sources,
58        );
59    }
60
61    /// Whether `finding` of this session may be delivered now, for every
62    /// renderer that prints a finding (`archive::bindings::serve`): a finding
63    /// bound to sources is re-authorized for the session's project — `false`
64    /// means it must be left out — and, served, publishes its files into the
65    /// call's capture; a finding without a binding marks the capture
66    /// incomplete and is delivered as before. Without protection every
67    /// finding is delivered and nothing is published.
68    pub fn finding_served(&self, finding: &super::types::Finding) -> bool {
69        bindings::serve(
70            &Item::SessionFinding {
71                session: &self.id,
72                file: finding.file.as_deref(),
73            },
74            &finding.summary,
75            self.project_root.as_deref(),
76        )
77        .is_ok()
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use crate::core::archive::delivered::LineageRefusal;
85    use crate::core::context_admission::stored::lineage_support::{
86        deliver, governed, project, reusable,
87    };
88    use crate::core::policy::runtime::TestPolicyOverride;
89
90    /// Deliver the last finding of `session` through the binding store.
91    fn delivered(root: &str, session: &SessionState) -> (String, usize) {
92        let finding = session.findings.last().expect("a finding").clone();
93        let id = session.id.clone();
94        deliver(root, || {
95            let item = Item::SessionFinding {
96                session: &id,
97                file: finding.file.as_deref(),
98            };
99            match bindings::serve(&item, &finding.summary, Some(root)) {
100                Ok(()) => format!("finding: {}", finding.summary),
101                Err(withheld) => withheld.notice(),
102            }
103        })
104    }
105
106    /// A finding recorded for a readable project file is bound to it; one
107    /// recorded for no file, for a missing file or for a restricted one is
108    /// recorded all the same, unbound.
109    #[test]
110    fn a_finding_is_bound_to_its_file_only_when_the_file_is_admitted() {
111        let _data = governed();
112        let _community = TestPolicyOverride::set(None);
113        let (_directory, root) = project();
114        std::fs::write(format!("{root}/pool.rs"), "pub fn lease() {}\n").expect("source");
115        std::fs::write(format!("{root}/plan.md"), "*** TOP SECRET ***\nlaunch\n").expect("marked");
116        let mut session = SessionState::new();
117        session.project_root = Some(root.clone());
118
119        session.add_finding_for_file(Some("pool.rs"), Some(1), "the pool leaks one lease");
120        let (reply, recorded) = delivered(&root, &session);
121        assert!(reply.contains("leaks one lease"), "{reply}");
122        assert_eq!(recorded, 1);
123        assert_eq!(reusable(&root, &reply), Ok(()));
124
125        for (file, summary) in [
126            (None, "noted without a file"),
127            (Some("missing.rs"), "noted for a missing file"),
128            (Some("plan.md"), "noted for a restricted file"),
129        ] {
130            session.add_finding_for_file(file, None, summary);
131            assert_eq!(
132                session
133                    .findings
134                    .last()
135                    .map(|finding| finding.summary.as_str()),
136                Some(summary),
137                "the finding is recorded either way"
138            );
139            let (unbound, recorded) = delivered(&root, &session);
140            assert_eq!(recorded, 0, "{summary}");
141            assert_eq!(reusable(&root, &unbound), Err(LineageRefusal::Unknown));
142        }
143
144        std::fs::remove_file(format!("{root}/pool.rs")).expect("delete");
145        assert_eq!(
146            reusable(&root, &reply),
147            Err(LineageRefusal::SourceNoLongerAdmitted)
148        );
149    }
150
151    /// A finding extracted from a call is bound to the call's source set, and
152    /// unbound without one. A session state serialized before bindings
153    /// existed loads unchanged; its findings are originless.
154    #[test]
155    fn a_call_finding_takes_the_sources_of_its_call() {
156        let _data = governed();
157        let _community = TestPolicyOverride::set(None);
158        let (_directory, root) = project();
159        let path = format!("{root}/build.rs");
160        std::fs::write(&path, "fn main() { panic!(\"linker\") }\n").expect("source");
161        let admitted =
162            crate::core::rooted_read::read_file_for_tool_rooted(&path, &root, "ctx_read")
163                .expect("admitted read");
164        let capture =
165            ArchiveAuthority::file(std::path::Path::new(&root), &path, "ctx_read", &admitted)
166                .expect("protection is active: the read is bound");
167        let mut session = SessionState::new();
168        session.project_root = Some(root.clone());
169
170        session.add_finding_from_call(Some("build.rs"), "build.rs panics", Some(&capture));
171        let (reply, recorded) = delivered(&root, &session);
172        assert_eq!(recorded, 1);
173        assert_eq!(reusable(&root, &reply), Ok(()));
174
175        session.add_finding_from_call(Some("build.rs"), "from an undeclared tool", None);
176        let (_, recorded) = delivered(&root, &session);
177        assert_eq!(recorded, 0);
178
179        let mut old: SessionState =
180            serde_json::from_str(&serde_json::to_string(&session).expect("serialize"))
181                .expect("a session state without bindings loads");
182        old.id = "a-session-written-before-bindings".to_owned();
183        assert_eq!(old.findings.len(), 2);
184        let (_, recorded) = delivered(&root, &old);
185        assert_eq!(recorded, 0, "no register entry: originless");
186    }
187
188    /// The renderers serve every finding they print. The delivered summary
189    /// of a session with one source-bound finding has that file's lineage; a
190    /// finding without a file, or a decision, leaves it unknown; when the
191    /// file is withheld by a tightened rule or deleted, the lineage refuses
192    /// and no renderer shows the finding any more.
193    #[test]
194    fn the_renderers_deliver_findings_under_their_current_sources() {
195        let _data = governed();
196        let _community = TestPolicyOverride::set(None);
197        let (_directory, root) = project();
198        std::fs::write(format!("{root}/pool.rs"), "pub fn lease() {}\n").expect("source");
199        let mut session = SessionState::new();
200        session.project_root = Some(root.clone());
201        session.add_finding_for_file(Some("pool.rs"), Some(1), "the pool leaks one lease");
202        let summary =
203            |session: &SessionState| deliver(&root, || session.format_compact_delivered());
204
205        let (reply, recorded) = summary(&session);
206        assert!(reply.contains("the pool leaks one lease"), "{reply}");
207        assert_eq!(recorded, 1);
208        assert_eq!(reusable(&root, &reply), Ok(()));
209
210        let mut mixed = session.clone();
211        mixed.add_finding_for_file(None, None, "noted without a file");
212        let (unknown, recorded) = summary(&mixed);
213        assert!(unknown.contains("noted without a file"), "{unknown}");
214        assert_eq!(recorded, 0, "one finding without a source: unknown");
215        assert_eq!(reusable(&root, &unknown), Err(LineageRefusal::Unknown));
216
217        let mut decided = session.clone();
218        decided.add_decision("lease from the pool only", None);
219        assert_eq!(summary(&decided).1, 0, "a decision has no source: unknown");
220
221        std::fs::write(
222            format!("{root}/pool.rs"),
223            "*** TOP SECRET ***\npub fn lease() {}\n",
224        )
225        .expect("mark");
226        assert_eq!(
227            reusable(&root, &reply),
228            Err(LineageRefusal::SourceNoLongerAdmitted)
229        );
230        for rendered in [
231            session.format_compact(),
232            session.build_compaction_snapshot(),
233            session.build_resume_block(),
234        ] {
235            assert!(!rendered.contains("the pool leaks one lease"), "{rendered}");
236        }
237        // The finding without a file is still shown.
238        assert!(mixed.format_compact().contains("noted without a file"));
239
240        std::fs::remove_file(format!("{root}/pool.rs")).expect("delete");
241        assert_eq!(
242            reusable(&root, &reply),
243            Err(LineageRefusal::SourceNoLongerAdmitted)
244        );
245        let (withheld, recorded) = summary(&session);
246        assert!(!withheld.contains("the pool leaks one lease"), "{withheld}");
247        assert_eq!(recorded, 0);
248    }
249}