Skip to main content

qk_cache/
hash.rs

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