1use std::fs::File;
27use std::path::{Path, PathBuf};
28use std::process::{Command, Stdio};
29
30use crate::manifest::{TreeGate, TreeTool};
31
32const COMPLETE: &str = ".complete";
33const MAX_TYPED_SAMPLE: usize = 256;
38const TYPED: &str = ".typed";
39const UNTYPED: &str = ".untyped";
40
41const 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
55pub 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
72pub fn gate_dir(gate: &TreeGate) -> Option<PathBuf> {
74 Some(git_dir()?.join("amont-cache").join(&gate.name))
75}
76
77pub 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 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 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
142fn 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
154pub 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 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
199pub 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
215pub fn try_lock(gate: &TreeGate) -> Option<Lock> {
219 lock_in(&gate_dir(gate)?)
220}
221
222fn 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 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
243pub 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
264pub 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
276pub 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
303pub 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
334pub 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
341pub 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
350pub 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
364pub 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
380pub 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
406pub 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 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 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 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 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
511pub 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 #[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 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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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}