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}