Skip to main content

forge/cmd/properties/
matcher.rs

1//! The root-cause matcher: decides which failing tests of different agents prove the same bug.
2//!
3//! [`root_cause_groups`] is the only entry point, so another matcher can replace this one. A claim
4//! with a `@bug <Contract>.<function> <file>:<line>` tag names its target, and two tagged claims
5//! agree on the target. Other claims are free text, and the word matcher compares them.
6
7use regex::Regex;
8use std::{collections::HashSet, sync::LazyLock};
9
10/// Two claims agree when at least 3/10 of their combined words are shared, the threshold of the
11/// offline analysis.
12const CLAIM_OVERLAP: (usize, usize) = (3, 10);
13/// Two tagged claims about one function agree only when their lines are at most this far apart. A
14/// function rarely spans more lines, so the window keeps apart functions with one name in one file,
15/// such as overloads or functions of two contracts, but not two lines of one function.
16const LINE_WINDOW: u64 = 50;
17/// Words that do not tell two claims apart.
18const STOP_WORDS: [&str; 31] = [
19    "the", "a", "an", "of", "to", "is", "in", "on", "and", "or", "must", "be", "it", "that",
20    "with", "for", "not", "when", "as", "by", "at", "from", "than", "its", "this", "are", "which",
21    "so", "any", "if", "no",
22];
23
24/// A `@bug <Contract>.<function> <file>:<line>` tag at the start of a claim, also as the NatSpec
25/// tag `@custom:bug`. The contract and an argument list after the function are optional.
26static BUG_TAG: LazyLock<Regex> = LazyLock::new(|| {
27    Regex::new(r"^@(?:custom:)?bug\s+(?:[A-Za-z_]\w*\.)?([A-Za-z_]\w*)(?:\([^)]*\))?\s+([\w./-]+\.sol):(\d+)")
28        .unwrap()
29});
30/// A Solidity file path.
31static SOLIDITY_FILE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[\w./-]+\.sol").unwrap());
32/// A Solidity file path and an optional line.
33static SOLIDITY_LOCATION: LazyLock<Regex> =
34    LazyLock::new(|| Regex::new(r"([\w./-]+\.sol)(?::(\d+))?").unwrap());
35/// A function name followed by its argument list.
36static FUNCTION_CALL: LazyLock<Regex> =
37    LazyLock::new(|| Regex::new(r"\b[A-Za-z_][A-Za-z0-9_]*\(").unwrap());
38/// A call that names no function of the code under test, such as a builtin or a type conversion.
39static GENERIC_CALL: LazyLock<Regex> = LazyLock::new(|| {
40    Regex::new(r"^(?:require|assert|revert|keccak256|sha256|ecrecover|encode\w*|decode|call|delegatecall|staticcall|address|payable|type|bool|string|bytes\d*|u?int\d*)\($")
41        .unwrap()
42});
43/// A camelCase identifier.
44static CAMEL_CASE: LazyLock<Regex> =
45    LazyLock::new(|| Regex::new(r"\b_?[a-z][A-Za-z0-9]*[A-Z][A-Za-z0-9]*\b").unwrap());
46/// A lowercase word.
47static WORD: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[a-z0-9_]+").unwrap());
48
49/// The target of a tagged claim: two tagged claims agree when they name the same function in the
50/// same file, at lines that are near.
51#[derive(Debug, PartialEq)]
52struct BugTag {
53    /// The path in the tag.
54    file: String,
55    function: String,
56    line: u64,
57}
58
59impl BugTag {
60    fn new(claim: &str) -> Option<Self> {
61        let captures = BUG_TAG.captures(claim)?;
62        Some(Self {
63            file: captures[2].to_string(),
64            function: captures[1].to_string(),
65            line: captures[3].parse().ok()?,
66        })
67    }
68
69    fn agrees(&self, other: &Self) -> bool {
70        same_file(&self.file, &other.file)
71            && self.function == other.function
72            && self.line.abs_diff(other.line) <= LINE_WINDOW
73    }
74}
75
76/// What two failing tests with free-text claims must share to have one root cause: the file that
77/// their claims name, and a function name at near lines or most of the claims' words.
78struct RootCause {
79    /// The first Solidity path in the claim.
80    file: String,
81    /// The line after that path, if any.
82    line: Option<u64>,
83    /// The function calls and camelCase identifiers in the claim, without builtins and type
84    /// conversions.
85    identifiers: HashSet<String>,
86    /// The words of the claim outside Solidity paths, without stop words and words of one or two
87    /// letters.
88    words: HashSet<String>,
89}
90
91impl RootCause {
92    fn new(claim: &str) -> Option<Self> {
93        let location = SOLIDITY_LOCATION.captures(claim)?;
94        let file = location[1].to_string();
95        let line = location.get(2).and_then(|line| line.as_str().parse().ok());
96        let identifiers = FUNCTION_CALL
97            .find_iter(claim)
98            .chain(CAMEL_CASE.find_iter(claim))
99            .map(|identifier| identifier.as_str())
100            .filter(|identifier| !GENERIC_CALL.is_match(identifier))
101            .map(String::from)
102            .collect();
103        let claim = SOLIDITY_FILE.replace_all(claim, " ").to_lowercase();
104        let words = WORD
105            .find_iter(&claim)
106            .map(|word| word.as_str())
107            .filter(|word| word.len() > 2 && !STOP_WORDS.contains(word))
108            .map(String::from)
109            .collect();
110        Some(Self { file, line, identifiers, words })
111    }
112
113    /// A shared identifier, such as `transfer(` or `totalSupply`, can name a function that many
114    /// claims call, so it agrees only when the lines that both claims name, if they do, are near.
115    fn agrees(&self, other: &Self) -> bool {
116        let shared = self.words.intersection(&other.words).count();
117        let all = self.words.union(&other.words).count();
118        let near = match (self.line, other.line) {
119            (Some(a), Some(b)) => a.abs_diff(b) <= LINE_WINDOW,
120            _ => true,
121        };
122        same_file(&self.file, &other.file)
123            && ((near && !self.identifiers.is_disjoint(&other.identifiers))
124                || (shared > 0 && shared * CLAIM_OVERLAP.1 >= all * CLAIM_OVERLAP.0))
125    }
126}
127
128/// Returns whether two Solidity paths of claims can name the same file: one is the other or ends
129/// with it. Agents name a file relative to the project, by its file name, or by its path in their
130/// copy of the project, so `Vault.sol` and `/tmp/copy/src/Vault.sol` agree with `src/Vault.sol`,
131/// but `src/one/Vault.sol` and `src/two/Vault.sol` do not.
132fn same_file(a: &str, b: &str) -> bool {
133    fn components(path: &str) -> impl Iterator<Item = &str> {
134        path.rsplit('/')
135            .filter(|component| !matches!(*component, "" | "."))
136            .take_while(|component| *component != "..")
137    }
138    components(a).zip(components(b)).all(|(a, b)| a == b)
139}
140
141/// Returns a group number for the claim of each failing test: tests with the same number have one
142/// root cause. Two tagged claims agree by their tags. Otherwise the word matcher compares the
143/// claims, also when only one of them has a tag. Groups use complete linkage: in claim order, a
144/// test joins the first group whose every test agrees with it, else starts a group numbered by its
145/// index. A chain of agreeing pairs, such as a bare `Vault.sol` between two `Vault.sol` files or
146/// lines that step across the line window, does not link two tests, so it cannot inflate the
147/// quorum. A test without a claim, or whose claim names no Solidity file, agrees with no other
148/// test.
149pub(super) fn root_cause_groups(claims: &[Option<&str>]) -> Vec<usize> {
150    let tags = claims.iter().map(|claim| BugTag::new((*claim)?)).collect::<Vec<_>>();
151    let causes = claims.iter().map(|claim| RootCause::new((*claim)?)).collect::<Vec<_>>();
152    let agree = |i: usize, j: usize| match (&tags[i], &tags[j]) {
153        (Some(a), Some(b)) => a.agrees(b),
154        _ => matches!((&causes[i], &causes[j]), (Some(a), Some(b)) if a.agrees(b)),
155    };
156    let mut groups = Vec::<Vec<usize>>::new();
157    (0..claims.len())
158        .map(|i| match groups.iter_mut().find(|group| group.iter().all(|&j| agree(i, j))) {
159            Some(group) => {
160                group.push(i);
161                group[0]
162            }
163            None => {
164                groups.push(vec![i]);
165                i
166            }
167        })
168        .collect()
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    fn cause(claim: &str) -> RootCause {
176        RootCause::new(claim).unwrap()
177    }
178
179    // Two claims agree when they name the same file and share a function name or enough words.
180    // The words of the paths do not count, and a claim without a Solidity path has no root cause.
181    #[test]
182    fn root_causes_agree_on_file_and_function_or_claim_words() {
183        let cap = cause("The fee is capped at 100 ether; src/Fee.sol:7 fee() caps at 10 ether.");
184        assert!(cap.agrees(&cause("Large amounts pay the maximum: src/Fee.sol:7 fee() is wrong")));
185        assert!(
186            cap.agrees(&cause("The fee is capped at 100 ether for large amounts (src/Fee.sol)"))
187        );
188        assert!(!cap.agrees(&cause("The fee is capped at 100 ether; src/Other.sol:7 fee()")));
189        assert!(!cap.agrees(&cause("A zero amount is charged; src/Fee.sol:11 charges()")));
190        assert!(!cause("Wrong result; src/Fee.sol:7").agrees(&cause("Bad output; src/Fee.sol:7")));
191        assert!(RootCause::new("The fee is capped at 100 ether.").is_none());
192    }
193
194    // A function that many claims call, or a builtin, does not make two claims about different
195    // lines agree. Without a line on both sides, a shared function name still agrees.
196    #[test]
197    fn root_causes_ignore_generic_calls_and_far_lines() {
198        let approve =
199            cause("src/Token.sol:40 approve() lets anyone reset the allowance via transfer(");
200        let burn = cause("src/Token.sol:200 burn() skips the balance check in transfer(");
201        assert!(!approve.agrees(&burn));
202        assert!(approve.agrees(&cause("src/Token.sol:44 transfer( drops the allowance check")));
203        assert!(approve.agrees(&cause("Allowance resets in src/Token.sol: transfer( is open")));
204        assert!(
205            !cause("src/Token.sol mint() skips require( on the cap")
206                .agrees(&cause("src/Token.sol pause() has no require( on the owner"))
207        );
208    }
209
210    // Paths agree when one ends with the other, so a file name or a path in an agent's copy of
211    // the project names the same file, but paths in different directories do not.
212    #[test]
213    fn paths_agree_by_their_trailing_components() {
214        assert!(same_file("src/Vault.sol", "Vault.sol"));
215        assert!(same_file("./src/Vault.sol", "/tmp/forge-properties-1/src/Vault.sol"));
216        assert!(!same_file("src/one/Vault.sol", "src/two/Vault.sol"));
217        assert!(!same_file("src/Vault.sol", "src/MyVault.sol"));
218    }
219
220    // A tag names the function and the source line. The contract and an argument list are
221    // optional, and a claim without the tag at its start has none.
222    #[test]
223    fn bug_tags_parse_the_target() {
224        let tag = |file: &str, function: &str, line| BugTag {
225            file: file.into(),
226            function: function.into(),
227            line,
228        };
229        assert_eq!(
230            BugTag::new("@bug Fee.fee src/Fee.sol:7 — the cap is 10 ether"),
231            Some(tag("src/Fee.sol", "fee", 7))
232        );
233        assert_eq!(
234            BugTag::new("@bug fee(uint256) contracts/Fee.sol:12 - wrong"),
235            Some(tag("contracts/Fee.sol", "fee", 12))
236        );
237        assert_eq!(
238            BugTag::new("@custom:bug Fee.charges Fee.sol:11"),
239            Some(tag("Fee.sol", "charges", 11))
240        );
241        assert_eq!(BugTag::new("The fee is capped; @bug Fee.fee src/Fee.sol:7"), None);
242        assert_eq!(BugTag::new("@bug Fee.fee src/Fee.sol — no line"), None);
243    }
244
245    // Tagged claims group on the file and the function, whatever their words, and lines far apart
246    // or files with one name in different directories split them. Free-text claims, and pairs
247    // with only one tag, fall back to the word matcher. A test joins a group only if it agrees
248    // with every test in it, so the free-text claim about `fee()` stays apart from the group that
249    // holds a tagged claim it does not agree with.
250    #[test]
251    fn tagged_claims_group_on_file_and_function() {
252        let groups = root_cause_groups(&[
253            Some("@bug Fee.fee src/Fee.sol:7 — the cap is 10 ether, not 100 ether"),
254            Some("@bug FeeLib.fee Fee.sol:8 — large amounts pay too little"),
255            Some("@bug Fee.fee src/Other.sol:7 — the cap is 10 ether, not 100 ether"),
256            Some("@bug Fee.charges src/Fee.sol:7 — charges() takes a fee on a zero amount"),
257            Some("@bug Fee.fee src/Fee.sol:90 — an overload rounds up"),
258            Some("A zero amount is charged; src/Fee.sol:11 charges() returns true"),
259            Some("The fee is capped at 100 ether; src/Fee.sol:7 fee() caps at 10 ether."),
260            None,
261            Some("@bug A.f src/one/Vault.sol:2 — returns zero"),
262            Some("@bug B.f src/two/Vault.sol:2 — returns one"),
263        ]);
264        assert_eq!(groups, [0, 0, 2, 3, 4, 3, 6, 7, 8, 9]);
265    }
266
267    // Agreeing pairs do not chain: a bare file name agrees with both `Vault.sol` files, and the
268    // middle line is near both ends, but each end starts its own group.
269    #[test]
270    fn groups_need_every_pair_to_agree() {
271        let groups = root_cause_groups(&[
272            Some("@bug A.f src/one/Vault.sol:2 — returns zero"),
273            Some("@bug A.f Vault.sol:2 — returns zero"),
274            Some("@bug A.f src/two/Vault.sol:2 — returns zero"),
275            Some("@bug A.g src/Pool.sol:7 — wrong"),
276            Some("@bug A.g src/Pool.sol:50 — wrong"),
277            Some("@bug A.g src/Pool.sol:95 — wrong"),
278        ]);
279        assert_eq!(groups, [0, 0, 2, 3, 3, 5]);
280    }
281}