Skip to main content

scan/
scan.rs

1//! Blast-radius scanner: run the normalizer over a tree of `.nix` files and
2//! report what it would do, without evaluating anything.
3//!
4//! Two numbers decide whether the normalizer is safe to turn on:
5//!
6//! * **planned** — files containing at least one binding group with a
7//!   duplicate static key or a dotted path. These are the only files whose
8//!   answers can change, so this IS the blast radius.
9//! * **rejected** — files the normalizer would refuse. Every one of these is
10//!   a file nix itself rejects, so a non-zero count is either a real
11//!   duplicate in the wild or a bug in this pass. It must be ZERO before the
12//!   rejection tier (stage 4) can flip, and each one inspected by hand.
13//!
14//! Usage: `cargo run -p sui-normalize --example scan -- <dir> [<dir>…]`
15//!
16//! Deliberately does NOT shell out to nix: this is a pure rnix walk, so it
17//! runs over tens of thousands of files in seconds and can be pointed at
18//! nixpkgs without a store or an evaluator.
19
20use std::path::{Path, PathBuf};
21
22fn nix_files(root: &Path, out: &mut Vec<PathBuf>) {
23    let Ok(entries) = std::fs::read_dir(root) else {
24        return;
25    };
26    for e in entries.flatten() {
27        let p = e.path();
28        let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
29        // ★ `file_type()` does NOT follow symlinks; `Path::is_dir()` DOES.
30        // Using `is_dir()` here walked every `result -> /nix/store/...` link
31        // in the fleet and pulled the entire store into the scan. Same trap as
32        // `DirEntry::metadata()` (lstat) vs `Path::metadata()` (stat).
33        let Ok(ft) = e.file_type() else { continue };
34        if ft.is_symlink() {
35            continue;
36        }
37        if ft.is_dir() {
38            // Skip build output and VCS metadata — neither is source.
39            if matches!(name, ".git" | "target" | "result" | "node_modules") {
40                continue;
41            }
42            nix_files(&p, out);
43        } else if p.extension().and_then(|s| s.to_str()) == Some("nix") {
44            out.push(p);
45        }
46    }
47}
48
49fn main() {
50    let roots: Vec<PathBuf> = std::env::args().skip(1).map(PathBuf::from).collect();
51    if roots.is_empty() {
52        eprintln!("usage: scan <dir> [<dir>…]");
53        std::process::exit(2);
54    }
55
56    let mut files = Vec::new();
57    for r in &roots {
58        nix_files(r, &mut files);
59    }
60
61    let (mut unparseable, mut clean, mut planned, mut rejected) = (0usize, 0usize, 0usize, 0usize);
62    let mut groups = 0usize;
63    let mut rejects: Vec<(PathBuf, String)> = Vec::new();
64
65    for f in &files {
66        let Ok(src) = std::fs::read_to_string(f) else {
67            continue;
68        };
69        let parse = rnix::Root::parse(&src);
70        if !parse.errors().is_empty() {
71            // rnix could not parse it — not this pass's business, and counted
72            // separately so it can never be mistaken for a clean result.
73            unparseable += 1;
74            continue;
75        }
76        match sui_normalize::normalize(&parse.tree()) {
77            Ok(table) if table.is_empty() => clean += 1,
78            Ok(table) => {
79                planned += 1;
80                groups += table.len();
81            }
82            Err(e) => {
83                rejected += 1;
84                rejects.push((f.clone(), e.to_string()));
85            }
86        }
87    }
88
89    println!("scanned    {}", files.len());
90    println!("  clean      {clean}   (no duplicate key, no dotted path — untouched)");
91    println!("  planned    {planned}   ({groups} binding groups — THE BLAST RADIUS)");
92    println!("  rejected   {rejected}   (must be 0 before the rejection tier flips)");
93    println!("  unparseable {unparseable}   (rnix could not read; not this pass's business)");
94
95    for (p, e) in rejects.iter().take(25) {
96        println!("  REJECT {}: {e}", p.display());
97    }
98    if rejects.len() > 25 {
99        println!("  … and {} more", rejects.len() - 25);
100    }
101}