Skip to main content

browser_automation_cli/
sg_local.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! One-shot structural lint / rewrite for product-forbidden patterns (§5AC / GAP-A011).
3//!
4//! Scans Rust sources under given roots for patterns that violate agent-first / one-shot
5//! product rules (remote telemetry strings, product secret env reads, naked `unwrap()` in
6//! non-test production modules). Default rewrite is dry-run; `--apply` writes in place.
7//!
8//! # Workload
9//!
10//! **Mista (I/O + CPU):**
11//! - Walk is disk I/O (`ignore::WalkBuilder` with parallel threads).
12//! - Per-file line scan is **CPU-bound** → Rayon `par_iter` over collected paths.
13//! - Rewrite path stays sequential when `--apply` (deterministic atomic writes; no
14//!   concurrent writers on the same tree).
15//! - Regex rules compile once via [`OnceLock`].
16
17use std::fs;
18use std::path::{Path, PathBuf};
19use std::sync::OnceLock;
20
21use ignore::WalkBuilder;
22use rayon::prelude::*;
23use regex::Regex;
24use serde_json::{json, Value};
25
26use crate::error::{CliError, ErrorKind};
27
28/// A single finding.
29#[derive(Debug, Clone)]
30struct Finding {
31    path: String,
32    line: usize,
33    rule: &'static str,
34    snippet: String,
35}
36
37/// Scan roots for forbidden structural patterns (one-shot, parallel CPU).
38pub fn sg_scan(roots: &[PathBuf], limit: usize) -> Result<Value, CliError> {
39    crate::concurrency::install_rayon_pool_once();
40    let roots = if roots.is_empty() {
41        vec![PathBuf::from(".")]
42    } else {
43        roots.to_vec()
44    };
45    let rules = compiled_rules();
46
47    // Stage 1: collect candidate paths (parallel walk threads via ignore).
48    // Multi-root: independent walks under Rayon (same pattern as find_paths).
49    let walk_threads = crate::concurrency::walk_threads();
50    let collect_root = |root: &PathBuf| -> Vec<PathBuf> {
51        let mut local = Vec::new();
52        let mut builder = WalkBuilder::new(root);
53        builder.hidden(false);
54        builder.git_ignore(true);
55        builder.threads(walk_threads);
56        for entry in builder.build() {
57            let entry = match entry {
58                Ok(e) => e,
59                Err(_) => continue,
60            };
61            let path = entry.path();
62            if path
63                .extension()
64                .and_then(|e| e.to_str())
65                .is_none_or(|e| e != "rs")
66            {
67                continue;
68            }
69            let s = path.to_string_lossy();
70            if s.contains("/target/") || s.contains("\\target\\") {
71                continue;
72            }
73            local.push(path.to_path_buf());
74        }
75        local
76    };
77    let paths: Vec<PathBuf> = if roots.len() <= 1 {
78        roots.iter().flat_map(collect_root).collect()
79    } else {
80        roots.par_iter().flat_map(collect_root).collect()
81    };
82
83    // Stage 2: CPU-bound line scan in parallel (Rayon).
84    let mut findings: Vec<Finding> = paths
85        .par_iter()
86        .flat_map_iter(|path| scan_file(path, rules))
87        .collect();
88
89    // Deterministic order for agents (path, line, rule). PAR-94/104: sort_cpu.
90    crate::concurrency::sort_by_cpu(&mut findings, |a, b| {
91        (&a.path, a.line, a.rule).cmp(&(&b.path, b.line, b.rule))
92    });
93    if limit > 0 && findings.len() > limit {
94        findings.truncate(limit);
95    }
96
97    Ok(findings_to_json(&findings, false))
98}
99
100fn scan_file(path: &Path, rules: &[(&'static str, Regex)]) -> Vec<Finding> {
101    let Ok(text) = fs::read_to_string(path) else {
102        return Vec::new();
103    };
104    let s = path.to_string_lossy();
105    let mut out = Vec::new();
106    for (lineno, line) in text.lines().enumerate() {
107        let lineno = lineno + 1;
108        for (rule, re) in rules {
109            if re.is_match(line) {
110                if *rule == "unwrap_prod"
111                    && (s.contains("/tests/")
112                        || s.contains("\\tests\\")
113                        || path.ends_with("test_utils.rs"))
114                {
115                    continue;
116                }
117                out.push(Finding {
118                    path: path.display().to_string(),
119                    line: lineno,
120                    rule,
121                    snippet: line.trim().chars().take(160).collect(),
122                });
123            }
124        }
125    }
126    out
127}
128
129/// Dry-run or apply safe rewrites (GAP-A011). Only applies trivial safe fixes.
130///
131/// # Parallelism
132///
133/// - **Collect paths:** multi-threaded `ignore` walk.
134/// - **Dry-run (`apply=false`):** Rayon `par_iter` over paths (CPU + disk read).
135/// - **Apply (`apply=true`):** **sequential** by design — concurrent writers would
136///   race on the same tree; atomic rename is per-file but ordering stays deterministic.
137pub fn sg_rewrite(roots: &[PathBuf], apply: bool) -> Result<Value, CliError> {
138    crate::concurrency::install_rayon_pool_once();
139    let roots = if roots.is_empty() {
140        vec![PathBuf::from(".")]
141    } else {
142        roots.to_vec()
143    };
144    // Safe rewrite: strip `RUST_LOG` product-env comments that reintroduce env secrets guidance.
145    // Real rewrites of unwrap are intentionally NOT automatic (would be blind rewrite — forbidden).
146    let re_env_hint = re_rust_log_export();
147    let walk_threads = crate::concurrency::walk_threads();
148    let collect_root = |root: &PathBuf| -> Vec<PathBuf> {
149        let mut local = Vec::new();
150        let mut builder = WalkBuilder::new(root);
151        builder.git_ignore(true);
152        builder.threads(walk_threads);
153        for entry in builder.build() {
154            let entry = match entry {
155                Ok(e) => e,
156                Err(_) => continue,
157            };
158            let path = entry.path();
159            if path
160                .extension()
161                .and_then(|e| e.to_str())
162                .is_none_or(|e| e != "md" && e != "txt" && e != "rs")
163            {
164                continue;
165            }
166            let s = path.to_string_lossy();
167            if s.contains("/target/") {
168                continue;
169            }
170            local.push(path.to_path_buf());
171        }
172        local
173    };
174    let mut paths: Vec<PathBuf> = if roots.len() <= 1 {
175        roots.iter().flat_map(collect_root).collect()
176    } else {
177        roots.par_iter().flat_map(collect_root).collect()
178    };
179
180    let replacement = "# (removed product RUST_LOG export — use XDG log_level) ";
181    let mut changed = Vec::new();
182    let planned;
183
184    if apply {
185        // Sequential apply: deterministic order + no concurrent writers (N-136).
186        // PAR-94: large path lists use par_sort before sequential apply.
187        crate::concurrency::sort_cpu(&mut paths);
188        let mut n = 0usize;
189        for path in &paths {
190            let Ok(text) = fs::read_to_string(path) else {
191                continue;
192            };
193            if !re_env_hint.is_match(&text) {
194                continue;
195            }
196            n += 1;
197            let new_text = re_env_hint.replace_all(&text, replacement);
198            atomic_write(path, new_text.as_ref())?;
199            changed.push(path.display().to_string());
200        }
201        planned = n;
202    } else {
203        // Dry-run: parallel CPU match over collected paths.
204        let mut hits: Vec<String> = paths
205            .par_iter()
206            .filter_map(|path| {
207                let text = fs::read_to_string(path).ok()?;
208                if re_env_hint.is_match(&text) {
209                    Some(path.display().to_string())
210                } else {
211                    None
212                }
213            })
214            .collect();
215        crate::concurrency::sort_cpu(&mut hits);
216        planned = hits.len();
217        changed = hits;
218    }
219
220    Ok(json!({
221        "ok": true,
222        "apply": apply,
223        "planned": planned,
224        "changed": changed,
225        "note": "Blind AST rewrite is forbidden; only safe RUST_LOG export hints are rewritten",
226        "chrome": false,
227        "parallel_walk": true,
228        "dry_run_rayon": !apply,
229        "apply_sequential": apply,
230        "concurrency": crate::concurrency::effective_limit(),
231    }))
232}
233
234fn re_rust_log_export() -> &'static Regex {
235    static RE: OnceLock<Regex> = OnceLock::new();
236    RE.get_or_init(|| {
237        Regex::new(r#"(?i)export\s+RUST_LOG\s*="#).expect("RUST_LOG export regex")
238    })
239}
240
241fn compiled_rules() -> &'static [(&'static str, Regex)] {
242    static RULES: OnceLock<Vec<(&'static str, Regex)>> = OnceLock::new();
243    RULES
244        .get_or_init(|| {
245            vec![
246                (
247                    "telemetry_string",
248                    Regex::new(
249                        r"(?i)\b(opentelemetry|sentry\.io|telemetry\.|posthog|datadog)\b",
250                    )
251                    .expect("telemetry regex"),
252                ),
253                (
254                    "product_env_secret",
255                    Regex::new(
256                        r#"std::env::var\(\s*"(API_KEY|OPENAI_API_KEY|SECRET|TOKEN|PASSWORD)""#,
257                    )
258                    .expect("env secret regex"),
259                ),
260                (
261                    "unwrap_prod",
262                    Regex::new(r"\.unwrap\(\)").expect("unwrap regex"),
263                ),
264                (
265                    "dotenv",
266                    Regex::new(r"(?i)\bdotenv\b|\.env\b").expect("dotenv regex"),
267                ),
268            ]
269        })
270        .as_slice()
271}
272
273fn findings_to_json(findings: &[Finding], apply: bool) -> Value {
274    let items: Vec<Value> = findings
275        .iter()
276        .map(|f| {
277            json!({
278                "path": f.path,
279                "line": f.line,
280                "rule": f.rule,
281                "snippet": f.snippet,
282            })
283        })
284        .collect();
285    json!({
286        "ok": true,
287        "count": items.len(),
288        "findings": items,
289        "apply": apply,
290        "engine": "sg-local",
291        "parallel": true,
292        "concurrency": crate::concurrency::effective_limit(),
293        "chrome": false,
294    })
295}
296
297fn atomic_write(path: &Path, body: &str) -> Result<(), CliError> {
298    let parent = path.parent().unwrap_or_else(|| Path::new("."));
299    let tmp = parent.join(format!(
300        ".browser-automation-cli-sg-{}.tmp",
301        std::process::id()
302    ));
303    fs::write(&tmp, body).map_err(|e| {
304        CliError::new(
305            ErrorKind::Io,
306            format!("write temp {}: {e}", tmp.display()),
307        )
308    })?;
309    fs::rename(&tmp, path).map_err(|e| {
310        let _ = fs::remove_file(&tmp);
311        CliError::new(
312            ErrorKind::Io,
313            format!("rename {} → {}: {e}", tmp.display(), path.display()),
314        )
315    })
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use std::io::Write;
322
323    #[test]
324    fn scan_finds_unwrap_in_temp_rs() {
325        let dir = tempfile::tempdir().unwrap();
326        let f = dir.path().join("prod.rs");
327        let mut file = fs::File::create(&f).unwrap();
328        writeln!(
329            file,
330            "fn x() {{ let _ = \"a\".parse::<u32>().unwrap(); }}"
331        )
332        .unwrap();
333        let v = sg_scan(&[dir.path().to_path_buf()], 50).unwrap();
334        assert!(v.get("count").and_then(|c| c.as_u64()).unwrap_or(0) >= 1);
335    }
336}