lean_ctx/core/bm25_index/source_gate.rs
1// SPDX-License-Identifier: Apache-2.0
2//! Current source rights for index entries at the moment they are served
3//! (G04).
4//!
5//! An index chunk is text copied out of a source at build time. The index
6//! knows which file it came from (`CodeChunk::file_path`) but nothing in the
7//! chunk changes when that file is deleted, restricted, reclassified or
8//! redacted by a rule that did not exist at the build. The digest an index is
9//! bound to (`BM25Index::admission_policy`) covers the gateway policy only,
10//! and a staleness check is a rebuild trigger, not an authorization.
11//!
12//! So an index its store loaded or handed out is asked again for every chunk
13//! it is about to serve: the source is read NOW through the admitted rooted
14//! reader (`core::admitted_sources::read` — path jail, role, project policy,
15//! gateway, decided on the original bytes), and the chunk is served only if
16//! that read is storable and still contains the chunk's text. A source that
17//! is gone, outside the root, withheld, restricted, lossily decoded, changed
18//! or now rewritten at that place does not serve the chunk. This is the same
19//! question `context_admission::stored::serve_stored` asks of a bound copy;
20//! the reader is the one admission, not a second one.
21//!
22//! Where a source authority is required (`source_authority_required`, asked
23//! for the gate's own project), a provider chunk (`scheme://…`) whose origin
24//! the index has on record (`provider_origin`) is asked of its provider the
25//! same way; without protection it follows the originless rule below. The item is
26//! reacquired NOW through `ProviderOrigin::current_chunks` — the check the
27//! knowledge store and the checkpoint import already use (provider tool
28//! allowed for the role and the project policy, same project, providers
29//! enabled, the provider's reuse binding unchanged, the item still returned
30//! and unchanged, content under the project policy) — then passed through the
31//! store admission it was written through, and the chunk is served only if
32//! that text still contains it. One provider request per distinct item,
33//! bounded per gate; over the bound, or without an answer, it is not served.
34//!
35//! The other chunks without a readable text file have an origin of their
36//! own kind on record (`external_origin`), asked the same way:
37//!
38//! - delivered tool output (where a source authority is required): the
39//! origin is bound to this chunk and the lineage of its delivery holds
40//! (`archive::delivered::Lineage::still_admitted` — complete, every source
41//! re-read under the current policy and role and unchanged). Its sources
42//! are then published to the running call like a file read;
43//! - downstream tool output (where a source authority is required): closed.
44//! A tool call cannot be replayed, so nothing proves the text current;
45//! it is not served, not ranked and not counted until downstream tools
46//! have a re-read contract (`external_origin::DownstreamOrigin`);
47//! - an extracted document (in every mode, a changed file is stale for
48//! everyone): the rooted reader's path decision admits the file and its
49//! bytes are the ones the extraction read (`DocumentOrigin::unchanged`).
50//!
51//! In each case the chunk's text must also pass the content admission
52//! unchanged. A served chunk of a provider or a document has no file
53//! lineage: it marks the call's source set unknown.
54//!
55//! A chunk with no origin on record — a chunk written before origins were
56//! kept, a recomputed source that records none — has no source authority
57//! and follows the originless rule of `serve_stored`: withheld while
58//! protection is active in any layer, re-admitted under the content rules in
59//! Community developer mode (an extracted document must also still exist).
60//!
61//! ONE gateway snapshot per gate: the gateway rules are captured when the
62//! gate is built (or handed in by a caller that already judged something
63//! under a snapshot, `SourceGate::with_admission`) and every decision of
64//! the gate uses them — file reads, provider re-admission, the content
65//! admission of chunk text, originless decisions. A configuration that
66//! changes and changes back during a call cannot admit a source the
67//! snapshot refuses. The project pack and the role are those current at
68//! each decision (a caller that needs them pinned holds a source view).
69//!
70//! The reads run inside the call's source capture and Decision Receipt: a
71//! served chunk gives the result an ordinary file lineage, and a refusal is
72//! counted like any other admission of that call. Nothing is cached beyond
73//! one gate; a gate lives for one search.
74
75use std::collections::HashMap;
76use std::path::{Path, PathBuf};
77use std::sync::Arc;
78
79use super::external_origin::ChunkOrigin;
80use super::{BM25Index, ChunkOrigins, CodeChunk, SearchResult};
81use crate::core::context_admission::stores::StoreAdmission;
82use crate::core::providers::provenance::{ProviderOrigin, with_reuse_deadline_until};
83
84/// Tool identity the sources of a search index are re-read for.
85pub(super) const SEARCH_TOOL: &str = "ctx_search";
86
87/// Delivery lineages one gate re-checks (each re-reads every source of one
88/// delivery). Over it an undecided tool-output chunk is refused.
89const MAX_LINEAGE_CHECKS: usize = 16;
90
91/// Distinct sources one gate reads AND remembers — one bound for both. Over
92/// it the gate refuses (fail closed): a source it has not decided yet is not
93/// read, not served and not added to the memo; sources already decided keep
94/// their decision. Fewer results, never an unchecked one, never unbounded
95/// memory.
96pub(crate) const MAX_SOURCES: usize = 128;
97
98/// Provider items one gate reacquires (each is a provider request with the
99/// provider's own deadline). Over it an undecided provider chunk is refused.
100const MAX_PROVIDER_CHECKS: usize = 16;
101
102/// Time one gate — one search — may spend on provider re-acquisition, counted
103/// from its first provider request. A hard bound: no request is started
104/// after it, and a request in flight ends at it
105/// (`providers::provenance::with_reuse_deadline_until` — the earlier of this
106/// deadline and the provider's own). The provider chunks left undecided are
107/// refused (not remembered) and the search returns what was decided. Three
108/// seconds keeps an interactive search answerable while leaving room for a
109/// few items of a healthy provider.
110const PROVIDER_BUDGET: std::time::Duration = std::time::Duration::from_secs(3);
111
112/// The serve-time check of one search over one index.
113pub struct SourceGate {
114 root: PathBuf,
115 tool: &'static str,
116 /// Per source asked so far: the admitted text as the reader returns it
117 /// now, with line endings normalised; `None` when it is not admitted.
118 sources: HashMap<String, Option<String>>,
119 /// Per source whose origin is not compared with a source text (delivered
120 /// or downstream tool output, a document): whether its authority holds.
121 authorities: HashMap<String, bool>,
122 /// The gateway rules every decision of this gate is made under.
123 admission: StoreAdmission,
124 /// Origins the index has on record, by chunk source.
125 origins: Arc<ChunkOrigins>,
126 provider_checks: usize,
127 lineage_checks: usize,
128 /// End of this gate's provider budget, set at its first provider
129 /// request ([`PROVIDER_BUDGET`]).
130 provider_deadline: Option<std::time::Instant>,
131 /// The root as the request scope names it (canonical when resolvable).
132 scope_root: PathBuf,
133 /// Whether a source authority is required for the project of this
134 /// gate's root; asked once per gate.
135 protected: Option<bool>,
136 withheld: usize,
137}
138
139impl SourceGate {
140 /// A gate that captures the gateway rules as they are configured now
141 /// (`carrier::gateway_snapshot`, unobserved) and decides everything
142 /// under them.
143 pub(crate) fn new(root: &Path, tool: &'static str) -> Self {
144 Self::with_admission(
145 root,
146 tool,
147 &crate::core::context_admission::carrier::gateway_snapshot(),
148 )
149 }
150
151 /// A gate that decides under the gateway snapshot its caller already
152 /// holds: what the caller judged under `admission` and the sources this
153 /// gate reads are then judged under the same gateway rules.
154 pub(crate) fn with_admission(
155 root: &Path,
156 tool: &'static str,
157 admission: &StoreAdmission,
158 ) -> Self {
159 Self {
160 root: root.to_path_buf(),
161 tool,
162 sources: HashMap::new(),
163 authorities: HashMap::new(),
164 admission: admission.clone(),
165 origins: Arc::default(),
166 provider_checks: 0,
167 lineage_checks: 0,
168 provider_deadline: None,
169 scope_root: root.canonicalize().unwrap_or_else(|_| root.to_path_buf()),
170 protected: None,
171 withheld: 0,
172 }
173 }
174
175 /// Run `operation` in the project scope of this gate's root: the gate
176 /// decides for the index it belongs to, whatever project the calling
177 /// request or the working directory names. A request already bound to
178 /// this project keeps its scope untouched (and with it an open source
179 /// view). Under a request bound to ANOTHER project with a source view
180 /// open, `policy::runtime::active()` answers deny-all for the rebound
181 /// scope and invalidates that view: the gate then sees protection, and
182 /// the enclosing operation ends with an error — closed, never the other
183 /// project's rules applied to this index.
184 fn in_root_scope<T>(&self, operation: impl FnOnce() -> T) -> T {
185 use crate::core::policy::runtime::REQUEST_PROJECT;
186 let bound = REQUEST_PROJECT
187 .try_with(|slot| slot.borrow().clone())
188 .ok()
189 .flatten();
190 if bound.is_some_and(|bound| {
191 bound == self.scope_root || bound.canonicalize().is_ok_and(|b| b == self.scope_root)
192 }) {
193 return operation();
194 }
195 REQUEST_PROJECT.sync_scope(
196 std::cell::RefCell::new(Some(self.scope_root.clone())),
197 operation,
198 )
199 }
200
201 /// Whether protection is active in some layer for this gate's project
202 /// (`stored::source_authority_required_under`): the gateway side is read
203 /// from THIS gate's snapshot — the mode cannot differ from the rules the
204 /// gate admits under —, the pack side from the project policy in scope
205 /// (pinned for the whole search by [`pinned`]). Asked once per gate.
206 pub(crate) fn protected(&mut self) -> bool {
207 if let Some(protected) = self.protected {
208 return protected;
209 }
210 let protected = self.in_root_scope(|| {
211 crate::core::context_admission::stored::source_authority_required_under(
212 self.admission.admission_policy(),
213 )
214 });
215 self.protected = Some(protected);
216 protected
217 }
218
219 /// This gate for an index that has `origins` on record.
220 #[must_use]
221 pub(crate) fn with_origins(mut self, origins: &Arc<ChunkOrigins>) -> Self {
222 self.origins = Arc::clone(origins);
223 self
224 }
225
226 /// Whether this gate already holds as many decisions as it may.
227 fn over_bound(&self) -> bool {
228 self.sources.len() + self.authorities.len() >= MAX_SOURCES
229 }
230
231 /// The decision for a source whose origin is not compared with a source
232 /// text, asked once per gate in the gate's project scope. Over the bound:
233 /// refused, neither asked nor remembered.
234 fn authority_holds(&mut self, source: &str, check: impl FnOnce(&Self) -> bool) -> bool {
235 if let Some(&holds) = self.authorities.get(source) {
236 return holds;
237 }
238 if self.over_bound() {
239 return false;
240 }
241 let holds = self.in_root_scope(|| check(self));
242 self.authorities.insert(source.to_owned(), holds);
243 holds
244 }
245
246 /// Whether the content admission, under this gate's gateway snapshot and
247 /// the current project policy, leaves `text` exactly as it is and lets
248 /// it be kept: a copy the rules would withhold, restrict or rewrite is
249 /// not the copy the index holds. `label` is the source the text was
250 /// admitted under when it has one (a path, an `mcp://` reference).
251 fn admits_verbatim(&self, text: &str, label: Option<&str>) -> bool {
252 use crate::core::context_admission as admission;
253 self.in_root_scope(|| {
254 let admitted = match label {
255 Some(label) => admission::admit_source_with_snapshot(
256 text,
257 label,
258 false,
259 admission::OutputCompleteness::complete(),
260 &self.admission,
261 )
262 .ok(),
263 None => admission::carrier::admit_carrier_text_under(
264 text,
265 Some(self.admission.admission_policy()),
266 )
267 .ok(),
268 };
269 admitted.is_some_and(|admitted| admitted.storable && admitted.text == text)
270 })
271 }
272
273 /// The originless rule (`serve_stored` without a binding) under this
274 /// gate's snapshot: withheld where a source authority is required,
275 /// otherwise the content admission alone.
276 fn originless(&mut self, text: &str) -> bool {
277 !self.protected() && self.admits_verbatim(text, None)
278 }
279
280 /// A chunk was served whose source the call's capture cannot name (a
281 /// provider item, a downstream result, a document): where a source
282 /// authority is required the call's source set is then unknown, never
283 /// silently the files alone. Always `true`.
284 fn served_without_file_lineage(&mut self) -> bool {
285 if self.protected() {
286 crate::core::archive::authority::mark_unbound();
287 }
288 true
289 }
290
291 /// The decision already on record for `text` of `source`, without a read
292 /// and without counting: `None` while the source is undecided.
293 pub(super) fn decided(&self, source: &str, text: &str) -> Option<bool> {
294 let admitted = self.sources.get(source)?;
295 Some(
296 admitted
297 .as_deref()
298 .is_some_and(|current| current.contains(normalised(text).as_str())),
299 )
300 }
301
302 /// The sources this gate decided are not admitted now.
303 pub(crate) fn refused_sources(&self) -> impl Iterator<Item = &str> {
304 self.sources
305 .iter()
306 .filter(|(_, admitted)| admitted.is_none())
307 .map(|(source, _)| source.as_str())
308 }
309
310 /// Whether `text`, derived from `source` (a path relative to the root,
311 /// or an external reference), may be served now.
312 pub(crate) fn serves(&mut self, source: &str, text: &str) -> bool {
313 let served = self.decide(source, text);
314 if !served {
315 self.withheld += 1;
316 }
317 served
318 }
319
320 /// Entries this gate refused. A count only: no path, no text.
321 #[cfg(test)]
322 pub(crate) fn withheld(&self) -> usize {
323 self.withheld
324 }
325
326 fn decide(&mut self, source: &str, text: &str) -> bool {
327 let external = source.contains("://");
328 let document = !external && crate::core::binary_detect::has_binary_extension(source);
329 let origins = Arc::clone(&self.origins);
330 let recorded = origins.get(source);
331 if let (true, Some(ChunkOrigin::Document(origin))) = (document, recorded) {
332 // In every mode: a document whose bytes changed is stale.
333 let label = self.root.join(source);
334 return self.authority_holds(source, |gate| {
335 origin.unchanged(&gate.root, source, gate.tool)
336 }) && self.admits_verbatim(text, label.to_str())
337 && self.served_without_file_lineage();
338 }
339 // An external origin is asked only where a source authority is
340 // required. Without protection (Community developer mode) an
341 // external chunk follows the originless rule: content re-admitted,
342 // no provider round trip, search usable offline.
343 let origin = match recorded {
344 Some(recorded) if external && self.protected() => match recorded {
345 ChunkOrigin::Provider(origin) => Some(&**origin),
346 ChunkOrigin::Delivery(origin) => {
347 if !origin.binds(text) {
348 return false;
349 }
350 if !self.authorities.contains_key(source) {
351 if self.lineage_checks >= MAX_LINEAGE_CHECKS {
352 return false;
353 }
354 self.lineage_checks += 1;
355 }
356 let served = self.authority_holds(source, |_| origin.still_admitted())
357 && self.admits_verbatim(text, None);
358 if served {
359 origin.publish();
360 }
361 return served;
362 }
363 // Closed: a downstream result cannot be re-read, so nothing
364 // proves it current. That its server and tool are still
365 // allowed is not such a proof (`DownstreamOrigin`). The
366 // refusal is the originless one, `store.no_source_authority`.
367 ChunkOrigin::Downstream(_) => return false,
368 ChunkOrigin::Document(_) => None,
369 },
370 _ => None,
371 };
372 if origin.is_none() && (external || document) {
373 // No source that is asked: the originless rule, judged for this
374 // gate's project. An extracted document must at least still exist.
375 return (external || self.root.join(source).is_file()) && self.originless(text);
376 }
377 if let Some(served) = self.decided(source, text) {
378 return served && (origin.is_none() || self.served_without_file_lineage());
379 }
380 if self.over_bound() {
381 // Over the bound: refused, neither read nor remembered.
382 return false;
383 }
384 let admitted = match origin {
385 Some(origin) => {
386 let deadline = *self
387 .provider_deadline
388 .get_or_insert_with(|| std::time::Instant::now() + PROVIDER_BUDGET);
389 if self.provider_checks >= MAX_PROVIDER_CHECKS
390 || std::time::Instant::now() >= deadline
391 {
392 // Over the budget: refused, neither asked nor remembered.
393 return false;
394 }
395 self.provider_checks += 1;
396 let admitted = self.read_provider(origin, deadline);
397 if admitted.is_none() && std::time::Instant::now() >= deadline {
398 // Cut off by the budget, not answered: not a decision.
399 return false;
400 }
401 admitted
402 }
403 None => self.read(source),
404 };
405 self.sources.insert(source.to_owned(), admitted);
406 self.decided(source, text).unwrap_or(false)
407 && (origin.is_none() || self.served_without_file_lineage())
408 }
409
410 /// What the provider returns for `origin` NOW, as the store admission
411 /// would keep it; `None` when the item is not admitted to this reader.
412 fn read_provider(
413 &self,
414 origin: &ProviderOrigin,
415 deadline: std::time::Instant,
416 ) -> Option<String> {
417 let root = self.root.to_str()?;
418 let view = match crate::core::policy::runtime::with_project_source_view(root, || {
419 with_reuse_deadline_until(Some(deadline), || origin.current_chunks(root))
420 }) {
421 Ok(Ok(view)) => view,
422 Ok(Err(refusal)) => {
423 // The kind only, never the item: a search must not tell an
424 // item it withholds from one that does not exist (K02). The
425 // call's receipt records the refused re-check (C04).
426 crate::core::context_admission::record_recheck_refusal(
427 &self.admission,
428 refusal.validity,
429 );
430 return None;
431 }
432 Err(_) => return None,
433 };
434 let mut current = String::new();
435 for chunk in &view.chunks {
436 let admitted = self
437 .admission
438 .admit(&chunk.content, Path::new(&chunk.file_path))?;
439 current.push_str(&normalised(&admitted));
440 current.push('\n');
441 }
442 Some(current)
443 }
444
445 /// The text of `source` as the admitted reader returns it now, when the
446 /// original may be kept in a derived store (complete, not restricted).
447 /// The gateway rules are this gate's snapshot; each read is bounded by
448 /// the read cap (the gate never shared a byte budget across sources).
449 fn read(&self, source: &str) -> Option<String> {
450 let path = self.root.join(source);
451 // NOTE the argument order of this entry: root first.
452 let read = crate::core::admitted_sources::read_with_admission(
453 self.root.to_str()?,
454 path.to_str()?,
455 self.tool,
456 &self.admission,
457 );
458 read.storable().map(normalised)
459 }
460}
461
462/// Line endings are not content: an index keeps `\n`-joined lines of a
463/// source that may use `\r\n`.
464fn normalised(text: &str) -> String {
465 text.lines().collect::<Vec<_>>().join("\n")
466}
467
468/// Whether `chunk` may be served through `gate`. Without a gate — a view
469/// built in the running call from sources the reader returned then — every
470/// chunk is served.
471pub fn chunk_served(gate: &mut Option<SourceGate>, chunk: &CodeChunk) -> bool {
472 gate.as_mut()
473 .is_none_or(|gate| gate.serves(&chunk.file_path, &chunk.content))
474}
475
476impl BM25Index {
477 /// This index as its store hands it out for `root`: every search over it
478 /// is gated ([`Self::source_gate`]).
479 #[must_use]
480 pub(crate) fn rooted_at(mut self, root: &Path) -> Self {
481 self.source_root = Some(root.to_path_buf());
482 self
483 }
484
485 /// The gate of one search: `None` for an index without a root.
486 pub fn source_gate(&self) -> Option<SourceGate> {
487 self.source_root
488 .as_deref()
489 .map(|root| SourceGate::new(root, SEARCH_TOOL).with_origins(&self.provider_origins))
490 }
491
492 /// The `top_k` best chunks for `query` that may be served now. Pack and
493 /// role are pinned for the whole search (`pinned`); if either changed
494 /// while it ran, nothing is returned.
495 pub fn search(&self, query: &str, top_k: usize) -> Vec<SearchResult> {
496 pinned(self.source_root.as_deref(), || {
497 self.search_gated(query, top_k, &mut self.source_gate())
498 })
499 .unwrap_or_default()
500 }
501}
502
503/// Run one search over an index handed out for `root` with the project pack
504/// and the role pinned from its first decision to its last
505/// (`policy::runtime::with_project_source_view`): every gate decision inside
506/// sees one pack and one role, and a change of either while the search ran
507/// makes the view report it — the result is then `None` and the caller
508/// returns nothing. Nested calls share the outermost view.
509///
510/// A request bound to ANOTHER project keeps its own view; the gate's
511/// rebinding to its root then answers deny-all and invalidates that view
512/// (`SourceGate::in_root_scope`), so such a search also ends in `None`. A
513/// root that cannot be resolved has no view: `None`. Without a root (a view
514/// built in the running call, no gate) the search runs as it is.
515pub fn pinned<T>(root: Option<&Path>, search: impl FnOnce() -> T) -> Option<T> {
516 let Some(root) = root else {
517 return Some(search());
518 };
519 // An index of a root the request's Workspace does not contain (detached,
520 // or never attached) serves nothing (#2120).
521 if !crate::core::workspace_authority::root_authorized(root) {
522 return None;
523 }
524 crate::core::policy::runtime::with_project_source_view(root.to_str()?, search).ok()
525}
526
527/// How many candidates a similarity search fetches to serve `top_k` through
528/// a gate: a refused candidate must not cost a result slot, and the walk
529/// stays bounded. More refusals than the surplus mean fewer results.
530#[cfg(feature = "embeddings")]
531pub(crate) fn overfetch(top_k: usize) -> usize {
532 top_k.saturating_add(top_k.clamp(16, MAX_SOURCES))
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538 use crate::core::policy::runtime::TestPolicyOverride;
539
540 /// A chunk of a persisted index is served while the admitted reader still
541 /// returns its text, and is no longer served once the source is withdrawn:
542 /// deleted, or rewritten so that the stored text is no longer in it. The
543 /// next chunk takes its place; an index without a root is not asked.
544 #[test]
545 fn a_loaded_index_serves_a_chunk_only_while_its_source_admits_it() {
546 let _data = crate::core::data_dir::isolated_data_dir();
547 let _community = TestPolicyOverride::set(None);
548 let project = tempfile::tempdir().expect("project");
549 let root = project.path();
550 let withdrawn = root.join("billing.rs");
551 std::fs::write(
552 &withdrawn,
553 "pub fn settle_canary() -> u32 {\n let reconciliation = 7319;\n reconciliation\n}\n",
554 )
555 .expect("source");
556 std::fs::write(
557 root.join("ledger.rs"),
558 "pub fn settle_ledger() -> u32 {\n let reconciliation = 42;\n reconciliation\n}\n",
559 )
560 .expect("source");
561
562 let built = BM25Index::build_from_directory(root);
563 built.save(root).expect("persisted");
564 let files = |index: &BM25Index| -> Vec<String> {
565 index
566 .search("reconciliation", 10)
567 .into_iter()
568 .map(|hit| hit.file_path)
569 .collect()
570 };
571
572 let loaded = BM25Index::load(root).expect("index of the current policy");
573 assert!(loaded.source_root.is_some());
574 let served = files(&loaded);
575 assert!(served.iter().any(|file| file == "billing.rs"), "{served:?}");
576 assert!(served.iter().any(|file| file == "ledger.rs"), "{served:?}");
577 assert!(!loaded.search("settle_canary", 5).is_empty());
578 // The same chunks without a root (taken before the source is
579 // withdrawn: a search that finds it deleted evicts it from the store).
580 let unrooted = BM25Index::load_persisted(root).expect("persisted file");
581
582 // Rewritten: the stored text is no longer what the reader returns.
583 std::fs::write(&withdrawn, "pub fn unrelated() {}\n").expect("rewrite");
584 let mut gate = loaded.source_gate();
585 let hits = loaded.search_gated("reconciliation", 10, &mut gate);
586 assert!(hits.iter().all(|hit| hit.file_path == "ledger.rs"));
587 assert!(!hits.is_empty());
588 assert!(gate.as_ref().is_some_and(|gate| gate.withheld() >= 1));
589
590 // Deleted: nothing of it is served, name and path included, and the
591 // chunk of the other source takes the slot.
592 std::fs::remove_file(&withdrawn).expect("delete");
593 let served = files(&loaded);
594 assert!(!served.is_empty());
595 assert!(served.iter().all(|file| file == "ledger.rs"), "{served:?}");
596 assert!(loaded.search("settle_canary", 5).is_empty());
597
598 // Over the bound: an undecided source is refused and not remembered.
599 let mut full = SourceGate::new(root, SEARCH_TOOL);
600 for n in 0..MAX_SOURCES {
601 full.sources.insert(format!("known-{n}.rs"), None);
602 }
603 assert!(!full.serves("ledger.rs", "reconciliation"));
604 assert_eq!(full.sources.len(), MAX_SOURCES);
605
606 // Without a root: a fresh view, not asked again.
607 assert!(unrooted.source_root.is_none());
608 assert!(files(&unrooted).iter().any(|file| file == "billing.rs"));
609 }
610}