Skip to main content

rto_graph/
paths.rs

1//! **Which repository paths the scan reads, and how much of each it mines** —
2//! the one rule every reader of repository bytes consults (ADR-0007 `[paths]`,
3//! ADR-0026 step 1, issue #840).
4//!
5//! # Why this exists
6//!
7//! Before this module there was **no mechanism to exclude a path from
8//! extraction**. The only exclusion list in the configuration was `[debt]
9//! ignore`, which is scoped to markers and filters the *report* rather than the
10//! *graph*: [`crate::debt`] drops an ignored path when reporting, and
11//! `debt_density` reuses it so the two agree — but the node is still in the
12//! store, and `search`, `explain`, `list_kind`, `path` and **`export`** all
13//! still see it. A repository could therefore suppress a false finding from the
14//! report it reads and still publish it to a consumer (issue #840).
15//!
16//! An exclusion that works by *removing the node* has no such gap, and it needs
17//! no per-surface honouring: a node that was never stored cannot be exported.
18//! That is the whole reason this sits at extraction rather than beside
19//! `[debt] ignore`.
20//!
21//! # Three states, because two requests are different
22//!
23//! *"Do not mine this as configuration"* and *"do not put this in the graph at
24//! all"* are different requests, and two live decisions each need a different
25//! one:
26//!
27//! | class | what it produces | the decision that needs it |
28//! |---|---|---|
29//! | [`PathClass::Extract`] | everything, as today | every path not named — the default |
30//! | [`PathClass::Opaque`] | a `file` node and **nothing else** | issue #812 — a corpus manifest must be **committed in every storage mode**, so a missing `raw/` is *detectable rather than silent*. It must therefore stay in the graph while not being shredded into `config_key` nodes (#839) or scanned for markers (#838). |
31//! | [`PathClass::Excluded`] | **no node, and the bytes are never read** | issue #817 — `raw/` is excluded from the standard scan so a source document is not graphed twice, once as its `knowledge/` summary and once as the raw file. |
32//!
33//! [`PathClass::Opaque`] is not "extract with some rules off". It is *identity
34//! without content*: path, blob id, byte and line counts — the facts a
35//! `(path, blob id, bytes)` derivation can state about a file whose contents it
36//! has agreed not to **mine**. The bytes are still read, because their length is
37//! one of those facts; what stops is deriving anything from what they *say*.
38//! Only [`PathClass::Excluded`] declines to read them. That is exactly what #812
39//! asks for, and nothing more.
40//!
41//! # Why the classes are not a fourth thing
42//!
43//! A fourth state — *in the graph, mined, but muted from the reports* — already
44//! exists: it is `[debt] ignore`, and it is the defect #840 was raised about.
45//! It is deliberately not reproduced here.
46//!
47//! # The rule is consulted, never copied
48//!
49//! There are two independent readers of committed blobs (ADR-0026 §"The
50//! exclusion covers **two** scans, not one"), and excluding a path from one does
51//! not exclude it from the other:
52//!
53//! 1. **Derived extraction** — [`crate::extract::Registry`], which produces
54//!    `file` nodes, config keys, symbols, markers and `meta.content`.
55//! 2. **The authored layer** — `rto_spec::authored_blobs` walks *every* path
56//!    independently and `rto_spec::authored_docs_from` then classifies what it
57//!    finds **by content, not by location**. A committed markdown file that
58//!    declares `type: adr` is parsed as one of *ours*, wherever it sits, which
59//!    is precisely what makes an ingested third-party document dangerous.
60//!
61//! Both consult *this* type. It travels inside [`crate::IngestConfig`], which
62//! already flows to every entry point that reads repository bytes, so a reader
63//! that has the ingestion configuration cannot fail to have the path policy too.
64
65/// How much of a path the scan may read — the three states, most permissive
66/// first.
67///
68/// Ordered by how much each admits, so the `Extract` → `Opaque` → `Excluded`
69/// progression reads as a narrowing. See the module docs for which decision
70/// needs which.
71///
72/// # Deliberately closed, and not `#[non_exhaustive]`
73///
74/// The attribute would push a downstream matcher onto a `_ =>` wildcard arm,
75/// which is the precise thing this type exists to prevent. Every reader of
76/// repository bytes asks [`PathClass::mines`] or [`PathClass::reads`]; a fourth
77/// class is a claim that some reader should treat some path a fourth way, and
78/// **every** such reader has to reconsider when one arrives. A wildcard arm is
79/// how that reconsideration gets skipped silently — the same argument
80/// `rto_exec`'s `Gate` is closed on, where folding `NotRun` into a wildcard is
81/// the defect its third state exists to catch.
82///
83/// The set is three because the fourth candidate is already taken: *in the
84/// graph, mined, but muted from the reports* is `[debt] ignore`, and reproducing
85/// it here is what issue #840 was raised against. Adding a variant is breaking
86/// for this published crate, and under `AGENTS.md`'s `rto-*` carve-out that
87/// ships as a minor — so the cost of getting it wrong later is a compile error
88/// at every call site, which is exactly the price this type wants paid.
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
90pub enum PathClass {
91    /// Extract normally: every extractor, every miner, content capture, markers.
92    /// The default for every path a policy does not name.
93    #[default]
94    Extract,
95    /// A `file` node and nothing else — **identity without content**. No
96    /// config-key mining, no symbols, no image/audio facts, no marker scan, no
97    /// `meta.content`, and the authored classifier declines it.
98    Opaque,
99    /// Not in the graph at all. No node is produced and the bytes are never
100    /// read, so nothing a screen would have caught can reach the store by this
101    /// route either.
102    Excluded,
103}
104
105impl PathClass {
106    /// The class's name, as `roteiro config` and a node's `meta.scan` spell it.
107    #[must_use]
108    pub const fn as_str(self) -> &'static str {
109        match self {
110            Self::Extract => "extract",
111            Self::Opaque => "opaque",
112            Self::Excluded => "excluded",
113        }
114    }
115
116    /// Whether a reader may derive anything **beyond a file's identity** from
117    /// this path: config keys, symbols, markers, annotations, content.
118    ///
119    /// The question every miner asks. It is deliberately not `== Extract`
120    /// spelled out at each call site: a fourth class added later must make every
121    /// miner reconsider, and a named predicate is where that reconsideration
122    /// lands.
123    #[must_use]
124    pub const fn mines(self) -> bool {
125        matches!(self, Self::Extract)
126    }
127
128    /// Whether the bytes at this path are read at all.
129    ///
130    /// `false` only for [`PathClass::Excluded`]. An [`PathClass::Opaque`] path
131    /// is read — its length and line count are facts about it — but nothing is
132    /// derived from what the bytes *say*.
133    #[must_use]
134    pub const fn reads(self) -> bool {
135        !matches!(self, Self::Excluded)
136    }
137}
138
139/// The repository's declared path policy: which paths are excluded from the
140/// scan, and which are read as opaque bytes (ADR-0007 `[paths]`).
141///
142/// Empty by default, and an empty policy is exactly today's behaviour — see
143/// [`PathPolicy::fingerprint`], which returns `0` for one so that declaring
144/// nothing leaves every existing extraction-cache key untouched.
145///
146/// # There is no built-in default, and that is a decision
147///
148/// No path is excluded unless a repository says so — not `raw/`, not
149/// `manifest/`, not `vendor/`. A built-in default would be a **silent behaviour
150/// change**: any existing repository that happens to have a directory of that
151/// name would lose it from its graph on upgrade, with nothing said. Opting in is
152/// a declaration somebody wrote and a reviewer can see; opting out of a default
153/// requires knowing the default exists. ADR-0026's `raw/` exclusion is therefore
154/// a line in `roteiro.toml`, not a constant here.
155#[derive(Debug, Clone, Default, PartialEq, Eq)]
156pub struct PathPolicy {
157    /// Globs whose matching paths are [`PathClass::Excluded`].
158    exclude: Vec<String>,
159    /// Globs whose matching paths are [`PathClass::Opaque`].
160    opaque: Vec<String>,
161}
162
163/// The policy a build with nothing declared uses — see [`PathPolicy::empty`].
164static EMPTY: PathPolicy = PathPolicy {
165    exclude: Vec::new(),
166    opaque: Vec::new(),
167};
168
169impl PathPolicy {
170    /// A policy from the two declared glob lists.
171    ///
172    /// Patterns are matched anchored end-to-end against the whole repo-relative
173    /// path, with the same semantics `[debt] ignore` uses — see [`glob_match`],
174    /// which is the one implementation both consult.
175    #[must_use]
176    pub fn new(exclude: Vec<String>, opaque: Vec<String>) -> Self {
177        Self { exclude, opaque }
178    }
179
180    /// The empty policy: every path is [`PathClass::Extract`].
181    ///
182    /// Returned by reference and `'static` so [`crate::IngestConfig`] can stay
183    /// `Copy` while carrying a policy — the default it borrows outlives every
184    /// caller.
185    #[must_use]
186    pub fn empty() -> &'static Self {
187        &EMPTY
188    }
189
190    /// Whether nothing is declared, so every path extracts normally.
191    #[must_use]
192    pub fn is_empty(&self) -> bool {
193        self.exclude.is_empty() && self.opaque.is_empty()
194    }
195
196    /// How `path` (repo-relative, slash-separated) is read.
197    ///
198    /// `exclude` is tested first, so a path matching both lists is excluded:
199    /// between two declarations the narrower one wins, because a repository that
200    /// said "never read this" should not have that undone by a second, weaker
201    /// statement about the same bytes.
202    #[must_use]
203    pub fn classify(&self, path: &str) -> PathClass {
204        if self.exclude.iter().any(|g| glob_match(g, path)) {
205            PathClass::Excluded
206        } else if self.opaque.iter().any(|g| glob_match(g, path)) {
207            PathClass::Opaque
208        } else {
209            PathClass::Extract
210        }
211    }
212
213    /// The declared `exclude` globs, in declaration order.
214    #[must_use]
215    pub fn exclude_patterns(&self) -> &[String] {
216        &self.exclude
217    }
218
219    /// The declared `opaque` globs, in declaration order.
220    #[must_use]
221    pub fn opaque_patterns(&self) -> &[String] {
222        &self.opaque
223    }
224
225    /// A cache-key contribution that is **`0` for an empty policy**, so a
226    /// repository declaring nothing keeps every extraction-cache key it already
227    /// has — the same property [`crate::IngestConfig`]'s toggles have.
228    ///
229    /// This is load-bearing rather than an optimisation. Extraction is cached by
230    /// `(path, blob id, env)`, and the policy changes what an *unchanged* blob
231    /// extracts to: without it in `env`, adding `manifest/**` to `opaque` would
232    /// serve the previously-mined `config_key` nodes straight back out of the
233    /// cache, and `sync` would report itself up to date while the graph still
234    /// held everything the declaration was written to remove.
235    #[must_use]
236    pub fn fingerprint(&self) -> u64 {
237        if self.is_empty() {
238            return 0;
239        }
240        // FNV-1a over the two lists, **length-prefixed**, with a distinct tag per
241        // list so moving a pattern between them changes the fingerprint.
242        //
243        // The prefix is what makes the encoding unambiguous, and a separator
244        // alone would not be: patterns are arbitrary user strings that may
245        // contain any byte, so `exclude = ["a", "b"]` and `exclude = ["a\0x\0b"]`
246        // fold identically under a tag-and-append scheme — two different policies
247        // with one cache key, reached by writing a pattern rather than by a hash
248        // collision. Encoding each pattern's length first cannot be forged from
249        // inside a pattern.
250        let mut h = 0xcbf2_9ce4_8422_2325_u64;
251        let mut fold = |bytes: &[u8]| {
252            for &b in bytes {
253                h ^= u64::from(b);
254                h = h.wrapping_mul(0x0000_0100_0000_01b3);
255            }
256        };
257        for (tag, list) in [(b'x', &self.exclude), (b'o', &self.opaque)] {
258            fold(&[tag]);
259            fold(&(list.len() as u64).to_le_bytes());
260            for pattern in list {
261                fold(&(pattern.len() as u64).to_le_bytes());
262                fold(pattern.as_bytes());
263            }
264        }
265        h
266    }
267}
268
269/// Match a slash-separated `path` against a glob `pattern`, anchored end-to-end.
270/// `?` matches one non-`/` character, `*` matches any run within a single path
271/// segment, and `**` matches zero or more whole segments.
272///
273/// One implementation, two consumers: `[debt] ignore`'s report filter
274/// ([`crate::debt`]) and `[paths]`'s extraction filter ([`PathPolicy`]). They
275/// are different mechanisms deliberately — one mutes a report, the other removes
276/// a node — but a user writing `vendor/**` in either is entitled to have it mean
277/// the same thing, and this repository has closed "the same rule in two copies"
278/// often enough to place the shared half here rather than beside one caller.
279#[must_use]
280pub fn glob_match(pattern: &str, path: &str) -> bool {
281    let pat: Vec<&str> = pattern.split('/').collect();
282    let seg: Vec<&str> = path.split('/').collect();
283    match_segments(&pat, &seg)
284}
285
286/// Anchored match of glob segments `pat` against path segments `seg`, with `**`
287/// consuming zero or more segments.
288///
289/// # Why this memoises, when the `[debt] ignore` original did not
290///
291/// Each `**` branches over every split of the remaining path, so a pattern with
292/// several of them revisits the same `(pattern suffix, path suffix)` state
293/// exponentially many times: twenty `**` tokens against a twenty-segment path is
294/// on the order of 10^11 calls, which does not return.
295///
296/// It was survivable while this matcher ran only when `roteiro debt` *reported*,
297/// over a handful of patterns. It is not survivable now: [`PathPolicy::classify`]
298/// asks it for **every declared pattern on every path**, at extraction, at the
299/// authored layer, and at every other reader — so a pattern a user is free to
300/// write turns a scan into a hang. Widening the blast radius of existing code is
301/// the change that has to pay for its own hardening, so it pays here.
302///
303/// Recording only *failures* is what keeps this a memo rather than a rewrite: a
304/// state that succeeded ends the search, so it is never revisited, and only the
305/// dead ends are worth remembering. Bounded at `(pat.len() + 1) * (seg.len() + 1)`
306/// states, and the semantics are untouched — the tests below are the ones that
307/// passed before it.
308fn match_segments(pat: &[&str], seg: &[&str]) -> bool {
309    let stride = seg.len() + 1;
310    let mut failed = vec![false; (pat.len() + 1) * stride];
311    match_segments_memo(pat, seg, &mut failed, stride)
312}
313
314/// [`match_segments`] with the dead-end memo threaded through. Keyed on the
315/// **suffix lengths**, which identify the state exactly: both slices only ever
316/// shrink from the front.
317fn match_segments_memo(pat: &[&str], seg: &[&str], failed: &mut [bool], stride: usize) -> bool {
318    let slot = pat.len() * stride + seg.len();
319    if failed[slot] {
320        return false;
321    }
322    let matched = match pat.first() {
323        None => seg.is_empty(),
324        Some(&"**") => {
325            (0..=seg.len()).any(|i| match_segments_memo(&pat[1..], &seg[i..], failed, stride))
326        }
327        Some(token) => {
328            !seg.is_empty()
329                && match_token(token, seg[0])
330                && match_segments_memo(&pat[1..], &seg[1..], failed, stride)
331        }
332    };
333    if !matched {
334        failed[slot] = true;
335    }
336    matched
337}
338
339/// Match a single path segment `s` against a `pattern` token containing `*`
340/// (any run, no `/`) and `?` (one char, no `/`).
341fn match_token(pattern: &str, s: &str) -> bool {
342    let pat: Vec<char> = pattern.chars().collect();
343    let chars: Vec<char> = s.chars().collect();
344    match_token_chars(&pat, &chars)
345}
346
347/// Recursive char-slice matcher backing [`match_token`], memoised on dead ends
348/// for the reason [`match_segments`] is: several `*` in one segment branch the
349/// same way `**` does across segments, so `*a*a*a*a*a*a*a*a.rs` is the
350/// within-segment form of the same hang.
351fn match_token_chars(pat: &[char], chars: &[char]) -> bool {
352    let stride = chars.len() + 1;
353    let mut failed = vec![false; (pat.len() + 1) * stride];
354    match_token_memo(pat, chars, &mut failed, stride)
355}
356
357/// [`match_token_chars`] with the dead-end memo threaded through.
358fn match_token_memo(pat: &[char], chars: &[char], failed: &mut [bool], stride: usize) -> bool {
359    let slot = pat.len() * stride + chars.len();
360    if failed[slot] {
361        return false;
362    }
363    let matched = match pat.first() {
364        None => chars.is_empty(),
365        Some('*') => {
366            (0..=chars.len()).any(|i| match_token_memo(&pat[1..], &chars[i..], failed, stride))
367        }
368        Some('?') => !chars.is_empty() && match_token_memo(&pat[1..], &chars[1..], failed, stride),
369        Some(&ch) => {
370            !chars.is_empty()
371                && chars[0] == ch
372                && match_token_memo(&pat[1..], &chars[1..], failed, stride)
373        }
374    };
375    if !matched {
376        failed[slot] = true;
377    }
378    matched
379}
380
381#[cfg(test)]
382mod tests {
383    use super::{PathClass, PathPolicy, glob_match};
384
385    /// The default is not "exclude nothing by accident" — it is "exclude nothing
386    /// because nothing was declared", and an empty policy must be free.
387    #[test]
388    fn an_empty_policy_extracts_everything_and_costs_no_cache_key() {
389        let policy = PathPolicy::default();
390        assert!(policy.is_empty());
391        assert_eq!(policy.classify("src/main.rs"), PathClass::Extract);
392        assert_eq!(policy.classify("raw/paper.pdf"), PathClass::Extract);
393        assert_eq!(
394            policy.fingerprint(),
395            0,
396            "a declared-nothing policy must leave every existing cache key alone"
397        );
398        assert_eq!(PathPolicy::empty(), &policy);
399    }
400
401    #[test]
402    fn exclude_and_opaque_classify_independently() {
403        let policy = PathPolicy::new(vec!["raw/**".into()], vec!["manifest/**".into()]);
404        assert_eq!(policy.classify("raw/paper.pdf"), PathClass::Excluded);
405        assert_eq!(policy.classify("raw/nested/deep.md"), PathClass::Excluded);
406        assert_eq!(policy.classify("manifest/papers.jsonl"), PathClass::Opaque);
407        assert_eq!(policy.classify("src/main.rs"), PathClass::Extract);
408        assert_eq!(policy.classify("rawish/a.rs"), PathClass::Extract);
409    }
410
411    /// The narrower declaration wins, so a second weaker statement about the same
412    /// bytes cannot undo "never read this".
413    #[test]
414    fn exclude_beats_opaque_when_both_match() {
415        let policy = PathPolicy::new(vec!["corpus/**".into()], vec!["corpus/**".into()]);
416        assert_eq!(policy.classify("corpus/a.json"), PathClass::Excluded);
417    }
418
419    /// Moving a pattern between the lists must change the extraction identity, or
420    /// the cache serves facts the new declaration was written to remove.
421    #[test]
422    fn the_fingerprint_separates_the_two_lists() {
423        let excluded = PathPolicy::new(vec!["raw/**".into()], Vec::new());
424        let opaque = PathPolicy::new(Vec::new(), vec!["raw/**".into()]);
425        assert_ne!(excluded.fingerprint(), opaque.fingerprint());
426        assert_ne!(excluded.fingerprint(), 0);
427        assert_eq!(
428            excluded.fingerprint(),
429            PathPolicy::new(vec!["raw/**".into()], Vec::new()).fingerprint(),
430            "deterministic for an identical declaration"
431        );
432    }
433
434    /// A pattern is an arbitrary user string, so the fingerprint's encoding has
435    /// to be unforgeable **from inside a pattern**. Under a tag-and-append
436    /// scheme these two policies fold identically — two different declarations
437    /// sharing one extraction-cache key, reached by typing rather than by a hash
438    /// collision.
439    #[test]
440    fn a_pattern_cannot_forge_the_fingerprint_of_another_policy() {
441        let two = PathPolicy::new(vec!["a".into(), "b".into()], Vec::new());
442        let one_forged = PathPolicy::new(vec!["a\0x\0b".into()], Vec::new());
443        assert_ne!(two.fingerprint(), one_forged.fingerprint());
444
445        // The same hazard across the list boundary: a pattern that spells the
446        // second list's tag must not be taken for the second list.
447        let split = PathPolicy::new(vec!["a".into()], vec!["b".into()]);
448        let forged = PathPolicy::new(vec!["a\0o\0b".into()], Vec::new());
449        assert_ne!(split.fingerprint(), forged.fingerprint());
450
451        // And a pattern moved between the lists still changes it.
452        assert_ne!(
453            PathPolicy::new(vec!["a".into()], vec!["b".into()]).fingerprint(),
454            PathPolicy::new(vec!["b".into()], vec!["a".into()]).fingerprint()
455        );
456    }
457
458    #[test]
459    fn the_three_classes_answer_the_two_questions_readers_ask() {
460        assert!(PathClass::Extract.mines() && PathClass::Extract.reads());
461        assert!(!PathClass::Opaque.mines() && PathClass::Opaque.reads());
462        assert!(!PathClass::Excluded.mines() && !PathClass::Excluded.reads());
463    }
464
465    /// A pattern a user is free to write must not turn a scan into a hang.
466    ///
467    /// Unmemoised, twenty `**` tokens against a twenty-segment non-matching path
468    /// explore on the order of `C(40, 20)` ≈ 10^11 states and never return. The
469    /// same shape within one segment (`*a*a*…`) is the other half. Both are
470    /// asserted here **with a wall-clock bound** rather than merely for their
471    /// answer, because the defect's signature is time rather than a wrong result
472    /// — without the memo this test does not fail, it fails to finish, and a
473    /// bound is what turns that into a red rather than a hung CI job.
474    ///
475    /// The bound is deliberately loose. The memoised search is microseconds, so
476    /// seconds of headroom cannot flake on a loaded machine while still being
477    /// four orders of magnitude tighter than the unmemoised version's hours.
478    #[test]
479    fn a_pathological_pattern_is_bounded_rather_than_exponential() {
480        let deep = vec!["**"; 20].join("/") + "/needle";
481        let path = (0..20)
482            .map(|i| format!("d{i}"))
483            .collect::<Vec<_>>()
484            .join("/");
485
486        let start = std::time::Instant::now();
487        assert!(
488            !glob_match(&deep, &path),
489            "no `needle` segment, so no match"
490        );
491        assert!(glob_match(&deep, &format!("{path}/needle")));
492
493        // The within-segment form of the same branching. The subject ends
494        // `.txt`, so the trailing literal can never match and every split of
495        // every `*` is explored before the answer is known — which is the case
496        // that costs, not the one that matches early.
497        let starred = format!("{}.rs", "*a".repeat(16));
498        assert!(
499            !glob_match(&starred, &format!("{}.txt", "a".repeat(40))),
500            "the trailing `.rs` cannot match `.txt`"
501        );
502        assert!(
503            glob_match(&starred, &format!("{}.rs", "a".repeat(40))),
504            "and the matching case still matches"
505        );
506
507        let elapsed = start.elapsed();
508        assert!(
509            elapsed < std::time::Duration::from_secs(5),
510            "the matcher must be bounded, not exponential — took {elapsed:?}"
511        );
512    }
513
514    #[test]
515    fn glob_matches_segments_and_wildcards() {
516        assert!(glob_match("vendor/**", "vendor/lib/a.rs"));
517        assert!(glob_match("vendor/**", "vendor"));
518        assert!(glob_match("**/generated/*", "src/gen/generated/x.rs"));
519        assert!(glob_match("**/*.jsonl", "manifest/papers.jsonl"));
520        assert!(!glob_match("src/*.rs", "src/a/b.rs"));
521    }
522}