Skip to main content

qk_cache/
hash.rs

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