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