Skip to main content

qk_cache/
hash.rs

1use std::collections::{BTreeMap, BTreeSet, HashMap};
2use std::fs::File;
3use std::io::Read;
4use std::path::{Path, PathBuf};
5use std::sync::atomic::AtomicBool;
6use std::sync::{Arc, Mutex};
7use std::time::{Duration, SystemTime};
8
9use anyhow::{Context, Result, bail};
10use qk_config::Workspace;
11use qk_executor::{Capture, Display, Outcome, PreparedTask, execute_captured, read_capture};
12use qk_graph::ProjectGraph;
13use qk_lockfile::Lockfile;
14use qk_taskgraph::{Task, TaskGraph};
15use serde_json::{Value, json};
16
17use crate::glob::is_literal;
18use crate::paths::{self, Outputs, Pattern};
19
20pub fn digest_file(path: &Path) -> Result<String> {
21    let mut hasher = blake3::Hasher::new();
22    let mut reader = File::open(path)?;
23    let mut buffer = [0; 64 * 1024];
24    loop {
25        let count = reader.read(&mut buffer)?;
26        if count == 0 {
27            break;
28        }
29        hasher.update(&buffer[..count]);
30    }
31    Ok(hasher.finalize().to_hex().to_string())
32}
33
34/// Metadata that changes whenever a file's content can have changed: size,
35/// modification time and, on Unix, change time, inode and mode.
36#[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)]
37struct Stamp(Vec<i64>);
38
39impl Stamp {
40    fn new(metadata: &std::fs::Metadata) -> Self {
41        let modified = metadata
42            .modified()
43            .ok()
44            .and_then(|time| time.duration_since(SystemTime::UNIX_EPOCH).ok())
45            .map_or(-1, |duration| duration.as_nanos() as i64);
46        #[allow(unused_mut)]
47        let mut values = vec![metadata.len() as i64, modified];
48        #[cfg(unix)]
49        {
50            use std::os::unix::fs::MetadataExt;
51            values.extend([
52                metadata.ctime(),
53                metadata.ctime_nsec(),
54                metadata.ino() as i64,
55                i64::from(metadata.mode()),
56            ]);
57        }
58        Self(values)
59    }
60
61    /// A file written within the timestamp resolution window could change again
62    /// without changing its stamp, so recent files are always re-read.
63    fn settled(&self) -> bool {
64        let modified = SystemTime::UNIX_EPOCH + Duration::from_nanos(self.0[1].max(0) as u64);
65        self.0[1] >= 0
66            && SystemTime::now()
67                .duration_since(modified)
68                .is_ok_and(|age| age > Duration::from_secs(2))
69    }
70}
71
72/// Digests persisted between runs in the worktree's state, keyed by path and
73/// stamp, so a warm run reads only the files that changed. Stamps include the
74/// inode, so they belong to one worktree.
75fn digests_path(root: &Path) -> PathBuf {
76    paths::worktree_state(root).join("digests.json")
77}
78
79/// Workspace state shared by every fingerprint in one run. The candidate file list
80/// is taken once, like Nx's file map; file contents are still re-read whenever
81/// their metadata changes, so edits during the run are detected.
82pub struct Snapshot {
83    pub(crate) files: BTreeSet<String>,
84    projects: ProjectGraph,
85    canonical_root: PathBuf,
86    workspace_prefix: Option<PathBuf>,
87    patterns: Mutex<HashMap<(String, bool), Arc<Pattern>>>,
88    digests: Mutex<HashMap<String, (Stamp, String)>>,
89    /// Whether `digests` gained entries worth saving.
90    digests_changed: std::sync::atomic::AtomicBool,
91    /// Directories already checked not to be symlinks.
92    directories: Mutex<std::collections::HashSet<PathBuf>>,
93    /// Runtime input results by command and environment, computed once per run like Nx.
94    runtime: Mutex<HashMap<String, Value>>,
95    /// The parsed pnpm lockfile, replaced whenever its content changes.
96    lockfile: Mutex<Option<Arc<Installed>>>,
97    /// Each dependency task's output files, listed when a task first asks and
98    /// dropped once a task whose outputs overlap them is done.
99    dependency_outputs: Mutex<HashMap<String, Arc<DependencyOutputs>>>,
100    /// Counts drops, so a listing taken while a task finished is not kept.
101    outputs_written: std::sync::atomic::AtomicU64,
102}
103
104/// One dependency task's output files, and the value of each one a pattern
105/// has selected so far.
106struct DependencyOutputs {
107    anchors: Vec<String>,
108    files: Vec<String>,
109    values: Mutex<HashMap<String, Value>>,
110}
111
112/// One revision of `pnpm-lock.yaml`, with digests of what it installs computed
113/// once and shared by every task that asks.
114struct Installed {
115    content: String,
116    /// `None` when the file cannot be read as a pnpm v9 lockfile.
117    lockfile: Option<Lockfile>,
118    digests: Mutex<HashMap<String, String>>,
119}
120
121impl Installed {
122    fn digest(&self, key: &str, fingerprints: impl FnOnce() -> BTreeSet<String>) -> String {
123        if let Some(digest) = self.digests.lock().unwrap().get(key) {
124            return digest.clone();
125        }
126        let mut hasher = blake3::Hasher::new();
127        for fingerprint in fingerprints() {
128            hasher.update(fingerprint.as_bytes());
129            hasher.update(b"\n");
130        }
131        let digest = hasher.finalize().to_hex().to_string();
132        self.digests
133            .lock()
134            .unwrap()
135            .insert(key.to_owned(), digest.clone());
136        digest
137    }
138}
139
140impl Snapshot {
141    pub fn new(workspace: &Workspace, graph: &TaskGraph, cache_path: &Path) -> Result<Self> {
142        // Parsing the lockfile takes as long as listing files, and every early
143        // task needs it, so the two overlap.
144        let (files, lockfile) = std::thread::scope(|scope| {
145            let lockfile = scope.spawn(|| parse_lockfile(&workspace.root.join("pnpm-lock.yaml")));
146            let files = source_files(&workspace.root);
147            (files, lockfile.join().expect("lockfile thread panicked"))
148        });
149        let mut files = files?;
150        if let Ok(cache_relative) = cache_path.strip_prefix(&workspace.root) {
151            files.retain(|path| !Path::new(path).starts_with(cache_relative));
152        }
153        // Generated artifacts must not make the next invocation invalidate itself.
154        // Another task's unsupported outputs stay candidates: that only costs misses,
155        // and so do Nx's default outputs, which may well hold sources.
156        let mut generated = BTreeSet::new();
157        for outputs in graph
158            .tasks
159            .values()
160            .filter_map(|task| Outputs::new(workspace, task).ok())
161            .filter(Outputs::is_explicit)
162        {
163            for anchor in outputs.anchors() {
164                generated.extend(
165                    under(&files, anchor)
166                        .filter(|path| outputs.matches(path))
167                        .cloned(),
168                );
169            }
170        }
171        // Warm scratch paths are never inputs either.
172        for task in graph.tasks.values() {
173            let Ok(Some(warm)) = crate::warm::config(workspace, task) else {
174                continue;
175            };
176            if let Ok(paths) = Outputs::from_paths(&warm.kept_paths()) {
177                for anchor in paths.anchors() {
178                    generated.extend(
179                        under(&files, anchor)
180                            .filter(|path| paths.matches(path))
181                            .cloned(),
182                    );
183                }
184            }
185        }
186        files.retain(|path| !generated.contains(path));
187        let canonical_root = workspace.root.canonicalize()?;
188        let workspace_prefix = paths::git_path(&workspace.root, "--show-toplevel")
189            .and_then(|root| root.canonicalize().ok())
190            .and_then(|root| canonical_root.strip_prefix(root).ok().map(PathBuf::from));
191        Ok(Self {
192            files,
193            projects: ProjectGraph::build(workspace)?,
194            canonical_root,
195            workspace_prefix,
196            patterns: Mutex::default(),
197            digests: Mutex::new(load_digests(&workspace.root)),
198            digests_changed: Default::default(),
199            directories: Mutex::default(),
200            runtime: Mutex::default(),
201            lockfile: Mutex::new(lockfile),
202            dependency_outputs: Mutex::default(),
203            outputs_written: Default::default(),
204        })
205    }
206
207    /// Saves the digests of files that still exist, for the next run.
208    pub fn save_digests(&self, root: &Path) {
209        if !self
210            .digests_changed
211            .load(std::sync::atomic::Ordering::Relaxed)
212        {
213            return;
214        }
215        let digests = self.digests.lock().unwrap();
216        let entries: BTreeMap<&String, (&Stamp, &String)> = digests
217            .iter()
218            .filter(|(path, _)| self.files.contains(*path))
219            .map(|(path, (stamp, digest))| (path, (stamp, digest)))
220            .collect();
221        let path = digests_path(root);
222        let saved = (|| -> Result<()> {
223            let directory = path.parent().context("digests have a directory")?;
224            std::fs::create_dir_all(directory)?;
225            let mut file = tempfile::NamedTempFile::new_in(directory)?;
226            serde_json::to_writer(
227                std::io::BufWriter::new(file.as_file_mut()),
228                &json!({"version": 1, "entries": entries}),
229            )?;
230            file.persist(&path)?;
231            Ok(())
232        })();
233        if let Err(error) = saved {
234            qk_executor::status!("qk: could not save file digests: {error:#}");
235        }
236    }
237
238    /// `paths::safe_parents`, checking each directory once per run.
239    fn safe_parents(&self, root: &Path, path: &str) -> Result<()> {
240        paths::validate_path(path)?;
241        let mut current = root.to_owned();
242        for part in Path::new(path)
243            .parent()
244            .into_iter()
245            .flat_map(Path::components)
246        {
247            current.push(part);
248            if self.directories.lock().unwrap().contains(&current) {
249                continue;
250            }
251            match std::fs::symlink_metadata(&current) {
252                Ok(metadata) if metadata.file_type().is_symlink() => {
253                    bail!("input parent is a symlink: {}", current.display())
254                }
255                Ok(metadata) if !metadata.is_dir() => {
256                    bail!("input parent is not a directory: {}", current.display())
257                }
258                Ok(_) => {
259                    self.directories.lock().unwrap().insert(current.clone());
260                }
261                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
262                Err(error) => return Err(error.into()),
263            }
264        }
265        Ok(())
266    }
267
268    fn installed(&self, root: &Path) -> Result<Option<Arc<Installed>>> {
269        let absolute = root.join("pnpm-lock.yaml");
270        let Ok(metadata) = std::fs::symlink_metadata(&absolute) else {
271            return Ok(None);
272        };
273        if !metadata.is_file() {
274            return Ok(None);
275        }
276        let content = self.digest("pnpm-lock.yaml", &absolute, &metadata)?;
277        let mut current = self.lockfile.lock().unwrap();
278        if let Some(installed) = &*current
279            && installed.content == content
280        {
281            return Ok(Some(installed.clone()));
282        }
283        let Some(installed) = parse_lockfile(&absolute) else {
284            return Ok(None);
285        };
286        *current = Some(installed.clone());
287        Ok(Some(installed))
288    }
289
290    /// Adds paths to the candidate files, such as files deleted since a base
291    /// revision.
292    pub fn with_candidates(mut self, paths: &[String]) -> Self {
293        self.files.extend(paths.iter().cloned());
294        self
295    }
296
297    fn pattern(&self, pattern: &str, negated: bool) -> Result<Arc<Pattern>> {
298        let key = (pattern.to_owned(), negated);
299        if let Some(pattern) = self.patterns.lock().unwrap().get(&key) {
300            return Ok(pattern.clone());
301        }
302        let compiled = Arc::new(Pattern::new(pattern, negated)?);
303        self.patterns.lock().unwrap().insert(key, compiled.clone());
304        Ok(compiled)
305    }
306
307    fn digest(&self, path: &str, absolute: &Path, metadata: &std::fs::Metadata) -> Result<String> {
308        let stamp = Stamp::new(metadata);
309        if let Some((known, digest)) = self.digests.lock().unwrap().get(path)
310            && *known == stamp
311        {
312            return Ok(digest.clone());
313        }
314        let digest = digest_file(absolute)?;
315        // Re-check after reading so a concurrent write is never recorded as settled.
316        let after = Stamp::new(&std::fs::symlink_metadata(absolute)?);
317        if after == stamp && stamp.settled() {
318            self.digests
319                .lock()
320                .unwrap()
321                .insert(path.to_owned(), (stamp, digest.clone()));
322            self.digests_changed
323                .store(true, std::sync::atomic::Ordering::Relaxed);
324        }
325        Ok(digest)
326    }
327
328    /// `task`'s output files that are not directories, as listed since the
329    /// last task that could have written them was done.
330    fn dependency_outputs(
331        &self,
332        workspace: &Workspace,
333        task: &Task,
334    ) -> Result<Arc<DependencyOutputs>> {
335        if let Some(outputs) = self.dependency_outputs.lock().unwrap().get(&task.id) {
336            return Ok(outputs.clone());
337        }
338        let written = self
339            .outputs_written
340            .load(std::sync::atomic::Ordering::SeqCst);
341        let declared = Outputs::new(workspace, task)?;
342        let mut files = Vec::new();
343        for entry in declared.entries(&workspace.root)? {
344            let (path, metadata) = entry?;
345            if !metadata.is_dir() {
346                files.push(path);
347            }
348        }
349        let outputs = Arc::new(DependencyOutputs {
350            anchors: declared.anchors().map(str::to_owned).collect(),
351            files,
352            values: Mutex::default(),
353        });
354        let mut listed = self.dependency_outputs.lock().unwrap();
355        if self
356            .outputs_written
357            .load(std::sync::atomic::Ordering::SeqCst)
358            != written
359        {
360            return Ok(outputs);
361        }
362        Ok(listed.entry(task.id.clone()).or_insert(outputs).clone())
363    }
364
365    /// Drops the listings `task` may have changed, once it is done. Without
366    /// its outputs, every listing goes.
367    pub(crate) fn outputs_written(&self, workspace: &Workspace, task: &Task) {
368        let anchors: Option<Vec<String>> = Outputs::new(workspace, task)
369            .ok()
370            .filter(Outputs::is_explicit)
371            .map(|outputs| outputs.anchors().map(str::to_owned).collect());
372        let overlap = |a: &str, b: &str| {
373            a == b
374                || b.strip_prefix(a).is_some_and(|rest| rest.starts_with('/'))
375                || a.strip_prefix(b).is_some_and(|rest| rest.starts_with('/'))
376        };
377        let mut listed = self.dependency_outputs.lock().unwrap();
378        self.outputs_written
379            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
380        listed.retain(|_, outputs| {
381            anchors.as_ref().is_some_and(|written| {
382                !written
383                    .iter()
384                    .any(|a| outputs.anchors.iter().any(|b| overlap(a, b)))
385            })
386        });
387    }
388
389    fn file_value(&self, root: &Path, path: &str) -> Result<Value> {
390        self.safe_parents(root, path)?;
391        let absolute = root.join(path);
392        let metadata = std::fs::symlink_metadata(&absolute)?;
393        if metadata.file_type().is_symlink() {
394            let target = std::fs::read_link(&absolute)?;
395            // A dangling link is keyed as such, so the key changes once it resolves.
396            let Ok(resolved) = absolute.canonicalize() else {
397                return Ok(json!({"link":target, "dangling":true}));
398            };
399            let Ok(relative) = resolved.strip_prefix(&self.canonical_root) else {
400                bail!("input symlink resolves outside the workspace: {path}");
401            };
402            if resolved.is_file() {
403                return Ok(json!({"link":target, "content":digest_file(&resolved)?}));
404            }
405            // A directory link is keyed by the files below its target. Links found
406            // there are keyed by their text only, which rules out cycles.
407            let relative = paths::relative(Path::new(""), relative)?;
408            if relative.is_empty() {
409                bail!("input symlink resolves to the workspace root: {path}");
410            }
411            let mut contents = BTreeMap::new();
412            for file in under(&self.files, &relative) {
413                let absolute = self.canonical_root.join(file);
414                let metadata = std::fs::symlink_metadata(&absolute)?;
415                let value = if metadata.file_type().is_symlink() {
416                    json!({"link": std::fs::read_link(&absolute)?})
417                } else {
418                    json!(self.digest(file, &absolute, &metadata)?)
419                };
420                contents.insert(file.strip_prefix(&relative).unwrap_or(file), value);
421            }
422            return Ok(json!({"link":target, "directory":contents}));
423        }
424        if !metadata.is_file() {
425            bail!("input is not a regular file: {path}");
426        }
427        // Only the executable bit, which is all git records: other permission
428        // bits follow the checkout's umask and would keep machines apart.
429        let mode = crate::store::mode(&metadata) & 0o111 != 0;
430        Ok(json!({"content":self.digest(path, &absolute, &metadata)?, "mode":mode}))
431    }
432}
433
434/// Reads and parses a pnpm lockfile, `None` when there is none. One qk cannot
435/// read is reported, and kept as unreadable so tasks key its whole content.
436fn parse_lockfile(path: &Path) -> Option<Arc<Installed>> {
437    let text = std::fs::read_to_string(path).ok()?;
438    let lockfile = match Lockfile::parse(&text) {
439        Ok(lockfile) => Some(lockfile),
440        Err(error) => {
441            qk_executor::status!("qk: pnpm-lock.yaml: keying tasks by the whole file ({error:#})");
442            None
443        }
444    };
445    Some(Arc::new(Installed {
446        content: blake3::hash(text.as_bytes()).to_hex().to_string(),
447        lockfile,
448        digests: Mutex::default(),
449    }))
450}
451
452fn load_digests(root: &Path) -> HashMap<String, (Stamp, String)> {
453    #[derive(serde::Deserialize)]
454    struct Saved {
455        version: u32,
456        entries: HashMap<String, (Stamp, String)>,
457    }
458    std::fs::read(digests_path(root))
459        .ok()
460        .and_then(|bytes| serde_json::from_slice::<Saved>(&bytes).ok())
461        .filter(|saved| saved.version == 1)
462        .map(|saved| saved.entries)
463        .unwrap_or_default()
464}
465
466/// Files equal to `prefix` or below it, using the sorted order of the set.
467fn under<'a>(files: &'a BTreeSet<String>, prefix: &'a str) -> impl Iterator<Item = &'a String> {
468    files
469        .range::<str, _>((
470            std::ops::Bound::Included(prefix),
471            std::ops::Bound::Unbounded,
472        ))
473        .take_while(move |path| path.starts_with(prefix))
474        .filter(move |path| path.len() == prefix.len() || path.as_bytes()[prefix.len()] == b'/')
475}
476
477/// Compiled root ignore rules used by input discovery and project watching.
478pub struct SourceIgnore(ignore::gitignore::Gitignore);
479
480impl SourceIgnore {
481    /// Root ignore rules shared by caching and watch, including tracked files.
482    pub fn new(root: &Path) -> Result<Self> {
483        let mut builder = ignore::gitignore::GitignoreBuilder::new(root);
484        for name in [".gitignore", ".nxignore"] {
485            let path = root.join(name);
486            if path.is_file()
487                && let Some(error) = builder.add(path)
488            {
489                return Err(error.into());
490            }
491        }
492        Ok(Self(builder.build()?))
493    }
494
495    /// Whether a workspace-relative file or its parent is ignored.
496    pub fn matches(&self, path: &str) -> bool {
497        // A file whitelist cannot reinclude it beneath an excluded directory.
498        Path::new(path)
499            .ancestors()
500            .skip(1)
501            .take_while(|parent| !parent.as_os_str().is_empty())
502            .any(|parent| self.0.matched(parent, true).is_ignore())
503            || self.0.matched(path, false).is_ignore()
504    }
505}
506
507/// Source files visible to Nx, with runner state and root ignore rules removed.
508pub fn source_files(root: &Path) -> Result<BTreeSet<String>> {
509    let lines =
510        |output: std::io::Result<std::process::Output>| -> Option<Result<BTreeSet<String>>> {
511            let output = output.ok().filter(|output| output.status.success())?;
512            Some(
513                output
514                    .stdout
515                    .split(|byte| *byte == 0)
516                    .filter(|path| !path.is_empty())
517                    .map(|path| {
518                        String::from_utf8(path.to_vec()).context("input paths must be UTF-8")
519                    })
520                    .collect(),
521            )
522        };
523    // Tracked files deleted from the working tree are listed as cached; git
524    // names them in a second listing, which is cheaper than checking each file.
525    // Skip-worktree entries are listed whether or not they are on disk and never
526    // as deleted, so those few are checked; `-v` tags them `S`.
527    let (listed, deleted) = if root.join(".nxignore").is_file() {
528        // Nx negations can reinclude Git-ignored files, so this policy needs
529        // the walker regardless of what Git lists. Avoid launching Git here.
530        (None, None)
531    } else {
532        std::thread::scope(|scope| {
533            let deleted =
534                scope.spawn(|| paths::git(root, &["ls-files", "--deleted", "-z", "--", "."]));
535            let listed = paths::git(
536                root,
537                &[
538                    "ls-files",
539                    "-v",
540                    "--cached",
541                    "--others",
542                    "--exclude-standard",
543                    "-z",
544                    "--",
545                    ".",
546                ],
547            );
548            (
549                lines(listed),
550                lines(deleted.join().expect("git listing thread panicked")),
551            )
552        })
553    };
554    let candidates = match (listed, deleted) {
555        (Some(listed), Some(deleted)) => {
556            let deleted = deleted?;
557            let mut files = BTreeSet::new();
558            for entry in listed? {
559                let Some((tag, path)) = entry.split_once(' ') else {
560                    bail!("unexpected git ls-files entry {entry:?}");
561                };
562                let absent =
563                    matches!(tag, "S" | "s") && std::fs::symlink_metadata(root.join(path)).is_err();
564                if !absent && !deleted.contains(path) {
565                    files.insert(path.to_owned());
566                }
567            }
568            files
569        }
570        _ => {
571            let mut files = BTreeSet::new();
572            for entry in ignore::WalkBuilder::new(root)
573                .hidden(false)
574                .parents(false)
575                .git_global(false)
576                .require_git(false)
577                .ignore(false)
578                .add_custom_ignore_filename(".nxignore")
579                .follow_links(false)
580                .filter_entry(|entry| {
581                    !matches!(
582                        entry.file_name().to_str(),
583                        Some(".git" | ".qk" | "node_modules")
584                    )
585                })
586                .build()
587            {
588                let entry = entry?;
589                if entry.file_type().is_some_and(|kind| !kind.is_dir()) {
590                    files.insert(paths::relative(root, entry.path())?);
591                }
592            }
593            files
594        }
595    };
596    let ignore = SourceIgnore::new(root)?;
597    Ok(candidates
598        .into_iter()
599        .filter(|path| {
600            !ignore.matches(path)
601                && !path
602                    .split('/')
603                    .any(|part| matches!(part, ".git" | ".qk" | "node_modules"))
604        })
605        .collect())
606}
607
608fn json_path<'v>(value: &'v Value, path: &str) -> Option<&'v Value> {
609    path.split('.')
610        .try_fold(value, |value, part| value.get(part))
611}
612
613fn set_json_path(target: &mut Value, path: &str, value: Value) {
614    let mut parts = path.split('.').peekable();
615    let mut current = target;
616    while let Some(part) = parts.next() {
617        let Value::Object(object) = current else {
618            return;
619        };
620        if parts.peek().is_none() {
621            object.insert(part.to_owned(), value);
622            return;
623        }
624        current = object.entry(part).or_insert_with(|| json!({}));
625    }
626}
627
628fn remove_json_path(target: &mut Value, path: &str) {
629    let (parent, last) = match path.rsplit_once('.') {
630        Some((parent, last)) => (Some(parent), last),
631        None => (None, path),
632    };
633    let parent = match parent {
634        Some(parent) => parent
635            .split('.')
636            .try_fold(&mut *target, |value, part| value.get_mut(part)),
637        None => Some(target),
638    };
639    if let Some(Value::Object(object)) = parent {
640        object.remove(last);
641    }
642}
643
644struct Resolver<'a> {
645    workspace: &'a Workspace,
646    snapshot: &'a Snapshot,
647    selected: BTreeSet<String>,
648    values: BTreeMap<String, Value>,
649    named_stack: Vec<(String, String)>,
650    /// Dependency named inputs already expanded for `^` inputs.
651    expanded: BTreeSet<(String, String)>,
652    external: BTreeSet<String>,
653    /// `None` resolves which inputs a task has without evaluating env or
654    /// runtime inputs.
655    prepared: Option<&'a PreparedTask>,
656    cancelled: &'a AtomicBool,
657}
658
659impl Resolver<'_> {
660    fn input(&mut self, project: &str, input: &Value) -> Result<()> {
661        match input {
662            // As in Nx, `^name` is `name` of every project the project depends
663            // on, directly or not.
664            Value::String(name) if name.starts_with('^') => {
665                self.dependencies_named(project, &name[1..])?;
666            }
667            // The object spellings of named inputs, as Nx reads them.
668            Value::Object(object)
669                if object.contains_key("input") && !object.contains_key("fileset") =>
670            {
671                let name = object["input"]
672                    .as_str()
673                    .context("input must name a named input")?;
674                let projects = object.get("projects");
675                let dependencies = object.get("dependencies") == Some(&json!(true))
676                    || projects == Some(&json!("dependencies"));
677                let listed = projects.filter(|projects| {
678                    **projects != json!("self") && **projects != json!("dependencies")
679                });
680                if (dependencies || listed.is_some()) && !self.named_stack.is_empty() {
681                    bail!("named inputs can only refer to named inputs of their own project");
682                }
683                if dependencies {
684                    self.dependencies_named(project, name)?;
685                } else if let Some(listed) = listed {
686                    let patterns: Vec<String> = match listed {
687                        Value::String(pattern) => vec![pattern.clone()],
688                        other => serde_json::from_value(other.clone())
689                            .context("input projects must be a string or an array")?,
690                    };
691                    for selected in
692                        qk_graph::select_projects(&self.workspace.projects, &patterns, &[])?
693                    {
694                        if self.expanded.insert((selected.clone(), name.to_owned())) {
695                            self.named(&selected, name)?;
696                        }
697                    }
698                } else {
699                    self.named(project, name)?;
700                }
701            }
702            Value::Object(object)
703                if object.contains_key("fileset")
704                    && object.get("dependencies") == Some(&json!(true)) =>
705            {
706                if !self.named_stack.is_empty() {
707                    bail!("named inputs can only refer to named inputs of their own project");
708                }
709                let pattern = object["fileset"]
710                    .as_str()
711                    .context("fileset input must be a glob")?
712                    .to_owned();
713                for dependency in self.dependency_closure(project) {
714                    self.fileset(&dependency, &pattern)?;
715                }
716            }
717            Value::Object(object)
718                if object.len() == 1 && object.contains_key("workingDirectory") =>
719            {
720                let mode = object["workingDirectory"].as_str().unwrap_or_default();
721                let directory = std::env::current_dir()?;
722                let value = match mode {
723                    "absolute" => directory.to_string_lossy().into_owned(),
724                    "relative" => {
725                        let root = self.workspace.root.canonicalize()?;
726                        let directory = directory.canonicalize()?;
727                        paths::relative(&root, &directory)
728                            .unwrap_or_else(|_| directory.to_string_lossy().into_owned())
729                    }
730                    _ => bail!("workingDirectory must be \"relative\" or \"absolute\""),
731                };
732                self.values
733                    .insert(format!("workingDirectory:{mode}"), json!(value));
734            }
735            Value::Object(object) if object.contains_key("json") => {
736                self.json(project, object)?;
737            }
738            Value::String(name)
739                if !name.contains("{projectRoot}") && !name.contains("{workspaceRoot}") =>
740            {
741                self.named(project, name)?
742            }
743            Value::Object(object) if object.len() == 1 && object.contains_key("fileset") => {
744                let pattern = object["fileset"]
745                    .as_str()
746                    .context("fileset input must be a glob")?;
747                self.fileset(project, pattern)?;
748            }
749            Value::String(pattern) => self.fileset(project, pattern)?,
750            Value::Object(object) if object.len() == 1 && object.contains_key("env") => {
751                let name = object["env"]
752                    .as_str()
753                    .context("env input must name a variable")?;
754                let Some(prepared) = self.prepared else {
755                    self.values.insert(format!("env:{name}"), Value::Null);
756                    return Ok(());
757                };
758                let value = prepared
759                    .env
760                    .get(std::ffi::OsStr::new(name))
761                    .map(|value| {
762                        value
763                            .to_str()
764                            .map(str::to_owned)
765                            .context("declared env input must be UTF-8")
766                    })
767                    .transpose()?;
768                self.values.insert(format!("env:{name}"), json!(value));
769            }
770            Value::Object(object) if object.len() == 1 && object.contains_key("runtime") => {
771                let command = object["runtime"]
772                    .as_str()
773                    .context("runtime input must be a command")?;
774                let Some(prepared) = self.prepared else {
775                    self.values
776                        .insert(format!("runtime:{command}"), Value::Null);
777                    return Ok(());
778                };
779                let memo = serde_json::to_string(&(
780                    command,
781                    prepared
782                        .env
783                        .iter()
784                        .map(|(name, value)| (name.to_string_lossy(), value.to_string_lossy()))
785                        .collect::<Vec<_>>(),
786                ))?;
787                if let Some(value) = self.snapshot.runtime.lock().unwrap().get(&memo) {
788                    self.values
789                        .insert(format!("runtime:{command}"), value.clone());
790                    return Ok(());
791                }
792                let mut prepared = prepared.clone();
793                prepared.commands = vec![command.into()];
794                prepared.cwd = self.workspace.root.clone();
795                prepared.parallel = false;
796                let log = tempfile::NamedTempFile::new()?;
797                let capture = Capture::new(Some(log.as_file().try_clone()?), Display::Hidden);
798                if execute_captured(&prepared, self.cancelled, Some(&capture))? != Outcome::Success
799                {
800                    bail!("runtime input did not succeed");
801                }
802                let mut stdout = blake3::Hasher::new();
803                let mut stderr = blake3::Hasher::new();
804                read_capture(File::open(log.path())?, |error, bytes| {
805                    if error {
806                        stderr.update(bytes);
807                    } else {
808                        stdout.update(bytes);
809                    }
810                    Ok(())
811                })?;
812                let value = json!([
813                    stdout.finalize().to_hex().to_string(),
814                    stderr.finalize().to_hex().to_string()
815                ]);
816                self.snapshot
817                    .runtime
818                    .lock()
819                    .unwrap()
820                    .insert(memo, value.clone());
821                self.values.insert(format!("runtime:{command}"), value);
822            }
823            Value::Object(object) if object.contains_key("dependentTasksOutputFiles") => {
824                if object
825                    .keys()
826                    .any(|key| !matches!(key.as_str(), "dependentTasksOutputFiles" | "transitive"))
827                {
828                    bail!("unsupported output input field");
829                }
830                let pattern = object["dependentTasksOutputFiles"]
831                    .as_str()
832                    .context("dependentTasksOutputFiles must be a glob")?;
833                self.snapshot.pattern(pattern, false)?;
834                let transitive = object
835                    .get("transitive")
836                    .map(|value| value.as_bool().context("transitive must be boolean"))
837                    .transpose()?
838                    .unwrap_or(false);
839                // Resolved before execution too, so inspection can display the
840                // selection without reading generated files. dependency_keys
841                // hashes these files after the dependencies finish.
842                let value = self
843                    .values
844                    .entry(format!("dependentTasksOutputFiles:{pattern}"))
845                    .or_insert(json!(false));
846                if transitive {
847                    *value = json!(true);
848                }
849            }
850            Value::Object(object)
851                if object.len() == 1 && object.contains_key("externalDependencies") =>
852            {
853                let names: Vec<String> =
854                    serde_json::from_value(object["externalDependencies"].clone())?;
855                self.external.extend(names);
856            }
857            _ => bail!("unsupported task input declaration"),
858        }
859        Ok(())
860    }
861
862    fn fileset(&mut self, project: &str, pattern: &str) -> Result<()> {
863        let (exclude, pattern) = pattern
864            .strip_prefix('!')
865            .map(|pattern| (true, pattern))
866            .unwrap_or((false, pattern));
867        let pattern = paths::expand(self.workspace, project, pattern)?;
868        let matcher = self.snapshot.pattern(&pattern, exclude)?;
869        // Only files below the pattern's literal directory prefix can match.
870        let prefix = pattern
871            .split('/')
872            .take_while(|part| is_literal(part))
873            .collect::<Vec<_>>()
874            .join("/");
875        let candidates: Box<dyn Iterator<Item = &String>> = if prefix.is_empty() {
876            Box::new(self.snapshot.files.iter())
877        } else {
878            Box::new(under(&self.snapshot.files, &prefix))
879        };
880        for file in candidates.filter(|file| matcher.is_match(file)) {
881            if exclude {
882                self.selected.remove(file);
883            } else {
884                self.selected.insert(file.clone());
885            }
886        }
887        Ok(())
888    }
889
890    /// Every project `project` depends on, directly or not.
891    fn dependency_closure(&self, project: &str) -> BTreeSet<String> {
892        let mut closure = BTreeSet::new();
893        let mut pending = vec![project.to_owned()];
894        while let Some(current) = pending.pop() {
895            for edge in &self.snapshot.projects.dependencies[&current] {
896                if edge.target != project && closure.insert(edge.target.clone()) {
897                    pending.push(edge.target.clone());
898                }
899            }
900        }
901        closure
902    }
903
904    /// As in Nx, `^name` is `name` of every project the project depends on.
905    fn dependencies_named(&mut self, project: &str, name: &str) -> Result<()> {
906        for dependency in self.dependency_closure(project) {
907            if self.expanded.insert((dependency.clone(), name.to_owned())) {
908                self.named(&dependency, name)?;
909            }
910        }
911        Ok(())
912    }
913
914    /// A JSON input: the fields of one JSON file, rather than all of it.
915    /// `fields` keeps only the named dotted paths; `excludeFields` removes
916    /// them.
917    fn json(&mut self, project: &str, object: &serde_json::Map<String, Value>) -> Result<()> {
918        for key in object.keys() {
919            if !matches!(key.as_str(), "json" | "fields" | "excludeFields") {
920                bail!("unsupported json input field {key:?}");
921            }
922        }
923        let path = object["json"]
924            .as_str()
925            .context("json input must name a file")?;
926        let path = paths::expand(self.workspace, project, path)?;
927        paths::validate_path(&path)?;
928        let list = |name: &str| -> Result<Option<Vec<String>>> {
929            object
930                .get(name)
931                .map(|value| {
932                    serde_json::from_value(value.clone())
933                        .with_context(|| format!("json input {name} must be an array of strings"))
934                })
935                .transpose()
936        };
937        let fields = list("fields")?;
938        let exclude = list("excludeFields")?.unwrap_or_default();
939        let value = match std::fs::read_to_string(self.workspace.root.join(&path)) {
940            Ok(text) => {
941                let mut value: Value =
942                    jsonc_parser::parse_to_serde_value(&text, &Default::default())
943                        .with_context(|| format!("cannot parse json input {path}"))?;
944                if let Some(fields) = &fields {
945                    let mut kept = json!({});
946                    for field in fields {
947                        if let Some(found) = json_path(&value, field) {
948                            set_json_path(&mut kept, field, found.clone());
949                        }
950                    }
951                    value = kept;
952                }
953                for field in &exclude {
954                    remove_json_path(&mut value, field);
955                }
956                value
957            }
958            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Value::Null,
959            Err(error) => return Err(error.into()),
960        };
961        self.values.insert(format!("json:{path}"), value);
962        Ok(())
963    }
964
965    fn named(&mut self, project: &str, name: &str) -> Result<()> {
966        let identity = (project.to_owned(), name.to_owned());
967        if self.named_stack.contains(&identity) {
968            bail!("named input cycle at {project}:{name}");
969        }
970        self.named_stack.push(identity);
971        let inputs = self.workspace.projects[project]
972            .named_inputs
973            .get(name)
974            .cloned()
975            .or_else(|| (name == "default").then(|| vec![json!("{projectRoot}/**/*")]))
976            .with_context(|| format!("unknown named input {name:?}"))?;
977        for input in &inputs {
978            self.input(project, input)?;
979        }
980        self.named_stack.pop();
981        Ok(())
982    }
983}
984
985/// What a task's key is computed from, as JSON: kept by the history so a
986/// changed key can be explained.
987/// What a task's key depends on, before any file is read.
988pub struct Resolved {
989    /// Workspace-relative files whose content is part of the key.
990    pub files: BTreeSet<String>,
991    /// Always-on workspace and package configuration, also useful to input analysis.
992    pub mandatory: BTreeSet<String>,
993    /// Env, runtime and other named values; env and runtime are evaluated only
994    /// with a prepared task.
995    pub values: BTreeMap<String, Value>,
996    /// With a readable pnpm lockfile: the importers whose installs count, and
997    /// packages named by `externalDependencies`.
998    pub lockfile: Option<(BTreeSet<String>, BTreeSet<String>)>,
999    /// `pnpm-workspace.yaml` counts without its resolution keys, which reach
1000    /// tasks through the lockfile instead.
1001    pub workspace_file: bool,
1002    /// Explicit external dependency names, even without a readable lockfile.
1003    pub external: BTreeSet<String>,
1004}
1005
1006pub fn resolve(
1007    snapshot: &Snapshot,
1008    workspace: &Workspace,
1009    task: &Task,
1010    prepared: Option<&PreparedTask>,
1011    cancelled: &AtomicBool,
1012) -> Result<Resolved> {
1013    let mut resolver = Resolver {
1014        workspace,
1015        snapshot,
1016        selected: BTreeSet::new(),
1017        values: BTreeMap::new(),
1018        named_stack: Vec::new(),
1019        expanded: BTreeSet::new(),
1020        external: BTreeSet::new(),
1021        prepared,
1022        cancelled,
1023    };
1024    let default = vec![json!("default"), json!("^default")];
1025    for input in task.definition.inputs.as_ref().unwrap_or(&default) {
1026        resolver.input(&task.project, input)?;
1027    }
1028    let readable = snapshot
1029        .installed(&workspace.root)?
1030        .is_some_and(|installed| installed.lockfile.is_some());
1031    // Always include workspace resolution and configuration. Dotenv files are
1032    // not keyed, as in Nx: they hold per-machine values and credentials, and a
1033    // task's `env` inputs key the variables it declares.
1034    // A pnpm lockfile qk can read is keyed by what the task's projects install instead.
1035    // The root tsconfig, as Nx hashes it into every task.
1036    let tsconfig = ["tsconfig.base.json", "tsconfig.json"]
1037        .into_iter()
1038        .find(|name| workspace.root.join(name).is_file());
1039    let mut mandatory = BTreeSet::new();
1040    for path in tsconfig.into_iter().chain([
1041        "nx.json",
1042        qk_config::LOCAL_WORKSPACE,
1043        "package.json",
1044        "pnpm-workspace.yaml",
1045        "pnpm-lock.yaml",
1046        "package-lock.json",
1047        "yarn.lock",
1048        "bun.lock",
1049    ]) {
1050        if workspace.root.join(path).is_file() && !(path == "pnpm-lock.yaml" && readable) {
1051            resolver.selected.insert(path.into());
1052            mandatory.insert(path.into());
1053        }
1054    }
1055    if let Some(extended) = &workspace.extended {
1056        resolver.selected.insert(extended.clone());
1057        mandatory.insert(extended.clone());
1058    }
1059    // Package scripts and dependency declarations remain inputs even when filesets exclude them.
1060    let mut packages = BTreeSet::from([task.project.clone()]);
1061    let mut pending = packages.clone();
1062    while let Some(project) = pending.pop_first() {
1063        for edge in &snapshot.projects.dependencies[&project] {
1064            if packages.insert(edge.target.clone()) {
1065                pending.insert(edge.target.clone());
1066            }
1067        }
1068    }
1069    for project in &packages {
1070        for name in ["project.json", qk_config::LOCAL_OVERRIDES, "package.json"] {
1071            let path = Path::new(&workspace.projects[project].root).join(name);
1072            let path = path.strip_prefix(".").unwrap_or(&path).to_owned();
1073            if workspace.root.join(&path).is_file() {
1074                let path = path
1075                    .to_str()
1076                    .context("project path must be UTF-8")?
1077                    .replace('\\', "/");
1078                resolver.selected.insert(path.clone());
1079                mandatory.insert(path);
1080            }
1081        }
1082    }
1083    // However it was selected, the workspace file's resolution keys reach the
1084    // task through the lockfile when qk can read it.
1085    let workspace_file = readable && resolver.selected.remove("pnpm-workspace.yaml");
1086    if workspace_file {
1087        mandatory.remove("pnpm-workspace.yaml");
1088    }
1089    let lockfile = readable.then(|| {
1090        // The root importer's packages resolve from every package in the
1091        // workspace, so they count for every task.
1092        let importers = std::iter::once(".".to_owned())
1093            .chain(
1094                packages
1095                    .iter()
1096                    .map(|project| workspace.projects[project].root.clone()),
1097            )
1098            .collect();
1099        (importers, resolver.external.clone())
1100    });
1101    Ok(Resolved {
1102        files: resolver.selected,
1103        mandatory,
1104        values: resolver.values,
1105        lockfile,
1106        workspace_file,
1107        external: resolver.external,
1108    })
1109}
1110
1111/// What a task's key is computed from, as JSON: kept by the history so a
1112/// changed key can be explained.
1113pub fn inputs(
1114    snapshot: &Snapshot,
1115    workspace: &Workspace,
1116    task: &Task,
1117    prepared: &PreparedTask,
1118    dependencies: &BTreeMap<String, String>,
1119    cancelled: &AtomicBool,
1120) -> Result<Value> {
1121    let Resolved {
1122        files,
1123        mut values,
1124        lockfile,
1125        workspace_file,
1126        ..
1127    } = resolve(snapshot, workspace, task, Some(prepared), cancelled)?;
1128    let installed = snapshot.installed(&workspace.root)?;
1129    if let (Some((importers, external)), Some(installed)) = (lockfile, &installed)
1130        && let Some(lockfile) = &installed.lockfile
1131    {
1132        let importers: BTreeMap<_, _> = importers
1133            .iter()
1134            .filter(|importer| lockfile.has_importer(importer))
1135            .map(|importer| {
1136                let digest = installed.digest(&format!("importer:{importer}"), || {
1137                    lockfile.installed(importer).unwrap_or_default()
1138                });
1139                (importer.clone(), digest)
1140            })
1141            .collect();
1142        let external: BTreeMap<_, _> = external
1143            .iter()
1144            .map(|name| {
1145                let digest =
1146                    installed.digest(&format!("package:{name}"), || lockfile.package(name));
1147                (name.clone(), digest)
1148            })
1149            .collect();
1150        values.insert(
1151            "lockfile".into(),
1152            json!({
1153                "global": installed.digest("global", || BTreeSet::from([lockfile.global().to_string()])),
1154                "importers": importers,
1155                "external": external,
1156            }),
1157        );
1158    }
1159    let mut files = files
1160        .iter()
1161        .map(|path| {
1162            let value = snapshot
1163                .file_value(&workspace.root, path)
1164                .with_context(|| format!("cannot read input {path}"))?;
1165            Ok((path.clone(), value))
1166        })
1167        .collect::<Result<BTreeMap<_, _>>>()?;
1168    if workspace_file {
1169        match workspace_without_resolution(&workspace.root) {
1170            Some(value) => {
1171                values.insert("pnpm-workspace".into(), value);
1172            }
1173            None => {
1174                files.insert(
1175                    "pnpm-workspace.yaml".into(),
1176                    snapshot.file_value(&workspace.root, "pnpm-workspace.yaml")?,
1177                );
1178            }
1179        }
1180    }
1181    // Warm state configuration never changes a result.
1182    let mut definition = task.definition.clone();
1183    definition.extra.remove("qk:warm");
1184    definition.extra.remove("qk:threads");
1185    let hash = json!({
1186        "schema":"qk-local-v1", "qk":env!("CARGO_PKG_VERSION"),
1187        "platform":[std::env::consts::OS, std::env::consts::ARCH], "workspace":snapshot.workspace_prefix,
1188        "id":task.id, "args":task.args, "definition":definition, "packageManager":workspace.package_manager,
1189        "files":files, "values":values, "dependencies":dependencies,
1190    });
1191    Ok(hash)
1192}
1193
1194/// `pnpm-workspace.yaml` without the keys that configure resolution, or `None`
1195/// when it cannot be read as YAML.
1196pub fn workspace_without_resolution(root: &Path) -> Option<Value> {
1197    let text = std::fs::read_to_string(root.join("pnpm-workspace.yaml")).ok()?;
1198    without_resolution(&text)
1199}
1200
1201/// A `pnpm-workspace.yaml` text without its resolution keys.
1202pub fn without_resolution(text: &str) -> Option<Value> {
1203    let mut value: Value = serde_yaml_ng::from_str(text).ok()?;
1204    if let Some(object) = value.as_object_mut() {
1205        object.retain(|key, _| !qk_lockfile::RESOLUTION_KEYS.contains(&key.as_str()));
1206    }
1207    Some(value)
1208}
1209
1210/// The key for a task's inputs.
1211pub fn key(inputs: &Value) -> Result<String> {
1212    Ok(blake3::hash(&serde_json::to_vec(inputs)?)
1213        .to_hex()
1214        .to_string())
1215}
1216
1217pub fn fingerprint(
1218    snapshot: &Snapshot,
1219    workspace: &Workspace,
1220    task: &Task,
1221    prepared: &PreparedTask,
1222    dependencies: &BTreeMap<String, String>,
1223    cancelled: &AtomicBool,
1224) -> Result<String> {
1225    key(&inputs(
1226        snapshot,
1227        workspace,
1228        task,
1229        prepared,
1230        dependencies,
1231        cancelled,
1232    )?)
1233}
1234
1235/// Use selected dependency artifacts when output inputs are declared; otherwise
1236/// preserve the normal dependency fingerprints. All dependencies must have
1237/// succeeded and be fingerprintable before this selection is applied.
1238pub(crate) fn dependency_keys(
1239    snapshot: &Snapshot,
1240    workspace: &Workspace,
1241    graph: &TaskGraph,
1242    task: &Task,
1243    dependencies: &BTreeMap<String, String>,
1244    cancelled: &AtomicBool,
1245) -> Result<BTreeMap<String, String>> {
1246    let resolved = resolve(snapshot, workspace, task, None, cancelled)?;
1247    let selections: Vec<_> = resolved
1248        .values
1249        .iter()
1250        .filter_map(|(name, transitive)| {
1251            name.strip_prefix("dependentTasksOutputFiles:")
1252                .map(|pattern| (pattern, transitive == &json!(true)))
1253        })
1254        .collect();
1255    if selections.is_empty() {
1256        return Ok(dependencies.clone());
1257    }
1258    let mut keys = BTreeMap::new();
1259    for (pattern, transitive) in selections {
1260        let matcher = snapshot.pattern(pattern, false)?;
1261        let mut selected = task.dependencies.clone();
1262        if transitive {
1263            let mut pending = selected.clone();
1264            while let Some(id) = pending.pop_first() {
1265                for dependency in &graph.tasks[&id].dependencies {
1266                    if selected.insert(dependency.clone()) {
1267                        pending.insert(dependency.clone());
1268                    }
1269                }
1270            }
1271        }
1272        for id in selected {
1273            let outputs = snapshot.dependency_outputs(workspace, &graph.tasks[&id])?;
1274            let mut files = BTreeMap::new();
1275            for path in outputs.files.iter().filter(|path| matcher.is_match(path)) {
1276                let known = outputs.values.lock().unwrap().get(path).cloned();
1277                let value = match known {
1278                    Some(value) => value,
1279                    None => {
1280                        let value = snapshot.file_value(&workspace.root, path)?;
1281                        // Links depend on paths outside the declared output roots.
1282                        if value.get("link").is_none() {
1283                            outputs
1284                                .values
1285                                .lock()
1286                                .unwrap()
1287                                .insert(path.clone(), value.clone());
1288                        }
1289                        value
1290                    }
1291                };
1292                files.insert(path.clone(), value);
1293            }
1294            keys.insert(format!("{id}:{pattern}"), key(&json!(files))?);
1295        }
1296    }
1297    Ok(keys)
1298}
1299
1300#[cfg(test)]
1301mod tests {
1302    use super::*;
1303    use qk_taskgraph::Request;
1304
1305    fn fixture(outputs: Option<Value>) -> (tempfile::TempDir, Workspace, TaskGraph) {
1306        let root = tempfile::tempdir().unwrap();
1307        std::fs::write(root.path().join("nx.json"), "{}").unwrap();
1308        let mut post = json!({"command": "true"});
1309        if let Some(outputs) = outputs {
1310            post["outputs"] = outputs;
1311        }
1312        std::fs::write(root.path().join("project.json"), json!({"name":"app", "targets": {
1313            "gen": {"command":"true", "outputs":["generated"]},
1314            "post": post,
1315            "build": {"command":"true", "dependsOn":["gen", "post"], "inputs":[{"dependentTasksOutputFiles":"**/*"}]}
1316        }}).to_string()).unwrap();
1317        std::fs::create_dir(root.path().join("generated")).unwrap();
1318        std::fs::write(root.path().join("generated/value"), "one").unwrap();
1319        let workspace = Workspace::load(root.path()).unwrap();
1320        let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
1321        (root, workspace, graph)
1322    }
1323
1324    #[test]
1325    fn undeclared_writes_invalidate_dependency_values() {
1326        for outputs in [None, Some(json!(["generated"]))] {
1327            let (root, workspace, graph) = fixture(outputs);
1328            let snapshot = Snapshot::new(&workspace, &graph, &root.path().join("cache")).unwrap();
1329            let keys = || {
1330                dependency_keys(
1331                    &snapshot,
1332                    &workspace,
1333                    &graph,
1334                    &graph.tasks["app:build"],
1335                    &BTreeMap::new(),
1336                    &AtomicBool::new(false),
1337                )
1338                .unwrap()
1339            };
1340            let before = keys();
1341            std::fs::write(root.path().join("generated/value"), "different").unwrap();
1342            snapshot.outputs_written(&workspace, &graph.tasks["app:post"]);
1343            assert_ne!(before, keys());
1344        }
1345    }
1346
1347    #[cfg(unix)]
1348    #[test]
1349    fn dependency_symlinks_recheck_targets_outside_output_roots() {
1350        let (root, mut workspace, _) = fixture(Some(json!(["generated"])));
1351        workspace
1352            .projects
1353            .get_mut("app")
1354            .unwrap()
1355            .targets
1356            .get_mut("gen")
1357            .unwrap()
1358            .outputs = Some(vec!["links".into()]);
1359        std::fs::create_dir(root.path().join("links")).unwrap();
1360        std::os::unix::fs::symlink("../generated/value", root.path().join("links/value")).unwrap();
1361        let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
1362        let snapshot = Snapshot::new(&workspace, &graph, &root.path().join("cache")).unwrap();
1363        let keys = || {
1364            dependency_keys(
1365                &snapshot,
1366                &workspace,
1367                &graph,
1368                &graph.tasks["app:build"],
1369                &BTreeMap::new(),
1370                &AtomicBool::new(false),
1371            )
1372            .unwrap()
1373        };
1374        let before = keys();
1375        std::fs::write(root.path().join("generated/value"), "different").unwrap();
1376        snapshot.outputs_written(&workspace, &graph.tasks["app:post"]);
1377        assert_ne!(before["app:gen:**/*"], keys()["app:gen:**/*"]);
1378    }
1379}