Skip to main content

slopguard_core/
scanner.rs

1use std::collections::HashMap;
2use std::fs::{self, read_to_string};
3use std::path::{Path, PathBuf};
4
5use ast_grep_config::{
6    CombinedScan, GlobalRules, RuleCollection, RuleConfig, RuleConfigError, SerializableRuleConfig,
7};
8use ast_grep_core::tree_sitter::LanguageExt;
9use ast_grep_core::Language as AstLanguage;
10use ast_grep_language::SupportLang;
11use globset::{Error as GlobError, Glob, GlobSet, GlobSetBuilder};
12use ignore::WalkBuilder;
13use rayon::prelude::*;
14use serde::Serialize;
15use serde_yaml::with::singleton_map_recursive;
16use serde_yaml::{to_value, Value};
17use strum::IntoEnumIterator;
18use thiserror::Error;
19
20use crate::cache::{file_content_hash, rules_hash, CacheStore};
21use crate::config::Config;
22use crate::disable::filter_disabled;
23use crate::finding::{CacheStats, Finding, ScanResult, ScanStats};
24use crate::rule::{Language, Rule, RuleId, Severity};
25use crate::test_filter::CfgTestRanges;
26
27#[derive(Debug, Error)]
28pub enum ScanError {
29    #[error("failed to compile rule '{id}': {source}")]
30    RuleCompile {
31        id: RuleId,
32        #[source]
33        source: RuleConfigError,
34    },
35
36    #[error("invalid glob pattern: {0}")]
37    Glob(#[from] GlobError),
38}
39
40/// The subset of a slopguard rule that ast-grep understands. slopguard-only
41/// fields (`category`, `fix` as free text, `tests`) are deliberately left out.
42#[derive(Serialize)]
43pub struct AstGrepRule<'a> {
44    pub id: &'a str,
45    pub language: SupportLang,
46    pub severity: &'a Severity,
47    pub message: &'a str,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub note: Option<&'a str>,
50    pub rule: &'a Value,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub files: Option<&'a [String]>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub ignores: Option<&'a [String]>,
55}
56
57/// Rules compiled for scanning, plus a way back to the slopguard rule that
58/// produced each ast-grep config.
59struct CompiledRules<'a> {
60    collection: RuleCollection<SupportLang>,
61    by_id: HashMap<(&'a str, Language), &'a Rule>,
62}
63
64fn extension_to_lang(path: &Path) -> Option<SupportLang> {
65    SupportLang::from_path(path)
66        .filter(|lang| Language::iter().any(|l| l.ast_grep_langs().contains(lang)))
67}
68
69fn support_lang_to_language(lang: SupportLang) -> Language {
70    match lang {
71        SupportLang::Rust => Language::Rust,
72        _ => Language::TypeScript,
73    }
74}
75
76pub(crate) fn compile_ast_grep_rule(
77    rule: &AstGrepRule,
78) -> Result<RuleConfig<SupportLang>, RuleConfigError> {
79    let inner: SerializableRuleConfig<SupportLang> = to_value(rule)
80        .and_then(singleton_map_recursive::deserialize)
81        .map_err(RuleConfigError::from)?;
82    RuleConfig::try_from(inner, &GlobalRules::default())
83}
84
85fn compile_rule(rule: &Rule, lang: SupportLang) -> Result<RuleConfig<SupportLang>, ScanError> {
86    let ast_grep_rule = AstGrepRule {
87        id: rule.id.as_str(),
88        language: lang,
89        severity: &rule.severity,
90        message: &rule.message,
91        note: rule.note.as_deref(),
92        rule: &rule.rule,
93        files: rule.files.as_deref(),
94        ignores: rule.ignores.as_deref(),
95    };
96    compile_ast_grep_rule(&ast_grep_rule).map_err(|source| ScanError::RuleCompile {
97        id: rule.id.clone(),
98        source,
99    })
100}
101
102fn compile_rules(rules: &[Rule]) -> Result<CompiledRules<'_>, ScanError> {
103    let configs = rules
104        .iter()
105        .flat_map(|rule| {
106            rule.language
107                .ast_grep_langs()
108                .iter()
109                .map(move |lang| compile_rule(rule, *lang))
110        })
111        .collect::<Result<Vec<_>, _>>()?;
112    Ok(CompiledRules {
113        collection: RuleCollection::try_new(configs)?,
114        by_id: rules
115            .iter()
116            .map(|rule| ((rule.id.as_str(), rule.language.clone()), rule))
117            .collect::<HashMap<_, _>>(),
118    })
119}
120
121fn build_glob_set(patterns: &[String]) -> Result<GlobSet, GlobError> {
122    let mut builder = GlobSetBuilder::new();
123    for pattern in patterns {
124        builder.add(Glob::new(pattern)?);
125    }
126    builder.build()
127}
128
129fn scan_file(path: &Path, lang: SupportLang, rules: &CompiledRules) -> Vec<Finding> {
130    let applicable = rules.collection.get_rule_from_lang(path, lang);
131    if applicable.is_empty() {
132        return Vec::new();
133    }
134    let Ok(source) = read_to_string(path) else {
135        return Vec::new();
136    };
137
138    let root = lang.ast_grep(&source);
139    let combined = CombinedScan::new(applicable);
140    let result = combined.scan(&root, false);
141
142    let cfg_test = (lang == SupportLang::Rust && !result.matches.is_empty())
143        .then(|| CfgTestRanges::from_root(&root));
144    let cfg_test = cfg_test.as_ref();
145
146    let findings = result
147        .matches
148        .into_iter()
149        .filter_map(|(config, matches)| {
150            let rule_lang = support_lang_to_language(lang);
151            let rule = rules.by_id.get(&(config.id.as_str(), rule_lang))?;
152            Some((*rule, matches))
153        })
154        .flat_map(|(rule, matches)| {
155            let skip_test_code = rule.skip_test_code;
156            matches
157                .into_iter()
158                .map(move |node_match| {
159                    let start = node_match.start_pos();
160                    let end = node_match.end_pos();
161                    Finding {
162                        rule_id: rule.id.clone(),
163                        severity: rule.severity.clone(),
164                        category: rule.category.clone().unwrap_or_default(),
165                        message: rule.message.clone(),
166                        note: rule.note.clone(),
167                        fix: rule.fix.clone(),
168                        file: path.to_path_buf(),
169                        line: start.line() + 1,
170                        column: start.byte_point().1 + 1,
171                        end_line: end.line() + 1,
172                        end_column: end.byte_point().1 + 1,
173                        matched_text: node_match.text().to_string(),
174                        confidence: None,
175                    }
176                })
177                .filter(move |f| {
178                    !(skip_test_code && cfg_test.is_some_and(|r| r.contains_line(f.line)))
179                })
180        })
181        .collect();
182
183    filter_disabled(findings, &source)
184}
185
186/// Scan the given paths for rule violations.
187pub fn scan(paths: &[PathBuf], rules: &[Rule], config: &Config) -> Result<ScanResult, ScanError> {
188    let compiled = compile_rules(rules)?;
189    let ignores = build_glob_set(&config.scan.ignores)?;
190
191    let files: Vec<(PathBuf, SupportLang)> = paths
192        .iter()
193        .flat_map(|path| WalkBuilder::new(path).build().flatten())
194        .filter(|entry| entry.file_type().is_some_and(|t| t.is_file()))
195        .map(|entry| entry.into_path())
196        .filter(|path| !ignores.is_match(path))
197        .filter_map(|path| extension_to_lang(&path).map(|lang| (path, lang)))
198        .collect();
199
200    let mut findings: Vec<Finding> = files
201        .par_iter()
202        .flat_map_iter(|(path, lang)| scan_file(path, *lang, &compiled))
203        .collect();
204    findings.sort_by(|a, b| (&a.file, a.line, a.column).cmp(&(&b.file, b.line, b.column)));
205    findings.dedup_by(|a, b| {
206        a.rule_id == b.rule_id && a.file == b.file && a.line == b.line && a.column == b.column
207    });
208
209    let (errors, warnings) =
210        findings
211            .iter()
212            .fold((0, 0), |(errors, warnings), f| match f.severity {
213                Severity::Error => (errors + 1, warnings),
214                Severity::Warning => (errors, warnings + 1),
215            });
216
217    Ok(ScanResult {
218        findings,
219        stats: ScanStats {
220            errors,
221            warnings,
222            total: errors + warnings,
223            files_scanned: files.len(),
224        },
225        cache_stats: None,
226    })
227}
228
229/// Scan with file-level caching. Files whose content hash matches a cached
230/// entry (and whose rules have not changed) return cached findings without
231/// reparsing.
232///
233/// `cache_dir` is the directory where cache files are stored. When a custom
234/// cache directory is specified (via `--cache-dir`, `SLOPGUARD_CACHE_DIR`,
235/// or `scan.cache_dir` in config), pass it directly. Otherwise pass the
236/// project root and use `CacheStore::new` which appends `.slopguard-cache`.
237pub fn scan_cached(
238    paths: &[PathBuf],
239    rules: &[Rule],
240    config: &Config,
241    cache_dir: &Path,
242) -> Result<ScanResult, ScanError> {
243    let compiled = compile_rules(rules)?;
244    let ignores = build_glob_set(&config.scan.ignores)?;
245
246    let files: Vec<(PathBuf, SupportLang)> = paths
247        .iter()
248        .flat_map(|path| WalkBuilder::new(path).build().flatten())
249        .filter(|entry| entry.file_type().is_some_and(|t| t.is_file()))
250        .map(|entry| entry.into_path())
251        .filter(|path| !ignores.is_match(path))
252        .filter_map(|path| extension_to_lang(&path).map(|lang| (path, lang)))
253        .collect();
254
255    let store = CacheStore::with_dir(cache_dir.to_path_buf());
256    let current_rules_hash = rules_hash(rules);
257    let rules_changed = store
258        .check_rules_changed(&current_rules_hash)
259        .unwrap_or(true);
260
261    let file_contents: Vec<(PathBuf, SupportLang, Vec<u8>, String)> = files
262        .into_iter()
263        .filter_map(|(path, lang)| {
264            let content = fs::read(&path).ok()?;
265            let hash = file_content_hash(&content);
266            Some((path, lang, content, hash))
267        })
268        .collect();
269
270    let mut cached_count = 0usize;
271    let mut changed_count = 0usize;
272    let mut all_findings: Vec<Finding> = Vec::new();
273
274    struct FileWork {
275        path: PathBuf,
276        lang: SupportLang,
277        hash: String,
278    }
279
280    let mut to_scan: Vec<FileWork> = Vec::new();
281
282    for (path, lang, _content, hash) in file_contents.iter() {
283        if !rules_changed {
284            if let Some(cached_findings) = store.get(hash) {
285                cached_count += 1;
286                all_findings.extend(cached_findings);
287                continue;
288            }
289        }
290        changed_count += 1;
291        to_scan.push(FileWork {
292            path: path.clone(),
293            lang: *lang,
294            hash: hash.clone(),
295        });
296    }
297
298    let scanned_findings: Vec<(String, Vec<Finding>)> = to_scan
299        .par_iter()
300        .map(|work| {
301            let findings = scan_file(&work.path, work.lang, &compiled);
302            (work.hash.clone(), findings)
303        })
304        .collect();
305
306    for (hash, findings) in scanned_findings {
307        store.put(&hash, &findings).ok();
308        all_findings.extend(findings);
309    }
310
311    let current_hashes: Vec<String> = file_contents.iter().map(|(_, _, _, h)| h.clone()).collect();
312    store.cleanup(&current_hashes).ok();
313
314    all_findings.sort_by(|a, b| (&a.file, a.line, a.column).cmp(&(&b.file, b.line, b.column)));
315    all_findings.dedup_by(|a, b| {
316        a.rule_id == b.rule_id && a.file == b.file && a.line == b.line && a.column == b.column
317    });
318
319    let (errors, warnings) =
320        all_findings
321            .iter()
322            .fold((0, 0), |(errors, warnings), f| match f.severity {
323                Severity::Error => (errors + 1, warnings),
324                Severity::Warning => (errors, warnings + 1),
325            });
326
327    let files_scanned = cached_count + changed_count;
328
329    Ok(ScanResult {
330        findings: all_findings,
331        stats: ScanStats {
332            errors,
333            warnings,
334            total: errors + warnings,
335            files_scanned,
336        },
337        cache_stats: Some(CacheStats {
338            cached: cached_count,
339            changed: changed_count,
340        }),
341    })
342}
343
344#[cfg(test)]
345mod tests {
346    use std::fs::{create_dir, write};
347
348    use tempfile::tempdir;
349
350    use super::*;
351    use crate::rule::{load_builtin_rules, parse_rule, RuleId};
352
353    fn unwrap_rule() -> Rule {
354        parse_rule(
355            r#"
356id: test-unwrap
357language: rust
358severity: error
359category: correctness
360message: ".unwrap() forbidden"
361rule:
362  kind: call_expression
363  regex: '\.unwrap\(\)\s*$'
364"#,
365        )
366        .unwrap()
367    }
368
369    fn scan_dir(dir: &Path, rules: &[Rule]) -> ScanResult {
370        scan(&[dir.to_path_buf()], rules, &Config::default()).unwrap()
371    }
372
373    #[test]
374    fn scan_finds_unwrap_violation() {
375        let dir = tempdir().unwrap();
376        write(
377            dir.path().join("main.rs"),
378            "fn main() {\n    foo().unwrap();\n}\n",
379        )
380        .unwrap();
381        let result = scan_dir(dir.path(), &[unwrap_rule()]);
382        assert_eq!(result.findings.len(), 1);
383        let f = &result.findings[0];
384        assert_eq!(f.rule_id, RuleId::from("test-unwrap"));
385        assert_eq!(f.severity, Severity::Error);
386        assert_eq!(f.line, 2);
387        assert!(f.matched_text.contains("unwrap()"));
388    }
389
390    #[test]
391    fn scan_clean_file_no_findings() {
392        let dir = tempdir().unwrap();
393        write(dir.path().join("main.rs"), "fn main() { let x = foo(); }\n").unwrap();
394        let result = scan_dir(dir.path(), &[unwrap_rule()]);
395        assert_eq!(result.findings.len(), 0);
396        assert_eq!(result.stats.files_scanned, 1);
397    }
398
399    #[test]
400    fn scan_unsupported_language_ignored() {
401        let dir = tempdir().unwrap();
402        write(dir.path().join("script.py"), "x.unwrap()\n").unwrap();
403        write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
404        let result = scan_dir(dir.path(), &[unwrap_rule()]);
405        assert_eq!(result.findings.len(), 0);
406        assert_eq!(result.stats.files_scanned, 1);
407    }
408
409    #[test]
410    fn scan_explicit_file_path() {
411        let dir = tempdir().unwrap();
412        let file = dir.path().join("main.rs");
413        write(&file, "fn main() { foo().unwrap(); }\n").unwrap();
414        let result = scan(&[file], &[unwrap_rule()], &Config::default()).unwrap();
415        assert_eq!(result.findings.len(), 1);
416        assert_eq!(result.stats.files_scanned, 1);
417    }
418
419    #[test]
420    fn rule_ignores_glob_respected() {
421        let dir = tempdir().unwrap();
422        let tests_dir = dir.path().join("tests");
423        create_dir(&tests_dir).unwrap();
424        write(
425            tests_dir.join("test_main.rs"),
426            "fn t() { foo().unwrap(); }\n",
427        )
428        .unwrap();
429        write(
430            dir.path().join("main.rs"),
431            "fn main() { foo().unwrap(); }\n",
432        )
433        .unwrap();
434        let rule = parse_rule(
435            r#"
436id: ign
437language: rust
438severity: error
439category: correctness
440message: x
441rule:
442  kind: call_expression
443  regex: '\.unwrap\(\)\s*$'
444ignores:
445  - "**/tests/**"
446"#,
447        )
448        .unwrap();
449        let result = scan_dir(dir.path(), &[rule]);
450        assert_eq!(result.findings.len(), 1);
451        assert!(result.findings[0]
452            .file
453            .to_string_lossy()
454            .contains("main.rs"));
455    }
456
457    #[test]
458    fn config_ignores_respected() {
459        let dir = tempdir().unwrap();
460        let gen = dir.path().join("generated");
461        create_dir(&gen).unwrap();
462        write(gen.join("out.rs"), "fn g() { foo().unwrap(); }\n").unwrap();
463        write(
464            dir.path().join("main.rs"),
465            "fn main() { foo().unwrap(); }\n",
466        )
467        .unwrap();
468        let mut cfg = Config::default();
469        cfg.scan.ignores = vec!["**/generated/**".to_string()];
470        let result = scan(&[dir.path().to_path_buf()], &[unwrap_rule()], &cfg).unwrap();
471        assert_eq!(result.stats.files_scanned, 1);
472        assert_eq!(result.findings.len(), 1);
473    }
474
475    #[test]
476    fn config_invalid_glob_is_an_error() {
477        let dir = tempdir().unwrap();
478        let mut cfg = Config::default();
479        cfg.scan.ignores = vec!["[".to_string()];
480        let err = scan(&[dir.path().to_path_buf()], &[unwrap_rule()], &cfg).unwrap_err();
481        assert!(matches!(err, ScanError::Glob(_)), "{err:?}");
482    }
483
484    #[test]
485    fn rule_without_kind_fails_to_compile() {
486        let dir = tempdir().unwrap();
487        let rule = parse_rule(
488            r#"
489id: no-kind
490language: rust
491severity: error
492category: correctness
493message: x
494rule:
495  regex: 'unwrap'
496"#,
497        )
498        .unwrap();
499        let err = scan(&[dir.path().to_path_buf()], &[rule], &Config::default()).unwrap_err();
500        assert!(
501            matches!(&err, ScanError::RuleCompile { id, .. } if *id == RuleId::from("no-kind")),
502            "{err:?}"
503        );
504    }
505
506    #[test]
507    fn stats_counting_accurate() {
508        let dir = tempdir().unwrap();
509        write(
510            dir.path().join("a.rs"),
511            "fn f() { foo().unwrap(); bar().unwrap(); }\n",
512        )
513        .unwrap();
514        let result = scan_dir(dir.path(), &[unwrap_rule()]);
515        assert_eq!(result.stats.errors, 2);
516        assert_eq!(result.stats.warnings, 0);
517        assert_eq!(result.stats.total, 2);
518        assert_eq!(result.stats.files_scanned, 1);
519    }
520
521    #[test]
522    fn scan_empty_file() {
523        let dir = tempdir().unwrap();
524        write(dir.path().join("empty.rs"), "").unwrap();
525        let result = scan_dir(dir.path(), &[unwrap_rule()]);
526        assert_eq!(result.findings.len(), 0);
527        assert_eq!(result.stats.files_scanned, 1);
528    }
529
530    #[test]
531    fn scan_complex_rule() {
532        let rule = parse_rule(
533            r#"
534id: dbg
535language: rust
536severity: error
537category: security
538message: debug on secret
539rule:
540  kind: attribute_item
541  all:
542    - regex: 'derive\([^)]*Debug'
543    - precedes:
544        kind: struct_item
545        has:
546          kind: field_identifier
547          regex: '^(api_key|password|secret)$'
548          stopBy: end
549"#,
550        )
551        .unwrap();
552        let dir = tempdir().unwrap();
553        write(
554            dir.path().join("t.rs"),
555            "#[derive(Debug)]\nstruct Config {\n    api_key: String,\n}\n",
556        )
557        .unwrap();
558        let result = scan_dir(dir.path(), &[rule]);
559        assert_eq!(result.findings.len(), 1);
560        assert_eq!(result.findings[0].rule_id, RuleId::from("dbg"));
561    }
562
563    #[test]
564    fn typescript_rule_applies_to_tsx_files() {
565        let rule = parse_rule(
566            r#"
567id: ts-any
568language: typescript
569severity: warning
570category: slop
571message: no any
572rule:
573  kind: predefined_type
574  regex: '^any$'
575"#,
576        )
577        .unwrap();
578        let dir = tempdir().unwrap();
579        write(dir.path().join("a.ts"), "let x: any = 1;\n").unwrap();
580        write(dir.path().join("b.tsx"), "let y: any = 2;\n").unwrap();
581        let result = scan_dir(dir.path(), &[rule]);
582        assert_eq!(result.stats.files_scanned, 2);
583        assert_eq!(result.stats.warnings, 2);
584    }
585
586    #[test]
587    fn scan_with_disable_comment_filters_findings() {
588        let dir = tempdir().unwrap();
589        write(
590            dir.path().join("main.rs"),
591            "fn main() {\n    // slopguard-disable-next-line\n    foo().unwrap();\n    bar().unwrap();\n}\n",
592        )
593        .unwrap();
594        let result = scan_dir(dir.path(), &[unwrap_rule()]);
595        assert_eq!(
596            result.findings.len(),
597            1,
598            "only the non-disabled unwrap should remain"
599        );
600        assert_eq!(result.findings[0].line, 4);
601    }
602
603    #[test]
604    fn scan_with_disable_rule_id_filters_only_that_rule() {
605        let dir = tempdir().unwrap();
606        write(
607            dir.path().join("main.rs"),
608            "fn main() {\n    // slopguard-disable-next-line test-unwrap\n    foo().unwrap();\n}\n",
609        )
610        .unwrap();
611        let result = scan_dir(dir.path(), &[unwrap_rule()]);
612        assert_eq!(result.findings.len(), 0);
613    }
614
615    fn unwrap_rule_skipping_test_code() -> Rule {
616        parse_rule(
617            r#"
618id: test-unwrap
619language: rust
620severity: error
621category: correctness
622message: ".unwrap() forbidden"
623rule:
624  kind: call_expression
625  regex: '\.unwrap\(\)\s*$'
626skip_test_code: true
627"#,
628        )
629        .unwrap()
630    }
631
632    #[test]
633    fn cfg_test_block_excluded_by_post_filter() {
634        let dir = tempdir().unwrap();
635        write(
636            dir.path().join("main.rs"),
637            "fn prod() {\n    foo().unwrap();\n}\n\n#[cfg(test)]\nmod tests {\n    fn test_it() {\n        bar().unwrap();\n    }\n}\n",
638        )
639        .unwrap();
640        let result = scan_dir(dir.path(), &[unwrap_rule_skipping_test_code()]);
641        assert_eq!(
642            result.findings.len(),
643            1,
644            "should only find the prod unwrap, not the one inside #[cfg(test)]"
645        );
646        assert_eq!(result.findings[0].line, 2);
647    }
648
649    #[test]
650    fn cfg_test_kept_without_skip_test_code() {
651        let dir = tempdir().unwrap();
652        write(
653            dir.path().join("main.rs"),
654            "fn prod() {\n    foo().unwrap();\n}\n\n#[cfg(test)]\nmod tests {\n    fn test_it() {\n        bar().unwrap();\n    }\n}\n",
655        )
656        .unwrap();
657        let result = scan_dir(dir.path(), &[unwrap_rule()]);
658        assert_eq!(
659            result.findings.len(),
660            2,
661            "rule without skip_test_code should find both"
662        );
663    }
664
665    #[test]
666    fn all_builtin_rules_compile() {
667        let rules = load_builtin_rules().unwrap();
668        assert!(
669            rules.len() >= 55,
670            "expected at least 55 rules, got {}",
671            rules.len()
672        );
673        let compiled = compile_rules(&rules).expect("all builtin rules should compile");
674        assert_eq!(compiled.by_id.len(), rules.len());
675    }
676
677    #[test]
678    fn scan_with_cache() {
679        let src_dir = tempdir().unwrap();
680        let cache_dir = tempdir().unwrap();
681        let cache_path = cache_dir.path().join("cache");
682        write(
683            src_dir.path().join("main.rs"),
684            "fn main() {\n    foo().unwrap();\n}\n",
685        )
686        .unwrap();
687
688        let rules = [unwrap_rule()];
689        let config = Config::default();
690
691        let result1 = scan_cached(
692            &[src_dir.path().to_path_buf()],
693            &rules,
694            &config,
695            &cache_path,
696        )
697        .unwrap();
698
699        assert_eq!(result1.findings.len(), 1);
700        let cache_stats1 = result1.cache_stats.as_ref().unwrap();
701        assert_eq!(cache_stats1.cached, 0, "first scan: nothing cached");
702        assert_eq!(cache_stats1.changed, 1, "first scan: one file scanned");
703
704        let result2 = scan_cached(
705            &[src_dir.path().to_path_buf()],
706            &rules,
707            &config,
708            &cache_path,
709        )
710        .unwrap();
711
712        assert_eq!(result2.findings.len(), 1);
713        let cache_stats2 = result2.cache_stats.as_ref().unwrap();
714        assert_eq!(cache_stats2.cached, 1, "second scan: one file from cache");
715        assert_eq!(cache_stats2.changed, 0, "second scan: nothing changed");
716    }
717}