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