Skip to main content

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}