Skip to main content

amont_runtime/
tree_cache.rs

1//! Tree-gate caches: namespaced by everything that can change a verdict,
2//! never trusted across a namespace (ADR-0024).
3//!
4//! A tool's own cache is not enough. Prettier's cache keys exclude plugin
5//! versions and implementations, so a plugin upgrade would reuse stale passes.
6//! So every gate's cache lives under a NAMESPACE — a hash of:
7//!
8//! - the declared command;
9//! - the version of the tool the gate actually runs;
10//! - the staged blob of every lockfile and every config-like file, matched by
11//!   basename anywhere in the tree;
12//! - the gate's `inputs=`.
13//!
14//! A plugin upgrade moves the lockfile, so it moves the namespace, and the old
15//! namespace is deleted (under the gate's lock) before anything warms the new
16//! one.
17//!
18//! **Warm** means a completion marker exists in the current namespace: a full
19//! run finished there. The marker is only a hint about cost ("starting this
20//! gate at commit is cheap"). Proof always comes from the commit-time run on
21//! the exact tree.
22//!
23//! Caches are per worktree (`$GIT_DIR`): eslint's and prettier's key files
24//! by absolute path, so a shared cache would give a new worktree no warmth.
25
26use std::fs::File;
27use std::path::{Path, PathBuf};
28use std::process::{Command, Stdio};
29
30use crate::manifest::{TreeGate, TreeTool};
31
32const COMPLETE: &str = ".complete";
33/// How many (directory, extension) pairs the typed-eslint probe asks about
34/// before it stops trusting its sample. It runs once per namespace, normally
35/// in the background warm-up (application-landscape: 83 pairs, ~1 min there),
36/// and a commit only reads the recorded answer.
37const MAX_TYPED_SAMPLE: usize = 256;
38const TYPED: &str = ".typed";
39const UNTYPED: &str = ".untyped";
40
41/// Lockfiles, by basename: a dependency or plugin upgrade moves one of them.
42const LOCKFILES: &[&str] = &[
43    "package-lock.json",
44    "npm-shrinkwrap.json",
45    "pnpm-lock.yaml",
46    "yarn.lock",
47    "bun.lockb",
48    "uv.lock",
49    "poetry.lock",
50    "Pipfile.lock",
51    "Cargo.lock",
52    "go.sum",
53];
54
55/// Whether a path's BASENAME is config-like: `*.json`, `*.toml`, `*.yaml`,
56/// `*.yml`, `*config*`, `.*rc*`, `.*ignore`, or a lockfile. Basename only, so
57/// `src/config/app.ts` is not config.
58pub fn config_like(path: &str) -> bool {
59    let base = path.rsplit('/').next().unwrap_or(path);
60    LOCKFILES.contains(&base)
61        || [".json", ".toml", ".yaml", ".yml"]
62            .iter()
63            .any(|ext| base.ends_with(ext))
64        || base.contains("config")
65        || (base.starts_with('.') && (base.contains("rc") || base.ends_with("ignore")))
66}
67
68fn git_dir() -> Option<PathBuf> {
69    crate::git::stdout(&["rev-parse", "--absolute-git-dir"]).map(PathBuf::from)
70}
71
72/// `$GIT_DIR/amont-cache/<gate>`.
73pub fn gate_dir(gate: &TreeGate) -> Option<PathBuf> {
74    Some(git_dir()?.join("amont-cache").join(&gate.name))
75}
76
77/// The version of the tool the gate actually runs, as best it can be read
78/// without running the gate. Never touches `.venv` or the network on its own
79/// account; a wrapper that does (pyright's) is bounded by `deadline` and
80/// `cancel`, like any gate run. `None` when it could not be read in time —
81/// the caller withholds rather than guess.
82pub fn tool_version(
83    cwd: &Path,
84    gate: &TreeGate,
85    deadline: std::time::Instant,
86    cancel: &std::sync::atomic::AtomicBool,
87) -> Option<String> {
88    let words: Vec<&str> = gate.command.split_whitespace().collect();
89    // `uvx ruff@0.16.0 …`: the pin is in the command, which is hashed anyway.
90    if words.first() == Some(&"uvx") && words.get(1).is_some_and(|w| w.contains('@')) {
91        return Some("pinned-in-command".into());
92    }
93    match gate.tool {
94        TreeTool::Eslint | TreeTool::Prettier => {
95            let pkg = cwd
96                .join("node_modules")
97                .join(gate.tool.as_str())
98                .join("package.json");
99            Some(
100                std::fs::read_to_string(pkg)
101                    .ok()
102                    .and_then(|t| json_string_field(&t, "version"))
103                    .unwrap_or_else(|| "absent".into()),
104            )
105        }
106        _ => {
107            let probe: Vec<String> = if words.starts_with(&["uv", "run"]) {
108                [
109                    "uv",
110                    "run",
111                    "--frozen",
112                    "--no-sync",
113                    gate.tool.as_str(),
114                    "--version",
115                ]
116                .iter()
117                .map(|s| s.to_string())
118                .collect()
119            } else if gate.tool == TreeTool::Gofmt {
120                vec!["go".into(), "version".into()]
121            } else {
122                vec![gate.tool.as_str().into(), "--version".into()]
123            };
124            // A tool that is not there, or answers with an error, is a
125            // definite answer ("absent"), as it always was. Only one that did
126            // not answer in time is unknown, and that withholds.
127            match crate::tree_run::run_output(&probe, cwd, deadline, cancel) {
128                Ok(out) => Some(
129                    Some(out.lines().next().unwrap_or("").trim().to_string())
130                        .filter(|v| !v.is_empty())
131                        .unwrap_or_else(|| "absent".into()),
132                ),
133                Err(crate::tree_run::TreeRun::TimedOut | crate::tree_run::TreeRun::Cancelled) => {
134                    None
135                }
136                Err(_) => Some("absent".into()),
137            }
138        }
139    }
140}
141
142/// The first `"<field>": "<value>"` in a JSON text. Enough for a
143/// `package.json`'s top-level `version`, which npm writes first-level and
144/// unescaped; anything stranger reads as absent.
145fn json_string_field(text: &str, field: &str) -> Option<String> {
146    let key = format!("\"{field}\"");
147    let at = text.find(&key)? + key.len();
148    let rest = text[at..].trim_start().strip_prefix(':')?.trim_start();
149    let rest = rest.strip_prefix('"')?;
150    let end = rest.find('"')?;
151    Some(rest[..end].to_string())
152}
153
154/// The namespace for `gate`: see the module doc. `None` when git cannot
155/// answer, which reads as cold.
156pub fn namespace(gate: &TreeGate, version: &str) -> Option<String> {
157    let staged = crate::git::stdout(&["ls-files", "-s"])?;
158    let mut material = format!(
159        "amont-tree-ns-v1\ncommand {}\nversion {}\n",
160        gate.command, version
161    );
162    for line in staged.lines() {
163        // `<mode> <blob> <stage>\t<path>`
164        let Some((meta, path)) = line.split_once('\t') else {
165            continue;
166        };
167        if config_like(path)
168            || gate
169                .inputs
170                .iter()
171                .any(|i| path == i || path.starts_with(&format!("{i}/")))
172        {
173            material.push_str(meta);
174            material.push(' ');
175            material.push_str(path);
176            material.push('\n');
177        }
178    }
179    hash(&material)
180}
181
182fn hash(material: &str) -> Option<String> {
183    use std::io::Write;
184    let mut child = Command::new("git")
185        .args(["hash-object", "--stdin"])
186        .stdin(Stdio::piped())
187        .stdout(Stdio::piped())
188        .stderr(Stdio::null())
189        .spawn()
190        .ok()?;
191    child.stdin.take()?.write_all(material.as_bytes()).ok()?;
192    let out = child.wait_with_output().ok()?;
193    out.status
194        .success()
195        .then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
196        .filter(|s| !s.is_empty())
197}
198
199/// A held, non-blocking lock on one gate's cache. Released on drop.
200pub struct Lock {
201    _file: File,
202}
203
204#[cfg(unix)]
205extern "C" {
206    #[link_name = "flock"]
207    fn libc_flock(fd: i32, op: i32) -> i32;
208}
209
210#[cfg(unix)]
211const LOCK_EX: i32 = 2;
212#[cfg(unix)]
213const LOCK_NB: i32 = 4;
214
215/// Take `gate`'s lock without waiting. `None` when another run holds it (or
216/// the lock file cannot be made) — which the caller reads as cold: a commit
217/// never waits on a warm-up.
218pub fn try_lock(gate: &TreeGate) -> Option<Lock> {
219    lock_in(&gate_dir(gate)?)
220}
221
222/// [`try_lock`] on `dir/.lock` — the part that does not ask git where the
223/// cache is, so it can be tested without depending on the process's cwd.
224fn lock_in(dir: &Path) -> Option<Lock> {
225    std::fs::create_dir_all(dir).ok()?;
226    let file = std::fs::OpenOptions::new()
227        .create(true)
228        .truncate(false)
229        .write(true)
230        .open(dir.join(".lock"))
231        .ok()?;
232    #[cfg(unix)]
233    {
234        use std::os::unix::io::AsRawFd;
235        // SAFETY: a valid fd we own; flock touches no memory.
236        if unsafe { libc_flock(file.as_raw_fd(), LOCK_EX | LOCK_NB) } != 0 {
237            return None;
238        }
239    }
240    Some(Lock { _file: file })
241}
242
243/// `gate_dir/<ns>`, created, with every OTHER namespace of the gate deleted.
244/// Call only while holding the gate's [`Lock`].
245pub fn enter_namespace(gate: &TreeGate, ns: &str) -> Option<PathBuf> {
246    let dir = gate_dir(gate)?;
247    if let Ok(entries) = std::fs::read_dir(&dir) {
248        for e in entries.filter_map(Result::ok) {
249            let name = e.file_name();
250            let name = name.to_string_lossy();
251            if name != ns && !name.starts_with('.') && e.path().is_dir() {
252                let _ = std::fs::remove_dir_all(e.path());
253            }
254        }
255    }
256    let ns_dir = dir.join(ns);
257    std::fs::create_dir_all(&ns_dir).ok()?;
258    Some(ns_dir)
259}
260
261const DURATIONS: &str = ".durations";
262const LAST_MS: &str = ".last_ms";
263
264/// The last measured duration of each declared commit check, `<id> <ms>` per
265/// line, in `$GIT_DIR/amont-cache/.durations`: how long a commit's own
266/// checks run, which is the cover a tree gate can hide behind.
267pub fn duration_of(id: &str) -> Option<u64> {
268    let path = git_dir()?.join("amont-cache").join(DURATIONS);
269    let text = std::fs::read_to_string(path).ok()?;
270    text.lines().find_map(|l| {
271        let (k, v) = l.split_once(' ')?;
272        (k == id).then(|| v.trim().parse().ok()).flatten()
273    })
274}
275
276/// Merge `measured` into the durations file (temp + rename).
277pub fn record_durations(measured: &[(String, u64)]) {
278    if measured.is_empty() {
279        return;
280    }
281    let Some(dir) = git_dir().map(|d| d.join("amont-cache")) else {
282        return;
283    };
284    let _ = std::fs::create_dir_all(&dir);
285    let path = dir.join(DURATIONS);
286    let mut rows: Vec<(String, u64)> = std::fs::read_to_string(&path)
287        .unwrap_or_default()
288        .lines()
289        .filter_map(|l| {
290            let (k, v) = l.split_once(' ')?;
291            Some((k.to_string(), v.trim().parse().ok()?))
292        })
293        .filter(|(k, _)| !measured.iter().any(|(m, _)| m == k))
294        .collect();
295    rows.extend(measured.iter().cloned());
296    let body: String = rows.iter().map(|(k, v)| format!("{k} {v}\n")).collect();
297    let tmp = dir.join(format!("{DURATIONS}.tmp.{}", std::process::id()));
298    if std::fs::write(&tmp, body).is_ok() {
299        let _ = std::fs::rename(&tmp, path);
300    }
301}
302
303/// How long this gate's last commit-time run took, kept in the gate's
304/// directory (not a namespace), so the fit test can run FIRST — before the
305/// version probe, the skew check and the namespace — and a gate that cannot
306/// fit costs the commit nothing. A cancelled run records its elapsed time: a
307/// lower bound, which is what matters.
308pub fn gate_last_ms(gate: &TreeGate) -> Option<u64> {
309    last_ms(&gate_dir(gate)?)
310}
311
312pub fn record_gate_last_ms(gate_name: &str, ms: u64) {
313    if let Some(dir) = git_dir().map(|d| d.join("amont-cache").join(gate_name)) {
314        let _ = std::fs::create_dir_all(&dir);
315        record_last_ms(&dir, ms);
316    }
317}
318
319fn last_ms(ns_dir: &Path) -> Option<u64> {
320    std::fs::read_to_string(ns_dir.join(LAST_MS))
321        .ok()?
322        .trim()
323        .parse()
324        .ok()
325}
326
327fn record_last_ms(ns_dir: &Path, ms: u64) {
328    let tmp = ns_dir.join(format!("{LAST_MS}.tmp.{}", std::process::id()));
329    if std::fs::write(&tmp, ms.to_string()).is_ok() {
330        let _ = std::fs::rename(&tmp, ns_dir.join(LAST_MS));
331    }
332}
333
334/// Whether a full run completed in this namespace.
335pub fn is_warm(gate: &TreeGate, ns: &str) -> bool {
336    gate_dir(gate)
337        .map(|d| d.join(ns).join(COMPLETE).is_file())
338        .unwrap_or(false)
339}
340
341/// Record that a full run completed in `ns_dir`: temp + rename, so a killed
342/// writer leaves no marker at all rather than a half one.
343pub fn mark_complete(ns_dir: &Path) {
344    let tmp = ns_dir.join(format!("{COMPLETE}.tmp.{}", std::process::id()));
345    if std::fs::write(&tmp, b"complete\n").is_ok() {
346        let _ = std::fs::rename(&tmp, ns_dir.join(COMPLETE));
347    }
348}
349
350/// Whether every config-like file under `cwd` is exactly as staged: no
351/// unstaged edit and no untracked one. A git that cannot answer reads "no".
352pub fn config_matches_index(cwd: &Path) -> bool {
353    let Some(changed) = crate::git::stdout_in(cwd, &["diff", "--name-only", "--relative"]) else {
354        return false;
355    };
356    let Some(untracked) =
357        crate::git::stdout_in(cwd, &["ls-files", "--others", "--exclude-standard"])
358    else {
359        return false;
360    };
361    !changed.lines().chain(untracked.lines()).any(config_like)
362}
363
364/// Whether any tracked eslint config under `cwd` names type information in
365/// its text. A heuristic that can only err towards typed.
366pub fn config_text_is_typed(cwd: &Path) -> bool {
367    let files = crate::git::stdout_in(cwd, &["ls-files"]).unwrap_or_default();
368    files
369        .lines()
370        .filter(|p| {
371            let base = p.rsplit('/').next().unwrap_or(p);
372            base.starts_with("eslint.config.") || base.starts_with(".eslintrc")
373        })
374        .filter_map(|p| std::fs::read_to_string(cwd.join(p)).ok())
375        .any(|t| {
376            t.contains("projectService") || (t.contains("parserOptions") && t.contains("project"))
377        })
378}
379
380/// Whether an `eslint --print-config` JSON uses type information
381/// (`parserOptions.project` set, or `projectService` true), which makes a
382/// per-file cache stale across files.
383pub fn config_is_typed(config: &crate::json_read::Value) -> bool {
384    let Some(opts) = config
385        .get("languageOptions")
386        .and_then(|l| l.get("parserOptions"))
387        .or_else(|| config.get("parserOptions"))
388    else {
389        return false;
390    };
391    let project = opts.get("project").is_some_and(|p| {
392        !matches!(
393            p,
394            crate::json_read::Value::Null | crate::json_read::Value::Bool(false)
395        )
396    });
397    let service = opts.get("projectService").is_some_and(|v| {
398        !matches!(
399            v,
400            crate::json_read::Value::Null | crate::json_read::Value::Bool(false)
401        )
402    });
403    project || service
404}
405
406/// Whether eslint here uses type information. FAIL-CLOSED: anything but a
407/// definite "untyped" reads as typed, which only costs the cache, where a
408/// wrong "untyped" would let a stale per-file cache prove a typed tree.
409///
410/// Two looks, either of which says typed:
411///
412/// - the TEXT of every tracked eslint config (`eslint.config.*`,
413///   `.eslintrc*`): `projectService`, or `parserOptions` with `project`. This
414///   catches a block that types files by name (`**/*.test.ts`) that no
415///   sample would hit;
416/// - `eslint --print-config` for one tracked file per (directory, extension)
417///   pair where eslint runs, as flat configs select files — bounded by
418///   `deadline`, cancellable, through `tree_run`. A file eslint ignores
419///   (it prints `undefined`) answers nothing.
420///
421/// Untyped is remembered only when both looks are clean and at least one
422/// sampled file answered. More pairs than [`MAX_TYPED_SAMPLE`], a probe error,
423/// or no answer at all reads typed and remembers nothing, so a transient
424/// failure never disables the cache for good. Known gap: a shared config
425/// PACKAGE that types by file name, whose words never appear in a local
426/// config and whose files no sample hits.
427pub fn typed_eslint(
428    cwd: &Path,
429    ns_dir: &Path,
430    deadline: std::time::Instant,
431    cancel: &std::sync::atomic::AtomicBool,
432) -> bool {
433    if ns_dir.join(TYPED).is_file() {
434        return true;
435    }
436    if ns_dir.join(UNTYPED).is_file() {
437        return false;
438    }
439    // Both looks below read config from the WORKING tree, while the namespace
440    // is keyed on the STAGED one. When they differ (a background warm-up
441    // running beside unstaged config edits), the answer describes another
442    // namespace: typed, nothing remembered. At commit the staged-only hold
443    // makes them equal, so this never fires there.
444    if !config_matches_index(cwd) {
445        return true;
446    }
447    let bin = cwd.join("node_modules").join(".bin").join("eslint");
448    if !bin.is_file() {
449        return true;
450    }
451    if config_text_is_typed(cwd) {
452        let _ = std::fs::write(ns_dir.join(TYPED), b"");
453        return true;
454    }
455    // Sampled where eslint RUNS (the gate's cwd), and all of them asked: a
456    // flat config can add type information to some files only, so one
457    // untyped answer proves nothing about the rest.
458    let files = crate::git::stdout_in(
459        cwd,
460        &[
461            "ls-files", "*.ts", "*.tsx", "*.js", "*.jsx", "*.mjs", "*.cjs",
462        ],
463    )
464    .unwrap_or_default();
465    // One file per (directory, extension): flat configs select files by
466    // directory glob and by extension, and `ls-files` is alphabetical, so a
467    // plain first-N sample could see only root and `scripts/` files and miss
468    // a typed `src/**` block entirely. Past the cap the sample is not the
469    // tree: typed, and nothing remembered.
470    let mut seen: Vec<(String, String)> = Vec::new();
471    let mut sample: Vec<&str> = Vec::new();
472    for file in files.lines() {
473        let (dir, base) = file.rsplit_once('/').unwrap_or(("", file));
474        let ext = base.rsplit_once('.').map(|(_, e)| e).unwrap_or("");
475        let key = (dir.to_string(), ext.to_string());
476        if !seen.contains(&key) {
477            seen.push(key);
478            sample.push(file);
479        }
480    }
481    if sample.len() > MAX_TYPED_SAMPLE {
482        return true;
483    }
484    let mut answered = 0;
485    for file in sample {
486        let argv = vec![
487            bin.display().to_string(),
488            "--print-config".to_string(),
489            file.to_string(),
490        ];
491        match crate::tree_run::run_output(&argv, cwd, deadline, cancel) {
492            Ok(out) => match crate::json_read::parse(out.trim()) {
493                Some(config) if config_is_typed(&config) => {
494                    let _ = std::fs::write(ns_dir.join(TYPED), b"");
495                    return true;
496                }
497                Some(_) => answered += 1,
498                // `undefined`: eslint ignores this file.
499                None => {}
500            },
501            Err(_) => return true,
502        }
503    }
504    if answered == 0 {
505        return true;
506    }
507    let _ = std::fs::write(ns_dir.join(UNTYPED), b"");
508    false
509}
510
511/// What `{cache}` expands to for `gate` in `ns_dir`, or `""` for a tool
512/// without a cache, or typed (or undetermined) eslint.
513pub fn cache_flags(
514    cwd: &Path,
515    gate: &TreeGate,
516    ns_dir: &Path,
517    deadline: std::time::Instant,
518    cancel: &std::sync::atomic::AtomicBool,
519) -> String {
520    let file = match gate.tool {
521        TreeTool::Eslint if !typed_eslint(cwd, ns_dir, deadline, cancel) => ".eslintcache",
522        TreeTool::Prettier => ".prettiercache",
523        _ => return String::new(),
524    };
525    format!(
526        "--cache --cache-strategy content --cache-location {}",
527        ns_dir.join(file).display()
528    )
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534
535    #[test]
536    fn tree_cache_config_like_by_basename() {
537        for p in [
538            "package.json",
539            "web/tsconfig.base.json",
540            "eslint.config.mjs",
541            ".prettierrc",
542            ".eslintrc.cjs",
543            ".gitignore",
544            ".prettierignore",
545            "pyproject.toml",
546            "uv.lock",
547            "go.sum",
548            "pnpm-lock.yaml",
549        ] {
550            assert!(config_like(p), "{p}");
551        }
552        for p in ["src/config/app.ts", "src/main.ts", "README.md", "Makefile"] {
553            assert!(!config_like(p), "{p}");
554        }
555    }
556
557    /// A held lock is not waited for: the second taker reads it as busy.
558    /// Unix only: there is no `flock` elsewhere, and tree gates do not run
559    /// there either.
560    #[cfg(unix)]
561    #[test]
562    fn tree_cache_a_held_lock_is_busy_not_awaited() {
563        let dir = std::env::temp_dir().join(format!("amont-lock-test-{}", std::process::id()));
564        let first = lock_in(&dir).expect("first lock");
565        assert!(lock_in(&dir).is_none(), "a second lock was granted");
566        drop(first);
567        // Released EVENTUALLY: a thread elsewhere in this process that forks
568        // (to spawn git) holds a copy of the lock's description until its
569        // exec closes it. Harmless — a gate reads busy once — but not instant.
570        let until = std::time::Instant::now() + std::time::Duration::from_secs(5);
571        let mut again = lock_in(&dir);
572        while again.is_none() && std::time::Instant::now() < until {
573            std::thread::sleep(std::time::Duration::from_millis(20));
574            again = lock_in(&dir);
575        }
576        assert!(again.is_some(), "the lock was never released");
577        let _ = std::fs::remove_dir_all(dir);
578    }
579
580    #[test]
581    fn tree_cache_typed_configs_are_recognised() {
582        use crate::json_read::parse;
583        let typed = [
584            r#"{"languageOptions":{"parserOptions":{"project":"./tsconfig.json"}}}"#,
585            r#"{"languageOptions":{"parserOptions":{"project":true}}}"#,
586            r#"{"languageOptions":{"parserOptions":{"projectService":true}}}"#,
587            r#"{"languageOptions":{"parserOptions":{"projectService":{"allowDefaultProject":["*.js"]}}}}"#,
588            r#"{"parserOptions":{"project":["a.json"]}}"#,
589        ];
590        for t in typed {
591            assert!(config_is_typed(&parse(t).unwrap()), "{t}");
592        }
593        let untyped = [
594            r#"{"languageOptions":{"parserOptions":{"project":null}}}"#,
595            r#"{"languageOptions":{"parserOptions":{"projectService":false}}}"#,
596            r#"{"languageOptions":{"parserOptions":{}}}"#,
597            r#"{"rules":{}}"#,
598        ];
599        for t in untyped {
600            assert!(!config_is_typed(&parse(t).unwrap()), "{t}");
601        }
602    }
603
604    /// No eslint to ask: typed (no cache), and nothing remembered.
605    #[test]
606    fn tree_cache_undetermined_eslint_reads_typed() {
607        let d = std::env::temp_dir().join(format!("amont-typed-{}", std::process::id()));
608        let ns = d.join("ns");
609        std::fs::create_dir_all(&ns).unwrap();
610        let typed = typed_eslint(
611            &d,
612            &ns,
613            std::time::Instant::now() + std::time::Duration::from_secs(5),
614            &std::sync::atomic::AtomicBool::new(false),
615        );
616        assert!(typed);
617        assert!(!ns.join(TYPED).exists() && !ns.join(UNTYPED).exists());
618        let _ = std::fs::remove_dir_all(d);
619    }
620
621    /// Every sampled file is asked, where eslint runs: one typed file among
622    /// untyped ones makes the whole namespace typed.
623    #[cfg(unix)]
624    #[test]
625    fn tree_cache_one_typed_file_makes_eslint_typed() {
626        use std::os::unix::fs::PermissionsExt;
627        let d = std::env::temp_dir().join(format!("amont-typed-mix-{}", std::process::id()));
628        let _ = std::fs::remove_dir_all(&d);
629        let bin = d.join("node_modules/.bin");
630        std::fs::create_dir_all(&bin).unwrap();
631        std::fs::create_dir_all(d.join("src")).unwrap();
632        std::fs::write(d.join("a.js"), "").unwrap();
633        std::fs::write(d.join("src/b.ts"), "").unwrap();
634        for args in [vec!["init", "-q"], vec!["add", "a.js", "src/b.ts"]] {
635            assert!(std::process::Command::new("git")
636                .args(&args)
637                .current_dir(&d)
638                .status()
639                .unwrap()
640                .success());
641        }
642        let eslint = bin.join("eslint");
643        std::fs::write(
644            &eslint,
645            "#!/bin/sh\ncase \"$2\" in\n  src/*) echo '{\"languageOptions\":{\"parserOptions\":{\"projectService\":true}}}' ;;\n  *) echo '{\"languageOptions\":{\"parserOptions\":{}}}' ;;\nesac\n",
646        )
647        .unwrap();
648        std::fs::set_permissions(&eslint, std::fs::Permissions::from_mode(0o755)).unwrap();
649        let ns = d.join("ns");
650        std::fs::create_dir_all(&ns).unwrap();
651        let typed = typed_eslint(
652            &d,
653            &ns,
654            std::time::Instant::now() + std::time::Duration::from_secs(20),
655            &std::sync::atomic::AtomicBool::new(false),
656        );
657        assert!(typed, "one typed file must make the namespace typed");
658        assert!(ns.join(TYPED).exists());
659        let _ = std::fs::remove_dir_all(d);
660    }
661
662    #[cfg(unix)]
663    fn git_repo(d: &Path, paths: &[String]) {
664        assert!(std::process::Command::new("git")
665            .args(["init", "-q"])
666            .current_dir(d)
667            .status()
668            .unwrap()
669            .success());
670        for chunk in paths.chunks(200) {
671            assert!(std::process::Command::new("git")
672                .arg("add")
673                .args(chunk)
674                .current_dir(d)
675                .status()
676                .unwrap()
677                .success());
678        }
679    }
680
681    /// A config that types files by NAME, which no sample hits, is caught by
682    /// its text.
683    #[cfg(unix)]
684    #[test]
685    fn tree_cache_a_filename_scoped_typed_config_reads_typed() {
686        use std::os::unix::fs::PermissionsExt;
687        let d = std::env::temp_dir().join(format!("amont-typed-text-{}", std::process::id()));
688        let _ = std::fs::remove_dir_all(&d);
689        std::fs::create_dir_all(d.join("node_modules/.bin")).unwrap();
690        std::fs::write(d.join("a.js"), "").unwrap();
691        std::fs::write(
692            d.join("eslint.config.mjs"),
693            "export default [{ files: ['**/*.test.ts'], languageOptions: { parserOptions: { projectService: true } } }];\n",
694        )
695        .unwrap();
696        git_repo(&d, &["a.js".into(), "eslint.config.mjs".into()]);
697        let eslint = d.join("node_modules/.bin/eslint");
698        std::fs::write(&eslint, "#!/bin/sh\necho '{\"rules\":{}}'\n").unwrap();
699        std::fs::set_permissions(&eslint, std::fs::Permissions::from_mode(0o755)).unwrap();
700        let ns = d.join("ns");
701        std::fs::create_dir_all(&ns).unwrap();
702        assert!(typed_eslint(
703            &d,
704            &ns,
705            std::time::Instant::now() + std::time::Duration::from_secs(20),
706            &std::sync::atomic::AtomicBool::new(false),
707        ));
708        assert!(ns.join(TYPED).exists(), "the text check remembers typed");
709        let _ = std::fs::remove_dir_all(d);
710    }
711
712    /// #290: an unstaged edit to an eslint config means the working tree is
713    /// not the namespace's tree — typed, nothing remembered, even though the
714    /// on-disk config is untyped.
715    #[cfg(unix)]
716    #[test]
717    fn tree_cache_unstaged_config_is_never_remembered() {
718        use std::os::unix::fs::PermissionsExt;
719        let d = std::env::temp_dir().join(format!("amont-typed-dirty-{}", std::process::id()));
720        let _ = std::fs::remove_dir_all(&d);
721        std::fs::create_dir_all(d.join("node_modules/.bin")).unwrap();
722        std::fs::write(d.join("a.js"), "").unwrap();
723        std::fs::write(
724            d.join("eslint.config.mjs"),
725            "export default [{ languageOptions: { parserOptions: { projectService: true } } }];\n",
726        )
727        .unwrap();
728        git_repo(&d, &["a.js".into(), "eslint.config.mjs".into()]);
729        // Unstaged: the typed block is removed on disk only.
730        std::fs::write(d.join("eslint.config.mjs"), "export default [];\n").unwrap();
731        let eslint = d.join("node_modules/.bin/eslint");
732        std::fs::write(&eslint, "#!/bin/sh\necho '{\"rules\":{}}'\n").unwrap();
733        std::fs::set_permissions(&eslint, std::fs::Permissions::from_mode(0o755)).unwrap();
734        let ns = d.join("ns");
735        std::fs::create_dir_all(&ns).unwrap();
736        assert!(typed_eslint(
737            &d,
738            &ns,
739            std::time::Instant::now() + std::time::Duration::from_secs(20),
740            &std::sync::atomic::AtomicBool::new(false),
741        ));
742        assert!(!ns.join(TYPED).exists() && !ns.join(UNTYPED).exists());
743        let _ = std::fs::remove_dir_all(d);
744    }
745
746    /// The uv version probe never syncs: `.venv` is untouched and no lock
747    /// is written, whatever the probe answers.
748    #[test]
749    fn tree_cache_the_uv_probe_never_syncs() {
750        if std::process::Command::new("uv")
751            .arg("--version")
752            .output()
753            .is_err()
754        {
755            eprintln!("uv unavailable — skipping");
756            return;
757        }
758        let d = std::env::temp_dir().join(format!("amont-uv-probe-{}", std::process::id()));
759        let _ = std::fs::remove_dir_all(&d);
760        std::fs::create_dir_all(&d).unwrap();
761        std::fs::write(
762            d.join("pyproject.toml"),
763            "[project]\nname = \"probe\"\nversion = \"0.0.0\"\nrequires-python = \">=3.9\"\ndependencies = [\"six\"]\n",
764        )
765        .unwrap();
766        let made = std::process::Command::new("uv")
767            .args(["venv", "-q", ".venv"])
768            .current_dir(&d)
769            .status()
770            .map(|s| s.success())
771            .unwrap_or(false);
772        if !made {
773            eprintln!("uv venv failed — skipping");
774            let _ = std::fs::remove_dir_all(d);
775            return;
776        }
777        let mtime = |p: &Path| std::fs::metadata(p).and_then(|m| m.modified()).ok();
778        let venv = d.join(".venv");
779        let before = (mtime(&venv), mtime(&venv.join("pyvenv.cfg")));
780        std::thread::sleep(std::time::Duration::from_millis(1100));
781        let gate =
782            crate::manifest::tree_gates("tree pr pyright * attest uv run pyright .").remove(0);
783        let _ = tool_version(
784            &d,
785            &gate,
786            std::time::Instant::now() + std::time::Duration::from_secs(30),
787            &std::sync::atomic::AtomicBool::new(false),
788        );
789        assert_eq!(
790            before,
791            (mtime(&venv), mtime(&venv.join("pyvenv.cfg"))),
792            ".venv changed"
793        );
794        assert!(!d.join("uv.lock").exists(), "the probe wrote a lock");
795        fn holds(dir: &Path, name: &str) -> bool {
796            std::fs::read_dir(dir)
797                .into_iter()
798                .flatten()
799                .flatten()
800                .any(|e| {
801                    e.file_name().to_string_lossy().starts_with(name)
802                        || (e.path().is_dir() && holds(&e.path(), name))
803                })
804        }
805        assert!(!holds(&venv, "six"), "the probe installed a dependency");
806        let _ = std::fs::remove_dir_all(d);
807    }
808
809    /// Past the cap the sample is not the tree: typed, nothing remembered.
810    #[cfg(unix)]
811    #[test]
812    fn tree_cache_more_pairs_than_the_cap_read_typed_unremembered() {
813        use std::os::unix::fs::PermissionsExt;
814        let d = std::env::temp_dir().join(format!("amont-typed-cap-{}", std::process::id()));
815        let _ = std::fs::remove_dir_all(&d);
816        std::fs::create_dir_all(d.join("node_modules/.bin")).unwrap();
817        let mut paths = Vec::new();
818        for i in 0..=MAX_TYPED_SAMPLE {
819            let p = format!("d{i:04}/f.js");
820            std::fs::create_dir_all(d.join(format!("d{i:04}"))).unwrap();
821            std::fs::write(d.join(&p), "").unwrap();
822            paths.push(p);
823        }
824        git_repo(&d, &paths);
825        let eslint = d.join("node_modules/.bin/eslint");
826        std::fs::write(&eslint, "#!/bin/sh\necho '{\"rules\":{}}'\n").unwrap();
827        std::fs::set_permissions(&eslint, std::fs::Permissions::from_mode(0o755)).unwrap();
828        let ns = d.join("ns");
829        std::fs::create_dir_all(&ns).unwrap();
830        assert!(typed_eslint(
831            &d,
832            &ns,
833            std::time::Instant::now() + std::time::Duration::from_secs(20),
834            &std::sync::atomic::AtomicBool::new(false),
835        ));
836        assert!(!ns.join(TYPED).exists() && !ns.join(UNTYPED).exists());
837        let _ = std::fs::remove_dir_all(d);
838    }
839
840    /// Alphabetical order must not hide a typed block: twelve untyped files
841    /// sorting before `src/` and one typed file under it still read typed.
842    #[cfg(unix)]
843    #[test]
844    fn tree_cache_many_untyped_files_before_src_do_not_hide_it() {
845        use std::os::unix::fs::PermissionsExt;
846        let d = std::env::temp_dir().join(format!("amont-typed-many-{}", std::process::id()));
847        let _ = std::fs::remove_dir_all(&d);
848        let bin = d.join("node_modules/.bin");
849        std::fs::create_dir_all(&bin).unwrap();
850        std::fs::create_dir_all(d.join("scripts")).unwrap();
851        std::fs::create_dir_all(d.join("src")).unwrap();
852        let mut paths = Vec::new();
853        for i in 0..12 {
854            let p = format!("a{i:02}.config.js");
855            std::fs::write(d.join(&p), "").unwrap();
856            paths.push(p);
857            let p = format!("scripts/s{i:02}.js");
858            std::fs::write(d.join(&p), "").unwrap();
859            paths.push(p);
860        }
861        std::fs::write(d.join("src/z.ts"), "").unwrap();
862        paths.push("src/z.ts".into());
863        assert!(std::process::Command::new("git")
864            .args(["init", "-q"])
865            .current_dir(&d)
866            .status()
867            .unwrap()
868            .success());
869        assert!(std::process::Command::new("git")
870            .arg("add")
871            .args(&paths)
872            .current_dir(&d)
873            .status()
874            .unwrap()
875            .success());
876        let eslint = bin.join("eslint");
877        std::fs::write(
878            &eslint,
879            "#!/bin/sh\ncase \"$2\" in\n  src/*) echo '{\"languageOptions\":{\"parserOptions\":{\"projectService\":true}}}' ;;\n  *) echo '{\"languageOptions\":{\"parserOptions\":{}}}' ;;\nesac\n",
880        )
881        .unwrap();
882        std::fs::set_permissions(&eslint, std::fs::Permissions::from_mode(0o755)).unwrap();
883        let ns = d.join("ns");
884        std::fs::create_dir_all(&ns).unwrap();
885        assert!(typed_eslint(
886            &d,
887            &ns,
888            std::time::Instant::now() + std::time::Duration::from_secs(30),
889            &std::sync::atomic::AtomicBool::new(false),
890        ));
891        let _ = std::fs::remove_dir_all(d);
892    }
893
894    #[test]
895    fn tree_cache_reads_a_package_version() {
896        let pkg = "{\n  \"name\": \"eslint\",\n  \"version\": \"9.12.0\",\n  \"x\": 1\n}";
897        assert_eq!(json_string_field(pkg, "version").as_deref(), Some("9.12.0"));
898        assert_eq!(json_string_field("{}", "version"), None);
899    }
900}