lanekeep_core/fact.rs
1//! Facts: what a per-file pass hands to the reduce phase.
2//!
3//! A cross-file rule cannot see the whole corpus during the per-file pass — nothing does,
4//! because files are checked in parallel and independently. What it can do is emit small
5//! serializable observations, which the engine collects, orders, and hands back in `reduce`.
6//!
7//! # Why facts rather than trees
8//!
9//! Invariant 1 in [`AGENTS.md`]: the reduce phase never touches parse trees. Handing a tree
10//! to `reduce` would make the whole corpus resident at once and destroy incrementality —
11//! every cross-file rule would force every file to be parsed on every run, which is the
12//! cost the cache exists to avoid.
13//!
14//! Facts are the price of that. They are small, they are JSON, and they can sit in a cache
15//! entry beside the violations for the file that produced them. A warm run that reparses
16//! nothing can still run `reduce` over the full corpus, because the facts came back from
17//! the cache.
18//!
19//! # Why the payload is a JSON string
20//!
21//! A fact is whatever shape its rule chose. Modeling that in Rust would mean either a
22//! dynamic value type this crate does not otherwise need, or a schema the engine would have
23//! to police — and policing it would make fact shape part of lanekeep's public API rather
24//! than the rule's private business.
25//!
26//! Storing the serialized form instead makes caching trivial and keeps the engine
27//! uninterested in what a fact means.
28//!
29//! [`AGENTS.md`]: https://github.com/fmsouza/lanekeep/blob/main/AGENTS.md
30
31use crate::location::FilePath;
32use crate::rule_id::RuleId;
33
34/// One observation a rule emitted while checking one file.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct Fact {
37 /// Which rule emitted it.
38 ///
39 /// A rule sees only its own facts. Letting one rule read another's would make an
40 /// internal payload shape into a contract between rules, and would make the result
41 /// depend on the order rules were declared in.
42 pub rule_id: RuleId,
43
44 /// The file being checked when it was emitted.
45 ///
46 /// Set by the host, not by the rule. A rule that puts its own `file` in the payload
47 /// does not get to change where the engine thinks the fact came from.
48 pub file: FilePath,
49
50 /// The fact's `kind`, lifted out of the payload so `ctx.facts('export')` can filter
51 /// without parsing every fact in the corpus.
52 pub kind: String,
53
54 /// The payload, as JSON. Always a JSON object.
55 pub data: String,
56
57 /// Position among the facts this rule emitted for this file, from zero.
58 ///
59 /// Emission order within a file is the rule's own doing and is worth preserving —
60 /// but it only orders facts *within* a file. Across files, the path breaks the tie.
61 pub sequence: u32,
62}
63
64/// Sort facts into the order `reduce` will always see them in.
65///
66/// `(rule, file, sequence)`. Files are checked in parallel, so the order facts arrive in is
67/// whatever the thread pool did that run. A `reduce` that iterates facts and reports as it
68/// goes would otherwise produce violations in a different order on every run — and while
69/// the final violation sort would hide that for output, it would not hide it from a rule
70/// that stops at the first match, or counts, or builds a "first seen wins" map.
71///
72/// Determinism has to hold for what the rule observes, not only for what gets printed.
73pub fn sort(facts: &mut [Fact]) {
74 facts.sort_by(|a, b| {
75 a.rule_id
76 .cmp(&b.rule_id)
77 .then_with(|| a.file.cmp(&b.file))
78 .then_with(|| a.sequence.cmp(&b.sequence))
79 });
80}
81
82#[cfg(test)]
83mod tests {
84 use super::*;
85
86 fn fact(rule: &str, file: &str, sequence: u32) -> Fact {
87 Fact {
88 rule_id: rule.parse().expect("valid id"),
89 file: FilePath::new(file),
90 kind: "export".to_owned(),
91 data: format!(r#"{{"kind":"export","n":{sequence}}}"#),
92 sequence,
93 }
94 }
95
96 fn order(facts: &[Fact]) -> Vec<(String, String, u32)> {
97 facts
98 .iter()
99 .map(|f| {
100 (
101 f.rule_id.to_string(),
102 f.file.as_str().to_owned(),
103 f.sequence,
104 )
105 })
106 .collect()
107 }
108
109 #[test]
110 fn orders_by_rule_then_file_then_sequence() {
111 let mut facts = vec![
112 fact("local/b", "a.ts", 0),
113 fact("local/a", "b.ts", 1),
114 fact("local/a", "a.ts", 1),
115 fact("local/a", "b.ts", 0),
116 fact("local/a", "a.ts", 0),
117 ];
118 sort(&mut facts);
119
120 assert_eq!(
121 order(&facts),
122 vec![
123 ("local/a".to_owned(), "a.ts".to_owned(), 0),
124 ("local/a".to_owned(), "a.ts".to_owned(), 1),
125 ("local/a".to_owned(), "b.ts".to_owned(), 0),
126 ("local/a".to_owned(), "b.ts".to_owned(), 1),
127 ("local/b".to_owned(), "a.ts".to_owned(), 0),
128 ]
129 );
130 }
131
132 #[test]
133 fn emission_order_within_a_file_survives() {
134 // A rule that emits `import` then `export` for the same statement is entitled to
135 // see them in that order — it is the only ordering it can have intended.
136 let mut facts = vec![fact("local/a", "a.ts", 2), fact("local/a", "a.ts", 0)];
137 sort(&mut facts);
138 assert_eq!(
139 facts.iter().map(|f| f.sequence).collect::<Vec<_>>(),
140 vec![0, 2]
141 );
142 }
143
144 #[test]
145 fn the_order_does_not_depend_on_the_order_facts_arrived_in() {
146 // The property that matters: whatever the thread pool did, `reduce` sees one order.
147 let canonical = {
148 let mut facts = vec![
149 fact("local/a", "a.ts", 0),
150 fact("local/a", "b.ts", 0),
151 fact("local/b", "a.ts", 0),
152 ];
153 sort(&mut facts);
154 order(&facts)
155 };
156
157 let mut shuffled = vec![
158 fact("local/b", "a.ts", 0),
159 fact("local/a", "b.ts", 0),
160 fact("local/a", "a.ts", 0),
161 ];
162 sort(&mut shuffled);
163 assert_eq!(order(&shuffled), canonical);
164
165 let mut reversed = vec![
166 fact("local/a", "b.ts", 0),
167 fact("local/b", "a.ts", 0),
168 fact("local/a", "a.ts", 0),
169 ];
170 sort(&mut reversed);
171 assert_eq!(order(&reversed), canonical);
172 }
173
174 #[test]
175 fn sorting_is_stable_for_facts_that_compare_equal() {
176 // Two facts a rule emitted with the same sequence cannot be told apart by the key,
177 // so their relative order has to come from somewhere stable rather than from the
178 // sort. `sort_by` is stable, which is why this holds.
179 let mut first = fact("local/a", "a.ts", 0);
180 first.data = r#"{"kind":"export","tag":"first"}"#.to_owned();
181 let mut second = fact("local/a", "a.ts", 0);
182 second.data = r#"{"kind":"export","tag":"second"}"#.to_owned();
183
184 let mut facts = vec![first, second];
185 sort(&mut facts);
186 assert!(facts[0].data.contains("first"), "stability was lost");
187 }
188}