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 refuses. Every one of these must be a
10//! file nix itself rejects; a rejection nix ACCEPTS is a false reject, which
11//! is the direction that breaks working code.
12//!
13//! ★ This used to say the count "must be ZERO before the rejection tier
14//! (stage 4) can flip". That was the wrong predicate and it was written
15//! against a wrong number. The tier flipped on 2026-08-18 with the count at
16//! **4**, not 0 — jitsi, keycloak, macos-developer and composed.nix — and
17//! flipping was correct, because `nix-instantiate --parse` refuses all four
18//! and names the same attribute path. The gate is not "zero rejections", it
19//! is "zero rejections nix accepts": a real duplicate in the wild is a
20//! finding about the fleet, not a blocker for this pass. Inspect each one by
21//! hand against `nix-instantiate --parse` — that check is the gate.
22//!
23//! Usage: `cargo run -p sui-normalize --example scan -- <dir> [<dir>…]`
24//!
25//! Deliberately does NOT shell out to nix: this is a pure rnix walk, so it
26//! runs over tens of thousands of files in seconds and can be pointed at
27//! nixpkgs without a store or an evaluator.
28
29use std::path::{Path, PathBuf};
30
31fn nix_files(root: &Path, out: &mut Vec<PathBuf>) {
32 let Ok(entries) = std::fs::read_dir(root) else {
33 return;
34 };
35 for e in entries.flatten() {
36 let p = e.path();
37 let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
38 // ★ `file_type()` does NOT follow symlinks; `Path::is_dir()` DOES.
39 // Using `is_dir()` here walked every `result -> /nix/store/...` link
40 // in the fleet and pulled the entire store into the scan. Same trap as
41 // `DirEntry::metadata()` (lstat) vs `Path::metadata()` (stat).
42 let Ok(ft) = e.file_type() else { continue };
43 if ft.is_symlink() {
44 continue;
45 }
46 if ft.is_dir() {
47 // Skip build output and VCS metadata — neither is source.
48 if matches!(name, ".git" | "target" | "result" | "node_modules") {
49 continue;
50 }
51 nix_files(&p, out);
52 } else if p.extension().and_then(|s| s.to_str()) == Some("nix") {
53 out.push(p);
54 }
55 }
56}
57
58fn main() {
59 let roots: Vec<PathBuf> = std::env::args().skip(1).map(PathBuf::from).collect();
60 if roots.is_empty() {
61 eprintln!("usage: scan <dir> [<dir>…]");
62 std::process::exit(2);
63 }
64
65 let mut files = Vec::new();
66 for r in &roots {
67 nix_files(r, &mut files);
68 }
69
70 let (mut unparseable, mut clean, mut planned, mut rejected) = (0usize, 0usize, 0usize, 0usize);
71 let mut groups = 0usize;
72 let mut rejects: Vec<(PathBuf, String)> = Vec::new();
73
74 for f in &files {
75 let Ok(src) = std::fs::read_to_string(f) else {
76 continue;
77 };
78 let parse = rnix::Root::parse(&src);
79 if !parse.errors().is_empty() {
80 // rnix could not parse it — not this pass's business, and counted
81 // separately so it can never be mistaken for a clean result.
82 unparseable += 1;
83 continue;
84 }
85 match sui_normalize::normalize(&parse.tree()) {
86 Ok(table) if table.is_empty() => clean += 1,
87 Ok(table) => {
88 planned += 1;
89 groups += table.len();
90 }
91 Err(e) => {
92 rejected += 1;
93 rejects.push((f.clone(), e.to_string()));
94 }
95 }
96 }
97
98 println!("scanned {}", files.len());
99 println!(" clean {clean} (no duplicate key, no dotted path — untouched)");
100 println!(" planned {planned} ({groups} binding groups — THE BLAST RADIUS)");
101 println!(" rejected {rejected} (each MUST be one `nix-instantiate --parse` also refuses)");
102 println!(" unparseable {unparseable} (rnix could not read; not this pass's business)");
103
104 for (p, e) in rejects.iter().take(25) {
105 println!(" REJECT {}: {e}", p.display());
106 }
107 if rejects.len() > 25 {
108 println!(" … and {} more", rejects.len() - 25);
109 }
110}